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

```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.
