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:
Bernd Rodler
2026-08-06 09:06:20 +02:00
parent 30e5059b94
commit 295170a842
4 changed files with 137 additions and 4 deletions
+35 -1
View File
@@ -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' } },