Files
SRCmail/vnc/plugins/smime
Bernd RodlerandClaude Opus 5 fb40e74713 fix(smime): certificate address binding prefers the deprecated DN attribute
Finding 11, found while writing the EJBCA runbook rather than from a test -
and it is a blocker that fix 1 created.

extractEmailAddresses collected the Subject DN emailAddress attribute
(OID 1.2.840.113549.1.9.1) BEFORE the SAN rfc822Name, and every consumer
reads emailAddresses[0]. Under RFC 5280/8550 the SAN is authoritative and
the DN attribute is legacy, retained only for old clients - so the order
was exactly backwards. Compounding it, signerEmailMatch compared the From
header against position 0 only, never against the other addresses a
certificate legitimately carries.

Two ways a perfectly valid certificate failed:

  1. DN and SAN disagree in any respect - case, domain form, a stale
     value. The DN wins, From never matches.
  2. A multi-alias certificate where the message was sent From the
     SECOND rfc822Name. Only [0] is compared, so it mismatches.

Before fix 1 that was a cosmetic amber "signer != From" banner. After fix
1 it BLOCKS auto-import, so the correspondent's encryption certificate is
never stored and encryption silently never becomes available for them.
I turned a latent wart into a functional blocker in the same audit.

This was not hypothetical for much longer: EJBCA populates both fields by
default once the end-entity profile has an email field, which is exactly
what the CA runbook configures. The internal CA would have shipped
certificates this client mishandles on day one.

Fix:
- collect SAN rfc822Name first, DN emailAddress second, de-duplicated
  case-insensitively, so [0] is the authoritative address
- add certAssertsAddress(), matching against every address the
  certificate asserts rather than only the first
- file the signer certificate under the address the message actually came
  from when the certificate asserts it. That address is the key used for
  encryption lookups later, so storing a usable certificate under a
  different one of its addresses hides it from the code that needs it.

The manual-import paths (index.js:961, pkcs12.js:114) have no From header
to match against and are corrected by the reordering alone.

Verified: new verify-address-binding.mjs, 18 assertions, self-contained -
it generates its own certificates with openssl, including one whose SAN
and DN deliberately disagree, and asserts openssl really emitted both
forms before drawing any conclusion.

Confirmed the bug was real rather than assumed, by running the same suite
against the pre-fix file restored from git with the old [0]-only matching
shimmed back in: emailAddresses[0] resolves to legacy.address@old.example
and all three match assertions fail. Every REFUSAL case still passed both
before and after, so this removes false negatives without loosening the
gate - lookalike domains, substrings and empty addresses are still
refused.

51 + 28 + 18 = 97 assertions passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:57:40 +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.