# 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: ```bash 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** (`/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: ```bash 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.