Files
SRCmail/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md
T
Bernd RodlerandClaude Opus 4.8 f7e487171c 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>
2026-08-04 10:00:11 +02:00

13 KiB
Raw Blame History

S/MIME plugin security audit — bulwarkmail/plugins/smime

Task: S-01 · Date: 2026-08-04 · Auditor: Claude Opus 4.8 (findings independently verified against host source) Subject: github.com/bulwarkmail/plugins @ 91085a3, smime/ — 2,935 lines of source across 13 modules Runs as: privileged (same-origin) tier — full DOM, network, IndexedDB and WebCrypto access; handles users' private keys

Verdict

Do not ship as-is. Fork and fix findings 13 first.

This is not malicious code and shows no sign of a backdoor. The cryptographic primitives are handled competently — PBKDF2-SHA256 at 600k iterations, AES-256-GCM wrapping, every private-key import non-extractable, no network egress anywhere in the bundle. The problems are trust-model and input-validation gaps, all fixable, two of them in a handful of lines.

The single most reassuring property: there is no exfiltration path. Across all 13 modules there is no fetch, XMLHttpRequest, WebSocket, sendBeacon, new Image, .src =, EventSource, dynamic import(), or URL literal of any kind. This matters more than usual because privileged tier is same-origin — the manifest's lack of http:fetch would not have constrained it, so the absence had to be verified in code rather than inferred from permissions.

Remediation status (updated 2026-08-04)

Forked to vnc/plugins/smime/source only; the upstream zip was deliberately not vendored (see Supply chain below).

# Status
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
— · 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

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.

Still not safe for real mail — finding 2 is unfixed. Suitable only for a throwaway sandbox account.

Findings

# Severity Finding Location
1 HIGH Certificate substitution via auto-import of unvalidated self-signed certs index.js:361-383
2 HIGH Unauthenticated CBC ciphers accepted on decrypt (EFAIL precondition) smime-decrypt.js:258-269, crypto-engine.js:114-135
3 MED-HIGH CRLF header injection — escaping covers 2 of ~6 insertion points mime-builder.js:115-139
4 MEDIUM Unlocked key handles persisted to durable IndexedDB, not memory key-storage.js:106-109
5 MEDIUM MIME parser: unbounded recursion + no size caps (DoS) mime-parse.js:47-50, smime-detect.js:88-109
6 MEDIUM RSAES-PKCS1-v1_5 decrypt via JS polyfill (Marvin-class oracle surface) pkcs12.js:196-201, smime-decrypt.js:51-65
7 LOW Signer-cert lookup falls back to a blind heuristic for SKI-addressed CMS smime-verify.js:158-161
8 LOW classifyCapabilities defaults canSign/canEncrypt to true when KU/EKU absent certificate-utils.js:170-191
9 LOW PKCS#12 passphrase via charCodeAt — mangles non-Latin1 passphrases pkcs12.js:18-23

1. Certificate substitution — HIGH

smime-verify.js:44 verifies with checkChain: false — cryptographic signature only, no trust anchor. This is a defensible design choice, and the module correctly computes both selfSigned (line 81) and signerEmailMatch (lines 74-77), returning them for the UI banner.

But maybeAutoImportSigner ignores both:

// index.js:361-364
async function maybeAutoImportSigner(status) {
  if (settings().autoImportSignerCerts === false) return;
  const cert = status && status.signerCert;
  if (!cert || !status.signatureValid || !cert.email) return;

It then calls savePublicCert(...), storing the certificate as the encryption target for the email address the certificate claims. autoImportSignerCerts defaults to true.

certificate-utils.js:134-167 collects the identity email from either the legacy Subject E= attribute or the SAN rfc822Name, treating both as equally authoritative — and for a self-signed cert both are entirely self-asserted.

Attack: generate a self-signed certificate asserting victim@vnc.biz, sign any message with it, send it to the user. On open, the signature verifies (it is internally consistent), the cert is silently stored as the encryption key for victim@vnc.biz. A later user-initiated "Encrypt" to that address encrypts to the attacker's key instead of the recipient's. The user sees an encrypted-send confirmation; the legitimate recipient cannot read it, and anyone holding the attacker's key who obtains the ciphertext can.

Fix (small — the data is already computed): require !selfSigned and signerEmailMatch === true before auto-import; once S-06's trust store exists, require chain validation instead. Ideally require explicit user confirmation before a certificate becomes an encryption target.

2. Unauthenticated CBC on decrypt — HIGH

There is no content-encryption-algorithm allowlist anywhere in the decrypt path (verified by exhaustive grep — no reference to contentEncryptionAlgorithm, GCM, or CBC in smime-decrypt.js). The decrypt call passes attacker-supplied CMS straight to pkijs:

// smime-decrypt.js:262-263
return withLinerEngine(async () => {
  const cryptoEngine = getLinerCryptoEngine();

And that engine deliberately widens the accepted set to legacy unauthenticated ciphers:

// crypto-engine.js:121-123
case OID_DES_EDE3_CBC: return { name: 'DES-EDE3-CBC', length: 192 };
case OID_DES_CBC:      return { name: 'DES-CBC', length: 64 };
case OID_RC2_CBC:      return { name: 'RC2-CBC', length: 128 };

CMS EnvelopedData carries no MAC; only AEAD modes provide integrity. Native AES-CBC is likewise accepted, since nothing inspects the algorithm at all. Decrypted bytes are returned to the renderer with no authenticity gate — the precondition for EFAIL direct-exfiltration and CBC-gadget attacks. End-to-end exploitability additionally depends on the host's HTML sanitiser blocking external resource loads, which is a separate control and should not be the only one.

Fix: allowlist AEAD content encryption (AES-GCM) on decrypt. If legacy CBC must be supported for old archived mail, gate it behind an explicit per-message user opt-in and never render its output as HTML.

3. CRLF header injection — MED-HIGH

encodeHeaderValue (mime-builder.js:141-152) does neutralise CR/LF — but only as a side effect of Q-encoding, and it is applied to just Subject and attachment filename. It is not applied to display names, raw addresses, Message-ID, In-Reply-To, References, or attachment Content-Type.

// mime-builder.js:115-121  — escapes only backslash and quote
function formatAddress(addr) {
  if (addr.name) {
    const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    return `"${escaped}" <${addr.email}>`;
  }
  return addr.email;
}

formatHeader (123-139) performs line folding only — no sanitisation. A display name containing \r\n is emitted verbatim into the header block.

Remote path: In-Reply-To / References / display names are typically copied from an inbound message when replying or forwarding, so the injected value is attacker-supplied. Impact ranges from spoofed Reply-To to added recipients, depending on whether the submission path passes explicit envelopeRecipients (the host's sendRawEmail accepts them; whether the plugin supplies them should be confirmed).

Fix: strip or encode CR/LF in one place — inside formatHeader — so every header value is covered by construction rather than per-call-site.

4. Unlocked keys persisted to disk — MEDIUM

key-storage.js:106-109 writes unlocked, non-extractable CryptoKey handles into the durable smime-plugin-store IndexedDB. Raw key material is never exposed (non-extractable survives structured clone), but the usable handle is on disk and survives tab close and browser restart unless wiped.

The wipe hooks are real — onAfterLogout and onAccountSwitch exist in our host at lib/plugin-hooks.ts:362-363 (an earlier reviewer flagged these as possibly fictional; they are not). Two caveats stand:

  • The lockOnLogout setting (default true) lets a user disable the wipe entirely.
  • If the wipe does not run — crash, killed tab, failed transaction — the handle persists, and anyone with the browser profile can decrypt mail without the passphrase.

This also contradicts the host's own plugin guidance in WRITING_PLUGINS.md §8 ("in memory only"), and it is weaker than the native implementation this replaced, which kept unlocked keys in an in-memory Map. The trade buys cross-iframe sharing between the settings slot and the background hooks.

59

Recursion in parseEntity (mime-parse.js:47-50) and both smime-detect.js walkers (88-109) is depth-unbounded; binaryString (mime-parse.js:28-32) concatenates byte-by-byte with no size cap. ReDoS specifically was checked across every regex and is clean — all linear. Finding 6 is an accepted-risk interop trade needing documentation rather than removal. Findings 79 are correctness/robustness issues.

Verified clean

Area Result
Network egress None across all 13 modules
eval / new Function / dynamic code None
XSS / DOM injection No innerHTML, document.write, or dangerouslySetInnerHTML; cert fields render as React text children (auto-escaped)
Passphrase handling Local scope only; never persisted to storage, api.storage, cookies, or logs
api.storage.* (plaintext localStorage) Non-secret prefs and verification metadata only — no keys, no passphrases
Key wrapping PBKDF2-SHA256 600k → AES-256-GCM; 32-byte salt, 12-byte IV
Key extractability extractable: false at all five importKey sites
Encrypt path Hardcoded AES-GCM + RSA-OAEP/SHA-256 — no downgrade negotiation, no RC2/DES reachable
Polyfill scoping sign/encrypt/verify use nativeEngine() exclusively; the JS polyfill is never used for signing or randomness
Engine restore withLinerEngine uses try/finally — no leaked global polyfill state
Fingerprints SHA-256 (not SHA-1)
Backdoors / hardcoded keys / TODO markers None

Supply-chain findings

Do not ship the marketplace zip. smime.zip in the repo contains a single 1.77 MB bundled index.js — not readable source — and its manifest reads 1.0.1 while the repo source is 1.0.2. The shipped artifact is stale relative to the code, so auditing src/ would not audit what that zip installs.

Build from source ourselves via the repo's own npm run package (esbuild bundle of src/ plus pkijs/asn1js/pvtsutils/webcrypto-liner). That way the artifact corresponds to the audited code and we control it.

Interaction with our own changes

  • B-04 is compatible. The plugin declares email:render-takeover and email:send, which our gate requires for onRenderEmailBody and onComposeSend. It will load correctly.
  • B-09 carries a real hazard here. The plugin registers onAfterLogout and onAccountSwitch but its manifest requests no auth:observe. If B-09 gates auth hooks on that permission, the session-key wipe silently stops running — turning finding 4 from a caveat into a live exposure. This is a concrete instance of the over-gating risk noted in B-09, and it is security-relevant: add the permission to the fork's manifest, or exempt these hooks deliberately.
  • B-01 is exercised by this bundle — 1.77 MB of minified pkijs/webcrypto-liner will trip the pattern scanner, which is exactly the case the override exists for.

Recommendation

  1. Fork smime/ into vnc/plugins/smime/do not vendor the upstream zip.
  2. Fix findings 1, 2, 3 before any user imports a key. Findings 1 and 3 are small, localised changes; finding 2 is an allowlist.
  3. Add auth:observe to the forked manifest so the wipe survives B-09.
  4. Document findings 4 and 6 as accepted risks with rationale, or fix 4 by moving session keys back to memory and accepting the cross-iframe cost.
  5. Re-audit the diff after fixes, then proceed to the self-signed-certificate spike.

Findings 13 are why S-01 was scheduled before A-01. All three would have shipped.