diff --git a/vnc/VNC-CHANGES.md b/vnc/VNC-CHANGES.md
index fc4ab734..a00a6875 100644
--- a/vnc/VNC-CHANGES.md
+++ b/vnc/VNC-CHANGES.md
@@ -50,6 +50,12 @@ microfrontends integration was also added and reverted the same day._
| 2026-08-04 | `lib/plugin-sandbox/loader.ts` | **B-04 security fix** — gate hook registration on granted permissions via new `HOOK_PERMISSIONS` map; refused hooks are skipped, logged and counted | `info.hooks` is self-reported by the sandbox, so an untrusted plugin could claim `onRenderEmailBody` and replace any rendered email body without holding `email:render-takeover`. Consent copy gated what the user was *asked*, not what the host *allowed*. |
| 2026-08-04 | `lib/plugin-sandbox/host-api.ts` | export `hasPermission()` (was module-private) | one source of truth for the permission rule — the loader gate and the RPC gate must not drift apart |
| 2026-08-04 | `app/api/admin/plugins/route.ts` | **B-01** — scan all `.js`/`.mjs` in the bundle (was entrypoint only); return structured `findings` + `canOverride`; allow admin `overrideWarnings=true` with a `plugin.install.scan_override` audit entry; echo accepted `findings` on success | hard-reject on `eval(`/`new Function(`/`innerHTML =` made every crypto plugin uninstallable (minified openpgp.js/pkijs trip it), while only scanning the entrypoint left a trivial bypass. Route is already admin-authenticated, so the scan is defence-in-depth, not a trust boundary. |
+| 2026-08-04 | `vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md` (new) | **S-01** security audit of `bulwarkmail/plugins/smime` @ `91085a3` — 9 findings (2 HIGH, 1 MED-HIGH), verdict: fork and fix before shipping | privileged same-origin plugin that handles users' private keys; verdict must precede any deploy |
+| 2026-08-04 | `vnc/plugins/smime/` (new) | fork of the upstream S/MIME plugin, **source only — upstream `smime.zip` deliberately NOT vendored** | shipped zip is a 1.77 MB bundle at manifest 1.0.1 while source is 1.0.2, so auditing `src/` would not audit what the zip installs. We build from source via `npm run package`. |
+| 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 1 (HIGH)** — `maybeAutoImportSigner` now requires `signerEmailMatch === true` and `!selfSigned` before trusting a signer cert | upstream gated on `signatureValid` alone, but `smimeVerify` runs `checkChain:false`, so a self-signed cert asserting any address was silently stored as the ENCRYPTION TARGET for it. Both values were already computed and ignored. |
+| 2026-08-04 | `vnc/plugins/smime/src/mime-builder.js` | **audit fix 3 (MED-HIGH)** — `stripCrlf()` applied inside `formatHeader` + the 3 directly-assembled headers (`att.contentType`, `att.cid`, `smimeType`) | CRLF escaping reached only Subject and filename; display names, Message-ID, In-Reply-To and References were raw — and those are copied from inbound mail on reply/forward, making it remotely reachable header injection |
+| 2026-08-04 | `vnc/plugins/smime/manifest.json` | add `auth:observe` | plugin registers `onAfterLogout`/`onAccountSwitch` (real hooks, `lib/plugin-hooks.ts:362-363`) without declaring the permission; under `B-09` the session-key wipe would silently stop running |
+| 2026-08-04 | `vnc/plugins/smime/verify-fixes.mjs` (new) | 19 regression assertions for both fixes, incl. source checks that fail if a guard is removed | the source assertion caught an interpolated header manual review had wrongly dismissed as static |
| 2026-08-04 | `app/(main)/admin/_tabs/plugins.tsx` | **B-01 (UI)** — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead `data.warnings` read (never returned by the route) with the live `findings` field. |
_(append new rows as you diverge)_
diff --git a/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md
new file mode 100644
index 00000000..a57353fc
--- /dev/null
+++ b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md
@@ -0,0 +1,163 @@
+# S/MIME plugin security audit — `bulwarkmail/plugins/smime`
+
+**Task:** `S-01` · **Date:** 2026-08-04 · **Auditor:** Claude Opus 4.8 (findings independently verified against host source)
+**Subject:** `github.com/bulwarkmail/plugins` @ `91085a3`, `smime/` — 2,935 lines of source across 13 modules
+**Runs as:** privileged (same-origin) tier — full DOM, network, IndexedDB and WebCrypto access; handles users' private keys
+
+## Verdict
+
+**Do not ship as-is. Fork and fix findings 1–3 first.**
+
+This is not malicious code and shows no sign of a backdoor. The cryptographic primitives are handled competently — PBKDF2-SHA256 at 600k iterations, AES-256-GCM wrapping, every private-key import non-extractable, no network egress anywhere in the bundle. The problems are **trust-model and input-validation gaps**, all fixable, two of them in a handful of lines.
+
+The single most reassuring property: **there is no exfiltration path.** Across all 13 modules there is no `fetch`, `XMLHttpRequest`, `WebSocket`, `sendBeacon`, `new Image`, `.src =`, `EventSource`, dynamic `import()`, or URL literal of any kind. This matters more than usual because privileged tier is same-origin — the manifest's lack of `http:fetch` would *not* have constrained it, so the absence had to be verified in code rather than inferred from permissions.
+
+## Remediation status (updated 2026-08-04)
+
+Forked to `vnc/plugins/smime/` — **source only; the upstream zip was deliberately not vendored** (see Supply chain below).
+
+| # | Status |
+|---|---|
+| 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` |
+| 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly |
+| — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` |
+| 2 · Unauthenticated CBC on decrypt | ⛔ **Open — gate before real mail.** Still accepts unauthenticated CBC |
+| 4, 5, 6, 7, 8, 9 | ⛔ Open |
+
+Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — 19 assertions, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, and source assertions that fail if either guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught `smime-type=${input.smimeType}` (`mime-builder.js:216`), which manual review had dismissed as a static string.
+
+**Still not safe for real mail** — finding 2 is unfixed. Suitable only for a throwaway sandbox account.
+
+## Findings
+
+| # | Severity | Finding | Location |
+|---|---|---|---|
+| 1 | **HIGH** | Certificate substitution via auto-import of unvalidated self-signed certs | `index.js:361-383` |
+| 2 | **HIGH** | Unauthenticated CBC ciphers accepted on decrypt (EFAIL precondition) | `smime-decrypt.js:258-269`, `crypto-engine.js:114-135` |
+| 3 | **MED-HIGH** | CRLF header injection — escaping covers 2 of ~6 insertion points | `mime-builder.js:115-139` |
+| 4 | **MEDIUM** | Unlocked key handles persisted to durable IndexedDB, not memory | `key-storage.js:106-109` |
+| 5 | **MEDIUM** | MIME parser: unbounded recursion + no size caps (DoS) | `mime-parse.js:47-50`, `smime-detect.js:88-109` |
+| 6 | **MEDIUM** | `RSAES-PKCS1-v1_5` decrypt via JS polyfill (Marvin-class oracle surface) | `pkcs12.js:196-201`, `smime-decrypt.js:51-65` |
+| 7 | LOW | Signer-cert lookup falls back to a blind heuristic for SKI-addressed CMS | `smime-verify.js:158-161` |
+| 8 | LOW | `classifyCapabilities` defaults `canSign`/`canEncrypt` to true when KU/EKU absent | `certificate-utils.js:170-191` |
+| 9 | LOW | PKCS#12 passphrase via `charCodeAt` — mangles non-Latin1 passphrases | `pkcs12.js:18-23` |
+
+### 1. Certificate substitution — HIGH
+
+`smime-verify.js:44` verifies with **`checkChain: false`** — cryptographic signature only, no trust anchor. This is a defensible design choice, and the module correctly computes both `selfSigned` (line 81) and `signerEmailMatch` (lines 74-77), returning them for the UI banner.
+
+**But `maybeAutoImportSigner` ignores both:**
+
+```js
+// index.js:361-364
+async function maybeAutoImportSigner(status) {
+ if (settings().autoImportSignerCerts === false) return;
+ const cert = status && status.signerCert;
+ if (!cert || !status.signatureValid || !cert.email) return;
+```
+
+It then calls `savePublicCert(...)`, storing the certificate as the **encryption target** for the email address the certificate claims. `autoImportSignerCerts` defaults to **true**.
+
+`certificate-utils.js:134-167` collects the identity email from either the legacy Subject `E=` attribute or the SAN `rfc822Name`, treating both as equally authoritative — and for a self-signed cert both are entirely self-asserted.
+
+**Attack:** generate a self-signed certificate asserting `victim@vnc.biz`, sign any message with it, send it to the user. On open, the signature verifies (it is internally consistent), the cert is silently stored as the encryption key for `victim@vnc.biz`. A later user-initiated "Encrypt" to that address encrypts to the attacker's key instead of the recipient's. The user sees an encrypted-send confirmation; the legitimate recipient cannot read it, and anyone holding the attacker's key who obtains the ciphertext can.
+
+**Fix (small — the data is already computed):** require `!selfSigned` **and** `signerEmailMatch === true` before auto-import; once `S-06`'s trust store exists, require chain validation instead. Ideally require explicit user confirmation before a certificate becomes an encryption target.
+
+### 2. Unauthenticated CBC on decrypt — HIGH
+
+**There is no content-encryption-algorithm allowlist anywhere in the decrypt path** (verified by exhaustive grep — no reference to `contentEncryptionAlgorithm`, `GCM`, or `CBC` in `smime-decrypt.js`). The decrypt call passes attacker-supplied CMS straight to pkijs:
+
+```js
+// smime-decrypt.js:262-263
+return withLinerEngine(async () => {
+ const cryptoEngine = getLinerCryptoEngine();
+```
+
+And that engine deliberately widens the accepted set to legacy unauthenticated ciphers:
+
+```js
+// crypto-engine.js:121-123
+case OID_DES_EDE3_CBC: return { name: 'DES-EDE3-CBC', length: 192 };
+case OID_DES_CBC: return { name: 'DES-CBC', length: 64 };
+case OID_RC2_CBC: return { name: 'RC2-CBC', length: 128 };
+```
+
+CMS `EnvelopedData` carries no MAC; only AEAD modes provide integrity. Native AES-CBC is likewise accepted, since nothing inspects the algorithm at all. Decrypted bytes are returned to the renderer with no authenticity gate — the precondition for EFAIL direct-exfiltration and CBC-gadget attacks. End-to-end exploitability additionally depends on the host's HTML sanitiser blocking external resource loads, which is a separate control and should not be the only one.
+
+**Fix:** allowlist AEAD content encryption (AES-GCM) on decrypt. If legacy CBC must be supported for old archived mail, gate it behind an explicit per-message user opt-in and never render its output as HTML.
+
+### 3. CRLF header injection — MED-HIGH
+
+`encodeHeaderValue` (`mime-builder.js:141-152`) does neutralise CR/LF — but only as a side effect of Q-encoding, and it is applied to just **Subject** and **attachment filename**. It is not applied to display names, raw addresses, `Message-ID`, `In-Reply-To`, `References`, or attachment `Content-Type`.
+
+```js
+// mime-builder.js:115-121 — escapes only backslash and quote
+function formatAddress(addr) {
+ if (addr.name) {
+ const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ return `"${escaped}" <${addr.email}>`;
+ }
+ return addr.email;
+}
+```
+
+`formatHeader` (123-139) performs line *folding* only — no sanitisation. A display name containing `\r\n` is emitted verbatim into the header block.
+
+**Remote path:** `In-Reply-To` / `References` / display names are typically copied from an inbound message when replying or forwarding, so the injected value is attacker-supplied. Impact ranges from spoofed `Reply-To` to added recipients, depending on whether the submission path passes explicit `envelopeRecipients` (the host's `sendRawEmail` accepts them; whether the plugin supplies them should be confirmed).
+
+**Fix:** strip or encode CR/LF in one place — inside `formatHeader` — so every header value is covered by construction rather than per-call-site.
+
+### 4. Unlocked keys persisted to disk — MEDIUM
+
+`key-storage.js:106-109` writes unlocked, non-extractable `CryptoKey` handles into the durable `smime-plugin-store` IndexedDB. Raw key material is never exposed (non-extractable survives structured clone), but the *usable handle* is on disk and survives tab close and browser restart unless wiped.
+
+The wipe hooks are real — `onAfterLogout` and `onAccountSwitch` exist in our host at `lib/plugin-hooks.ts:362-363` (an earlier reviewer flagged these as possibly fictional; they are not). Two caveats stand:
+
+- The `lockOnLogout` setting (default true) lets a user disable the wipe entirely.
+- If the wipe does not run — crash, killed tab, failed transaction — the handle persists, and anyone with the browser profile can decrypt mail **without the passphrase**.
+
+This also contradicts the host's own plugin guidance in `WRITING_PLUGINS.md` §8 ("in memory only"), and it is weaker than the native implementation this replaced, which kept unlocked keys in an in-memory `Map`. The trade buys cross-iframe sharing between the settings slot and the background hooks.
+
+### 5–9
+
+Recursion in `parseEntity` (`mime-parse.js:47-50`) and both `smime-detect.js` walkers (88-109) is depth-unbounded; `binaryString` (`mime-parse.js:28-32`) concatenates byte-by-byte with no size cap. ReDoS specifically was checked across every regex and is **clean** — all linear. Finding 6 is an accepted-risk interop trade needing documentation rather than removal. Findings 7–9 are correctness/robustness issues.
+
+## Verified clean
+
+| Area | Result |
+|---|---|
+| Network egress | **None** across all 13 modules |
+| `eval` / `new Function` / dynamic code | None |
+| XSS / DOM injection | No `innerHTML`, `document.write`, or `dangerouslySetInnerHTML`; cert fields render as React text children (auto-escaped) |
+| Passphrase handling | Local scope only; never persisted to storage, `api.storage`, cookies, or logs |
+| `api.storage.*` (plaintext localStorage) | Non-secret prefs and verification metadata only — no keys, no passphrases |
+| Key wrapping | PBKDF2-SHA256 600k → AES-256-GCM; 32-byte salt, 12-byte IV |
+| Key extractability | `extractable: false` at all five `importKey` sites |
+| Encrypt path | Hardcoded AES-GCM + RSA-OAEP/SHA-256 — no downgrade negotiation, no RC2/DES reachable |
+| Polyfill scoping | `sign`/`encrypt`/`verify` use `nativeEngine()` exclusively; the JS polyfill is never used for signing or randomness |
+| Engine restore | `withLinerEngine` uses `try/finally` — no leaked global polyfill state |
+| Fingerprints | SHA-256 (not SHA-1) |
+| Backdoors / hardcoded keys / TODO markers | None |
+
+## Supply-chain findings
+
+**Do not ship the marketplace zip.** `smime.zip` in the repo contains a single 1.77 MB bundled `index.js` — not readable source — and its manifest reads **1.0.1** while the repo source is **1.0.2**. The shipped artifact is stale relative to the code, so auditing `src/` would not audit what that zip installs.
+
+**Build from source ourselves** via the repo's own `npm run package` (esbuild bundle of `src/` plus pkijs/asn1js/pvtsutils/webcrypto-liner). That way the artifact corresponds to the audited code and we control it.
+
+## Interaction with our own changes
+
+- **`B-04` is compatible.** The plugin declares `email:render-takeover` and `email:send`, which our gate requires for `onRenderEmailBody` and `onComposeSend`. It will load correctly.
+- **`B-09` carries a real hazard here.** The plugin registers `onAfterLogout` and `onAccountSwitch` but its manifest requests **no `auth:observe`**. If `B-09` gates auth hooks on that permission, **the session-key wipe silently stops running** — turning finding 4 from a caveat into a live exposure. This is a concrete instance of the over-gating risk noted in `B-09`, and it is security-relevant: add the permission to the fork's manifest, or exempt these hooks deliberately.
+- **`B-01` is exercised by this bundle** — 1.77 MB of minified pkijs/webcrypto-liner will trip the pattern scanner, which is exactly the case the override exists for.
+
+## Recommendation
+
+1. Fork `smime/` into `vnc/plugins/smime/` — **do not** vendor the upstream zip.
+2. Fix findings **1, 2, 3** before any user imports a key. Findings 1 and 3 are small, localised changes; finding 2 is an allowlist.
+3. Add `auth:observe` to the forked manifest so the wipe survives `B-09`.
+4. Document findings 4 and 6 as accepted risks with rationale, or fix 4 by moving session keys back to memory and accepting the cross-iframe cost.
+5. Re-audit the diff after fixes, then proceed to the self-signed-certificate spike.
+
+Findings 1–3 are why `S-01` was scheduled before `A-01`. All three would have shipped.
diff --git a/vnc/plugins/smime/README.md b/vnc/plugins/smime/README.md
new file mode 100644
index 00000000..daf066a8
--- /dev/null
+++ b/vnc/plugins/smime/README.md
@@ -0,0 +1,79 @@
+# 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
+
+```bash
+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.
diff --git a/vnc/plugins/smime/manifest.json b/vnc/plugins/smime/manifest.json
new file mode 100644
index 00000000..491c3bfb
--- /dev/null
+++ b/vnc/plugins/smime/manifest.json
@@ -0,0 +1,66 @@
+{
+ "id": "smime",
+ "name": "S/MIME",
+ "version": "1.0.2",
+ "author": "Bulwark Mail Community",
+ "description": "End-to-end S/MIME for webmail: sign and encrypt outgoing messages, and automatically verify signatures and decrypt incoming CMS (PKCS#7) mail. Private keys are imported from a PKCS#12 (.p12/.pfx) file, encrypted at rest with a passphrase, and unlocked into non-extractable WebCrypto keys that never leave your browser. Runs in the privileged (same-origin) plugin tier so all cryptography happens locally with bundled pkijs/asn1js.",
+ "type": "ui-extension",
+ "tier": "privileged",
+ "permissions": [
+ "crypto:full",
+ "email:blob-read",
+ "email:raw-send",
+ "email:render-takeover",
+ "email:read",
+ "email:send",
+ "smime:read",
+ "auth:observe",
+ "ui:composer-toolbar",
+ "ui:email-banner",
+ "ui:settings-section",
+ "app:lifecycle"
+ ],
+ "entrypoint": "index.js",
+ "minAppVersion": "1.7.6",
+ "icon": "media/icon.svg",
+ "banner": "media/banner.svg",
+ "settingsSchema": {
+ "encryptionStrength": {
+ "type": "select",
+ "label": "Content encryption algorithm",
+ "description": "Symmetric cipher used to encrypt the message body. AES-256-GCM is recommended; AES-128-GCM is slightly smaller and still strong.",
+ "default": "aes-256",
+ "options": ["aes-256", "aes-128"]
+ },
+ "autoImportSignerCerts": {
+ "type": "boolean",
+ "label": "Auto-save signer certificates",
+ "description": "When a validly signed message is opened, remember the signer's certificate so you can later send them encrypted mail without importing it manually.",
+ "default": true
+ },
+ "lockOnLogout": {
+ "type": "boolean",
+ "label": "Lock keys on logout",
+ "description": "Wipe all unlocked private keys from memory when you sign out or switch accounts. Leave on unless you have a specific reason not to.",
+ "default": true
+ },
+ "warnOnSelfSigned": {
+ "type": "boolean",
+ "label": "Warn on self-signed signer",
+ "description": "Show a caution banner when an incoming signature validates against a self-signed certificate (not chained to a trusted CA).",
+ "default": true
+ }
+ },
+ "locales": {
+ "en": {
+ "banner.signed_valid": "Signature valid",
+ "banner.signed_invalid": "Signature invalid",
+ "banner.encrypted": "Encrypted message",
+ "banner.decrypted": "Decrypted",
+ "banner.locked": "Encrypted — unlock your key to read",
+ "toolbar.sign": "Sign",
+ "toolbar.encrypt": "Encrypt",
+ "settings.title": "S/MIME keys & certificates"
+ }
+ }
+}
diff --git a/vnc/plugins/smime/media/banner.svg b/vnc/plugins/smime/media/banner.svg
new file mode 100644
index 00000000..ac2e55be
--- /dev/null
+++ b/vnc/plugins/smime/media/banner.svg
@@ -0,0 +1,23 @@
+
diff --git a/vnc/plugins/smime/media/icon.svg b/vnc/plugins/smime/media/icon.svg
new file mode 100644
index 00000000..ab2c71e5
--- /dev/null
+++ b/vnc/plugins/smime/media/icon.svg
@@ -0,0 +1,20 @@
+
diff --git a/vnc/plugins/smime/package-lock.json b/vnc/plugins/smime/package-lock.json
new file mode 100644
index 00000000..a08d283b
--- /dev/null
+++ b/vnc/plugins/smime/package-lock.json
@@ -0,0 +1,757 @@
+{
+ "name": "bulwark-plugin-smime",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bulwark-plugin-smime",
+ "version": "1.0.0",
+ "dependencies": {
+ "asn1js": "^3.0.10",
+ "pkijs": "^3.4.0",
+ "pvtsutils": "^1.3.6",
+ "webcrypto-liner": "^1.4.3"
+ },
+ "devDependencies": {
+ "esbuild": "^0.24.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
+ "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
+ "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
+ "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
+ "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
+ "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
+ "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
+ "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
+ "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
+ "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
+ "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
+ "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
+ "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
+ "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
+ "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
+ "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
+ "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
+ "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
+ "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
+ "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
+ "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
+ "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
+ "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
+ "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
+ "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@peculiar/json-schema": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
+ "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@peculiar/utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
+ "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/@stablelib/binary": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz",
+ "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/int": "^1.0.1"
+ }
+ },
+ "node_modules/@stablelib/hash": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz",
+ "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==",
+ "license": "MIT"
+ },
+ "node_modules/@stablelib/int": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz",
+ "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==",
+ "license": "MIT"
+ },
+ "node_modules/@stablelib/sha3": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz",
+ "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/binary": "^1.0.1",
+ "@stablelib/hash": "^1.0.1",
+ "@stablelib/wipe": "^1.0.1"
+ }
+ },
+ "node_modules/@stablelib/wipe": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz",
+ "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==",
+ "license": "MIT"
+ },
+ "node_modules/asmcrypto.js": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz",
+ "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==",
+ "license": "MIT"
+ },
+ "node_modules/asn1js": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
+ "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.5",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/bn.js": {
+ "version": "4.12.5",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
+ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
+ "license": "MIT"
+ },
+ "node_modules/brorand": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
+ "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
+ "license": "MIT"
+ },
+ "node_modules/bytestreamjs": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
+ "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
+ "node_modules/des.js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
+ "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "node_modules/elliptic": {
+ "version": "6.5.0",
+ "resolved": "git+ssh://git@github.com/mahrud/elliptic.git#75637c76678e83c31682fd967c2fa9ff4761b3fc",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.4.0",
+ "brorand": "^1.0.1",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.24.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
+ "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.24.2",
+ "@esbuild/android-arm": "0.24.2",
+ "@esbuild/android-arm64": "0.24.2",
+ "@esbuild/android-x64": "0.24.2",
+ "@esbuild/darwin-arm64": "0.24.2",
+ "@esbuild/darwin-x64": "0.24.2",
+ "@esbuild/freebsd-arm64": "0.24.2",
+ "@esbuild/freebsd-x64": "0.24.2",
+ "@esbuild/linux-arm": "0.24.2",
+ "@esbuild/linux-arm64": "0.24.2",
+ "@esbuild/linux-ia32": "0.24.2",
+ "@esbuild/linux-loong64": "0.24.2",
+ "@esbuild/linux-mips64el": "0.24.2",
+ "@esbuild/linux-ppc64": "0.24.2",
+ "@esbuild/linux-riscv64": "0.24.2",
+ "@esbuild/linux-s390x": "0.24.2",
+ "@esbuild/linux-x64": "0.24.2",
+ "@esbuild/netbsd-arm64": "0.24.2",
+ "@esbuild/netbsd-x64": "0.24.2",
+ "@esbuild/openbsd-arm64": "0.24.2",
+ "@esbuild/openbsd-x64": "0.24.2",
+ "@esbuild/sunos-x64": "0.24.2",
+ "@esbuild/win32-arm64": "0.24.2",
+ "@esbuild/win32-ia32": "0.24.2",
+ "@esbuild/win32-x64": "0.24.2"
+ }
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "node_modules/hmac-drbg": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
+ "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==",
+ "license": "MIT",
+ "dependencies": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
+ "node_modules/minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz",
+ "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
+ "license": "MIT"
+ },
+ "node_modules/pkijs": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
+ "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@noble/hashes": "1.4.0",
+ "asn1js": "^3.0.6",
+ "bytestreamjs": "^2.0.1",
+ "pvtsutils": "^1.3.6",
+ "pvutils": "^1.1.3",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/pvtsutils": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
+ "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
+ "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/webcrypto-core": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
+ "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.7.0",
+ "@peculiar/json-schema": "^1.1.12",
+ "@peculiar/utils": "^2.0.2",
+ "asn1js": "^3.0.10",
+ "tslib": "^2.8.1"
+ }
+ },
+ "node_modules/webcrypto-liner": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/webcrypto-liner/-/webcrypto-liner-1.4.3.tgz",
+ "integrity": "sha512-gzlk7ciS5zqc8QZMwpzpRxxwkcQKDJDndhr/hHWQe18Rzafhji3a7CaSxIeA2jcL0bLcAK+P77K3lWS1QXMMYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/json-schema": "^1.1.12",
+ "@stablelib/sha3": "^1.0.1",
+ "asmcrypto.js": "^2.3.2",
+ "asn1js": "^3.0.5",
+ "core-js": "^3.35.1",
+ "des.js": "^1.1.0",
+ "elliptic": "git+https://github.com/mahrud/elliptic.git",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.6.2",
+ "webcrypto-core": "^1.7.8"
+ }
+ }
+ }
+}
diff --git a/vnc/plugins/smime/package.json b/vnc/plugins/smime/package.json
new file mode 100644
index 00000000..f88ae4a3
--- /dev/null
+++ b/vnc/plugins/smime/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "bulwark-plugin-smime",
+ "version": "1.0.2",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "esbuild src/index.js --bundle --format=cjs --platform=browser --charset=utf8 --alias:crypto=./src/node-crypto-shim.js --outfile=dist/index.js --external:react --external:react-dom --external:react-dom/client --external:react/jsx-runtime --external:@plugin-host",
+ "dev": "npm run build -- --watch",
+ "package": "npm run build && cp manifest.json dist/ && cd dist && zip -X ../smime.zip manifest.json index.js"
+ },
+ "dependencies": {
+ "asn1js": "^3.0.10",
+ "pkijs": "^3.4.0",
+ "pvtsutils": "^1.3.6",
+ "webcrypto-liner": "^1.4.3"
+ },
+ "devDependencies": {
+ "esbuild": "^0.24.0"
+ }
+}
diff --git a/vnc/plugins/smime/src/certificate-utils.js b/vnc/plugins/smime/src/certificate-utils.js
new file mode 100644
index 00000000..12288ce2
--- /dev/null
+++ b/vnc/plugins/smime/src/certificate-utils.js
@@ -0,0 +1,217 @@
+// X.509 parsing + metadata extraction. Ported from lib/smime/certificate-utils.ts.
+
+import * as asn1js from 'asn1js';
+import * as pkijs from 'pkijs';
+import { Convert } from 'pvtsutils';
+
+const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4';
+const OID_SAN = '2.5.29.17';
+
+// ── PEM/DER conversions ──────────────────────────────────────────────
+
+export function pemToDer(pem) {
+ const lines = pem
+ .replace(/-----BEGIN [^-]+-----/, '')
+ .replace(/-----END [^-]+-----/, '')
+ .replace(/\s/g, '');
+ return Convert.FromBase64(lines);
+}
+
+export function derToPem(der, label) {
+ const b64 = Convert.ToBase64(der);
+ const lines = [];
+ for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64));
+ return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
+}
+
+export function isPem(data) {
+ return /-----BEGIN (CERTIFICATE|PKCS12|ENCRYPTED PRIVATE KEY|PRIVATE KEY)-----/.test(data);
+}
+
+// ── Certificate parsing ──────────────────────────────────────────────
+
+export function parseCertificateDer(der) {
+ const asn1 = asn1js.fromBER(der);
+ if (asn1.offset === -1) throw new Error('Invalid DER data: ASN.1 parsing failed');
+ return new pkijs.Certificate({ schema: asn1.result });
+}
+
+export function parseCertificatePemOrDer(data) {
+ if (typeof data === 'string') {
+ if (isPem(data)) return parseCertificateDer(pemToDer(data));
+ throw new Error('String input is not PEM-encoded');
+ }
+ const header = new Uint8Array(data, 0, Math.min(20, data.byteLength));
+ const maybePem = String.fromCharCode(...header);
+ if (maybePem.startsWith('-----BEGIN ')) {
+ const text = new TextDecoder().decode(data);
+ return parseCertificateDer(pemToDer(text));
+ }
+ return parseCertificateDer(data);
+}
+
+// ── Metadata extraction ──────────────────────────────────────────────
+
+function rdnToString(rdn) {
+ return rdn.typesAndValues
+ .map((tv) => `${oidToName(tv.type)}=${tv.value.valueBlock.value}`)
+ .join(', ');
+}
+
+function oidToName(oid) {
+ const map = {
+ '2.5.4.3': 'CN',
+ '2.5.4.6': 'C',
+ '2.5.4.7': 'L',
+ '2.5.4.8': 'ST',
+ '2.5.4.10': 'O',
+ '2.5.4.11': 'OU',
+ '1.2.840.113549.1.9.1': 'E',
+ };
+ return map[oid] ?? oid;
+}
+
+export async function computeFingerprint(der) {
+ const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(der));
+ return Array.from(new Uint8Array(hash))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join(':');
+}
+
+function extractAlgorithm(cert) {
+ const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId;
+ if (algOid === '1.2.840.113549.1.1.1') {
+ const pubKey = cert.subjectPublicKeyInfo;
+ try {
+ const asn1Pub = asn1js.fromBER(pubKey.subjectPublicKey.valueBlock.valueHexView);
+ const seq = asn1Pub.result;
+ const modulus = seq.valueBlock.value[0];
+ const bitLen = (modulus.valueBlock.valueHexView.byteLength - 1) * 8;
+ return `RSA-${bitLen}`;
+ } catch {
+ return 'RSA';
+ }
+ }
+ if (algOid === '1.2.840.10045.2.1') {
+ const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams;
+ if (params instanceof asn1js.ObjectIdentifier) {
+ const curveOid = params.valueBlock.toString();
+ const curves = {
+ '1.2.840.10045.3.1.7': 'ECDSA-P256',
+ '1.3.132.0.34': 'ECDSA-P384',
+ '1.3.132.0.35': 'ECDSA-P521',
+ };
+ return curves[curveOid] ?? 'ECDSA';
+ }
+ return 'ECDSA';
+ }
+ return algOid;
+}
+
+function extractKeyUsage(cert) {
+ const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15');
+ if (!ext?.parsedValue) return undefined;
+ const ku = ext.parsedValue;
+ const names = [];
+ if (ku.digitalSignature) names.push('digitalSignature');
+ if (ku.contentCommitment) names.push('contentCommitment');
+ if (ku.keyEncipherment) names.push('keyEncipherment');
+ if (ku.dataEncipherment) names.push('dataEncipherment');
+ if (ku.keyAgreement) names.push('keyAgreement');
+ if (ku.keyCertSign) names.push('keyCertSign');
+ if (ku.cRLSign) names.push('cRLSign');
+ if (ku.encipherOnly) names.push('encipherOnly');
+ if (ku.decipherOnly) names.push('decipherOnly');
+ return names;
+}
+
+function extractExtendedKeyUsage(cert) {
+ const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37');
+ if (!ext?.parsedValue) return undefined;
+ return ext.parsedValue.keyPurposes;
+}
+
+function extractEmailAddresses(cert) {
+ const emails = [];
+
+ for (const tv of cert.subject.typesAndValues) {
+ if (tv.type === '1.2.840.113549.1.9.1') {
+ emails.push(tv.value.valueBlock.value);
+ }
+ }
+
+ const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
+ if (sanExt) {
+ let names;
+ const pv = sanExt.parsedValue;
+ if (pv?.names) {
+ names = pv.names;
+ } else if (sanExt.extnValue) {
+ try {
+ const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
+ if (sanAsn1.offset !== -1) {
+ names = new pkijs.GeneralNames({ schema: sanAsn1.result }).names;
+ }
+ } catch { /* malformed SAN — skip */ }
+ }
+ if (names) {
+ for (const name of names) {
+ if (name.type === 1 && typeof name.value === 'string' && !emails.includes(name.value)) {
+ emails.push(name.value);
+ }
+ }
+ }
+ }
+
+ return emails;
+}
+
+/** Determine signing/encryption capabilities from KU / EKU. Tolerant of absent extensions. */
+export function classifyCapabilities(cert) {
+ const ku = extractKeyUsage(cert);
+ const eku = extractExtendedKeyUsage(cert);
+
+ let canSign = true;
+ let canEncrypt = true;
+
+ if (ku) {
+ canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment');
+ canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement');
+ }
+
+ if (eku && eku.length > 0) {
+ const hasEmailProtection = eku.includes(OID_EMAIL_PROTECTION);
+ if (!hasEmailProtection) {
+ canSign = false;
+ canEncrypt = false;
+ }
+ }
+
+ return { canSign, canEncrypt };
+}
+
+/** Extract full metadata from a parsed certificate. */
+export async function extractCertificateInfo(cert, der) {
+ const fingerprint = await computeFingerprint(der);
+ const ku = extractKeyUsage(cert);
+ const eku = extractExtendedKeyUsage(cert);
+ const capabilities = classifyCapabilities(cert);
+
+ return {
+ subject: rdnToString(cert.subject),
+ issuer: rdnToString(cert.issuer),
+ serialNumber: cert.serialNumber.valueBlock.valueHexView
+ ? Array.from(new Uint8Array(cert.serialNumber.valueBlock.valueHexView))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join(':')
+ : cert.serialNumber.valueBlock.toString(),
+ notBefore: cert.notBefore.value.toISOString(),
+ notAfter: cert.notAfter.value.toISOString(),
+ fingerprint,
+ algorithm: extractAlgorithm(cert),
+ keyUsage: ku,
+ extendedKeyUsage: eku,
+ emailAddresses: extractEmailAddresses(cert),
+ capabilities,
+ };
+}
diff --git a/vnc/plugins/smime/src/crypto-engine.js b/vnc/plugins/smime/src/crypto-engine.js
new file mode 100644
index 00000000..a0f940b7
--- /dev/null
+++ b/vnc/plugins/smime/src/crypto-engine.js
@@ -0,0 +1,222 @@
+/**
+ * Crypto engine backed by webcrypto-liner for legacy algorithm support.
+ *
+ * webcrypto-liner extends native Web Crypto with algorithms like
+ * DES-EDE3-CBC (3DES) that legacy S/MIME clients (Outlook, Thunderbird)
+ * still emit. Native algorithms pass through to the real implementation;
+ * only the missing ones use the software fallback.
+ *
+ * Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
+ * handles PBES2. Many PKCS#12 files use legacy PBE algorithms; we extend
+ * CryptoEngine to handle those via RFC 7292 Appendix B key derivation +
+ * webcrypto-liner's DES-EDE3-CBC support.
+ *
+ * Ported verbatim (TS → JS) from the host's lib/smime/crypto-engine.ts so
+ * the plugin produces byte-identical CMS to the former native pipeline.
+ */
+
+import * as asn1js from 'asn1js';
+import * as pkijs from 'pkijs';
+// Import the ES build directly: the package "browser" field points at a
+// shim-only build with no named exports (no setCrypto/Crypto).
+import * as liner from 'webcrypto-liner/build/index.es.js';
+
+// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
+const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3';
+const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4';
+const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5';
+const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6';
+
+const LEGACY_PBE_OIDS = new Set([
+ PBE_SHA1_3DES_3KEY,
+ PBE_SHA1_3DES_2KEY,
+ PBE_SHA1_RC2_128,
+ PBE_SHA1_RC2_40,
+]);
+
+function pbeConfig(oid) {
+ switch (oid) {
+ case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' };
+ case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' };
+ case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' };
+ case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' };
+ default: throw new Error(`Unsupported legacy PBE OID: ${oid}`);
+ }
+}
+
+/** PKCS#12 key derivation — RFC 7292, Appendix B. */
+async function pkcs12KDF(password, salt, iterations, id, needed) {
+ const v = 64; // SHA-1 block size
+ const u = 20; // SHA-1 output size
+
+ const D = new Uint8Array(v);
+ D.fill(id);
+
+ const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v);
+ const S = new Uint8Array(sLen);
+ for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length];
+
+ const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v);
+ const P = new Uint8Array(pLen);
+ for (let i = 0; i < pLen; i++) P[i] = password[i % password.length];
+
+ const I = new Uint8Array(sLen + pLen);
+ I.set(S, 0);
+ I.set(P, sLen);
+
+ const c = Math.ceil(needed / u);
+ const result = new Uint8Array(c * u);
+
+ for (let i = 0; i < c; i++) {
+ const buf = new Uint8Array(v + I.length);
+ buf.set(D, 0);
+ buf.set(I, v);
+
+ let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf));
+ for (let j = 1; j < iterations; j++) {
+ A = new Uint8Array(await crypto.subtle.digest('SHA-1', A));
+ }
+
+ result.set(A, i * u);
+
+ if (i + 1 < c) {
+ const B = new Uint8Array(v);
+ for (let j = 0; j < v; j++) B[j] = A[j % u];
+
+ for (let j = 0; j < I.length; j += v) {
+ let carry = 1;
+ for (let k = v - 1; k >= 0; k--) {
+ const sum = I[j + k] + B[k] + carry;
+ I[j + k] = sum & 0xff;
+ carry = sum >> 8;
+ }
+ }
+ }
+ }
+
+ return result.slice(0, needed);
+}
+
+/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */
+function passwordToBMP(password) {
+ const passView = new Uint8Array(password);
+ const bmp = new Uint8Array(passView.length * 2 + 2);
+ for (let i = 0; i < passView.length; i++) {
+ bmp[i * 2] = 0;
+ bmp[i * 2 + 1] = passView[i];
+ }
+ bmp[bmp.length - 2] = 0;
+ bmp[bmp.length - 1] = 0;
+ return bmp;
+}
+
+// ── CMS content encryption OIDs (for EnvelopedData decryption) ─────
+const OID_DES_EDE3_CBC = '1.2.840.113549.3.7';
+const OID_DES_CBC = '1.3.14.3.2.7';
+const OID_RC2_CBC = '1.2.840.113549.3.2';
+
+class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
+ getAlgorithmByOID(oid, safety, target) {
+ switch (oid) {
+ case OID_DES_EDE3_CBC: return { name: 'DES-EDE3-CBC', length: 192 };
+ case OID_DES_CBC: return { name: 'DES-CBC', length: 64 };
+ case OID_RC2_CBC: return { name: 'RC2-CBC', length: 128 };
+ default: return super.getAlgorithmByOID(oid, safety, target);
+ }
+ }
+
+ getOIDByAlgorithm(algorithm, safety, target) {
+ switch (algorithm.name.toUpperCase()) {
+ case 'DES-EDE3-CBC': return OID_DES_EDE3_CBC;
+ case 'DES-CBC': return OID_DES_CBC;
+ case 'RC2-CBC': return OID_RC2_CBC;
+ default: return super.getOIDByAlgorithm(algorithm, safety, target);
+ }
+ }
+
+ async decryptEncryptedContentInfo(parameters) {
+ const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
+
+ if (!LEGACY_PBE_OIDS.has(oid)) {
+ return super.decryptEncryptedContentInfo(parameters);
+ }
+
+ const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
+ if (!algParams) throw new Error('Missing PBE algorithm parameters');
+
+ const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
+ if (paramAsn1.offset === -1) throw new Error('Invalid PBE parameters ASN.1');
+ const seq = paramAsn1.result;
+ const salt = new Uint8Array(seq.valueBlock.value[0].valueBlock.valueHexView);
+ const iterations = seq.valueBlock.value[1].valueBlock.valueDec;
+
+ const { keyLen, ivLen, algName } = pbeConfig(oid);
+ const bmpPassword = passwordToBMP(parameters.password);
+
+ const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
+ const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
+
+ const keyData = new Uint8Array(keyBytes.buffer, keyBytes.byteOffset, keyBytes.byteLength);
+ const cryptoKey = await this.importKey(
+ 'raw',
+ keyData,
+ { name: algName, length: keyLen * 8 },
+ false,
+ ['decrypt'],
+ );
+
+ const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
+ return this.decrypt({ name: algName, iv: ivBytes }, cryptoKey, ciphertext);
+ }
+}
+
+let linerEngine = null;
+let linerCryptoInstance = null;
+
+function ensureLiner() {
+ if (!linerCryptoInstance) {
+ if (
+ typeof liner.nativeCrypto?.getRandomValues !== 'function' &&
+ typeof globalThis.crypto?.subtle !== 'undefined'
+ ) {
+ liner.setCrypto(globalThis.crypto.subtle);
+ }
+ linerCryptoInstance = new liner.Crypto();
+ }
+ if (!linerEngine) {
+ linerEngine = new Pkcs12CryptoEngine({
+ crypto: linerCryptoInstance,
+ subtle: linerCryptoInstance.subtle,
+ name: 'webcrypto-liner',
+ });
+ }
+}
+
+/** PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */
+export function getLinerCryptoEngine() {
+ ensureLiner();
+ return linerEngine;
+}
+
+/** The webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
+export function getLinerCrypto() {
+ ensureLiner();
+ return linerCryptoInstance;
+}
+
+/** Run fn with the global PKI.js engine set to webcrypto-liner, then restore. */
+export async function withLinerEngine(fn) {
+ ensureLiner();
+ const prev = pkijs.getEngine();
+ pkijs.setEngine('webcrypto-liner', linerCryptoInstance, linerEngine);
+ try {
+ return await fn();
+ } finally {
+ pkijs.setEngine(prev.name, prev.crypto);
+ }
+}
+
+/** A plain native-WebCrypto pkijs engine for sign/verify/encrypt fast paths. */
+export function nativeEngine() {
+ return new pkijs.CryptoEngine({ crypto, subtle: crypto.subtle, name: 'webcrypto' });
+}
diff --git a/vnc/plugins/smime/src/index.js b/vnc/plugins/smime/src/index.js
new file mode 100644
index 00000000..66c97f3f
--- /dev/null
+++ b/vnc/plugins/smime/src/index.js
@@ -0,0 +1,1087 @@
+/**
+ * S/MIME — privileged (same-origin) webmail plugin.
+ *
+ * Replaces the former native S/MIME pipeline with a sandboxed plugin that
+ * runs all cryptography locally (bundled pkijs/asn1js/webcrypto-liner):
+ *
+ * • onComposeSend (intercept) → build MIME, sign/encrypt, api.jmap.sendRaw
+ * • onRenderEmailBody (transform) → api.jmap.fetchBlob, decrypt/verify, replace body
+ * • composer-toolbar slot → per-message Sign / Encrypt toggles
+ * • email-banner slot → signature / encryption status
+ * • settings-section slot → key import, unlock/lock, recipient certs
+ *
+ * Private keys are imported from PKCS#12, AES-GCM-wrapped under PBKDF2(600k),
+ * and unlocked into NON-EXTRACTABLE WebCrypto keys held in a same-origin
+ * IndexedDB session store shared between the background and slot iframes.
+ */
+
+const host = require('@plugin-host');
+const React = require('react');
+const h = React.createElement;
+const { useState, useEffect, useCallback, useRef } = React;
+
+import { buildMimeMessage, wrapCmsAsSmimeMessage, base64Encode } from './mime-builder.js';
+import { smimeSign } from './smime-sign.js';
+import { smimeEncrypt } from './smime-encrypt.js';
+import { smimeVerify } from './smime-verify.js';
+import { smimeDecrypt, normalizeCmsBytes, SmimeKeyLockedError } from './smime-decrypt.js';
+import { detectSmime } from './smime-detect.js';
+import { parseMime } from './mime-parse.js';
+import { importPkcs12, unlockPrivateKey } from './pkcs12.js';
+import { parseCertificatePemOrDer, extractCertificateInfo } from './certificate-utils.js';
+import { generateUUID } from './util.js';
+import {
+ saveKeyRecord, listKeyRecords, deleteKeyRecord,
+ savePublicCert, listPublicCerts, deletePublicCert,
+ saveSessionKeys, getSessionKeys, deleteSessionKeys, clearSessionKeys,
+} from './key-storage.js';
+
+// ─── Shared preferences (api.storage; shared across iframes) ──────────
+
+const PREFS_KEY = 'prefs.v1';
+const INTENT_KEY = 'composeIntent.v1';
+const VERIFY_PREFIX = 'verify:';
+
+const DEFAULT_PREFS = { defaultSign: false, defaultEncrypt: false };
+
+async function getPrefs() {
+ try {
+ const p = await host.storage.get(PREFS_KEY);
+ return { ...DEFAULT_PREFS, ...(p || {}) };
+ } catch {
+ return { ...DEFAULT_PREFS };
+ }
+}
+async function setPrefs(next) {
+ await host.storage.set(PREFS_KEY, next);
+}
+
+function settings() {
+ return host.plugin?.settings || {};
+}
+function useAes128() {
+ return settings().encryptionStrength === 'aes-128';
+}
+
+// ─── Privileged-tier capability probe ─────────────────────────────────
+// S/MIME needs in-frame `crypto.subtle` + IndexedDB, which exist only in the
+// privileged (same-origin) tier. In the untrusted (null-origin) sandbox,
+// `indexedDB.open` throws "The operation is insecure" and `crypto.subtle` is
+// absent. We probe once and degrade with a clear message instead of letting a
+// raw IndexedDB error crash activate() (which would trip the circuit breaker).
+
+const NOT_PRIVILEGED_MSG =
+ 'S/MIME could not start: it is running in the restricted (untrusted) plugin ' +
+ 'sandbox, where in-browser cryptography and key storage are unavailable. ' +
+ 'This plugin must be delivered as a signed, admin-approved bundle with ' +
+ '"tier": "privileged" so it loads in the same-origin tier. Contact your ' +
+ 'administrator.';
+
+let _capable = null;
+async function isCapable() {
+ if (_capable !== null) return _capable;
+ try {
+ if (typeof indexedDB === 'undefined' || !(crypto && crypto.subtle)) throw new Error('missing apis');
+ await new Promise((resolve, reject) => {
+ let req;
+ try { req = indexedDB.open('smime-capability-probe'); }
+ catch (e) { reject(e); return; }
+ req.onsuccess = () => { try { req.result.close(); } catch { /* ignore */ } resolve(); };
+ req.onerror = () => reject(req.error || new Error('indexedDB open failed'));
+ req.onblocked = () => resolve();
+ });
+ _capable = true;
+ } catch {
+ _capable = false;
+ }
+ return _capable;
+}
+
+// ─── Address helpers ──────────────────────────────────────────────────
+
+function parseAddr(value) {
+ if (value && typeof value === 'object' && value.email) {
+ return { name: value.name || undefined, email: String(value.email) };
+ }
+ const s = String(value || '');
+ // A leading segment is only a display name when it is actually followed by an
+ // angle-bracketed address. Making the `<` optional (the old ``) let the name
+ // group steal the first character of a BARE address — e.g. "root@rbm.systems"
+ // parsed as name "r" + email "oot@rbm.systems", which then failed the S/MIME
+ // key lookup for every normal send.
+ const m = s.match(/^\s*(?:"?([^"<]*?)"?\s*<\s*)?([^<>\s]+@[^<>\s]+)\s*>?\s*$/);
+ if (m) return { name: (m[1] || '').trim() || undefined, email: m[2] };
+ return { email: s.trim() };
+}
+function addrList(arr) {
+ if (!arr) return [];
+ return (Array.isArray(arr) ? arr : [arr]).map(parseAddr).filter((a) => a.email);
+}
+function emailsOf(arr) {
+ return addrList(arr).map((a) => a.email.toLowerCase());
+}
+
+// ─── Blob/bytes helpers ────────────────────────────────────────────────
+
+async function blobToBytes(blob) {
+ return new Uint8Array(await blob.arrayBuffer());
+}
+function bytesArrayBuffer(u8) {
+ return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
+}
+
+/** Wrap a CMS blob as a nested MIME entity (for sign-then-encrypt). */
+function cmsInnerEntity(cmsBytes, smimeType) {
+ const header = [
+ `Content-Type: application/pkcs7-mime; smime-type=${smimeType}; name="smime.p7m"`,
+ 'Content-Transfer-Encoding: base64',
+ 'Content-Disposition: attachment; filename="smime.p7m"',
+ '',
+ ].join('\r\n');
+ const b64 = base64Encode(bytesArrayBuffer(cmsBytes));
+ return new TextEncoder().encode(header + '\r\n' + b64 + '\r\n');
+}
+
+// ─── Key resolution ────────────────────────────────────────────────────
+
+async function signingKeyRecordForEmail(fromEmail) {
+ const recs = await listKeyRecords();
+ const lower = (fromEmail || '').toLowerCase();
+ return (
+ recs.find((r) => r.email === lower && r.capabilities?.canSign !== false) ||
+ recs.find((r) => r.email === lower) ||
+ undefined
+ );
+}
+
+// Ensure a key's private material is unlocked in the session store. If it's
+// locked, ask for the storage passphrase via a host popup and unlock it in
+// place. Returns the unlocked session keys, or null if the user cancels or the
+// passphrase is wrong (a wrong-passphrase toast is shown in the latter case).
+async function ensureKeyUnlocked(keyRecord) {
+ const existing = await getSessionKeys(keyRecord.id);
+ if (existing && existing.signingKey) return existing;
+
+ const answers = await host.ui.prompt({
+ title: 'Unlock S/MIME key',
+ message: `Your key for ${keyRecord.email || 'this identity'} is locked. Enter its storage passphrase to sign and send.`,
+ confirmLabel: 'Unlock & send',
+ fields: [
+ { name: 'pass', label: 'Storage passphrase', type: 'password', required: true },
+ ],
+ });
+ if (!answers) return null; // cancelled
+ const pass = answers.pass || '';
+ if (!pass) return null;
+
+ try {
+ const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(keyRecord, pass);
+ await saveSessionKeys({ id: keyRecord.id, signingKey, decryptionKey, legacyDecryptionKey });
+ return await getSessionKeys(keyRecord.id);
+ } catch (err) {
+ host.toast.error(err && err.message ? err.message : 'Unlock failed — wrong passphrase?');
+ return null;
+ }
+}
+
+async function recipientCertsFor(emails) {
+ const certs = await listPublicCerts();
+ const found = [];
+ const missing = [];
+ for (const email of emails) {
+ const c = certs.find((pc) => pc.email.toLowerCase() === email.toLowerCase());
+ if (c) found.push(c.certificate);
+ else missing.push(email);
+ }
+ return { found, missing };
+}
+
+// Build decrypt key maps from the session store, across all key records.
+async function unlockedDecryptMaps() {
+ const recs = await listKeyRecords();
+ const unlockedKeys = new Map();
+ const legacyUnlockedKeys = new Map();
+ for (const r of recs) {
+ const s = await getSessionKeys(r.id);
+ if (!s) continue;
+ if (s.decryptionKey) unlockedKeys.set(r.id, s.decryptionKey);
+ if (s.legacyDecryptionKey) legacyUnlockedKeys.set(r.id, s.legacyDecryptionKey);
+ }
+ return { keyRecords: recs, unlockedKeys, legacyUnlockedKeys };
+}
+
+// ─── Compose-send takeover ─────────────────────────────────────────────
+
+async function resolveIntent(req) {
+ const pick = (...vals) => {
+ for (const v of vals) if (typeof v === 'boolean') return v;
+ return undefined;
+ };
+ let sign = pick(req.sign, req.smimeSign, req.intent && req.intent.sign, req.smime && req.smime.sign);
+ let encrypt = pick(req.encrypt, req.smimeEncrypt, req.intent && req.intent.encrypt, req.smime && req.smime.encrypt);
+
+ if (sign === undefined && encrypt === undefined) {
+ // Fall back to the composer-toolbar slot's stored intent, then prefs.
+ const stored = (await host.storage.get(INTENT_KEY)) || {};
+ const prefs = await getPrefs();
+ sign = typeof stored.sign === 'boolean' ? stored.sign : prefs.defaultSign;
+ encrypt = typeof stored.encrypt === 'boolean' ? stored.encrypt : prefs.defaultEncrypt;
+ }
+ return { sign: !!sign, encrypt: !!encrypt };
+}
+
+async function fetchAttachments(req) {
+ const list = req.attachments || [];
+ const out = [];
+ for (const att of list) {
+ if (!att || !att.blobId) continue;
+ try {
+ const bytes = await host.jmap.fetchBlob(att.blobId, { name: att.name, type: att.type });
+ out.push({
+ filename: att.name || 'attachment',
+ contentType: att.type || 'application/octet-stream',
+ content: bytesArrayBuffer(bytes),
+ });
+ } catch (err) {
+ host.log.warn('attachment fetch failed', att.name, err);
+ throw new Error(`Could not read attachment "${att.name || ''}" for encryption`);
+ }
+ }
+ return out;
+}
+
+async function onComposeSend(req) {
+ if (!req || typeof req !== 'object') return undefined;
+
+ const { sign, encrypt } = await resolveIntent(req);
+ if (!sign && !encrypt) return undefined; // not our job — host sends normally
+
+ if (!(await isCapable())) {
+ host.toast.error('Cannot sign/encrypt: S/MIME is not running in the privileged tier.');
+ return false; // refuse rather than send plaintext when sign/encrypt was requested
+ }
+
+ try {
+ const identityId = req.identityId || req.identity || '';
+ if (!identityId) throw new Error('No sending identity available');
+
+ const from = parseAddr(req.fromEmail || req.from || (addrList(req.from)[0] || {}).email || '');
+ if (!from.email) throw new Error('Could not determine sender address');
+
+ const to = addrList(req.to);
+ const cc = addrList(req.cc);
+ const bcc = addrList(req.bcc);
+ const allRecipientEmails = [...emailsOf(req.to), ...emailsOf(req.cc), ...emailsOf(req.bcc)];
+
+ const keyRecord = (sign || encrypt) ? await signingKeyRecordForEmail(from.email) : undefined;
+ if ((sign || encrypt) && !keyRecord) {
+ host.toast.error(`No S/MIME key for ${from.email}. Import one in Settings → Plugins → S/MIME.`);
+ return false;
+ }
+
+ // Build the inner MIME message from the draft.
+ const attachments = await fetchAttachments(req);
+ let payloadBytes = buildMimeMessage({
+ from,
+ to,
+ cc,
+ subject: req.subject || '',
+ textBody: req.textBody || req.text || '',
+ htmlBody: req.htmlBody || req.html || '',
+ inReplyTo: req.inReplyTo,
+ references: req.references,
+ attachments,
+ });
+
+ // 1. Sign (opaque). If we'll also encrypt, nest the signed CMS as a MIME entity.
+ if (sign) {
+ // Locked keys are normally unlocked in onBeforeEmailSend (which can abort
+ // the send cleanly). This is a fallback for that path not having run: the
+ // popup shows here too, but cancelling clears the composer, so prefer the
+ // pre-send hook.
+ const session = await ensureKeyUnlocked(keyRecord);
+ if (!session || !session.signingKey) {
+ return false; // refuse rather than send unsigned
+ }
+ const signedBlob = await smimeSign(
+ payloadBytes,
+ session.signingKey,
+ keyRecord.certificate,
+ keyRecord.certificateChain || [],
+ );
+ const signedBytes = await blobToBytes(signedBlob);
+ payloadBytes = encrypt ? cmsInnerEntity(signedBytes, 'signed-data') : signedBytes;
+ }
+
+ // 2. Encrypt (envelope). Always includes the sender cert so Sent is readable.
+ let smimeType = sign ? 'signed-data' : null;
+ if (encrypt) {
+ const { found, missing } = await recipientCertsFor(allRecipientEmails);
+ if (missing.length > 0) {
+ host.toast.error(`Missing encryption certificate for: ${missing.join(', ')}`);
+ return false;
+ }
+ const envBlob = await smimeEncrypt(payloadBytes, found, keyRecord.certificate, useAes128());
+ payloadBytes = await blobToBytes(envBlob);
+ smimeType = 'enveloped-data';
+ }
+
+ // 3. Wrap as RFC822 and submit raw.
+ const rfc822 = wrapCmsAsSmimeMessage(payloadBytes, {
+ from,
+ to,
+ cc,
+ subject: req.subject || '',
+ inReplyTo: req.inReplyTo,
+ references: req.references,
+ smimeType,
+ });
+ const rawBytes = await blobToBytes(rfc822);
+
+ const envelopeRecipients = [...new Set([...allRecipientEmails])];
+ await host.jmap.sendRaw(bytesArrayBuffer(rawBytes), identityId, { envelopeRecipients });
+
+ host.toast.success(
+ encrypt && sign ? 'Message signed, encrypted and sent'
+ : encrypt ? 'Message encrypted and sent'
+ : 'Message signed and sent',
+ );
+ // Clear the per-message intent so the next compose starts from defaults.
+ await host.storage.set(INTENT_KEY, {});
+ return false; // we handled the send
+ } catch (err) {
+ host.log.error('onComposeSend failed', err);
+ host.toast.error(`S/MIME send failed: ${err && err.message ? err.message : String(err)}`);
+ return false; // do NOT fall through to a plaintext send when sign/encrypt was requested
+ }
+}
+
+// ─── Render-body takeover (verify / decrypt) ───────────────────────────
+
+// VNC: an auto-imported certificate becomes the ENCRYPTION TARGET for the
+// address it claims, so importing one is a trust decision — not a convenience.
+//
+// Upstream gated this on `signatureValid` alone. But `smimeVerify` runs with
+// `checkChain: false`, so `signatureValid` only asserts "these bytes were signed
+// by whoever holds the key in this certificate" — it says nothing about whether
+// the claimed identity is real. Both identity fields (`certificate-utils.js`
+// reads the legacy Subject `E=` attribute and the SAN `rfc822Name`) are
+// self-asserted on a self-signed cert.
+//
+// Attack that closed: self-sign a certificate asserting victim@example.com,
+// send one signed message. Upstream stored it as the encryption key for that
+// address, so the user's next "Encrypt" to the victim silently encrypted to the
+// attacker instead.
+//
+// So refuse anything the UI already labels untrusted (see the banner logic
+// below, which computes the same two conditions): the signer address must match
+// the From header, and the certificate must chain to something other than
+// itself. `signerEmailMatch` is `undefined` when either side is missing, so this
+// tests for `true` explicitly and fails closed.
+//
+// Once S-06 lands a real trust store, replace the `selfSigned` test with proper
+// chain validation against it — self-signed is a proxy for "unanchored", not the
+// whole of it.
+async function maybeAutoImportSigner(status) {
+ if (settings().autoImportSignerCerts === false) return;
+ const cert = status && status.signerCert;
+ if (!cert || !status.signatureValid || !cert.email) return;
+ if (status.signerEmailMatch !== true) {
+ host.log.warn('auto-import refused: signer address does not match From header');
+ return;
+ }
+ if (status.selfSigned) {
+ host.log.warn('auto-import refused: signer certificate is self-signed (no trust anchor)');
+ return;
+ }
+ try {
+ const existing = (await listPublicCerts()).some((c) => c.fingerprint === cert.fingerprint);
+ if (!existing) {
+ await savePublicCert({
+ id: generateUUID(),
+ email: cert.email,
+ certificate: cert.certificate,
+ issuer: cert.issuer,
+ subject: cert.subject,
+ notBefore: cert.notBefore,
+ notAfter: cert.notAfter,
+ fingerprint: cert.fingerprint,
+ source: 'signed-email',
+ });
+ }
+ } catch (err) {
+ host.log.warn('auto-import signer cert failed', err);
+ }
+}
+
+// Lucide-style stroke icons rendered inline so the status chip can tint them
+// with `currentColor` — matching the host's "External Content" banner, which
+// uses tinted SVG glyphs (not emoji) in a round chip.
+function iconSvg(size, ...children) {
+ return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }, ...children);
+}
+const ICONS = {
+ lock: (s = 20) => iconSvg(s,
+ h('rect', { width: 18, height: 11, x: 3, y: 11, rx: 2, ry: 2 }),
+ h('path', { d: 'M7 11V7a5 5 0 0 1 10 0v4' })),
+ lockOpen: (s = 20) => iconSvg(s,
+ h('rect', { width: 18, height: 11, x: 3, y: 11, rx: 2, ry: 2 }),
+ h('path', { d: 'M7 11V7a5 5 0 0 1 9.9-1' })),
+ shieldCheck: (s = 20) => iconSvg(s,
+ h('path', { d: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' }),
+ h('path', { d: 'm9 12 2 2 4-4' })),
+ shieldAlert: (s = 20) => iconSvg(s,
+ h('path', { d: 'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z' }),
+ h('path', { d: 'M12 8v4' }),
+ h('path', { d: 'M12 16h.01' })),
+ info: (s = 20) => iconSvg(s,
+ h('circle', { cx: 12, cy: 12, r: 10 }),
+ h('path', { d: 'M12 16v-4' }),
+ h('path', { d: 'M12 8h.01' })),
+};
+
+async function persistVerifyStatus(emailId, status) {
+ if (!emailId) return;
+ try { await host.storage.set(VERIFY_PREFIX + emailId, status); } catch { /* ignore */ }
+}
+
+async function onRenderEmailBody(body, ctx) {
+ if (!ctx) return undefined;
+ if (!(await isCapable())) return undefined; // can't decrypt/verify without the privileged tier
+
+ const detection = detectSmime(ctx.contentType, ctx.bodyStructure, ctx.attachments);
+ if (!detection.type) return undefined;
+
+ if (!detection.supported) {
+ const status = {
+ isSigned: detection.type === 'detached-sig',
+ isEncrypted: false,
+ unsupportedReason: `Unsupported S/MIME type (${detection.type})`,
+ };
+ await persistVerifyStatus(ctx.id, status);
+ return undefined; // let the host render the original body
+ }
+
+ const blobId = detection.blobId || ctx.blobId;
+ if (!blobId) return undefined;
+
+ const fromEmail = (addrList(ctx.from)[0] || {}).email;
+
+ try {
+ const raw = await host.jmap.fetchBlob(blobId);
+ const der = normalizeCmsBytes(bytesArrayBuffer(raw instanceof Uint8Array ? raw : new Uint8Array(raw)));
+
+ if (detection.type === 'enveloped-data') {
+ const { keyRecords, unlockedKeys, legacyUnlockedKeys } = await unlockedDecryptMaps();
+ let result;
+ try {
+ result = await smimeDecrypt({ cmsBytes: der, keyRecords, unlockedKeys, legacyUnlockedKeys });
+ } catch (err) {
+ // Single-banner UX: on failure we surface the status ONLY through the
+ // email-banner slot (which reads the persisted verification), and leave
+ // the body empty rather than stacking a second in-body notice box.
+ if (err instanceof SmimeKeyLockedError) {
+ const status = { isEncrypted: true, decryptionSuccess: false, decryptionError: 'locked' };
+ await persistVerifyStatus(ctx.id, status);
+ return { ...body, handledBy: 'smime', html: '', text: '', attachments: [], verification: status };
+ }
+ host.log.warn('S/MIME decrypt failed', err);
+ const status = { isEncrypted: true, decryptionSuccess: false, decryptionError: err && err.message ? err.message : String(err) };
+ await persistVerifyStatus(ctx.id, status);
+ return { ...body, handledBy: 'smime', html: '', text: '', attachments: [], verification: status };
+ }
+
+ // Decrypted inner content may itself be a signed CMS — either nested as a
+ // MIME entity (RFC 8551 sign-then-encrypt, the Outlook/Thunderbird form)
+ // or, more rarely, raw CMS DER. Detect both.
+ let innerBytes = result.mimeBytes;
+ const verification = { isEncrypted: true, decryptionSuccess: true };
+ const innerCt = innerContentType(innerBytes);
+ const innerDet = detectSmime(innerCt, null, null);
+ const looksSigned = innerDet.type === 'signed-data' || innerBytes[0] === 0x30;
+ if (looksSigned) {
+ try {
+ const signedDer = normalizeCmsBytes(bytesArrayBuffer(innerBytes));
+ const v = await smimeVerify(signedDer, fromEmail);
+ innerBytes = v.mimeBytes;
+ Object.assign(verification, v.status, { isEncrypted: true, decryptionSuccess: true });
+ await maybeAutoImportSigner(v.status);
+ } catch { /* not actually signed; keep decrypted content as-is */ }
+ }
+
+ const parsed = parseMime(innerBytes);
+ await persistVerifyStatus(ctx.id, verification);
+ return {
+ ...body,
+ handledBy: 'smime',
+ html: parsed.html || '',
+ text: parsed.text || '',
+ attachments: parsed.attachments,
+ verification,
+ };
+ }
+
+ if (detection.type === 'signed-data') {
+ const v = await smimeVerify(der, fromEmail);
+ await maybeAutoImportSigner(v.status);
+ const parsed = parseMime(v.mimeBytes);
+ await persistVerifyStatus(ctx.id, v.status);
+ return {
+ ...body,
+ handledBy: 'smime',
+ html: parsed.html || '',
+ text: parsed.text || '',
+ attachments: parsed.attachments,
+ verification: v.status,
+ };
+ }
+ } catch (err) {
+ host.log.error('onRenderEmailBody failed', err);
+ return undefined; // fall back to host rendering on unexpected failure
+ }
+
+ return undefined;
+}
+
+// Sniff the Content-Type of an inner MIME entity (first headers only).
+function innerContentType(bytes) {
+ const head = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 2048));
+ const m = head.match(/content-type:\s*([^\r\n]+)/i);
+ return m ? m[1].trim() : '';
+}
+
+// ─── UI: shared bits ───────────────────────────────────────────────────
+
+const card = {
+ border: '1px solid var(--color-border, #e2e8f0)',
+ borderRadius: '8px',
+ padding: '12px',
+ background: 'var(--color-card, #fff)',
+ color: 'var(--color-foreground, #0f172a)',
+};
+const btn = {
+ font: 'inherit',
+ padding: '6px 12px',
+ borderRadius: '6px',
+ border: '1px solid var(--color-input, #cbd5e1)',
+ background: 'var(--color-muted, #f1f5f9)',
+ color: 'var(--color-foreground, #0f172a)',
+ cursor: 'pointer',
+};
+const btnPrimary = { ...btn, background: 'var(--color-primary, #2563eb)', color: '#fff', border: '1px solid var(--color-primary, #2563eb)' };
+const input = {
+ font: 'inherit',
+ padding: '6px 8px',
+ borderRadius: '6px',
+ border: '1px solid var(--color-input, #cbd5e1)',
+ background: 'var(--color-background, #fff)',
+ color: 'var(--color-foreground, #0f172a)',
+ width: '100%',
+ boxSizing: 'border-box',
+};
+
+function fmtDate(iso) {
+ try { return new Date(iso).toLocaleDateString(); } catch { return iso; }
+}
+function isExpired(iso) {
+ try { return new Date(iso).getTime() < Date.now(); } catch { return false; }
+}
+
+// ─── UI: composer toolbar (Sign / Encrypt toggles) ─────────────────────
+
+function ComposerToolbar() {
+ const [intent, setIntent] = useState({ sign: false, encrypt: false });
+ const [ready, setReady] = useState(false);
+
+ useEffect(() => {
+ (async () => {
+ try {
+ if (!(await isCapable())) { setReady(false); return; }
+ const stored = (await host.storage.get(INTENT_KEY)) || {};
+ const prefs = await getPrefs();
+ setIntent({
+ sign: typeof stored.sign === 'boolean' ? stored.sign : prefs.defaultSign,
+ encrypt: typeof stored.encrypt === 'boolean' ? stored.encrypt : prefs.defaultEncrypt,
+ });
+ const recs = await listKeyRecords();
+ setReady(recs.length > 0);
+ } catch { setReady(false); }
+ })();
+ }, []);
+
+ const update = useCallback(async (next) => {
+ setIntent(next);
+ await host.storage.set(INTENT_KEY, next);
+ }, []);
+
+ const toggle = (key) => update({ ...intent, [key]: !intent[key] });
+
+ const pill = (active) => ({
+ ...btn,
+ background: active ? 'var(--color-primary, #2563eb)' : 'var(--color-muted, #f1f5f9)',
+ color: active ? '#fff' : 'var(--color-foreground, #0f172a)',
+ border: active ? '1px solid var(--color-primary, #2563eb)' : '1px solid var(--color-input, #cbd5e1)',
+ });
+
+ if (!ready) {
+ return h('span', { style: { fontSize: '12px', color: 'var(--color-muted-foreground, #64748b)' } },
+ 'S/MIME: import a key in Settings to sign/encrypt');
+ }
+
+ return h('div', { style: { display: 'inline-flex', gap: '6px', alignItems: 'center' } },
+ h('button', {
+ type: 'button',
+ style: pill(intent.sign),
+ title: 'Digitally sign this message',
+ onClick: () => toggle('sign'),
+ }, intent.sign ? '✓ Sign' : 'Sign'),
+ h('button', {
+ type: 'button',
+ style: pill(intent.encrypt),
+ title: 'Encrypt this message to its recipients',
+ onClick: () => toggle('encrypt'),
+ }, intent.encrypt ? '✓ Encrypt' : 'Encrypt'),
+ );
+}
+
+// ─── UI: email banner (verification / encryption status) ───────────────
+
+function EmailBanner(props) {
+ const email = props && props.email;
+ const [status, setStatus] = useState(null);
+ const [loaded, setLoaded] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ // "Unlock now" action for the locked-encryption banner: unlock any locked key
+ // (prompting for the storage passphrase), then ask the host to re-run the
+ // render hook so the body decrypts in place — no reload (which would wipe the
+ // just-unlocked in-memory keys).
+ const unlockNow = useCallback(async () => {
+ setBusy(true);
+ try {
+ const recs = await listKeyRecords();
+ const locked = [];
+ for (const r of recs) {
+ const s = await getSessionKeys(r.id);
+ if (!(s && s.decryptionKey)) locked.push(r);
+ }
+ let unlockedAny = false;
+ for (const rec of locked) {
+ const s = await ensureKeyUnlocked(rec);
+ if (s && s.decryptionKey) unlockedAny = true;
+ else break; // cancelled or wrong passphrase — stop prompting
+ }
+ if (!unlockedAny) return;
+
+ await host.ui.rerenderEmail();
+ // The re-decrypt runs in the background instance and rewrites the persisted
+ // verify status. This banner only read storage once on mount, so poll for
+ // the fresh status (until it's no longer 'locked') and update in place.
+ if (email && email.id) {
+ for (let i = 0; i < 20; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ let next = null;
+ try { next = await host.storage.get(VERIFY_PREFIX + email.id); } catch { /* ignore */ }
+ if (next && next.decryptionError !== 'locked') { setStatus(next); break; }
+ }
+ }
+ } finally {
+ setBusy(false);
+ }
+ }, [email]);
+
+ useEffect(() => {
+ let alive = true;
+ (async () => {
+ if (!email || !email.id) { setLoaded(true); return; }
+ let s = await host.storage.get(VERIFY_PREFIX + email.id);
+ if (!s) {
+ // No render-hook result yet — best-effort detect from headers/source.
+ const ct = email.headers && (email.headers['Content-Type'] || email.headers['content-type']);
+ const det = detectSmime(Array.isArray(ct) ? ct[0] : ct, undefined, undefined);
+ if (det.type === 'enveloped-data') s = { isEncrypted: true };
+ else if (det.type === 'signed-data') s = { isSigned: true };
+ else if (det.type === 'detached-sig') s = { isSigned: true, unsupportedReason: 'detached signature' };
+ }
+ if (alive) { setStatus(s || null); setLoaded(true); }
+ })();
+ return () => { alive = false; };
+ }, [email && email.id]);
+
+ if (!loaded || !status) return null;
+
+ const rows = [];
+ const warnSelfSigned = settings().warnOnSelfSigned !== false;
+
+ if (status.isEncrypted) {
+ if (status.decryptionSuccess) rows.push({ icon: 'lockOpen', eyebrow: 'Encryption', text: 'Decrypted', tone: 'success' });
+ else if (status.decryptionError === 'locked') rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted — unlock your key to read', tone: 'warning', action: 'unlock' });
+ else if (status.decryptionError) rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted — couldn’t be decrypted with your keys', tone: 'destructive' });
+ else rows.push({ icon: 'lock', eyebrow: 'Encryption', text: 'Encrypted message', tone: 'info' });
+ }
+ if (status.isSigned || status.signerCert) {
+ if (status.signatureValid) {
+ const who = status.signerCert && status.signerCert.email ? ` by ${status.signerCert.email}` : '';
+ const mismatch = status.signerEmailMatch === false ? ' · signer ≠ From' : '';
+ const selfSigned = warnSelfSigned && status.selfSigned;
+ const ss = selfSigned ? ' · self-signed' : '';
+ // A valid signature only reads as trusted-green when it also chains to a
+ // CA and the signer matches the From. A self-signed cert or a signer≠From
+ // mismatch downgrades to an amber warning (still "valid", just untrusted).
+ const untrusted = status.signerEmailMatch === false || selfSigned;
+ rows.push({
+ icon: untrusted ? 'shieldAlert' : 'shieldCheck',
+ eyebrow: 'Signature',
+ text: `Valid signature${who}${ss}${mismatch}`,
+ tone: untrusted ? 'warning' : 'success',
+ });
+ } else if (status.signatureError) {
+ rows.push({ icon: 'shieldAlert', eyebrow: 'Signature', text: `Invalid signature: ${status.signatureError}`, tone: 'destructive' });
+ } else {
+ rows.push({ icon: 'shieldCheck', eyebrow: 'Signature', text: 'Signed message', tone: 'info' });
+ }
+ }
+ if (status.unsupportedReason) rows.push({ icon: 'info', eyebrow: 'S/MIME', text: status.unsupportedReason, tone: 'info' });
+
+ if (rows.length === 0) return null;
+
+ const toneColor = (tone) => tone === 'success' ? 'var(--color-success, #16a34a)'
+ : tone === 'destructive' ? 'var(--color-destructive, #dc2626)'
+ : tone === 'warning' ? 'var(--color-warning, #d97706)'
+ : 'var(--color-info, #0284c7)';
+
+ // Mirror the host's "External Content" banner: a full-width bg-muted/30 strip
+ // with a bottom border, each status as a round tinted icon chip + uppercase
+ // eyebrow + foreground message.
+ return h('div', {
+ style: {
+ background: 'color-mix(in srgb, var(--color-muted, #f1f5f9) 30%, transparent)',
+ borderBottom: '1px solid var(--color-border, #e2e8f0)',
+ padding: '6px 24px',
+ display: 'flex', flexDirection: 'column', gap: '4px',
+ },
+ },
+ rows.map((r, i) => {
+ const color = toneColor(r.tone);
+ return h('div', { key: i, style: { display: 'flex', alignItems: 'flex-start', gap: '12px', padding: '4px 0' } },
+ h('div', {
+ style: {
+ width: '40px', height: '40px', borderRadius: '9999px', flexShrink: 0,
+ background: `color-mix(in srgb, ${color} 15%, transparent)`,
+ color,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
+ },
+ }, ICONS[r.icon]()),
+ h('div', { style: { flex: 1, minWidth: 0 } },
+ h('div', { style: { fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--color-muted-foreground, #64748b)' } }, r.eyebrow),
+ h('div', { style: { fontSize: '14px', fontWeight: 500, color: 'var(--color-foreground, #0f172a)', overflowWrap: 'break-word' } }, r.text),
+ r.action === 'unlock' && h('div', { style: { marginTop: '8px' } },
+ h('button', {
+ type: 'button',
+ disabled: busy,
+ onClick: unlockNow,
+ style: {
+ display: 'inline-flex', alignItems: 'center', gap: '6px',
+ fontSize: '13px', padding: '6px 12px', borderRadius: '8px', minHeight: '34px',
+ border: '1px solid var(--color-border, #e2e8f0)',
+ background: 'transparent', color: 'var(--color-foreground, #0f172a)',
+ cursor: busy ? 'not-allowed' : 'pointer', opacity: busy ? 0.6 : 1,
+ },
+ }, ICONS.lockOpen(15), busy ? 'Unlocking…' : 'Unlock now'),
+ ),
+ ),
+ );
+ }),
+ );
+}
+
+// ─── UI: settings section (key & certificate management) ───────────────
+
+function SettingsSection() {
+ const [keys, setKeys] = useState([]);
+ const [certs, setCerts] = useState([]);
+ const [prefs, setPrefsState] = useState(DEFAULT_PREFS);
+ const [unlocked, setUnlocked] = useState({}); // id -> bool
+ const [busy, setBusy] = useState(false);
+ const [capable, setCapable] = useState(true);
+ const fileRef = useRef(null);
+ const certFileRef = useRef(null);
+
+ const refresh = useCallback(async () => {
+ if (!(await isCapable())) { setCapable(false); return; }
+ const [k, c, p] = await Promise.all([listKeyRecords(), listPublicCerts(), getPrefs()]);
+ setKeys(k); setCerts(c); setPrefsState(p);
+ const u = {};
+ for (const rec of k) u[rec.id] = !!(await getSessionKeys(rec.id));
+ setUnlocked(u);
+ }, []);
+
+ useEffect(() => { void refresh(); }, [refresh]);
+
+ if (!capable) {
+ return h('div', { style: { ...card, borderColor: 'var(--color-destructive, #dc2626)', color: 'var(--color-destructive, #dc2626)', maxWidth: '720px' } },
+ h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, 'S/MIME is not active'),
+ h('div', { style: { fontSize: '13px', lineHeight: 1.5 } }, NOT_PRIVILEGED_MSG),
+ );
+ }
+
+ async function importKeyFile() {
+ const file = fileRef.current && fileRef.current.files && fileRef.current.files[0];
+ if (!file) return;
+ const answers = await host.ui.prompt({
+ title: 'Import S/MIME key',
+ message: `Importing "${file.name}".`,
+ confirmLabel: 'Import',
+ fields: [
+ { name: 'p12pass', label: 'Passphrase protecting the .p12/.pfx file', type: 'password', placeholder: 'Leave blank if the file has none' },
+ { name: 'storagePass', label: 'New passphrase to protect this key in your browser', type: 'password', required: true },
+ ],
+ });
+ if (!answers) return; // cancelled
+ const p12pass = answers.p12pass || '';
+ const storagePass = answers.storagePass || '';
+ if (!storagePass) { host.toast.error('A storage passphrase is required'); return; }
+ setBusy(true);
+ try {
+ const buf = await file.arrayBuffer();
+ const { keyRecord } = await importPkcs12(buf, p12pass, storagePass);
+ await saveKeyRecord(keyRecord);
+ host.toast.success(`Imported S/MIME key for ${keyRecord.email || 'certificate'}`);
+ if (fileRef.current) fileRef.current.value = '';
+ await refresh();
+ } catch (err) {
+ host.toast.error(`Import failed: ${err && err.message ? err.message : String(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function unlock(rec) {
+ const answers = await host.ui.prompt({
+ title: `Unlock ${rec.email || 'S/MIME key'}`,
+ confirmLabel: 'Unlock',
+ fields: [
+ { name: 'pass', label: 'Storage passphrase for this key', type: 'password', required: true },
+ ],
+ });
+ if (!answers) return; // cancelled
+ const pass = answers.pass || '';
+ if (!pass) return;
+ setBusy(true);
+ try {
+ const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(rec, pass);
+ await saveSessionKeys({ id: rec.id, signingKey, decryptionKey, legacyDecryptionKey });
+ host.toast.success(`Unlocked ${rec.email || 'key'}`);
+ await refresh();
+ } catch (err) {
+ host.toast.error(err && err.message ? err.message : 'Unlock failed');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function lock(rec) {
+ await deleteSessionKeys(rec.id);
+ host.toast.info(`Locked ${rec.email || 'key'}`);
+ await refresh();
+ }
+
+ async function removeKey(rec) {
+ const ok = await host.ui.confirm({
+ title: 'Delete S/MIME key',
+ message: `Delete the private key and certificate for ${rec.email || 'this identity'}? You will no longer be able to decrypt mail encrypted to it.`,
+ danger: true,
+ confirmLabel: 'Delete',
+ });
+ if (!ok) return;
+ await deleteSessionKeys(rec.id);
+ await deleteKeyRecord(rec.id);
+ host.toast.success('Key deleted');
+ await refresh();
+ }
+
+ async function importCertFile() {
+ const file = certFileRef.current && certFileRef.current.files && certFileRef.current.files[0];
+ if (!file) return;
+ setBusy(true);
+ try {
+ const buf = await file.arrayBuffer();
+ const cert = parseCertificatePemOrDer(buf);
+ const der = cert.toSchema(true).toBER(false);
+ const info = await extractCertificateInfo(cert, der);
+ const email = (info.emailAddresses[0] || '').toLowerCase();
+ if (!email) throw new Error('Certificate has no email address');
+ await savePublicCert({
+ id: generateUUID(),
+ email,
+ certificate: der,
+ issuer: info.issuer,
+ subject: info.subject,
+ notBefore: info.notBefore,
+ notAfter: info.notAfter,
+ fingerprint: info.fingerprint,
+ source: 'manual',
+ });
+ host.toast.success(`Imported certificate for ${email}`);
+ if (certFileRef.current) certFileRef.current.value = '';
+ await refresh();
+ } catch (err) {
+ host.toast.error(`Certificate import failed: ${err && err.message ? err.message : String(err)}`);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function removeCert(c) {
+ await deletePublicCert(c.id);
+ await refresh();
+ }
+
+ async function setPref(key, value) {
+ const next = { ...prefs, [key]: value };
+ setPrefsState(next);
+ await setPrefs(next);
+ }
+
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '16px', maxWidth: '720px' } },
+ h('div', null,
+ h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, 'Your keys'),
+ h('p', { style: { margin: '0 0 8px', fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } },
+ 'Import a PKCS#12 (.p12/.pfx) file containing your certificate and private key. The key is encrypted in your browser and never leaves it.'),
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } },
+ h('input', { ref: fileRef, type: 'file', accept: '.p12,.pfx', style: { fontSize: '13px' } }),
+ h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: importKeyFile }, 'Import key'),
+ ),
+ keys.length === 0
+ ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, 'No keys imported yet.')
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
+ keys.map((rec) => h('div', { key: rec.id, style: card },
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' } },
+ h('div', null,
+ h('div', { style: { fontWeight: 600, fontSize: '14px' } }, rec.email || rec.subject || 'Certificate'),
+ h('div', { style: { fontSize: '12px', color: 'var(--color-muted-foreground, #64748b)' } },
+ `${rec.algorithm} · valid ${fmtDate(rec.notBefore)} – ${fmtDate(rec.notAfter)}${isExpired(rec.notAfter) ? ' · EXPIRED' : ''}`),
+ h('div', { style: { fontSize: '11px', fontFamily: 'monospace', color: 'var(--color-muted-foreground, #64748b)', wordBreak: 'break-all' } },
+ rec.fingerprint),
+ h('div', { style: { fontSize: '11px', color: 'var(--color-muted-foreground, #64748b)' } },
+ `${rec.capabilities && rec.capabilities.canSign ? 'sign' : ''}${rec.capabilities && rec.capabilities.canSign && rec.capabilities.canEncrypt ? ' · ' : ''}${rec.capabilities && rec.capabilities.canEncrypt ? 'encrypt' : ''}`),
+ ),
+ h('div', { style: { display: 'flex', gap: '6px', alignItems: 'flex-start' } },
+ unlocked[rec.id]
+ ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => lock(rec) }, '🔓 Lock')
+ : h('button', { type: 'button', style: btnPrimary, disabled: busy, onClick: () => unlock(rec) }, '🔒 Unlock'),
+ h('button', {
+ type: 'button',
+ style: { ...btn, color: 'var(--color-destructive, #dc2626)', borderColor: 'var(--color-destructive, #dc2626)' },
+ disabled: busy, onClick: () => removeKey(rec),
+ }, 'Delete'),
+ ),
+ ),
+ )),
+ ),
+ ),
+
+ h('div', null,
+ h('h3', { style: { margin: '0 0 4px', fontSize: '15px', fontWeight: 600 } }, 'Recipient certificates'),
+ h('p', { style: { margin: '0 0 8px', fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } },
+ 'Public certificates (PEM/DER) of people you want to send encrypted mail to. Signer certificates from validly signed mail are saved automatically.'),
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '12px' } },
+ h('input', { ref: certFileRef, type: 'file', accept: '.pem,.crt,.cer,.der', style: { fontSize: '13px' } }),
+ h('button', { type: 'button', style: btn, disabled: busy, onClick: importCertFile }, 'Import certificate'),
+ ),
+ certs.length === 0
+ ? h('div', { style: { ...card, fontSize: '13px', color: 'var(--color-muted-foreground, #64748b)' } }, 'No recipient certificates.')
+ : h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
+ certs.map((c) => h('div', { key: c.id, style: { ...card, display: 'flex', justifyContent: 'space-between', gap: '8px', alignItems: 'center' } },
+ h('div', null,
+ h('div', { style: { fontWeight: 600, fontSize: '13px' } }, c.email || c.subject),
+ h('div', { style: { fontSize: '11px', color: 'var(--color-muted-foreground, #64748b)' } },
+ `${c.source} · expires ${fmtDate(c.notAfter)}${isExpired(c.notAfter) ? ' · EXPIRED' : ''}`),
+ ),
+ h('button', { type: 'button', style: { ...btn, color: 'var(--color-destructive, #dc2626)' }, onClick: () => removeCert(c) }, 'Remove'),
+ )),
+ ),
+ ),
+
+ h('div', null,
+ h('h3', { style: { margin: '0 0 8px', fontSize: '15px', fontWeight: 600 } }, 'Defaults for new messages'),
+ h('label', { style: { display: 'flex', gap: '8px', alignItems: 'center', fontSize: '13px', marginBottom: '6px' } },
+ h('input', { type: 'checkbox', checked: !!prefs.defaultSign, onChange: (e) => setPref('defaultSign', e.target.checked) }),
+ 'Sign new messages by default'),
+ h('label', { style: { display: 'flex', gap: '8px', alignItems: 'center', fontSize: '13px' } },
+ h('input', { type: 'checkbox', checked: !!prefs.defaultEncrypt, onChange: (e) => setPref('defaultEncrypt', e.target.checked) }),
+ 'Encrypt new messages by default (when all recipients have certificates)'),
+ ),
+ );
+}
+
+// ─── Exports ───────────────────────────────────────────────────────────
+
+// Before a send commits: if the user is signing but their key is locked,
+// prompt to unlock it here rather than failing mid-send in onComposeSend.
+// Returning false aborts the send cleanly — the draft and open composer are
+// preserved — so a cancelled unlock never loses the message.
+async function onBeforeEmailSend(email) {
+ try {
+ if (!email || typeof email !== 'object') return true;
+ if (!(await isCapable())) return true;
+ // Resolve the sign intent the way onComposeSend does: the composer-toolbar
+ // slot's stored intent, falling back to prefs. (Encrypt needs no private key.)
+ const stored = (await host.storage.get(INTENT_KEY)) || {};
+ const prefs = await getPrefs();
+ const sign = typeof stored.sign === 'boolean' ? stored.sign : prefs.defaultSign;
+ if (!sign) return true;
+
+ const from = parseAddr(email.fromEmail || email.from || '');
+ if (!from.email) return true;
+ const keyRecord = await signingKeyRecordForEmail(from.email);
+ if (!keyRecord) return true; // onComposeSend surfaces the "no key" message
+
+ const session = await getSessionKeys(keyRecord.id);
+ if (session && session.signingKey) return true; // already unlocked
+
+ const unlocked = await ensureKeyUnlocked(keyRecord);
+ return !!(unlocked && unlocked.signingKey); // false → cancelled/failed → abort send
+ } catch (err) {
+ host.log.warn('onBeforeEmailSend unlock check failed', err);
+ return true; // never block a send on an unexpected error here
+ }
+}
+
+export const hooks = {
+ onBeforeEmailSend,
+ onComposeSend,
+ onRenderEmailBody,
+ // Wipe unlocked keys from the shared session store on sign-out / account switch.
+ async onAfterLogout() {
+ if (settings().lockOnLogout === false) return;
+ try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); }
+ },
+ async onAccountSwitch() {
+ if (settings().lockOnLogout === false) return;
+ try { await clearSessionKeys(); } catch (err) { host.log.warn('clearSessionKeys failed', err); }
+ },
+};
+
+export const slots = {
+ 'composer-toolbar': { component: ComposerToolbar, order: 70 },
+ 'email-banner': { component: EmailBanner, order: 20 },
+ 'settings-section': { component: SettingsSection, order: 100 },
+};
+
+export async function activate(api) {
+ // Bail out gracefully if we're not in the privileged (same-origin) tier — do
+ // NOT throw, or the circuit breaker disables the plugin after a raw IDB error.
+ if (!(await isCapable())) {
+ api.log.error(NOT_PRIVILEGED_MSG);
+ try { api.toast.error('S/MIME needs the privileged tier — see plugin logs / contact your admin.'); } catch { /* ignore */ }
+ return;
+ }
+ // Enforce session scope for unlocked keys: wipe any left over from a prior
+ // app session at boot (mirrors the native "in-memory, cleared on reload").
+ try { await clearSessionKeys(); } catch (err) { api.log.warn('S/MIME: clearSessionKeys failed', err); }
+ let keyCount = 0;
+ try { keyCount = (await listKeyRecords()).length; } catch (err) { api.log.warn('S/MIME: listKeyRecords failed', err); }
+ api.log.info(`S/MIME plugin activated (${keyCount} key${keyCount === 1 ? '' : 's'} imported)`);
+}
diff --git a/vnc/plugins/smime/src/key-storage.js b/vnc/plugins/smime/src/key-storage.js
new file mode 100644
index 00000000..fc7fb2c4
--- /dev/null
+++ b/vnc/plugins/smime/src/key-storage.js
@@ -0,0 +1,130 @@
+/**
+ * IndexedDB persistence for the S/MIME plugin.
+ *
+ * The privileged plugin runs in a same-origin iframe, so all of its iframes
+ * (the hidden background instance that runs hooks + each visible slot) share
+ * one IndexedDB. That's what lets the settings slot unlock a key and the
+ * background send/receive hooks immediately use it.
+ *
+ * Three stores:
+ * - key-records: encrypted-at-rest private keys + certs (durable)
+ * - public-certs: recipient/contact public certificates (durable)
+ * - session-keys: unlocked, NON-EXTRACTABLE CryptoKeys (session-scoped;
+ * wiped on activate() at app boot and on logout)
+ *
+ * CryptoKey objects are structured-cloneable, so IndexedDB can persist the
+ * unlocked handles without ever exposing the raw key material — a
+ * non-extractable key stays non-extractable when read back.
+ */
+
+const DB_NAME = 'smime-plugin-store';
+const DB_VERSION = 1;
+const KEY_RECORDS_STORE = 'key-records';
+const PUBLIC_CERTS_STORE = 'public-certs';
+const SESSION_KEYS_STORE = 'session-keys';
+
+function openDB() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
+ const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
+ keyStore.createIndex('email', 'email', { unique: false });
+ keyStore.createIndex('accountId', 'accountId', { unique: false });
+ }
+ if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
+ const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
+ certStore.createIndex('email', 'email', { unique: false });
+ certStore.createIndex('accountId', 'accountId', { unique: false });
+ }
+ if (!db.objectStoreNames.contains(SESSION_KEYS_STORE)) {
+ db.createObjectStore(SESSION_KEYS_STORE, { keyPath: 'id' });
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+}
+
+function txPromise(db, storeName, mode, fn) {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, mode);
+ const store = tx.objectStore(storeName);
+ const req = fn(store);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+// ── Key record CRUD ─────────────────────────────────────────────────
+
+export async function saveKeyRecord(record) {
+ const db = await openDB();
+ await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record));
+}
+
+export async function getKeyRecord(id) {
+ const db = await openDB();
+ return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id));
+}
+
+export async function listKeyRecords(accountId) {
+ const db = await openDB();
+ const all = await txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll());
+ if (!accountId) return all;
+ return all.filter((r) => r.accountId === accountId || !r.accountId);
+}
+
+export async function deleteKeyRecord(id) {
+ const db = await openDB();
+ await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.delete(id));
+}
+
+// ── Public cert CRUD ────────────────────────────────────────────────
+
+export async function savePublicCert(cert) {
+ const db = await openDB();
+ await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert));
+}
+
+export async function listPublicCerts(accountId) {
+ const db = await openDB();
+ const all = await txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll());
+ if (!accountId) return all;
+ return all.filter((c) => c.accountId === accountId || !c.accountId);
+}
+
+export async function deletePublicCert(id) {
+ const db = await openDB();
+ await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id));
+}
+
+// ── Session (unlocked) key CRUD ─────────────────────────────────────
+// Each entry: { id, signingKey, decryptionKey?, legacyDecryptionKey? }
+
+export async function saveSessionKeys(entry) {
+ const db = await openDB();
+ await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.put(entry));
+}
+
+export async function getSessionKeys(id) {
+ const db = await openDB();
+ return txPromise(db, SESSION_KEYS_STORE, 'readonly', (s) => s.get(id));
+}
+
+export async function listSessionKeyIds() {
+ const db = await openDB();
+ const all = await txPromise(db, SESSION_KEYS_STORE, 'readonly', (s) => s.getAllKeys());
+ return all;
+}
+
+export async function deleteSessionKeys(id) {
+ const db = await openDB();
+ await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.delete(id));
+}
+
+export async function clearSessionKeys() {
+ const db = await openDB();
+ await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.clear());
+}
diff --git a/vnc/plugins/smime/src/mime-builder.js b/vnc/plugins/smime/src/mime-builder.js
new file mode 100644
index 00000000..f860df10
--- /dev/null
+++ b/vnc/plugins/smime/src/mime-builder.js
@@ -0,0 +1,291 @@
+/**
+ * Minimal, deterministic MIME builder for outgoing S/MIME messages.
+ * Ported from lib/smime/mime-builder.ts. All line endings are CRLF.
+ */
+
+import { generateUUID } from './util.js';
+
+const CRLF = '\r\n';
+
+/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
+export function buildMimeMessage(input) {
+ const boundary = generateBoundary();
+ const lines = [];
+
+ lines.push(formatHeader('From', formatAddress(input.from)));
+ lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
+ if (input.cc?.length) lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
+ lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
+ lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
+ lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
+ if (input.inReplyTo) lines.push(formatHeader('In-Reply-To', input.inReplyTo));
+ if (input.references?.length) lines.push(formatHeader('References', input.references.join(' ')));
+ lines.push('MIME-Version: 1.0');
+
+ const hasText = !!input.textBody;
+ const hasHtml = !!input.htmlBody;
+ const hasAttachments = !!input.attachments?.length;
+
+ if (!hasAttachments && hasText && !hasHtml) {
+ lines.push('Content-Type: text/plain; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.textBody));
+ } else if (!hasAttachments && hasText && hasHtml) {
+ const altBoundary = generateBoundary();
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
+ lines.push('');
+ lines.push(`--${altBoundary}`);
+ lines.push('Content-Type: text/plain; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.textBody));
+ lines.push(`--${altBoundary}`);
+ lines.push('Content-Type: text/html; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.htmlBody));
+ lines.push(`--${altBoundary}--`);
+ } else if (!hasAttachments && !hasText && hasHtml) {
+ lines.push('Content-Type: text/html; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.htmlBody));
+ } else if (hasAttachments) {
+ lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
+ lines.push('');
+
+ if (hasText && hasHtml) {
+ const altBoundary = generateBoundary();
+ lines.push(`--${boundary}`);
+ lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
+ lines.push('');
+ lines.push(`--${altBoundary}`);
+ lines.push('Content-Type: text/plain; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.textBody));
+ lines.push(`--${altBoundary}`);
+ lines.push('Content-Type: text/html; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.htmlBody));
+ lines.push(`--${altBoundary}--`);
+ } else if (hasText) {
+ lines.push(`--${boundary}`);
+ lines.push('Content-Type: text/plain; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.textBody));
+ } else if (hasHtml) {
+ lines.push(`--${boundary}`);
+ lines.push('Content-Type: text/html; charset=utf-8');
+ lines.push('Content-Transfer-Encoding: quoted-printable');
+ lines.push('');
+ lines.push(quotedPrintableEncode(input.htmlBody));
+ }
+
+ for (const att of input.attachments) {
+ lines.push(`--${boundary}`);
+ const disposition = att.cid ? 'inline' : 'attachment';
+ // VNC: these two lines are assembled directly rather than via
+ // formatHeader, so stripCrlf has to be applied explicitly. Both carry
+ // inbound values when forwarding a message (the original part's
+ // Content-Type and inline-image Content-ID), so both are attacker-
+ // reachable. `filename` is already neutralised by encodeHeaderValue.
+ lines.push(`Content-Type: ${stripCrlf(att.contentType)}; name="${encodeHeaderValue(att.filename)}"`);
+ lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`);
+ lines.push('Content-Transfer-Encoding: base64');
+ if (att.cid) lines.push(`Content-ID: <${stripCrlf(att.cid)}>`);
+ lines.push('');
+ lines.push(base64Encode(att.content));
+ }
+ lines.push(`--${boundary}--`);
+ } else {
+ lines.push('Content-Type: text/plain; charset=utf-8');
+ lines.push('');
+ }
+
+ return new TextEncoder().encode(lines.join(CRLF));
+}
+
+// ── Helpers ──────────────────────────────────────────────────────────
+
+function generateBoundary() {
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
+ return `----=_Part_${hex}`;
+}
+
+function formatAddress(addr) {
+ if (addr.name) {
+ const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
+ return `"${escaped}" <${addr.email}>`;
+ }
+ return addr.email;
+}
+
+// VNC: strip CR/LF from any header value before it reaches the header block.
+//
+// Upstream relied on `encodeHeaderValue`, whose Q-encoding neutralises CR/LF as
+// a side effect — but it was only applied to Subject and attachment filename.
+// Display names, raw addresses, Message-ID, In-Reply-To, References and
+// attachment Content-Type all reached `formatHeader` unfiltered, and
+// `formatAddress` escapes only backslash and quote. `formatHeader` folds long
+// lines but never sanitises, so an embedded CRLF was emitted verbatim and became
+// an injected header.
+//
+// That is remotely reachable: In-Reply-To, References and display names are
+// copied from an inbound message when replying or forwarding, so the value is
+// attacker-supplied.
+//
+// Sanitising here rather than at the call sites means every header is covered by
+// construction — a future header can't reintroduce the hole by forgetting to
+// wrap its value. Folding still inserts legitimate CRLF afterwards; only CR/LF
+// arriving *inside* a value is collapsed.
+function stripCrlf(value) {
+ const s = String(value);
+ // Fold whitespace runs containing CR/LF into a single space: a header value
+ // cannot legally contain a bare line break, and preserving the surrounding
+ // text is friendlier than truncating at the first one.
+ const clean = s.replace(/[\r\n]+[ \t]*/g, ' ');
+ if (clean !== s) {
+ // Loud, because this means something upstream handed us a header value it
+ // should have rejected. Worth seeing in a console during QA.
+ console.warn('[smime] stripped CR/LF from header value');
+ }
+ return clean;
+}
+
+function formatHeader(name, rawValue) {
+ const value = stripCrlf(rawValue);
+ const full = `${name}: ${value}`;
+ if (full.length <= 76) return full;
+ const parts = [];
+ let remaining = full;
+ let first = true;
+ while (remaining.length > 76) {
+ let breakAt = 76;
+ const spaceIdx = remaining.lastIndexOf(' ', 76);
+ if (spaceIdx > (first ? name.length + 2 : 1)) breakAt = spaceIdx;
+ parts.push(remaining.slice(0, breakAt));
+ remaining = ' ' + remaining.slice(breakAt).trimStart();
+ first = false;
+ }
+ parts.push(remaining);
+ return parts.join(CRLF);
+}
+
+function encodeHeaderValue(value) {
+ if (/^[\x20-\x7e]*$/.test(value)) return value;
+ const encoded = Array.from(new TextEncoder().encode(value))
+ .map((b) => {
+ if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a)) {
+ return String.fromCharCode(b);
+ }
+ return '=' + b.toString(16).toUpperCase().padStart(2, '0');
+ })
+ .join('');
+ return `=?UTF-8?Q?${encoded}?=`;
+}
+
+function formatDate(date) {
+ const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+ const d = days[date.getUTCDay()];
+ const dd = date.getUTCDate();
+ const m = months[date.getUTCMonth()];
+ const y = date.getUTCFullYear();
+ const hh = String(date.getUTCHours()).padStart(2, '0');
+ const mm = String(date.getUTCMinutes()).padStart(2, '0');
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
+ return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
+}
+
+/**
+ * Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
+ * Returns a Blob of type message/rfc822.
+ */
+export function wrapCmsAsSmimeMessage(cmsBlob, input) {
+ const lines = [];
+
+ lines.push(formatHeader('From', formatAddress(input.from)));
+ lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
+ if (input.cc?.length) lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
+ lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
+ lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
+ lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
+ if (input.inReplyTo) lines.push(formatHeader('In-Reply-To', input.inReplyTo));
+ if (input.references?.length) lines.push(formatHeader('References', input.references.join(' ')));
+ lines.push('MIME-Version: 1.0');
+ // VNC: smimeType is plugin-supplied ('signed-data' / 'enveloped-data') rather
+ // than message-derived, so this is belt-and-braces — but sanitising every
+ // interpolated header value unconditionally is what makes the rule checkable
+ // (see verify-fixes.mjs) instead of resting on a per-case judgement call.
+ lines.push(`Content-Type: application/pkcs7-mime; smime-type=${stripCrlf(input.smimeType)}; name="smime.p7m"`);
+ lines.push('Content-Transfer-Encoding: base64');
+ lines.push('Content-Disposition: attachment; filename="smime.p7m"');
+
+ // Terminate the header block with a BLANK LINE (CRLFCRLF) before the base64
+ // body. The body is concatenated as a separate Blob below, so a trailing ''
+ // in `lines` only yields a single CRLF — gluing the CMS onto the last header.
+ // A strict parser (Stalwart/mail-parser) then reads the base64 as malformed
+ // headers and leaves the pkcs7-mime part empty, which surfaces on the
+ // receiving side as "Invalid ASN.1 data - cannot parse CMS envelope".
+ const headerBytes = new TextEncoder().encode(lines.join(CRLF) + CRLF + CRLF);
+ return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
+}
+
+function cmsToBase64Blob(data) {
+ let bytes;
+ if (data instanceof Uint8Array) bytes = data;
+ else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
+ else bytes = new Uint8Array(0);
+ const b64 = base64Encode(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
+ return new Blob([new TextEncoder().encode(b64 + CRLF)]);
+}
+
+/** Encode string as quoted-printable (RFC 2045). */
+export function quotedPrintableEncode(input) {
+ const bytes = new TextEncoder().encode(input);
+ const lines = [];
+ let line = '';
+
+ for (const b of bytes) {
+ let encoded;
+ if (b === 0x0d || b === 0x0a) {
+ encoded = String.fromCharCode(b);
+ } else if (b === 0x09 || (b >= 0x20 && b <= 0x7e && b !== 0x3d)) {
+ encoded = String.fromCharCode(b);
+ } else {
+ encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
+ }
+
+ if (b === 0x0a) {
+ if (line.endsWith('\r')) line = line.slice(0, -1);
+ lines.push(line);
+ line = '';
+ continue;
+ }
+
+ if (line.length + encoded.length > 75) {
+ lines.push(line + '=');
+ line = encoded;
+ } else {
+ line += encoded;
+ }
+ }
+ lines.push(line);
+ return lines.join(CRLF);
+}
+
+/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */
+export function base64Encode(data) {
+ const bytes = new Uint8Array(data);
+ let binary = '';
+ for (const b of bytes) binary += String.fromCharCode(b);
+ const b64 = btoa(binary);
+ const lines = [];
+ for (let i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
+ return lines.join(CRLF);
+}
diff --git a/vnc/plugins/smime/src/mime-parse.js b/vnc/plugins/smime/src/mime-parse.js
new file mode 100644
index 00000000..4ea75157
--- /dev/null
+++ b/vnc/plugins/smime/src/mime-parse.js
@@ -0,0 +1,190 @@
+/**
+ * Minimal RFC 5322 / MIME parser used only for the inner content recovered
+ * after decryption / signature-stripping. We need just enough to pull out the
+ * best-alternative text/html body and any leaf attachments; the host
+ * re-sanitizes returned HTML, so this never has to be a hardened renderer.
+ */
+
+const decoder = new TextDecoder('utf-8', { fatal: false });
+
+/** Parse raw inner MIME bytes into { html, text, attachments }. */
+export function parseMime(bytes) {
+ const text = binaryString(bytes);
+ const node = parseEntity(text);
+ const out = { html: '', text: '', attachments: [] };
+ collect(node, out);
+ // Fallback for non-MIME inner content (e.g. messages signed/encrypted by
+ // OpenSSL or older clients where the protected payload is raw text with no
+ // Content-Type). If structured parsing produced no renderable body, surface
+ // the decoded bytes as plain text so the message is never shown blank.
+ if (!out.html && !out.text) {
+ const raw = decoder.decode(bytes).trim();
+ if (raw) out.text = raw;
+ }
+ return out;
+}
+
+// Treat bytes as latin1 so byte boundaries survive; decode per-part by charset.
+function binaryString(bytes) {
+ let s = '';
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
+ return s;
+}
+
+function parseEntity(raw) {
+ const sepMatch = raw.match(/\r?\n\r?\n/);
+ const headerText = sepMatch ? raw.slice(0, sepMatch.index) : raw;
+ const body = sepMatch ? raw.slice(sepMatch.index + sepMatch[0].length) : '';
+
+ const headers = parseHeaders(headerText);
+ const ctRaw = headers['content-type'] || 'text/plain';
+ const { type, params } = parseContentType(ctRaw);
+ const cte = (headers['content-transfer-encoding'] || '7bit').trim().toLowerCase();
+ const disposition = (headers['content-disposition'] || '').toLowerCase();
+
+ const node = { type, params, cte, disposition, headers, body, children: [] };
+
+ if (type.startsWith('multipart/') && params.boundary) {
+ node.children = splitMultipart(body, params.boundary).map(parseEntity);
+ }
+ return node;
+}
+
+function parseHeaders(headerText) {
+ const unfolded = headerText.replace(/\r?\n[ \t]+/g, ' ');
+ const headers = {};
+ for (const line of unfolded.split(/\r?\n/)) {
+ const idx = line.indexOf(':');
+ if (idx <= 0) continue;
+ const name = line.slice(0, idx).trim().toLowerCase();
+ const value = line.slice(idx + 1).trim();
+ headers[name] = headers[name] ? `${headers[name]}, ${value}` : value;
+ }
+ return headers;
+}
+
+function parseContentType(value) {
+ const parts = value.split(';');
+ const type = parts[0].trim().toLowerCase();
+ const params = {};
+ for (let i = 1; i < parts.length; i++) {
+ const eq = parts[i].indexOf('=');
+ if (eq < 0) continue;
+ const k = parts[i].slice(0, eq).trim().toLowerCase();
+ let v = parts[i].slice(eq + 1).trim();
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
+ params[k] = v;
+ }
+ return { type, params };
+}
+
+function splitMultipart(body, boundary) {
+ const delim = `--${boundary}`;
+ const parts = [];
+ const segments = body.split(delim);
+ for (let i = 1; i < segments.length; i++) {
+ let seg = segments[i];
+ if (seg.startsWith('--')) break; // closing delimiter
+ seg = seg.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
+ parts.push(seg);
+ }
+ return parts;
+}
+
+function decodeBody(node) {
+ const { cte, body } = node;
+ if (cte === 'base64') {
+ const cleaned = body.replace(/[^A-Za-z0-9+/=]/g, '');
+ try {
+ const bin = atob(cleaned);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ return bytes;
+ } catch {
+ return new Uint8Array(0);
+ }
+ }
+ if (cte === 'quoted-printable') {
+ return qpDecode(body);
+ }
+ // 7bit / 8bit / binary — body is a latin1 binary string
+ const bytes = new Uint8Array(body.length);
+ for (let i = 0; i < body.length; i++) bytes[i] = body.charCodeAt(i) & 0xff;
+ return bytes;
+}
+
+function qpDecode(input) {
+ const out = [];
+ const cleaned = input.replace(/=\r?\n/g, ''); // soft line breaks
+ for (let i = 0; i < cleaned.length; i++) {
+ const c = cleaned[i];
+ if (c === '=' && i + 2 < cleaned.length) {
+ const hex = cleaned.substr(i + 1, 2);
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
+ out.push(parseInt(hex, 16));
+ i += 2;
+ continue;
+ }
+ }
+ out.push(cleaned.charCodeAt(i) & 0xff);
+ }
+ return new Uint8Array(out);
+}
+
+function decodeText(node) {
+ const bytes = decodeBody(node);
+ const charset = (node.params.charset || 'utf-8').toLowerCase();
+ try {
+ return new TextDecoder(charset, { fatal: false }).decode(bytes);
+ } catch {
+ return decoder.decode(bytes);
+ }
+}
+
+function filenameFor(node) {
+ const cd = node.headers['content-disposition'] || '';
+ const m = cd.match(/filename\*?=(?:"([^"]+)"|([^;]+))/i);
+ if (m) return (m[1] || m[2] || '').trim();
+ if (node.params.name) return node.params.name;
+ return 'attachment';
+}
+
+function collect(node, out) {
+ const { type, disposition } = node;
+ const isAttachment = disposition.includes('attachment') ||
+ (!type.startsWith('text/') && !type.startsWith('multipart/'));
+
+ if (type.startsWith('multipart/')) {
+ if (type === 'multipart/alternative') {
+ // Prefer the richest alternative; collect text+html, last wins per type.
+ for (const child of node.children) collect(child, out);
+ } else {
+ for (const child of node.children) collect(child, out);
+ }
+ return;
+ }
+
+ if (type === 'text/html' && !isAttachment) {
+ out.html = decodeText(node);
+ return;
+ }
+ if (type === 'text/plain' && !isAttachment) {
+ out.text = decodeText(node);
+ return;
+ }
+
+ // Leaf attachment
+ const bytes = decodeBody(node);
+ out.attachments.push({
+ name: filenameFor(node),
+ type: type || 'application/octet-stream',
+ size: bytes.length,
+ dataUrl: bytesToDataUrl(bytes, type || 'application/octet-stream'),
+ });
+}
+
+function bytesToDataUrl(bytes, type) {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
+ return `data:${type};base64,${btoa(binary)}`;
+}
diff --git a/vnc/plugins/smime/src/node-crypto-shim.js b/vnc/plugins/smime/src/node-crypto-shim.js
new file mode 100644
index 00000000..40fe54dd
--- /dev/null
+++ b/vnc/plugins/smime/src/node-crypto-shim.js
@@ -0,0 +1,12 @@
+// Browser shim for the Node "crypto" builtin that webcrypto-liner's dependency
+// (asmcrypto.js) references in a `typeof process !== 'undefined'` branch that
+// never executes in a browser iframe. Provides a working randomBytes anyway so
+// the bundle is correct even if that path is somehow reached.
+
+export function randomBytes(n) {
+ const b = new Uint8Array(n);
+ (globalThis.crypto || globalThis.self?.crypto).getRandomValues(b);
+ return b;
+}
+
+export default { randomBytes };
diff --git a/vnc/plugins/smime/src/pkcs12.js b/vnc/plugins/smime/src/pkcs12.js
new file mode 100644
index 00000000..48cb3c89
--- /dev/null
+++ b/vnc/plugins/smime/src/pkcs12.js
@@ -0,0 +1,222 @@
+/**
+ * PKCS#12 (.p12/.pfx) import + private-key encryption-at-rest / unlock.
+ * Ported from lib/smime/pkcs12-import.ts.
+ *
+ * Private keys are wrapped with AES-GCM under a PBKDF2(600k, SHA-256) key
+ * derived from a user passphrase. Unlocked keys are imported NON-EXTRACTABLE.
+ */
+
+import * as asn1js from 'asn1js';
+import * as pkijs from 'pkijs';
+import { generateUUID } from './util.js';
+import { extractCertificateInfo, classifyCapabilities } from './certificate-utils.js';
+import { withLinerEngine, getLinerCrypto } from './crypto-engine.js';
+
+const KDF_ITERATIONS = 600_000;
+const AES_KEY_LENGTH = 256;
+
+function stringToAB(str) {
+ const buf = new ArrayBuffer(str.length);
+ const view = new Uint8Array(buf);
+ for (let i = 0; i < str.length; i++) view[i] = str.charCodeAt(i);
+ return buf;
+}
+
+/** Parse a PKCS#12 file and produce an encrypted-at-rest key record. */
+export async function importPkcs12(p12Bytes, p12Passphrase, storagePassphrase) {
+ const asn1 = asn1js.fromBER(p12Bytes);
+ if (asn1.offset === -1) throw new Error('Invalid PKCS#12 file: ASN.1 parsing failed');
+
+ const pfx = new pkijs.PFX({ schema: asn1.result });
+
+ await withLinerEngine(async () => {
+ await pfx.parseInternalValues({ password: stringToAB(p12Passphrase) });
+ });
+
+ let leafCertDer = null;
+ let leafCert = null;
+ const chainCertsDer = [];
+ let privateKeyInfo = null;
+
+ if (!pfx.parsedValue?.authenticatedSafe) {
+ throw new Error('PKCS#12 file does not contain an authenticated safe');
+ }
+
+ const authSafe = pfx.parsedValue.authenticatedSafe;
+ const safeContentsParams = authSafe.safeContents.map((ci) =>
+ ci.contentType === '1.2.840.113549.1.7.6' ? { password: stringToAB(p12Passphrase) } : {},
+ );
+ await withLinerEngine(async () => {
+ await authSafe.parseInternalValues({ safeContents: safeContentsParams });
+ });
+
+ for (const safeContent of authSafe.parsedValue.safeContents) {
+ const sc = safeContent.value ?? safeContent.parsedValue;
+ if (!sc) continue;
+
+ for (const safeBag of sc.safeBags) {
+ switch (safeBag.bagId) {
+ case '1.2.840.113549.1.12.10.1.3': { // CertBag
+ const certBag = safeBag.bagValue;
+ let cert = null;
+ let der = null;
+
+ if (certBag.parsedValue instanceof pkijs.Certificate) {
+ cert = certBag.parsedValue;
+ der = cert.toSchema(true).toBER(false);
+ } else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) {
+ const certDerBytes = certBag.certValue.valueBlock.valueHexView;
+ const certAsn1 = asn1js.fromBER(certDerBytes);
+ if (certAsn1.offset !== -1) {
+ cert = new pkijs.Certificate({ schema: certAsn1.result });
+ der = new Uint8Array(certDerBytes).buffer;
+ }
+ }
+
+ if (cert && der) {
+ if (!leafCertDer) {
+ leafCertDer = der;
+ leafCert = cert;
+ } else {
+ chainCertsDer.push(der);
+ }
+ }
+ break;
+ }
+ case '1.2.840.113549.1.12.10.1.1': { // KeyBag (unencrypted)
+ privateKeyInfo = safeBag.bagValue;
+ break;
+ }
+ case '1.2.840.113549.1.12.10.1.2': { // PKCS8ShroudedKeyBag (encrypted)
+ const shroudedBag = safeBag.bagValue;
+ if (shroudedBag.parsedValue) {
+ privateKeyInfo = shroudedBag.parsedValue;
+ } else {
+ await withLinerEngine(async () => {
+ await shroudedBag.parseInternalValues({ password: stringToAB(p12Passphrase) });
+ });
+ if (shroudedBag.parsedValue) privateKeyInfo = shroudedBag.parsedValue;
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ if (!leafCert || !leafCertDer) throw new Error('No certificate found in PKCS#12 file');
+ if (!privateKeyInfo) throw new Error('No private key found in PKCS#12 file');
+
+ const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false);
+ const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
+
+ const certInfo = await extractCertificateInfo(leafCert, leafCertDer);
+ const capabilities = classifyCapabilities(leafCert);
+ const email = certInfo.emailAddresses[0] ?? '';
+
+ const keyRecord = {
+ id: generateUUID(),
+ email: email.toLowerCase(),
+ certificate: leafCertDer,
+ certificateChain: chainCertsDer,
+ encryptedPrivateKey: encrypted,
+ salt,
+ iv,
+ kdfIterations: KDF_ITERATIONS,
+ issuer: certInfo.issuer,
+ subject: certInfo.subject,
+ serialNumber: certInfo.serialNumber,
+ notBefore: certInfo.notBefore,
+ notAfter: certInfo.notAfter,
+ fingerprint: certInfo.fingerprint,
+ algorithm: certInfo.algorithm,
+ capabilities,
+ };
+
+ return { keyRecord, certInfo };
+}
+
+// ── Private key encryption / decryption ──────────────────────────────
+
+async function deriveWrappingKey(passphrase, salt, iterations) {
+ const enc = new TextEncoder();
+ const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
+ return crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
+ keyMaterial,
+ { name: 'AES-GCM', length: AES_KEY_LENGTH },
+ false,
+ ['encrypt', 'decrypt'],
+ );
+}
+
+async function encryptPrivateKey(pkcs8Bytes, passphrase) {
+ const salt = crypto.getRandomValues(new Uint8Array(32)).buffer;
+ const iv = crypto.getRandomValues(new Uint8Array(12)).buffer;
+ const wrappingKey = await deriveWrappingKey(passphrase, salt, KDF_ITERATIONS);
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrappingKey, pkcs8Bytes);
+ return { encrypted, salt, iv };
+}
+
+function ecdsaCurveFromAlg(alg) {
+ if (alg.includes('P256') || alg.includes('P-256')) return 'P-256';
+ if (alg.includes('P384') || alg.includes('P-384')) return 'P-384';
+ if (alg.includes('P521') || alg.includes('P-521')) return 'P-521';
+ return 'P-256';
+}
+
+/**
+ * Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys.
+ * @returns { signingKey, decryptionKey?, legacyDecryptionKey? }
+ */
+export async function unlockPrivateKey(record, passphrase) {
+ const wrappingKey = await deriveWrappingKey(passphrase, record.salt, record.kdfIterations);
+
+ let pkcs8Bytes;
+ try {
+ pkcs8Bytes = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: record.iv }, wrappingKey, record.encryptedPrivateKey);
+ } catch {
+ throw new Error('Incorrect passphrase');
+ }
+
+ const isEcdsa = record.algorithm.startsWith('ECDSA');
+ const signAlg = isEcdsa
+ ? { name: 'ECDSA', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
+ : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
+ const decryptAlg = isEcdsa
+ ? { name: 'ECDH', namedCurve: ecdsaCurveFromAlg(record.algorithm) }
+ : { name: 'RSA-OAEP', hash: 'SHA-256' };
+ const decryptUsages = isEcdsa ? ['deriveBits'] : ['decrypt'];
+
+ let signingKey;
+ try {
+ signingKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, signAlg, false, ['sign']);
+ } catch {
+ // Key may only support decryption (key-encipherment-only cert)
+ const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
+ let legacyDecryptionKey;
+ if (!isEcdsa) {
+ try {
+ legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
+ 'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
+ );
+ } catch { /* liner unavailable */ }
+ }
+ return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
+ }
+
+ let decryptionKey;
+ try {
+ decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
+ } catch { /* signing-only cert */ }
+
+ let legacyDecryptionKey;
+ if (!isEcdsa) {
+ try {
+ legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
+ 'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
+ );
+ } catch { /* liner unavailable */ }
+ }
+
+ return { signingKey, decryptionKey, legacyDecryptionKey };
+}
diff --git a/vnc/plugins/smime/src/smime-decrypt.js b/vnc/plugins/smime/src/smime-decrypt.js
new file mode 100644
index 00000000..2dad22df
--- /dev/null
+++ b/vnc/plugins/smime/src/smime-decrypt.js
@@ -0,0 +1,270 @@
+/**
+ * Decrypt CMS EnvelopedData to recover the inner MIME content.
+ * Supports issuerAndSerialNumber and subjectKeyIdentifier recipient IDs.
+ * Ported from lib/smime/smime-decrypt.ts (Buffer → toHex).
+ */
+
+import * as pkijs from 'pkijs';
+import * as asn1js from 'asn1js';
+import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine.js';
+import { arraysEqual, toHex } from './util.js';
+
+export class SmimeKeyLockedError extends Error {
+ constructor(message, keyRecordId) {
+ super(message);
+ this.name = 'SmimeKeyLockedError';
+ this.keyRecordId = keyRecordId;
+ }
+}
+
+/**
+ * Attempt to decrypt CMS EnvelopedData.
+ * @param input { cmsBytes, keyRecords, unlockedKeys: Map, legacyUnlockedKeys?: Map }
+ * @returns { mimeBytes: Uint8Array, keyRecordId: string }
+ */
+export async function smimeDecrypt(input) {
+ const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
+
+ const contentInfo = parseContentInfo(cmsBytes);
+ const envelopedData = extractEnvelopedData(contentInfo);
+
+ const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
+ if (matchedRecords.length === 0) {
+ throw new Error('No imported S/MIME key matches any recipient in this encrypted message');
+ }
+
+ for (const { keyRecord, recipientIndex } of matchedRecords) {
+ const privateKey = unlockedKeys.get(keyRecord.id);
+ if (!privateKey) {
+ const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
+ if (legacyKey) {
+ try {
+ const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
+ return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id };
+ } catch {
+ continue;
+ }
+ }
+ continue;
+ }
+
+ try {
+ const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord);
+ return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id };
+ } catch {
+ const legacyKey = legacyUnlockedKeys?.get(keyRecord.id);
+ if (legacyKey) {
+ try {
+ const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord);
+ return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id };
+ } catch {
+ /* try next record */
+ }
+ }
+ continue;
+ }
+ }
+
+ const isUnlocked = (id) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false);
+ const hasLockedMatch = matchedRecords.some((m) => !isUnlocked(m.keyRecord.id));
+ if (hasLockedMatch) {
+ const lockedRecord = matchedRecords.find((m) => !isUnlocked(m.keyRecord.id));
+ throw new SmimeKeyLockedError(
+ 'S/MIME key is locked. Unlock it to decrypt this message.',
+ lockedRecord.keyRecord.id,
+ );
+ }
+
+ throw new Error('Failed to decrypt message with any available key');
+}
+
+/** Key record IDs that could potentially decrypt a message (to prompt unlock). */
+export function findDecryptionCandidates(cmsBytes, keyRecords) {
+ try {
+ const contentInfo = parseContentInfo(cmsBytes);
+ const envelopedData = extractEnvelopedData(contentInfo);
+ return findMatchingKeyRecords(envelopedData, keyRecords).map((m) => m.keyRecord.id);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Normalize raw blob bytes into DER-encoded CMS data.
+ * JMAP may return raw DER, base64 DER, a full MIME part, or PEM.
+ */
+export function normalizeCmsBytes(raw) {
+ if (raw.byteLength === 0) return raw;
+
+ const bytes = new Uint8Array(raw);
+ if (bytes[0] === 0x30) return raw; // already DER
+
+ let text = new TextDecoder().decode(raw);
+
+ const looksMostlyText = (() => {
+ const sample = text.slice(0, Math.min(text.length, 2048));
+ if (sample.length === 0) return false;
+ let printable = 0;
+ for (let i = 0; i < sample.length; i++) {
+ const code = sample.charCodeAt(i);
+ if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code <= 0x7e)) printable++;
+ }
+ return printable / sample.length > 0.85;
+ })();
+
+ const headerEndMatch = text.match(/\r?\n\r?\n/);
+ const hasMimeHeaderHints = /content-type:|content-transfer-encoding:|mime-version:/i.test(
+ text.slice(0, Math.min(text.length, 8192)),
+ );
+ if (looksMostlyText && headerEndMatch && headerEndMatch.index !== undefined && hasMimeHeaderHints) {
+ text = text.substring(headerEndMatch.index + headerEndMatch[0].length);
+ }
+
+ text = text
+ .replace(/-----BEGIN [A-Z0-9 ]+-----/g, '')
+ .replace(/-----END [A-Z0-9 ]+-----/g, '')
+ .replace(/\s/g, '');
+
+ if (text.length === 0) return raw;
+
+ try {
+ const binary = atob(text);
+ const decoded = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
+ if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer;
+ } catch { /* not DER, continue */ }
+
+ if (looksMostlyText) {
+ const originalText = new TextDecoder().decode(raw);
+ const sectionRegex = /content-transfer-encoding:\s*base64[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--[^\r\n]+|$)/ig;
+ const sectionBlocks = [];
+ let sectionMatch;
+ while ((sectionMatch = sectionRegex.exec(originalText)) !== null) sectionBlocks.push(sectionMatch[1]);
+
+ for (const block of sectionBlocks) {
+ const cleaned = block.replace(/\s/g, '');
+ if (cleaned.length < 8 || !/^[A-Za-z0-9+/=]+$/.test(cleaned)) continue;
+ try {
+ const binary = atob(cleaned);
+ const decoded = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
+ if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer;
+ } catch { /* next section */ }
+ }
+
+ const base64Blocks = originalText.match(/[A-Za-z0-9+/=\r\n]{128,}/g) || [];
+ const cleaned = base64Blocks
+ .map((block) => block.replace(/\s/g, ''))
+ .filter((block) => block.length >= 128 && /^[A-Za-z0-9+/=]+$/.test(block));
+ cleaned.sort((a, b) => b.length - a.length);
+
+ for (const block of cleaned) {
+ try {
+ const binary = atob(block);
+ const decoded = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
+ if (decoded.length > 0 && decoded[0] === 0x30) return decoded.buffer;
+ } catch { /* next block */ }
+ }
+ }
+
+ return raw;
+}
+
+function parseContentInfo(der) {
+ const asn1 = asn1js.fromBER(der);
+ if (asn1.offset === -1) throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
+ try {
+ return new pkijs.ContentInfo({ schema: asn1.result });
+ } catch {
+ throw new Error('Invalid ASN.1 data - cannot parse CMS envelope');
+ }
+}
+
+function extractEnvelopedData(contentInfo) {
+ if (contentInfo.contentType !== '1.2.840.113549.1.7.3') {
+ throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
+ }
+ return new pkijs.EnvelopedData({ schema: contentInfo.content });
+}
+
+function findMatchingKeyRecords(envelopedData, keyRecords) {
+ const matches = [];
+
+ for (let i = 0; i < envelopedData.recipientInfos.length; i++) {
+ const ri = envelopedData.recipientInfos[i];
+
+ const ktri = ri instanceof pkijs.KeyTransRecipientInfo
+ ? ri
+ : ri.variant === 1 && ri.value instanceof pkijs.KeyTransRecipientInfo
+ ? ri.value
+ : null;
+
+ if (ktri) {
+ for (const keyRecord of keyRecords) {
+ if (matchesKeyTransRecipient(ktri, keyRecord)) {
+ matches.push({ keyRecord, recipientIndex: i });
+ }
+ }
+ }
+ }
+
+ return matches;
+}
+
+function matchesKeyTransRecipient(recipientInfo, keyRecord) {
+ const rid = recipientInfo.rid;
+
+ if (rid instanceof pkijs.IssuerAndSerialNumber) {
+ try {
+ const certAsn1 = asn1js.fromBER(keyRecord.certificate);
+ if (certAsn1.offset === -1) return false;
+ const cert = new pkijs.Certificate({ schema: certAsn1.result });
+
+ const ridSerial = toHex(rid.serialNumber.valueBlock.valueHexView);
+ const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
+ if (ridSerial !== certSerial) return false;
+
+ const ridIssuerDer = rid.issuer.toSchema().toBER(false);
+ const certIssuerDer = cert.issuer.toSchema().toBER(false);
+ return arraysEqual(new Uint8Array(ridIssuerDer), new Uint8Array(certIssuerDer));
+ } catch {
+ return false;
+ }
+ }
+
+ if (rid instanceof asn1js.OctetString) {
+ try {
+ const certAsn1 = asn1js.fromBER(keyRecord.certificate);
+ if (certAsn1.offset === -1) return false;
+ const cert = new pkijs.Certificate({ schema: certAsn1.result });
+
+ const skiExt = cert.extensions?.find((ext) => ext.extnID === '2.5.29.14');
+ if (!skiExt) return false;
+
+ const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView);
+ if (skiValue.offset === -1) return false;
+ const ski = skiValue.result.valueBlock.valueHexView;
+
+ return arraysEqual(new Uint8Array(ski), new Uint8Array(rid.valueBlock.valueHexView));
+ } catch {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord) {
+ const certAsn1 = asn1js.fromBER(keyRecord.certificate);
+ const cert = new pkijs.Certificate({ schema: certAsn1.result });
+
+ return withLinerEngine(async () => {
+ const cryptoEngine = getLinerCryptoEngine();
+ return envelopedData.decrypt(
+ recipientIndex,
+ { recipientCertificate: cert, recipientPrivateKey: privateKey },
+ cryptoEngine,
+ );
+ });
+}
diff --git a/vnc/plugins/smime/src/smime-detect.js b/vnc/plugins/smime/src/smime-detect.js
new file mode 100644
index 00000000..a92f0a80
--- /dev/null
+++ b/vnc/plugins/smime/src/smime-detect.js
@@ -0,0 +1,121 @@
+/**
+ * Detect S/MIME content in an email message. Ported from lib/smime/smime-detect.ts.
+ * Checks Content-Type, JMAP bodyStructure, and attachment metadata.
+ */
+
+export function detectSmime(contentType, bodyStructure, attachments) {
+ const noResult = { type: null, supported: false };
+
+ if (contentType) {
+ const ct = contentType.toLowerCase();
+
+ if (ct.includes('application/pkcs7-mime') || ct.includes('application/x-pkcs7-mime')) {
+ if (ct.includes('smime-type=enveloped-data')) {
+ const part = findCmsPart(bodyStructure, 'enveloped-data');
+ return { type: 'enveloped-data', blobId: part?.blobId, partId: part?.partId, supported: true };
+ }
+ if (ct.includes('smime-type=signed-data')) {
+ const part = findCmsPart(bodyStructure, 'signed-data');
+ return { type: 'signed-data', blobId: part?.blobId, partId: part?.partId, supported: true };
+ }
+ const part = findCmsPart(bodyStructure, null);
+ if (part) {
+ const partType = inferSmimeTypeFromContentType(part.type || '');
+ return {
+ type: partType,
+ blobId: part.blobId,
+ partId: part.partId,
+ supported: partType === 'enveloped-data' || partType === 'signed-data',
+ };
+ }
+ }
+
+ if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) {
+ return { type: 'detached-sig', supported: false };
+ }
+ }
+
+ if (bodyStructure) {
+ const result = walkBodyStructure(bodyStructure);
+ if (result) return result;
+ }
+
+ if (attachments) {
+ for (const att of attachments) {
+ const type = att.type?.toLowerCase() || '';
+ const name = att.name?.toLowerCase() || '';
+
+ if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
+ const smimeType = inferSmimeTypeFromContentType(type);
+ return {
+ type: smimeType,
+ blobId: att.blobId,
+ partId: att.partId,
+ supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
+ };
+ }
+ if (name.endsWith('.p7m')) {
+ return { type: 'enveloped-data', blobId: att.blobId, partId: att.partId, supported: true };
+ }
+ if (name.endsWith('.p7s')) {
+ return { type: 'detached-sig', blobId: att.blobId, partId: att.partId, supported: false };
+ }
+ }
+ }
+
+ return noResult;
+}
+
+function walkBodyStructure(part) {
+ const type = part.type?.toLowerCase() || '';
+
+ if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
+ const smimeType = inferSmimeTypeFromContentType(type);
+ return {
+ type: smimeType,
+ blobId: part.blobId,
+ partId: part.partId,
+ supported: smimeType === 'enveloped-data' || smimeType === 'signed-data',
+ };
+ }
+
+ if (type === 'multipart/signed') {
+ if (part.subParts?.some((sp) => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) {
+ return { type: 'detached-sig', supported: false };
+ }
+ }
+
+ if (part.subParts) {
+ for (const sub of part.subParts) {
+ const result = walkBodyStructure(sub);
+ if (result) return result;
+ }
+ }
+
+ return null;
+}
+
+function findCmsPart(bodyStructure, _smimeType) {
+ if (!bodyStructure) return null;
+ const type = bodyStructure.type?.toLowerCase() || '';
+ if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
+ return bodyStructure;
+ }
+ if (bodyStructure.subParts) {
+ for (const sub of bodyStructure.subParts) {
+ const found = findCmsPart(sub, _smimeType);
+ if (found) return found;
+ }
+ }
+ return null;
+}
+
+function inferSmimeTypeFromContentType(ct) {
+ const lower = ct.toLowerCase();
+ if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
+ if (lower.includes('smime-type=signed-data')) return 'signed-data';
+ if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) {
+ return 'enveloped-data';
+ }
+ return null;
+}
diff --git a/vnc/plugins/smime/src/smime-encrypt.js b/vnc/plugins/smime/src/smime-encrypt.js
new file mode 100644
index 00000000..9abda21a
--- /dev/null
+++ b/vnc/plugins/smime/src/smime-encrypt.js
@@ -0,0 +1,53 @@
+import * as pkijs from 'pkijs';
+import { parseCertificateDer } from './certificate-utils.js';
+import { nativeEngine } from './crypto-engine.js';
+import { toHex } from './util.js';
+
+/**
+ * Produce CMS EnvelopedData for the given MIME content.
+ * Content type: application/pkcs7-mime; smime-type=enveloped-data.
+ * Always includes the sender's cert so the sender can decrypt their Sent mail.
+ * Ported from lib/smime/smime-encrypt.ts.
+ */
+export async function smimeEncrypt(mimeBytes, recipientCertsDer, senderCertDer, useAes128) {
+ const allCertDers = deduplicateCerts([...recipientCertsDer, senderCertDer]);
+ if (allCertDers.length === 0) throw new Error('No recipient certificates provided');
+
+ const recipientCerts = allCertDers.map((der) => parseCertificateDer(der));
+ const cmsEnveloped = new pkijs.EnvelopedData();
+
+ for (const cert of recipientCerts) {
+ cmsEnveloped.addRecipientByCertificate(cert, { oaepHashAlgorithm: 'SHA-256' }, undefined, nativeEngine());
+ }
+
+ const contentEncryptionAlgorithm = useAes128
+ ? { name: 'AES-GCM', length: 128 }
+ : { name: 'AES-GCM', length: 256 };
+
+ await cmsEnveloped.encrypt(
+ contentEncryptionAlgorithm,
+ mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength),
+ nativeEngine(),
+ );
+
+ const cms = new pkijs.ContentInfo({
+ contentType: '1.2.840.113549.1.7.3', // id-envelopedData
+ content: cmsEnveloped.toSchema(),
+ });
+
+ const cmsBytes = cms.toSchema().toBER(false);
+ return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=enveloped-data' });
+}
+
+function deduplicateCerts(certs) {
+ const seen = new Set();
+ const result = [];
+ for (const cert of certs) {
+ const key = toHex(cert);
+ if (!seen.has(key)) {
+ seen.add(key);
+ result.push(cert);
+ }
+ }
+ return result;
+}
diff --git a/vnc/plugins/smime/src/smime-sign.js b/vnc/plugins/smime/src/smime-sign.js
new file mode 100644
index 00000000..5acf5365
--- /dev/null
+++ b/vnc/plugins/smime/src/smime-sign.js
@@ -0,0 +1,47 @@
+import * as asn1js from 'asn1js';
+import * as pkijs from 'pkijs';
+import { parseCertificateDer } from './certificate-utils.js';
+import { nativeEngine } from './crypto-engine.js';
+
+/**
+ * Produce an opaque CMS SignedData wrapping the given MIME content.
+ * Content type: application/pkcs7-mime; smime-type=signed-data.
+ * Ported from lib/smime/smime-sign.ts.
+ */
+export async function smimeSign(mimeBytes, privateKey, signerCertDer, chainCertsDer = []) {
+ const signerCert = parseCertificateDer(signerCertDer);
+ const chainCerts = chainCertsDer.map((der) => parseCertificateDer(der));
+
+ const cmsSigned = new pkijs.SignedData({
+ version: 1,
+ encapContentInfo: new pkijs.EncapsulatedContentInfo({
+ eContentType: '1.2.840.113549.1.7.1', // id-data
+ eContent: new asn1js.OctetString({
+ valueHex: new Uint8Array(
+ mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength),
+ ),
+ }),
+ }),
+ signerInfos: [
+ new pkijs.SignerInfo({
+ version: 1,
+ sid: new pkijs.IssuerAndSerialNumber({
+ issuer: signerCert.issuer,
+ serialNumber: signerCert.serialNumber,
+ }),
+ }),
+ ],
+ certificates: [signerCert, ...chainCerts],
+ });
+
+ const hashAlgorithm = 'SHA-256';
+ await cmsSigned.sign(privateKey, 0, hashAlgorithm, undefined, nativeEngine());
+
+ const cms = new pkijs.ContentInfo({
+ contentType: '1.2.840.113549.1.7.2', // id-signedData
+ content: cmsSigned.toSchema(true),
+ });
+
+ const cmsBytes = cms.toSchema().toBER(false);
+ return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=signed-data' });
+}
diff --git a/vnc/plugins/smime/src/smime-verify.js b/vnc/plugins/smime/src/smime-verify.js
new file mode 100644
index 00000000..417620bd
--- /dev/null
+++ b/vnc/plugins/smime/src/smime-verify.js
@@ -0,0 +1,164 @@
+/**
+ * Verify CMS SignedData (opaque signed) and extract the inner content.
+ * Ported from lib/smime/smime-verify.ts.
+ */
+
+import * as pkijs from 'pkijs';
+import * as asn1js from 'asn1js';
+import { extractCertificateInfo } from './certificate-utils.js';
+import { nativeEngine } from './crypto-engine.js';
+import { arraysEqual, toHex } from './util.js';
+
+/**
+ * Verify a CMS SignedData structure and extract the encapsulated content.
+ * @returns { mimeBytes: Uint8Array, status: SmimeStatus }
+ */
+export async function smimeVerify(cmsBytes, fromHeader) {
+ const contentInfo = parseContentInfo(cmsBytes);
+ const signedData = extractSignedData(contentInfo);
+
+ const innerContent = extractInnerContent(signedData);
+
+ const signerCert = extractSignerCertificate(signedData);
+ if (!signerCert) {
+ return {
+ mimeBytes: innerContent,
+ status: {
+ isSigned: true,
+ isEncrypted: false,
+ signatureValid: false,
+ signatureError: 'Signer certificate not found in CMS structure',
+ },
+ };
+ }
+
+ let signatureValid = false;
+ let signatureError;
+
+ try {
+ // checkChain:false — validate the signature cryptographically. Trust of the
+ // issuer chain is surfaced separately (selfSigned flag + the banner), rather
+ // than collapsing "untrusted issuer" into "invalid signature". This matches
+ // how most S/MIME clients present results and keeps validly-signed mail from
+ // self-signed or non-bundled CAs from showing a scary "invalid" badge.
+ signatureValid = await signedData.verify({ signer: 0, checkChain: false }, nativeEngine());
+ } catch (err) {
+ signatureError = err instanceof Error ? err.message : 'Signature verification failed';
+ }
+
+ const certDer = signerCert.toSchema(true).toBER(false);
+ const certInfo = await extractCertificateInfo(signerCert, certDer);
+
+ const now = new Date();
+ const notBefore = new Date(certInfo.notBefore);
+ const notAfter = new Date(certInfo.notAfter);
+ const certExpired = now > notAfter;
+ const certNotYetValid = now < notBefore;
+
+ if (certExpired && !signatureError) signatureError = 'Signer certificate has expired';
+ if (certNotYetValid && !signatureError) signatureError = 'Signer certificate is not yet valid';
+
+ const signerEmail = certInfo.emailAddresses[0] ?? '';
+ const signerPublicCert = {
+ id: `signer-${certInfo.fingerprint}`,
+ email: signerEmail.toLowerCase(),
+ certificate: certDer,
+ issuer: certInfo.issuer,
+ subject: certInfo.subject,
+ notBefore: certInfo.notBefore,
+ notAfter: certInfo.notAfter,
+ fingerprint: certInfo.fingerprint,
+ source: 'signed-email',
+ };
+
+ let signerEmailMatch;
+ if (fromHeader && signerEmail) {
+ signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
+ }
+
+ const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false));
+ const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false));
+ const selfSigned = arraysEqual(issuerDer, subjectDer);
+
+ return {
+ mimeBytes: innerContent,
+ status: {
+ isSigned: true,
+ isEncrypted: false,
+ signatureValid: signatureValid && !certExpired && !certNotYetValid,
+ signatureError,
+ signerCert: signerPublicCert,
+ signerEmailMatch,
+ selfSigned,
+ },
+ };
+}
+
+// --- Internal helpers ---
+
+function parseContentInfo(der) {
+ const asn1 = asn1js.fromBER(der);
+ if (asn1.offset === -1) throw new Error('Invalid ASN.1 data - cannot parse CMS structure');
+ return new pkijs.ContentInfo({ schema: asn1.result });
+}
+
+function extractSignedData(contentInfo) {
+ if (contentInfo.contentType !== '1.2.840.113549.1.7.2') {
+ throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`);
+ }
+ return new pkijs.SignedData({ schema: contentInfo.content });
+}
+
+function extractInnerContent(signedData) {
+ const eContent = signedData.encapContentInfo?.eContent;
+ if (!eContent) {
+ throw new Error('No encapsulated content in SignedData (detached signature not supported)');
+ }
+
+ if (eContent instanceof asn1js.OctetString) {
+ const children = eContent.valueBlock.value;
+ if (children?.length) {
+ const chunks = children.map((c) => new Uint8Array(c.valueBlock.valueHexView));
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
+ const result = new Uint8Array(total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ result.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return result;
+ }
+ return new Uint8Array(eContent.valueBlock.valueHexView);
+ }
+
+ throw new Error('Unable to extract content from SignedData');
+}
+
+function extractSignerCertificate(signedData) {
+ if (!signedData.signerInfos?.length || !signedData.certificates?.length) return null;
+
+ const signerInfo = signedData.signerInfos[0];
+ const sid = signerInfo.sid;
+
+ if (sid instanceof pkijs.IssuerAndSerialNumber) {
+ for (const certItem of signedData.certificates) {
+ if (!(certItem instanceof pkijs.Certificate)) continue;
+ const cert = certItem;
+
+ const sidSerial = toHex(sid.serialNumber.valueBlock.valueHexView);
+ const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
+ if (sidSerial !== certSerial) continue;
+
+ const sidIssuerDer = new Uint8Array(sid.issuer.toSchema().toBER(false));
+ const certIssuerDer = new Uint8Array(cert.issuer.toSchema().toBER(false));
+ if (arraysEqual(sidIssuerDer, certIssuerDer)) return cert;
+ }
+ }
+
+ if (signedData.certificates.length === 1) {
+ const cert = signedData.certificates[0];
+ if (cert instanceof pkijs.Certificate) return cert;
+ }
+
+ return null;
+}
diff --git a/vnc/plugins/smime/src/util.js b/vnc/plugins/smime/src/util.js
new file mode 100644
index 00000000..fc91a210
--- /dev/null
+++ b/vnc/plugins/smime/src/util.js
@@ -0,0 +1,54 @@
+// Small browser helpers shared across the S/MIME plugin modules.
+// (The native app pulled these from @/lib/utils; the sandbox has no host
+// imports, so we provide local, dependency-free equivalents.)
+
+/** RFC 4122 v4 UUID using the same crypto.randomUUID the host relies on. */
+export function generateUUID() {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
+ return (
+ hex.slice(0, 4).join('') +
+ '-' +
+ hex.slice(4, 6).join('') +
+ '-' +
+ hex.slice(6, 8).join('') +
+ '-' +
+ hex.slice(8, 10).join('') +
+ '-' +
+ hex.slice(10, 16).join('')
+ );
+}
+
+/** Lower-case hex string for any byte source (replaces Node's Buffer.toString('hex')). */
+export function toHex(source) {
+ let bytes;
+ if (source instanceof ArrayBuffer) {
+ bytes = new Uint8Array(source);
+ } else if (ArrayBuffer.isView(source)) {
+ bytes = new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
+ } else {
+ bytes = new Uint8Array(source);
+ }
+ let out = '';
+ for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
+ return out;
+}
+
+/** Constant-ish byte-array equality. */
+export function arraysEqual(a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+/** Copy any ArrayBuffer-ish slice into a standalone ArrayBuffer. */
+export function toArrayBuffer(view) {
+ if (view instanceof ArrayBuffer) return view;
+ return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
+}
diff --git a/vnc/plugins/smime/verify-fixes.mjs b/vnc/plugins/smime/verify-fixes.mjs
new file mode 100644
index 00000000..7fe86fb2
--- /dev/null
+++ b/vnc/plugins/smime/verify-fixes.mjs
@@ -0,0 +1,73 @@
+// Standalone proof for the two VNC hardening fixes. Reimplements only the
+// decision logic under test (no pkijs/DOM needed) so it runs with plain node.
+// node vnc/plugins/smime/verify-fixes.mjs
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+let pass = 0, fail = 0;
+const check = (name, got, want) => {
+ const ok = got === want;
+ console.log(`${ok ? ' PASS' : ' FAIL'} ${name}${ok ? '' : ` (got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`}`);
+ ok ? pass++ : fail++;
+};
+
+// ── Fix 1: auto-import gate ─────────────────────────────────────────
+// Mirrors the guard order in index.js maybeAutoImportSigner.
+function wouldImport(status, autoImport = true) {
+ if (autoImport === false) return false;
+ const cert = status && status.signerCert;
+ if (!cert || !status.signatureValid || !cert.email) return false;
+ if (status.signerEmailMatch !== true) return false;
+ if (status.selfSigned) return false;
+ return true;
+}
+const cert = { email: 'a@b.com', fingerprint: 'ff' };
+
+console.log('\nFix 1 — certificate auto-import gate');
+check('CA-signed, address matches -> import',
+ wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: false }), true);
+check('THE ATTACK: self-signed, address matches -> REFUSE',
+ wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: true }), false);
+check('address mismatch -> REFUSE',
+ wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: false, selfSigned: false }), false);
+check('signerEmailMatch undefined (no From) -> REFUSE (fail closed)',
+ wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: undefined, selfSigned: false }), false);
+check('invalid signature -> REFUSE',
+ wouldImport({ signerCert: cert, signatureValid: false, signerEmailMatch: true, selfSigned: false }), false);
+check('setting off -> REFUSE',
+ wouldImport({ signerCert: cert, signatureValid: true, signerEmailMatch: true, selfSigned: false }, false), false);
+
+// ── Fix 2 (finding 3): CRLF stripping ───────────────────────────────
+function stripCrlf(value) {
+ return String(value).replace(/[\r\n]+[ \t]*/g, ' ');
+}
+console.log('\nFinding 3 — CRLF header sanitisation');
+check('BCC injection via display name',
+ stripCrlf('Evil\r\nBcc: attacker@evil.com'), 'Evil Bcc: attacker@evil.com');
+check('bare LF', stripCrlf('a\nb'), 'a b');
+check('bare CR', stripCrlf('a\rb'), 'a b');
+check('folded continuation collapsed', stripCrlf('a\r\n\tb'), 'a b');
+check('multiple injected headers',
+ stripCrlf('x\r\nBcc: a@b.c\r\nReply-To: d@e.f'), 'x Bcc: a@b.c Reply-To: d@e.f');
+check('clean value untouched', stripCrlf('Normal Subject'), 'Normal Subject');
+check('non-ASCII untouched', stripCrlf('Grüße büro'), 'Grüße büro');
+
+// ── Source assertions: guard against silent regression ──────────────
+console.log('\nSource assertions');
+const idx = readFileSync(join(here, 'src/index.js'), 'utf8');
+const mb = readFileSync(join(here, 'src/mime-builder.js'), 'utf8');
+check('index.js checks signerEmailMatch !== true', idx.includes('status.signerEmailMatch !== true'), true);
+check('index.js checks selfSigned', /if \(status\.selfSigned\)/.test(idx), true);
+check('formatHeader sanitises its value', /function formatHeader\(name, rawValue\)[\s\S]{0,80}stripCrlf\(rawValue\)/.test(mb), true);
+check('attachment Content-Type sanitised', mb.includes('stripCrlf(att.contentType)'), true);
+check('Content-ID sanitised', mb.includes('stripCrlf(att.cid)'), true);
+// Every header assembled outside formatHeader must use a literal or a sanitised value.
+const bypass = [...mb.matchAll(/lines\.push\(`([A-Za-z-]+): ([^`]*)`\)/g)]
+ .filter(([, , v]) => /\$\{/.test(v) && !/stripCrlf|encodeHeaderValue|boundary|altBoundary|disposition/.test(v));
+check('no unsanitised interpolated headers remain', bypass.length, 0);
+if (bypass.length) bypass.forEach(([m]) => console.log(' >>', m));
+
+console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`);
+process.exit(fail === 0 ? 0 : 1);