feat(smime): client-side certificate enrolment — web S/MIME now fully functional
New enroll.js: generates an RSA-2048 keypair with WebCrypto (extractable only long enough to export to PKCS#8), builds and signs a real CSR with pkijs (same per-call-engine convention as smime-sign.js/smime-verify.js — nativeEngine() passed explicitly, no global pkijs.setEngine call), POSTs it to the already-existing /api/smime/enroll (same-origin fetch — the plugin's privileged tier gets allow-same-origin, cookies included by default), and packages the result into a key record using the EXACT same encrypted-at-rest convention as a PKCS#12 import (AES-GCM/PBKDF2 600k, exported from pkcs12.js) so every downstream sign/encrypt/decrypt/verify path is identical regardless of how the key arrived. New "Get a certificate" button in the settings-section UI, next to "Import key" — prompts for a storage passphrase, calls enroll(), saves the key record, and refreshes the list. No changes needed to the CA route or the CA provider — both were already real and already tested. Live end-to-end verified (not just unit-level): logged in via the real dev-mode session flow, clicked through the actual plugin UI, got back a real certificate (RSA-2048, correct validity window, real fingerprint) for dev@localhost, then unlocked it with the same passphrase — the encrypted private key round-trips correctly through the identical code path a PKCS#12 import would use. Also fixes a real bug hit during that verification: SESSION_SECRET must be >= 32 chars (lib/auth/crypto.ts), but .env.dev.example's own documented placeholder was 29 - failing "Failed to store Stalwart auth context" on every feature needing the real session-cookie flow (this enrolment route, offline sync, AI server class). Anyone following the setup doc verbatim would have hit this. Padded the placeholder to 37 chars.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Client-side certificate enrolment (C-08, client half).
|
||||
*
|
||||
* The private key is generated in the browser with WebCrypto and never
|
||||
* leaves it — only the CSR (a public-key self-assertion) is sent to
|
||||
* /api/smime/enroll. The server decides which addresses the issued
|
||||
* certificate may assert, from the account's real JMAP identities, never
|
||||
* from the CSR's own subject — see that route's module header for why a
|
||||
* CSR's self-asserted subject cannot be trusted for identity.
|
||||
*
|
||||
* This module's job ends at: generate a keypair, build a CSR, POST it, and
|
||||
* package the result using the EXACT same encrypted-at-rest convention as a
|
||||
* PKCS#12 import (pkcs12.js's AES-GCM/PBKDF2), producing a key record with
|
||||
* the identical shape `importPkcs12()` produces — every downstream sign/
|
||||
* encrypt/decrypt/verify code path (smime-sign.js etc.) is then identical
|
||||
* regardless of whether the key arrived via import or enrolment.
|
||||
*/
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { nativeEngine } from './crypto-engine.js';
|
||||
import { parseCertificateDer, pemToDer, derToPem, extractCertificateInfo } from './certificate-utils.js';
|
||||
import { encryptPrivateKey, KDF_ITERATIONS } from './pkcs12.js';
|
||||
import { generateUUID } from './util.js';
|
||||
|
||||
export class EnrollError extends Error {}
|
||||
|
||||
async function buildCsr(keyPair, commonName) {
|
||||
const csr = new pkijs.CertificationRequest();
|
||||
csr.version = 0;
|
||||
csr.subject.typesAndValues = [
|
||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: commonName }) }),
|
||||
];
|
||||
// Per-call engine argument, matching this bundle's convention throughout
|
||||
// smime-sign.js/smime-verify.js/smime-encrypt.js — pkijs's global engine is
|
||||
// never set here, so every operation that touches crypto passes one explicitly.
|
||||
await csr.subjectPublicKeyInfo.importKey(keyPair.publicKey, nativeEngine());
|
||||
await csr.sign(keyPair.privateKey, 'SHA-256', nativeEngine());
|
||||
return derToPem(csr.toSchema().toBER(false), 'CERTIFICATE REQUEST');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a keypair + CSR, submit it to the configured CA, and return an
|
||||
* encrypted-at-rest key record ready for `saveKeyRecord()`.
|
||||
*/
|
||||
export async function enroll(storagePassphrase) {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true, // extractable — only long enough to export to PKCS#8 below, then discarded
|
||||
['sign', 'verify'],
|
||||
);
|
||||
|
||||
const csrPem = await buildCsr(keyPair, 'VNCmail+ user');
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('/api/smime/enroll', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ csrPem }),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new EnrollError(`Could not reach the enrolment service: ${cause && cause.message ? cause.message : String(cause)}`);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new EnrollError(data.error || `Enrolment failed (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const leafDer = pemToDer(data.certificatePem);
|
||||
const leafCert = parseCertificateDer(leafDer);
|
||||
const certInfo = await extractCertificateInfo(leafCert, leafDer);
|
||||
const chainDer = (data.chainPem || []).map((pem) => pemToDer(pem));
|
||||
|
||||
const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
|
||||
|
||||
const email = (certInfo.emailAddresses[0] || (data.addresses && data.addresses[0]) || '').toLowerCase();
|
||||
|
||||
const keyRecord = {
|
||||
id: generateUUID(),
|
||||
email,
|
||||
certificate: leafDer,
|
||||
certificateChain: chainDer,
|
||||
encryptedPrivateKey: encrypted,
|
||||
salt,
|
||||
iv,
|
||||
kdfIterations: KDF_ITERATIONS,
|
||||
issuer: certInfo.issuer,
|
||||
subject: certInfo.subject,
|
||||
serialNumber: certInfo.serialNumber,
|
||||
notBefore: certInfo.notBefore,
|
||||
notAfter: certInfo.notAfter,
|
||||
fingerprint: certInfo.fingerprint,
|
||||
algorithm: certInfo.algorithm,
|
||||
capabilities: certInfo.capabilities,
|
||||
};
|
||||
|
||||
return { keyRecord, certInfo, addresses: data.addresses || [] };
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { smimeDecrypt, normalizeCmsBytes, SmimeKeyLockedError } from './smime-de
|
||||
import { detectSmime } from './smime-detect.js';
|
||||
import { parseMime } from './mime-parse.js';
|
||||
import { importPkcs12, unlockPrivateKey } from './pkcs12.js';
|
||||
import { enroll, EnrollError } from './enroll.js';
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from './certificate-utils.js';
|
||||
import { generateUUID } from './util.js';
|
||||
import {
|
||||
@@ -905,6 +906,34 @@ function SettingsSection() {
|
||||
}
|
||||
}
|
||||
|
||||
async function enrollForCertificate() {
|
||||
const answers = await host.ui.prompt({
|
||||
title: 'Get a certificate',
|
||||
message: "Requests a certificate from your organisation's S/MIME CA for your own mail address(es). Your private key is generated in your browser and never leaves it — only the certificate request is sent.",
|
||||
confirmLabel: 'Request certificate',
|
||||
fields: [
|
||||
{ name: 'storagePass', label: 'New passphrase to protect this key in your browser', type: 'password', required: true },
|
||||
],
|
||||
});
|
||||
if (!answers) return; // cancelled
|
||||
const storagePass = answers.storagePass || '';
|
||||
if (!storagePass) { host.toast.error('A storage passphrase is required'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
const { keyRecord, addresses } = await enroll(storagePass);
|
||||
await saveKeyRecord(keyRecord);
|
||||
host.toast.success(`Certificate issued for ${addresses.join(', ') || keyRecord.email}`);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message = err instanceof EnrollError
|
||||
? err.message
|
||||
: `Enrolment failed: ${err && err.message ? err.message : String(err)}`;
|
||||
host.toast.error(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlock(rec) {
|
||||
const answers = await host.ui.prompt({
|
||||
title: `Unlock ${rec.email || 'S/MIME key'}`,
|
||||
@@ -997,10 +1026,15 @@ function SettingsSection() {
|
||||
h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, 'Your keys'),
|
||||
h('p', { style: { margin: '0 0 8px', fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } },
|
||||
'Import a PKCS#12 (.p12/.pfx) file containing your certificate and private key. The key is encrypted in your browser and never leaves it.'),
|
||||
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } },
|
||||
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px', flexWrap: 'wrap' } },
|
||||
h('input', { ref: fileRef, type: 'file', accept: '.p12,.pfx', style: { fontSize: '13px' } }),
|
||||
h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: importKeyFile }, 'Import key'),
|
||||
),
|
||||
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } },
|
||||
h('button', { type: 'button', style: btn, disabled: busy, onClick: enrollForCertificate }, 'Get a certificate'),
|
||||
h('span', { style: { fontSize: '12px', color: 'var(--color-muted-foreground, #64748b)' } },
|
||||
"— or, request one from your organisation's CA instead of importing a file"),
|
||||
),
|
||||
keys.length === 0
|
||||
? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, 'No keys imported yet.')
|
||||
: h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
|
||||
|
||||
@@ -12,7 +12,7 @@ import { generateUUID } from './util.js';
|
||||
import { extractCertificateInfo, classifyCapabilities } from './certificate-utils.js';
|
||||
import { withLinerEngine, getLinerCrypto } from './crypto-engine.js';
|
||||
|
||||
const KDF_ITERATIONS = 600_000;
|
||||
export const KDF_ITERATIONS = 600_000;
|
||||
const AES_KEY_LENGTH = 256;
|
||||
|
||||
function stringToAB(str) {
|
||||
@@ -149,7 +149,7 @@ async function deriveWrappingKey(passphrase, salt, iterations) {
|
||||
);
|
||||
}
|
||||
|
||||
async function encryptPrivateKey(pkcs8Bytes, passphrase) {
|
||||
export async function encryptPrivateKey(pkcs8Bytes, passphrase) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(32)).buffer;
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12)).buffer;
|
||||
const wrappingKey = await deriveWrappingKey(passphrase, salt, KDF_ITERATIONS);
|
||||
|
||||
Reference in New Issue
Block a user