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:
Bernd Rodler
2026-08-04 10:00:11 +02:00
co-authored by Claude Opus 4.8
parent e9746fcf78
commit f7e487171c
23 changed files with 4287 additions and 0 deletions
+222
View File
@@ -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 };
}