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.
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 & 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/pluginsstops 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.