security(smime): fork upstream plugin and fix two audit findings
S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine findings, two HIGH. No backdoor and no exfiltration path anywhere in the bundle — the problems are trust-model and input-validation gaps. Full report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md. Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing src/ would not audit what that zip installs. We build from source. Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated on signatureValid alone, but smimeVerify runs checkChain:false, so that only proves "signed by whoever holds this key", not that the claimed identity is real. Self-sign a cert asserting victim@example.com, send one signed message, and it was stored as the encryption target for that address — the user's next Encrypt to the victim went to the attacker. Now requires signerEmailMatch === true and !selfSigned. Both values were already computed and displayed as untrusted in the banner; only the import path ignored them. Tests for `true` explicitly so an undefined match (missing From header) fails closed. Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only Subject and attachment filename; display names, raw addresses, Message-ID, In-Reply-To, References and attachment Content-Type were emitted verbatim, and formatAddress escapes only backslash and quote. In-Reply-To/References/display names are copied from inbound mail when replying or forwarding, so the value is attacker-supplied. Sanitising inside formatHeader covers all 17 call sites by construction; the three headers assembled directly get stripCrlf explicitly. Also adds auth:observe to the manifest. The plugin registers onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363) — without declaring the permission, so under B-09 the session-key wipe would silently stop running. verify-fixes.mjs carries 19 assertions including source checks that fail if either guard is removed or a new unsanitised interpolated header appears. That last one immediately caught the interpolated smime-type Content-Type header, which manual review had dismissed as static. Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This is not safe for real mail yet — sandbox accounts only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e9746fcf78
commit
f7e487171c
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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'],
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user