/** * 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 || [] }; }