Files
SRCmail/vnc/plugins/smime
Bernd RodlerandClaude Opus 4.8 bc5d2a57e8 security(smime): fix audit finding 2 — unauthenticated CBC on decrypt
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>
2026-08-04 10:47:43 +02:00
..

S/MIME plugin

End-to-end S/MIME (CMS / PKCS#7) for Bulwark Webmail, implemented as a privileged (same-origin) plugin. All cryptography runs locally in the browser using a bundled pkijs / asn1js / webcrypto-liner stack, with no key material ever leaves the device.

What it does

Capability How
Sign outgoing mail onComposeSend builds the MIME, wraps it in opaque CMS SignedData, and submits via api.jmap.sendRaw.
Encrypt outgoing mail onComposeSend builds CMS EnvelopedData to every recipient (AES-256-GCM by default; AES-128 optional) plus the sender, then submits raw. Sign + Encrypt does proper sign-then-encrypt.
Verify incoming signatures onRenderEmailBody fetches the CMS blob (api.jmap.fetchBlob), validates the signature cryptographically, checks validity dates, flags self-signed signers and signer≠From mismatches, and renders the inner body.
Decrypt incoming mail onRenderEmailBody decrypts EnvelopedData with your unlocked key (RSA-OAEP, with an RSAES-PKCS1-v1_5 + 3DES/RC2 legacy fallback for old Outlook/Thunderbird mail).
Key management settings-section slot: import PKCS#12 (.p12/.pfx), unlock/lock, delete, import recipient certificates, set sign/encrypt defaults.
Status email-banner slot shows signature / encryption state; composer-toolbar slot has per-message Sign / Encrypt toggles.

Security model

  • Privileged tier. Declares tier: "privileged" + crypto:full. Per resolvePluginTier, the same-origin tier is only granted to a signed, admin-approved (managed) bundle after high-risk consent. A self-uploaded copy is refused rather than downgraded; sign and ship it through the admin channel.
  • Keys at rest. Private keys are imported from PKCS#12 and re-wrapped with AES-256-GCM under a PBKDF2(SHA-256, 600 000) key derived from a passphrase you choose. Stored in IndexedDB; the raw key bytes are never persisted.
  • Keys in use. Unlocking imports the key as a non-extractable CryptoKey. Because the background (hooks) iframe and the visible slot iframes are same-origin, the unlocked handle is shared through a session IndexedDB store. It stays non-extractable and is wiped on app boot and on logout / account switch (configurable), mirroring the former native "in-memory, cleared on reload" behaviour.
  • Returned HTML still passes through the host sanitizer.

Build

cd repos/plugins/smime
npm install          # pulls pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild
npm run build        # → dist/index.js  (~1.7 MB, under the privileged cap)
npm run package      # → smime.zip (manifest.json + index.js) for admin upload

The build aliases the Node crypto builtin (referenced by a dead typeof process branch in asmcrypto.js) to a browser shim so the bundle is self-contained.

Layout

src/
  index.js            entry: activate + hooks + slots (React.createElement UI)
  crypto-engine.js    pkijs CryptoEngine w/ 3DES/RC2 + legacy PKCS#12 PBE
  certificate-utils.js X.509 parse + metadata + capability classification
  mime-builder.js     deterministic CRLF MIME builder + CMS RFC822 wrapper
  mime-parse.js       inner-MIME parser for decrypted/verified content
  smime-detect.js     detect CMS from Content-Type / bodyStructure / attachments
  smime-sign.js       CMS SignedData (opaque)
  smime-encrypt.js    CMS EnvelopedData
  smime-decrypt.js    CMS decrypt + blob normalisation + recipient matching
  smime-verify.js     CMS signature verification + signer status
  pkcs12.js           PKCS#12 import + key wrap/unlock
  key-storage.js      IndexedDB: key records, recipient certs, session keys
  util.js             uuid / hex / equality helpers
  node-crypto-shim.js browser shim for the Node "crypto" builtin

The crypto modules are faithful ports of the host's lib/smime/* (the former native pipeline), so the plugin produces byte-compatible CMS.

Note on host wiring

The onComposeSend and onRenderEmailBody hook buses and the privileged api.jmap surface exist in the host (see lib/plugin-hooks.ts, lib/plugin-sandbox/host-api.ts). The send/render takeover fires once the host emits those buses from the composer and viewer (the migration that retires the inline native path). The settings-section, composer-toolbar, and email-banner slots are active today.