Files
SRCmail/vnc/plugins/smime/src/pkcs12.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

223 lines
7.9 KiB
JavaScript

/**
* PKCS#12 (.p12/.pfx) import + private-key encryption-at-rest / unlock.
* Ported from lib/smime/pkcs12-import.ts.
*
* Private keys are wrapped with AES-GCM under a PBKDF2(600k, SHA-256) key
* derived from a user passphrase. Unlocked keys are imported NON-EXTRACTABLE.
*/
import * as asn1js from 'asn1js';
import * as pkijs from 'pkijs';
import { generateUUID } from './util.js';
import { extractCertificateInfo, classifyCapabilities } from './certificate-utils.js';
import { withLinerEngine, getLinerCrypto } from './crypto-engine.js';
export const KDF_ITERATIONS = 600_000;
const AES_KEY_LENGTH = 256;
function stringToAB(str) {
const buf = new ArrayBuffer(str.length);
const view = new Uint8Array(buf);
for (let i = 0; i < str.length; i++) view[i] = str.charCodeAt(i);
return buf;
}
/** Parse a PKCS#12 file and produce an encrypted-at-rest key record. */
export async function importPkcs12(p12Bytes, p12Passphrase, storagePassphrase) {
const asn1 = asn1js.fromBER(p12Bytes);
if (asn1.offset === -1) throw new Error('Invalid PKCS#12 file: ASN.1 parsing failed');
const pfx = new pkijs.PFX({ schema: asn1.result });
await withLinerEngine(async () => {
await pfx.parseInternalValues({ password: stringToAB(p12Passphrase) });
});
let leafCertDer = null;
let leafCert = null;
const chainCertsDer = [];
let privateKeyInfo = null;
if (!pfx.parsedValue?.authenticatedSafe) {
throw new Error('PKCS#12 file does not contain an authenticated safe');
}
const authSafe = pfx.parsedValue.authenticatedSafe;
const safeContentsParams = authSafe.safeContents.map((ci) =>
ci.contentType === '1.2.840.113549.1.7.6' ? { password: stringToAB(p12Passphrase) } : {},
);
await withLinerEngine(async () => {
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
});
for (const safeContent of authSafe.parsedValue.safeContents) {
const sc = safeContent.value ?? safeContent.parsedValue;
if (!sc) continue;
for (const safeBag of sc.safeBags) {
switch (safeBag.bagId) {
case '1.2.840.113549.1.12.10.1.3': { // CertBag
const certBag = safeBag.bagValue;
let cert = null;
let der = null;
if (certBag.parsedValue instanceof pkijs.Certificate) {
cert = certBag.parsedValue;
der = cert.toSchema(true).toBER(false);
} else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
const certDerBytes = certBag.certValue.valueBlock.valueHexView;
const certAsn1 = asn1js.fromBER(certDerBytes);
if (certAsn1.offset !== -1) {
cert = new pkijs.Certificate({ schema: certAsn1.result });
der = new Uint8Array(certDerBytes).buffer;
}
}
if (cert && der) {
if (!leafCertDer) {
leafCertDer = der;
leafCert = cert;
} else {
chainCertsDer.push(der);
}
}
break;
}
case '1.2.840.113549.1.12.10.1.1': { // KeyBag (unencrypted)
privateKeyInfo = safeBag.bagValue;
break;
}
case '1.2.840.113549.1.12.10.1.2': { // PKCS8ShroudedKeyBag (encrypted)
const shroudedBag = safeBag.bagValue;
if (shroudedBag.parsedValue) {
privateKeyInfo = shroudedBag.parsedValue;
} else {
await withLinerEngine(async () => {
await shroudedBag.parseInternalValues({ password: stringToAB(p12Passphrase) });
});
if (shroudedBag.parsedValue) privateKeyInfo = shroudedBag.parsedValue;
}
break;
}
}
}
}
if (!leafCert || !leafCertDer) throw new Error('No certificate found in PKCS#12 file');
if (!privateKeyInfo) throw new Error('No private key found in PKCS#12 file');
const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false);
const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
const certInfo = await extractCertificateInfo(leafCert, leafCertDer);
const capabilities = classifyCapabilities(leafCert);
const email = certInfo.emailAddresses[0] ?? '';
const keyRecord = {
id: generateUUID(),
email: email.toLowerCase(),
certificate: leafCertDer,
certificateChain: chainCertsDer,
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,
};
return { keyRecord, certInfo };
}
// ── Private key encryption / decryption ──────────────────────────────
async function deriveWrappingKey(passphrase, salt, iterations) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: AES_KEY_LENGTH },
false,
['encrypt', 'decrypt'],
);
}
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);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrappingKey, pkcs8Bytes);
return { encrypted, salt, iv };
}
function ecdsaCurveFromAlg(alg) {
if (alg.includes('P256') || alg.includes('P-256')) return 'P-256';
if (alg.includes('P384') || alg.includes('P-384')) return 'P-384';
if (alg.includes('P521') || alg.includes('P-521')) return 'P-521';
return 'P-256';
}
/**
* Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys.
* @returns { signingKey, decryptionKey?, legacyDecryptionKey? }
*/
export async function unlockPrivateKey(record, passphrase) {
const wrappingKey = await deriveWrappingKey(passphrase, record.salt, record.kdfIterations);
let pkcs8Bytes;
try {
pkcs8Bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: record.iv }, wrappingKey, record.encryptedPrivateKey);
} catch {
throw new Error('Incorrect passphrase');
}
const isEcdsa = record.algorithm.startsWith('ECDSA');
const signAlg = isEcdsa
? { name: 'ECDSA', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
const decryptAlg = isEcdsa
? { name: 'ECDH', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
: { name: 'RSA-OAEP', hash: 'SHA-256' };
const decryptUsages = isEcdsa ? ['deriveBits'] : ['decrypt'];
let signingKey;
try {
signingKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, signAlg, false, ['sign']);
} catch {
// Key may only support decryption (key-encipherment-only cert)
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
let legacyDecryptionKey;
if (!isEcdsa) {
try {
legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
);
} catch { /* liner unavailable */ }
}
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
}
let decryptionKey;
try {
decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
} catch { /* signing-only cert */ }
let legacyDecryptionKey;
if (!isEcdsa) {
try {
legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
);
} catch { /* liner unavailable */ }
}
return { signingKey, decryptionKey, legacyDecryptionKey };
}