Files
SRCmail/vnc/plugins/smime/src/enroll.js
T
Bernd Rodler 295170a842 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.
2026-08-06 09:06:20 +02:00

100 lines
4.1 KiB
JavaScript

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