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.
223 lines
7.9 KiB
JavaScript
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 };
|
|
}
|