Files
SRCmail/vnc/plugins/smime
Bernd Rodler 295170a842 feat(smime): client-side certificate enrolment — web S/MIME now fully functional
New enroll.js: generates an RSA-2048 keypair with WebCrypto (extractable
only long enough to export to PKCS#8), builds and signs a real CSR with
pkijs (same per-call-engine convention as smime-sign.js/smime-verify.js —
nativeEngine() passed explicitly, no global pkijs.setEngine call), POSTs
it to the already-existing /api/smime/enroll (same-origin fetch — the
plugin's privileged tier gets allow-same-origin, cookies included by
default), and packages the result into a key record using the EXACT same
encrypted-at-rest convention as a PKCS#12 import (AES-GCM/PBKDF2 600k,
exported from pkcs12.js) so every downstream sign/encrypt/decrypt/verify
path is identical regardless of how the key arrived.

New "Get a certificate" button in the settings-section UI, next to
"Import key" — prompts for a storage passphrase, calls enroll(), saves
the key record, and refreshes the list. No changes needed to the CA route
or the CA provider — both were already real and already tested.

Live end-to-end verified (not just unit-level): logged in via the real
dev-mode session flow, clicked through the actual plugin UI, got back a
real certificate (RSA-2048, correct validity window, real fingerprint) for
dev@localhost, then unlocked it with the same passphrase — the encrypted
private key round-trips correctly through the identical code path a
PKCS#12 import would use.

Also fixes a real bug hit during that verification: SESSION_SECRET must be
>= 32 chars (lib/auth/crypto.ts), but .env.dev.example's own documented
placeholder was 29 - failing "Failed to store Stalwart auth context" on
every feature needing the real session-cookie flow (this enrolment route,
offline sync, AI server class). Anyone following the setup doc verbatim
would have hit this. Padded the placeholder to 37 chars.
2026-08-06 09:06:20 +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 & installation

Nothing manual is required in a normal build. From the repo root:

npm run build:plugins    # → vnc/plugins/smime/dist/index.js
                         #   + staged at vnc/plugins/build/smime/{manifest.json,index.js}

npm run dev, npm run build and npm run build:standalone all run it first, and the Dockerfile runs node scripts/build-plugins.mjs in the builder stage. The staged directory is copied into the container image (Dockerfile) and into .next/standalone (scripts/assemble-standalone.mjs), which is what the Electron package ships.

At server startup lib/admin/bundled-plugins.ts installs the staged bundle into the server plugin registry (<ADMIN_CONFIG_DIR>/plugins/) — the same place an operator-uploaded ZIP lands. That matters for the security model below: the registry is the signed admin channel, so /api/admin/plugins/smime/bundle Ed25519-signs the bytes on the way out and /api/plugins marks the plugin managed, which is what resolvePluginTier needs before it will grant the privileged tier. No gate is bypassed to get there.

Installation is idempotent (a boot where the version + bundle hash are unchanged writes nothing) and gated on the smimeEnabled feature policy, which is therefore the operator's on/off switch for S/MIME:

  • smimeEnabled: true (the default) → installed, forceEnabled, served.
  • smimeEnabled: false → the registry entry is disabled, /api/plugins stops serving it, and clients clean it up on their next sync.

Force-enabling is deliberate: pluginsEnabled defaults to false, which hides the user-facing Settings ▸ Plugins tab, so a user would otherwise have no way to switch the plugin on. To remove the plugin, turn the policy toggle off — deleting it in the admin plugin list is undone by the next restart.

For manual/one-off distribution the plugin also still packages as a ZIP:

cd vnc/plugins/smime
npm ci               # pkijs / asn1js / pvtsutils / webcrypto-liner + esbuild
npm run build        # → dist/index.js  (~1.7 MB, under the 5 MB plugin 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.

Known import limitation

PKCS#12 files whose bags are encrypted with the old pbeWithSHA1And40BitRC2-CBC / pbeWithSHA1And128BitRC2-CBC PBEs fail to import with a bare Unrecognized name error. crypto-engine.js maps those OIDs to an RC2-CBC WebCrypto algorithm that neither the browser nor webcrypto-liner actually provides, so the declared support is not real. This is the default openssl pkcs12 -export certificate PBE on LibreSSL (i.e. macOS's system openssl). 3DES and PBES2/AES bags — what current OpenSSL, Windows and Thunderbird produce — import fine. Re-export with -certpbe aes-256-cbc -keypbe aes-256-cbc (or -certpbe PBE-SHA1-3DES) as a workaround; a real fix needs either an RC2 implementation or an explicit, actionable error.

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 former lib/smime/* native pipeline (since removed), so the plugin produces byte-compatible CMS. With that directory gone, this plugin is the S/MIME feature — which is why the smimeEnabled policy gate now controls it.

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.