/** * Decrypt CMS EnvelopedData to recover the inner MIME content. * Supports issuerAndSerialNumber and subjectKeyIdentifier recipient IDs. * Ported from lib/smime/smime-decrypt.ts (Buffer → toHex). */ import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; import { getLinerCryptoEngine, withLinerEngine, nativeEngine } from './crypto-engine.js'; import { arraysEqual, toHex } from './util.js'; // ─── VNC: content-encryption allowlist (audit finding 2) ─────────────── // // Upstream applied NO algorithm check on decrypt and ran every decryption // through the liner engine, which deliberately widens the accepted set to // DES-CBC (56-bit), 3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js // for PKCS#12 *password-based* encryption; the CMS content path reused the same // engine and inherited them, so a crafted message could be decrypted under a // broken cipher. // // The tempting fix — accept only AEAD — would break most real S/MIME mail. // RFC 5751 makes AES-128-CBC the MUST-implement content cipher and both Outlook // and Thunderbird default to CBC; AES-GCM in CMS (RFC 5084) is barely deployed. // An AEAD-only allowlist would be a functionality catastrophe wearing a security // fix's clothes. // // So: allow the AES family (CBC for interop, GCM preferred), refuse everything // else, and tell the caller whether what it got was actually authenticated. // CMS EnvelopedData carries no MAC, so CBC output is malleable — that is the // EFAIL precondition, and the real mitigation is refusing to render // unauthenticated plaintext as HTML with external resources. `contentAuthenticated` // is what lets the render path make that decision instead of guessing. const CONTENT_ENCRYPTION_ALLOWLIST = new Map([ ['2.16.840.1.101.3.4.1.2', { name: 'AES-128-CBC', authenticated: false }], ['2.16.840.1.101.3.4.1.22', { name: 'AES-192-CBC', authenticated: false }], ['2.16.840.1.101.3.4.1.42', { name: 'AES-256-CBC', authenticated: false }], ['2.16.840.1.101.3.4.1.6', { name: 'AES-128-GCM', authenticated: true }], ['2.16.840.1.101.3.4.1.26', { name: 'AES-192-GCM', authenticated: true }], ['2.16.840.1.101.3.4.1.46', { name: 'AES-256-GCM', authenticated: true }], ]); /** * Refuse content-encryption algorithms outside the allowlist, before any * decryption is attempted. Returns the matched descriptor. */ function checkContentEncryption(envelopedData) { const oid = envelopedData?.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId; if (!oid) throw new Error('Encrypted message has no content-encryption algorithm'); const allowed = CONTENT_ENCRYPTION_ALLOWLIST.get(oid); if (!allowed) { // Deliberately refuse rather than fall through — a message asking to be // decrypted under DES/RC2 in 2026 is not a message we want to read. throw new Error( `Refusing to decrypt: unsupported or insecure content-encryption algorithm (${oid}). ` + 'Only AES-CBC and AES-GCM are accepted.', ); } return allowed; } export class SmimeKeyLockedError extends Error { constructor(message, keyRecordId) { super(message); this.name = 'SmimeKeyLockedError'; this.keyRecordId = keyRecordId; } } /** * Attempt to decrypt CMS EnvelopedData. * @param input { cmsBytes, keyRecords, unlockedKeys: Map, legacyUnlockedKeys?: Map } * @returns { mimeBytes: Uint8Array, keyRecordId: string } */ export async function smimeDecrypt(input) { const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input; const contentInfo = parseContentInfo(cmsBytes); const envelopedData = extractEnvelopedData(contentInfo); // VNC: gate the algorithm BEFORE touching any private key, so a message using // a refused cipher never reaches a decrypt primitive at all. const contentAlg = checkContentEncryption(envelopedData); const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords); if (matchedRecords.length === 0) { throw new Error('No imported S/MIME key matches any recipient in this encrypted message'); } const result = (decrypted, keyRecord) => ({ mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id, // VNC: true only for AEAD content encryption. The caller must not render // unauthenticated plaintext as HTML with external resources (EFAIL). contentAuthenticated: contentAlg.authenticated, contentAlgorithm: contentAlg.name, }); for (const { keyRecord, recipientIndex } of matchedRecords) { const privateKey = unlockedKeys.get(keyRecord.id); if (!privateKey) { const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); if (legacyKey) { try { const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); return result(decrypted, keyRecord); } catch { continue; } } continue; } try { const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord, false); return result(decrypted, keyRecord); } catch { const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); if (legacyKey) { try { const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); return result(decrypted, keyRecord); } catch { /* try next record */ } } continue; } } const isUnlocked = (id) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false); const hasLockedMatch = matchedRecords.some((m) => !isUnlocked(m.keyRecord.id)); if (hasLockedMatch) { const lockedRecord = matchedRecords.find((m) => !isUnlocked(m.keyRecord.id)); throw new SmimeKeyLockedError( 'S/MIME key is locked. Unlock it to decrypt this message.', lockedRecord.keyRecord.id, ); } throw new Error('Failed to decrypt message with any available key'); } /** Key record IDs that could potentially decrypt a message (to prompt unlock). */ export function findDecryptionCandidates(cmsBytes, keyRecords) { try { const contentInfo = parseContentInfo(cmsBytes); const envelopedData = extractEnvelopedData(contentInfo); return findMatchingKeyRecords(envelopedData, keyRecords).map((m) => m.keyRecord.id); } catch { return []; } } /** * Normalize raw blob bytes into DER-encoded CMS data. * JMAP may return raw DER, base64 DER, a full MIME part, or PEM. */ export function normalizeCmsBytes(raw) { if (raw.byteLength === 0) return raw; const bytes = new Uint8Array(raw); if (bytes[0] === 0x30) return raw; // already DER let text = new TextDecoder().decode(raw); const looksMostlyText = (() => { const sample = text.slice(0, Math.min(text.length, 2048)); if (sample.length === 0) return false; let printable = 0; for (let i = 0; i < sample.length; i++) { const code = sample.charCodeAt(i); if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code <= 0x7e)) printable++; } return printable / sample.length > 0.85; })(); const headerEndMatch = text.match(/\r?\n\r?\n/); const hasMimeHeaderHints = /content-type:|content-transfer-encoding:|mime-version:/i.test( text.slice(0, Math.min(text.length, 8192)), ); if (looksMostlyText && headerEndMatch && headerEndMatch.index !== undefined && hasMimeHeaderHints) { text = text.substring(headerEndMatch.index + headerEndMatch[0].length); } text = text .replace(/-----BEGIN [A-Z0-9 ]+-----/g, '') .replace(/-----END [A-Z0-9 ]+-----/g, '') .replace(/\s/g, ''); if (text.length === 0) return raw; try { const binary = atob(text); const decoded = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer; } catch { /* not DER, continue */ } if (looksMostlyText) { const originalText = new TextDecoder().decode(raw); const sectionRegex = /content-transfer-encoding:\s*base64[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--[^\r\n]+|$)/ig; const sectionBlocks = []; let sectionMatch; while ((sectionMatch = sectionRegex.exec(originalText)) !== null) sectionBlocks.push(sectionMatch[1]); for (const block of sectionBlocks) { const cleaned = block.replace(/\s/g, ''); if (cleaned.length < 8 || !/^[A-Za-z0-9+/=]+$/.test(cleaned)) continue; try { const binary = atob(cleaned); const decoded = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer; } catch { /* next section */ } } const base64Blocks = originalText.match(/[A-Za-z0-9+/=\r\n]{128,}/g) || []; const cleaned = base64Blocks .map((block) => block.replace(/\s/g, '')) .filter((block) => block.length >= 128 && /^[A-Za-z0-9+/=]+$/.test(block)); cleaned.sort((a, b) => b.length - a.length); for (const block of cleaned) { try { const binary = atob(block); const decoded = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer; } catch { /* next block */ } } } return raw; } function parseContentInfo(der) { const asn1 = asn1js.fromBER(der); if (asn1.offset === -1) throw new Error('Invalid ASN.1 data - cannot parse CMS envelope'); try { return new pkijs.ContentInfo({ schema: asn1.result }); } catch { throw new Error('Invalid ASN.1 data - cannot parse CMS envelope'); } } function extractEnvelopedData(contentInfo) { if (contentInfo.contentType !== '1.2.840.113549.1.7.3') { throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`); } return new pkijs.EnvelopedData({ schema: contentInfo.content }); } function findMatchingKeyRecords(envelopedData, keyRecords) { const matches = []; for (let i = 0; i < envelopedData.recipientInfos.length; i++) { const ri = envelopedData.recipientInfos[i]; const ktri = ri instanceof pkijs.KeyTransRecipientInfo ? ri : ri.variant === 1 && ri.value instanceof pkijs.KeyTransRecipientInfo ? ri.value : null; if (ktri) { for (const keyRecord of keyRecords) { if (matchesKeyTransRecipient(ktri, keyRecord)) { matches.push({ keyRecord, recipientIndex: i }); } } } } return matches; } function matchesKeyTransRecipient(recipientInfo, keyRecord) { const rid = recipientInfo.rid; if (rid instanceof pkijs.IssuerAndSerialNumber) { try { const certAsn1 = asn1js.fromBER(keyRecord.certificate); if (certAsn1.offset === -1) return false; const cert = new pkijs.Certificate({ schema: certAsn1.result }); const ridSerial = toHex(rid.serialNumber.valueBlock.valueHexView); const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView); if (ridSerial !== certSerial) return false; const ridIssuerDer = rid.issuer.toSchema().toBER(false); const certIssuerDer = cert.issuer.toSchema().toBER(false); return arraysEqual(new Uint8Array(ridIssuerDer), new Uint8Array(certIssuerDer)); } catch { return false; } } if (rid instanceof asn1js.OctetString) { try { const certAsn1 = asn1js.fromBER(keyRecord.certificate); if (certAsn1.offset === -1) return false; const cert = new pkijs.Certificate({ schema: certAsn1.result }); const skiExt = cert.extensions?.find((ext) => ext.extnID === '2.5.29.14'); if (!skiExt) return false; const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView); if (skiValue.offset === -1) return false; const ski = skiValue.result.valueBlock.valueHexView; return arraysEqual(new Uint8Array(ski), new Uint8Array(rid.valueBlock.valueHexView)); } catch { return false; } } return false; } /** * @param useLiner Only for the legacy RSAES-PKCS1-v1_5 key-transport key, which * is imported through webcrypto-liner and can only be used by that engine. * * VNC: upstream ran EVERY decryption through the liner engine, which is what put * DES-CBC/3DES-CBC/RC2-CBC within reach of live mail — those OIDs are registered * for PKCS#12 password-based encryption, not CMS content encryption. Native * WebCrypto handles RSA-OAEP key transport and AES-CBC/GCM content perfectly * well, so the normal path now uses the native engine and the legacy engine is * reachable only when a legacy key is genuinely in play. Combined with * checkContentEncryption() this removes the weak ciphers structurally, not just * by policy. */ async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord, useLiner) { const certAsn1 = asn1js.fromBER(keyRecord.certificate); const cert = new pkijs.Certificate({ schema: certAsn1.result }); const params = { recipientCertificate: cert, recipientPrivateKey: privateKey }; if (!useLiner) { return envelopedData.decrypt(recipientIndex, params, nativeEngine()); } return withLinerEngine(async () => envelopedData.decrypt(recipientIndex, params, getLinerCryptoEngine())); }