diff --git a/.env.dev.example b/.env.dev.example index 2c876a01..697fb4b0 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -35,7 +35,7 @@ APP_NAME=Bulwark Webmail (Dev) # Session & Settings Sync (optional for dev) # ============================================================================= -SESSION_SECRET=dev-secret-not-for-production +SESSION_SECRET=dev-secret-not-for-production-32chars SETTINGS_SYNC_ENABLED=true # ============================================================================= diff --git a/vnc/plugins/smime/src/enroll.js b/vnc/plugins/smime/src/enroll.js new file mode 100644 index 00000000..f5296a63 --- /dev/null +++ b/vnc/plugins/smime/src/enroll.js @@ -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 || [] }; +} diff --git a/vnc/plugins/smime/src/index.js b/vnc/plugins/smime/src/index.js index ed755646..3b6f4e58 100644 --- a/vnc/plugins/smime/src/index.js +++ b/vnc/plugins/smime/src/index.js @@ -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' } }, diff --git a/vnc/plugins/smime/src/pkcs12.js b/vnc/plugins/smime/src/pkcs12.js index 48cb3c89..d5ae5b57 100644 --- a/vnc/plugins/smime/src/pkcs12.js +++ b/vnc/plugins/smime/src/pkcs12.js @@ -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);