Finding 5 — the MIME parser runs on attacker-controlled input: the inner content recovered after decrypt/verify is whatever the sender put there. Upstream had no depth limit on nested multiparts and no size cap anywhere. Verified against the unpatched upstream parser with the same input: UPSTREAM CRASHED: RangeError - Maximum call stack size exceeded UPSTREAM: 65MB accepted (no size cap) So this was a live decrypt-time DoS reachable by anyone who can send mail. Caps added: depth 20, parts 500, bytes 64 MB — generous enough that no legitimate message comes close (real mail nests 3-4 levels). Past a limit a subtree degrades to a leaf rather than throwing, so one pathological branch doesn't discard the legitimate parts above it. Oversize input is refused outright rather than truncated: half a MIME tree parses into misleading nonsense, and showing part of a message is worse than saying no. Both bodyStructure walkers in smime-detect.js are capped too — those run on server-supplied structure BEFORE any decrypt/verify gate. Finding 4 — hardened, not eliminated, per the agreed scope. Unlocked CryptoKeys still live in durable IndexedDB rather than memory; moving them would mean refactoring how the plugin shares state across iframes and risking the unlock->decrypt path just verified. What changed instead: - Removed the lockOnLogout opt-out from the logout/account-switch wipes. A non-extractable key cannot be exported but can still be USED, so a handle outliving the session lets anyone with the browser profile decrypt mail without knowing the passphrase. That is not a preference to toggle off. - Added a best-effort wipe on pagehide and beforeunload to narrow the window in which a usable handle exists on disk. Best-effort by nature: an IndexedDB write may not complete during teardown and neither event fires on a crash — which is precisely why the boot wipe in activate() remains the load-bearing control. - Deliberately NOT wiping on visibilitychange: tabbing away would drop the unlock and force a passphrase re-entry every time, which trains users into turning S/MIME off entirely. - Dropped the now-dead lockOnLogout setting from the manifest. A toggle that silently does nothing is worse than no toggle. Tests: 49 unit assertions + 28 round trip. The round trip now feeds genuinely hostile MIME through the real parser (5000-level nesting, 5000 siblings, 65 MB) and still confirms a normal multipart/alternative parses correctly. Full crypto round trip unchanged and passing, so neither fix broke S/MIME. Findings 6, 7, 8 and 9 remain open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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. PerresolvePluginTier, 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.