Upstream applied no content-encryption check at all on decrypt, and ran every decryption through the liner engine — which registers DES-CBC, 3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js for PKCS#12 password-based encryption; the CMS content path merely reused the same engine and inherited them. A crafted message could therefore be decrypted under a broken cipher, and unauthenticated plaintext was handed straight to the renderer — the EFAIL precondition. The obvious fix would have been wrong. Accepting only AEAD breaks most real S/MIME mail: RFC 5751 makes AES-128-CBC the MUST-implement content cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS (RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality catastrophe wearing a security fix's clothes. Three layers instead: 1. Allowlist the AES family and refuse everything else, with the gate running before any private key is touched. CBC stays for interop; DES/3DES/RC2 are refused. 2. Take the mail path off the legacy engine. Normal decryption now uses nativeEngine(); the liner engine is reachable only when a genuine legacy RSAES-PKCS1-v1_5 key is in play. This removes the weak ciphers structurally rather than by policy — native WebCrypto handles RSA-OAEP key transport and AES-CBC/GCM content perfectly well. 3. Refuse to render unauthenticated plaintext as HTML. CBC output is malleable and HTML is EFAIL's exfiltration channel. The host does block remote content by default (allowExternalContent starts false), but that is a user/admin setting this plugin cannot observe, so we don't lean on it. New renderUnauthenticatedHtml setting (default false) is the documented opt-out. Our own encrypt path always uses AES-GCM, so mail we send renders fully; only legacy inbound CBC degrades to text. Built from source with the repo's own pipeline (esbuild, 1.69 MB) and packaged to smime-vnc.zip (0.27 MB). All four fixes verified present in the built bundle. Build output is gitignored — never vendor a prebuilt bundle, which was the upstream mistake. Correcting an earlier assumption: this bundle does NOT trip the B-01 pattern scanner (zero matches on all five patterns), so the override is not needed to install it. B-01 remains correct — it closed a real entrypoint-only coverage gap — but it isn't load-bearing here. verify-fixes.mjs now carries 36 assertions covering all three fixes, including source checks that fail if a guard is removed, if a legacy CBC OID reappears in the allowlist, or if the mail path stops using the native engine. Findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6 (PKCS1v1.5 oracle surface) remain open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
13 KiB
JavaScript
344 lines
13 KiB
JavaScript
/**
|
|
* 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()));
|
|
}
|