From bc5d2a57e8bfa47fa68767da1ead4a78defe1ebe Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 10:47:43 +0200 Subject: [PATCH] =?UTF-8?q?security(smime):=20fix=20audit=20finding=202=20?= =?UTF-8?q?=E2=80=94=20unauthenticated=20CBC=20on=20decrypt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + vnc/VNC-CHANGES.md | 3 + vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md | 24 ++++- vnc/plugins/smime/manifest.json | 6 ++ vnc/plugins/smime/package-lock.json | 4 +- vnc/plugins/smime/src/index.js | 34 ++++++- vnc/plugins/smime/src/smime-decrypt.js | 105 +++++++++++++++++--- vnc/plugins/smime/verify-fixes.mjs | 41 ++++++++ 8 files changed, 197 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index f2c59e24..18966410 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ next-env.d.ts # k8s deploy secret (create from deploy/k8s/secret.example.yaml) /deploy/k8s/secret.yaml + +# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build) +vnc/plugins/smime/node_modules/ +vnc/plugins/smime/dist/ +vnc/plugins/smime/smime-vnc.zip diff --git a/vnc/VNC-CHANGES.md b/vnc/VNC-CHANGES.md index a00a6875..adae1df0 100644 --- a/vnc/VNC-CHANGES.md +++ b/vnc/VNC-CHANGES.md @@ -54,6 +54,9 @@ microfrontends integration was also added and reverted the same day._ | 2026-08-04 | `vnc/plugins/smime/` (new) | fork of the upstream S/MIME plugin, **source only — upstream `smime.zip` deliberately NOT vendored** | shipped zip is a 1.77 MB bundle at manifest 1.0.1 while source is 1.0.2, so auditing `src/` would not audit what the zip installs. We build from source via `npm run package`. | | 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 1 (HIGH)** — `maybeAutoImportSigner` now requires `signerEmailMatch === true` and `!selfSigned` before trusting a signer cert | upstream gated on `signatureValid` alone, but `smimeVerify` runs `checkChain:false`, so a self-signed cert asserting any address was silently stored as the ENCRYPTION TARGET for it. Both values were already computed and ignored. | | 2026-08-04 | `vnc/plugins/smime/src/mime-builder.js` | **audit fix 3 (MED-HIGH)** — `stripCrlf()` applied inside `formatHeader` + the 3 directly-assembled headers (`att.contentType`, `att.cid`, `smimeType`) | CRLF escaping reached only Subject and filename; display names, Message-ID, In-Reply-To and References were raw — and those are copied from inbound mail on reply/forward, making it remotely reachable header injection | +| 2026-08-04 | `vnc/plugins/smime/src/smime-decrypt.js` | **audit fix 2 (HIGH)** — content-encryption allowlist (AES-CBC + AES-GCM only, gate runs before any key use); normal decrypt moved to `nativeEngine()` so the liner engine is reachable only for a genuine legacy RSAES-PKCS1-v1_5 key; return `contentAuthenticated` | upstream applied NO algorithm check and ran every decrypt through the liner engine, which registers DES-CBC/3DES-CBC/RC2-CBC for PKCS#12 password encryption — the CMS path inherited them. AEAD-only would break interop (RFC 5751 mandates AES-128-CBC), so CBC stays and the weak ciphers go. | +| 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 2 (cont.)** — suppress HTML when content is unauthenticated (CBC), text-only, behind new `renderUnauthenticatedHtml` setting (default false) | CMS EnvelopedData has no MAC, so CBC plaintext is malleable and HTML rendering is EFAIL's exfiltration channel. The host blocks remote content by default but that's a setting the plugin can't observe — don't lean on it. Our own encrypt path is always AES-GCM, so outbound mail renders fully. | +| 2026-08-04 | `.gitignore` | ignore `vnc/plugins/smime/{node_modules,dist,smime-vnc.zip}` | build output is reproducible from source; never vendor a prebuilt bundle (that was the upstream mistake) | | 2026-08-04 | `vnc/plugins/smime/manifest.json` | add `auth:observe` | plugin registers `onAfterLogout`/`onAccountSwitch` (real hooks, `lib/plugin-hooks.ts:362-363`) without declaring the permission; under `B-09` the session-key wipe would silently stop running | | 2026-08-04 | `vnc/plugins/smime/verify-fixes.mjs` (new) | 19 regression assertions for both fixes, incl. source checks that fail if a guard is removed | the source assertion caught an interpolated header manual review had wrongly dismissed as static | | 2026-08-04 | `app/(main)/admin/_tabs/plugins.tsx` | **B-01 (UI)** — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead `data.warnings` read (never returned by the route) with the live `findings` field. | diff --git a/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md index a57353fc..1488b11d 100644 --- a/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md +++ b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md @@ -20,13 +20,29 @@ Forked to `vnc/plugins/smime/` — **source only; the upstream zip was deliberat |---|---| | 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` | | 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly | +| 2 · Unauthenticated CBC on decrypt | ✅ **Fixed** — content-encryption allowlist + native engine on the mail path + HTML suppressed for unauthenticated plaintext | | — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` | -| 2 · Unauthenticated CBC on decrypt | ⛔ **Open — gate before real mail.** Still accepts unauthenticated CBC | -| 4, 5, 6, 7, 8, 9 | ⛔ Open | +| 4, 5, 6, 7, 8, 9 | ⛔ Open — see the findings table | -Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — 19 assertions, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, and source assertions that fail if either guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught `smime-type=${input.smimeType}` (`mime-builder.js:216`), which manual review had dismissed as a static string. +Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — **36 assertions**, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, the algorithm allowlist and HTML-suppression decision for finding 2, plus source assertions that fail if any guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught the interpolated `smime-type` Content-Type header (`mime-builder.js:216`), which manual review had wrongly dismissed as a static string. -**Still not safe for real mail** — finding 2 is unfixed. Suitable only for a throwaway sandbox account. +### How finding 2 was fixed, and why not the obvious way + +The tempting fix — accept only AEAD — would have broken 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, refuse everything else** (`smime-decrypt.js`). CBC stays for interop; DES-CBC (56-bit), 3DES-CBC and RC2-CBC are refused. The gate runs *before any private key is touched*. +2. **Take the mail path off the legacy engine.** Those weak OIDs are registered in `crypto-engine.js` for PKCS#12 *password-based* encryption; the CMS content path merely reused the same engine and inherited them. Normal decryption now uses `nativeEngine()`, and the liner engine is reachable only when a legacy RSAES-PKCS1-v1_5 key is genuinely in play. This removes the weak ciphers **structurally**, not just by policy. +3. **Refuse to render unauthenticated plaintext as HTML.** CBC output is malleable, and HTML rendering is EFAIL's exfiltration channel. The host does block remote content by default (`allowExternalContent` starts `false`, `email-viewer.tsx:780`) — but that is a user/admin setting the plugin cannot observe, so we don't lean on it. `renderUnauthenticatedHtml` (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 mail degrades to text. + +### Build provenance + +Built from the forked source with the repo's own pipeline (`npm run build` → esbuild → `dist/index.js`, 1.69 MB) and packaged to `smime-vnc.zip` (0.27 MB, well under the 5 MB ceiling). All four fixes verified present in the built bundle. + +Worth noting against an earlier assumption: **this bundle does not trip the `B-01` pattern scanner** — zero matches on all five patterns. `B-01` remains correct (it closed a real entrypoint-only coverage gap, and openpgp.js for the PGP plugin may yet need the override) but it is not required to install this plugin. + +**Remaining risk:** findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6 (PKCS1v1.5 oracle surface) are open. Suitable for sandbox use; findings 4 and 5 should be closed before real mailboxes. ## Findings diff --git a/vnc/plugins/smime/manifest.json b/vnc/plugins/smime/manifest.json index 491c3bfb..704d5462 100644 --- a/vnc/plugins/smime/manifest.json +++ b/vnc/plugins/smime/manifest.json @@ -44,6 +44,12 @@ "description": "Wipe all unlocked private keys from memory when you sign out or switch accounts. Leave on unless you have a specific reason not to.", "default": true }, + "renderUnauthenticatedHtml": { + "type": "boolean", + "label": "Render HTML in legacy-encrypted mail", + "description": "Messages encrypted with AES-CBC carry no integrity protection, so their contents can be tampered with in transit. By default such mail is shown as plain text, which prevents a known attack that can leak the decrypted message. Turn this on only if you need HTML rendering for older encrypted mail and accept that risk. Mail encrypted with AES-GCM is unaffected and always renders fully.", + "default": false + }, "warnOnSelfSigned": { "type": "boolean", "label": "Warn on self-signed signer", diff --git a/vnc/plugins/smime/package-lock.json b/vnc/plugins/smime/package-lock.json index a08d283b..e1000cf7 100644 --- a/vnc/plugins/smime/package-lock.json +++ b/vnc/plugins/smime/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "asn1js": "^3.0.10", "pkijs": "^3.4.0", diff --git a/vnc/plugins/smime/src/index.js b/vnc/plugins/smime/src/index.js index 66c97f3f..5f782e2a 100644 --- a/vnc/plugins/smime/src/index.js +++ b/vnc/plugins/smime/src/index.js @@ -495,7 +495,15 @@ async function onRenderEmailBody(body, ctx) { // MIME entity (RFC 8551 sign-then-encrypt, the Outlook/Thunderbird form) // or, more rarely, raw CMS DER. Detect both. let innerBytes = result.mimeBytes; - const verification = { isEncrypted: true, decryptionSuccess: true }; + const verification = { + isEncrypted: true, + decryptionSuccess: true, + // VNC (audit finding 2): CMS EnvelopedData carries no MAC, so only AEAD + // content encryption yields authenticated plaintext. Surface it so the + // banner can say so rather than implying all decrypted mail is equal. + contentAuthenticated: result.contentAuthenticated, + contentAlgorithm: result.contentAlgorithm, + }; const innerCt = innerContentType(innerBytes); const innerDet = detectSmime(innerCt, null, null); const looksSigned = innerDet.type === 'signed-data' || innerBytes[0] === 0x30; @@ -511,13 +519,33 @@ async function onRenderEmailBody(body, ctx) { const parsed = parseMime(innerBytes); await persistVerifyStatus(ctx.id, verification); + + // VNC (audit finding 2): unauthenticated (CBC) plaintext is malleable, so + // rendering it as HTML is the EFAIL exfiltration channel — an attacker who + // holds the ciphertext can splice in a gadget that leaks the plaintext via + // an external resource load. The host does block remote content by default + // (`allowExternalContent` starts false), but that is a user/admin setting + // this plugin cannot see, so we do not lean on it. + // + // Suppressing HTML and rendering text only closes the channel regardless of + // host configuration. Our own encrypt path always uses AES-GCM, so mail we + // send renders fully; this only degrades legacy inbound CBC mail, and the + // banner explains why. `renderUnauthenticatedHtml` is the documented opt-out. + const allowUnauthHtml = settings().renderUnauthenticatedHtml === true; + const suppressHtml = !result.contentAuthenticated && !allowUnauthHtml; + if (suppressHtml && parsed.html) { + host.log.warn( + `rendering text-only: ${result.contentAlgorithm} is not authenticated (EFAIL mitigation)`, + ); + } + return { ...body, handledBy: 'smime', - html: parsed.html || '', + html: suppressHtml ? '' : (parsed.html || ''), text: parsed.text || '', attachments: parsed.attachments, - verification, + verification: { ...verification, htmlSuppressed: suppressHtml }, }; } diff --git a/vnc/plugins/smime/src/smime-decrypt.js b/vnc/plugins/smime/src/smime-decrypt.js index 2dad22df..23af83f5 100644 --- a/vnc/plugins/smime/src/smime-decrypt.js +++ b/vnc/plugins/smime/src/smime-decrypt.js @@ -6,9 +6,58 @@ import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; -import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine.js'; +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); @@ -28,19 +77,32 @@ export async function smimeDecrypt(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); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); + return result(decrypted, keyRecord); } catch { continue; } @@ -49,14 +111,14 @@ export async function smimeDecrypt(input) { } try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + 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); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); + return result(decrypted, keyRecord); } catch { /* try next record */ } @@ -255,16 +317,27 @@ function matchesKeyTransRecipient(recipientInfo, keyRecord) { return false; } -async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord) { +/** + * @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 }; - return withLinerEngine(async () => { - const cryptoEngine = getLinerCryptoEngine(); - return envelopedData.decrypt( - recipientIndex, - { recipientCertificate: cert, recipientPrivateKey: privateKey }, - cryptoEngine, - ); - }); + if (!useLiner) { + return envelopedData.decrypt(recipientIndex, params, nativeEngine()); + } + + return withLinerEngine(async () => envelopedData.decrypt(recipientIndex, params, getLinerCryptoEngine())); } diff --git a/vnc/plugins/smime/verify-fixes.mjs b/vnc/plugins/smime/verify-fixes.mjs index 7fe86fb2..c8258627 100644 --- a/vnc/plugins/smime/verify-fixes.mjs +++ b/vnc/plugins/smime/verify-fixes.mjs @@ -54,6 +54,34 @@ check('multiple injected headers', check('clean value untouched', stripCrlf('Normal Subject'), 'Normal Subject'); check('non-ASCII untouched', stripCrlf('Grüße büro'), 'Grüße büro'); +// ── Finding 2: content-encryption allowlist ───────────────────────── +const ALLOW = 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 }], +]); +const accepts = (oid) => ALLOW.has(oid); +const authed = (oid) => ALLOW.get(oid)?.authenticated ?? null; + +console.log('\nFinding 2 — content-encryption allowlist'); +check('AES-256-GCM accepted', accepts('2.16.840.1.101.3.4.1.46'), true); +check('AES-256-GCM is authenticated', authed('2.16.840.1.101.3.4.1.46'), true); +check('AES-128-CBC accepted (RFC 5751 interop)', accepts('2.16.840.1.101.3.4.1.2'), true); +check('AES-128-CBC NOT authenticated', authed('2.16.840.1.101.3.4.1.2'), false); +check('3DES-CBC REFUSED', accepts('1.2.840.113549.3.7'), false); +check('DES-CBC REFUSED', accepts('1.3.14.3.2.7'), false); +check('RC2-CBC REFUSED', accepts('1.2.840.113549.3.2'), false); +check('unknown OID REFUSED', accepts('1.2.3.4.5'), false); + +// HTML suppression decision (EFAIL mitigation) +const suppress = (contentAuthenticated, optOut = false) => !contentAuthenticated && !optOut; +check('GCM -> HTML rendered', suppress(true), false); +check('CBC -> HTML suppressed by default', suppress(false), true); +check('CBC + explicit opt-out -> HTML rendered', suppress(false, true), false); + // ── Source assertions: guard against silent regression ────────────── console.log('\nSource assertions'); const idx = readFileSync(join(here, 'src/index.js'), 'utf8'); @@ -69,5 +97,18 @@ const bypass = [...mb.matchAll(/lines\.push\(`([A-Za-z-]+): ([^`]*)`\)/g)] check('no unsanitised interpolated headers remain', bypass.length, 0); if (bypass.length) bypass.forEach(([m]) => console.log(' >>', m)); +const dec = readFileSync(join(here, 'src/smime-decrypt.js'), 'utf8'); +check('allowlist gate runs before any key use', + dec.indexOf('checkContentEncryption(envelopedData)') < dec.indexOf('unlockedKeys.get'), true); +check('decrypt refuses non-allowlisted algorithms', /Refusing to decrypt/.test(dec), true); +check('no legacy CBC OID appears in the decrypt allowlist', + /1\.2\.840\.113549\.3\.7|1\.3\.14\.3\.2\.7|1\.2\.840\.113549\.3\.2/.test(dec), false); +check('normal decrypt path uses the NATIVE engine', + /if \(!useLiner\)[\s\S]{0,120}nativeEngine\(\)/.test(dec), true); +check('liner engine reachable only via useLiner', + (dec.match(/getLinerCryptoEngine\(\)/g) || []).length, 1); +check('index.js suppresses HTML for unauthenticated content', + idx.includes('suppressHtml') && idx.includes('result.contentAuthenticated'), true); + console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`); process.exit(fail === 0 ? 0 : 1);