security(smime): fork upstream plugin and fix two audit findings
S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine findings, two HIGH. No backdoor and no exfiltration path anywhere in the bundle — the problems are trust-model and input-validation gaps. Full report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md. Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing src/ would not audit what that zip installs. We build from source. Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated on signatureValid alone, but smimeVerify runs checkChain:false, so that only proves "signed by whoever holds this key", not that the claimed identity is real. Self-sign a cert asserting victim@example.com, send one signed message, and it was stored as the encryption target for that address — the user's next Encrypt to the victim went to the attacker. Now requires signerEmailMatch === true and !selfSigned. Both values were already computed and displayed as untrusted in the banner; only the import path ignored them. Tests for `true` explicitly so an undefined match (missing From header) fails closed. Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only Subject and attachment filename; display names, raw addresses, Message-ID, In-Reply-To, References and attachment Content-Type were emitted verbatim, and formatAddress escapes only backslash and quote. In-Reply-To/References/display names are copied from inbound mail when replying or forwarding, so the value is attacker-supplied. Sanitising inside formatHeader covers all 17 call sites by construction; the three headers assembled directly get stripCrlf explicitly. Also adds auth:observe to the manifest. The plugin registers onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363) — without declaring the permission, so under B-09 the session-key wipe would silently stop running. verify-fixes.mjs carries 19 assertions including source checks that fail if either guard is removed or a new unsanitised interpolated header appears. That last one immediately caught the interpolated smime-type Content-Type header, which manual review had dismissed as static. Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This is not safe for real mail yet — sandbox accounts only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e9746fcf78
commit
f7e487171c
@@ -0,0 +1,79 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user