From f7e487171c9c9ef6ff34cd663f89cf1edd33a4dd Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 10:00:11 +0200 Subject: [PATCH] security(smime): fork upstream plugin and fix two audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine findings, two HIGH. No backdoor and no exfiltration path anywhere in the bundle — the problems are trust-model and input-validation gaps. Full report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md. Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing src/ would not audit what that zip installs. We build from source. Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated on signatureValid alone, but smimeVerify runs checkChain:false, so that only proves "signed by whoever holds this key", not that the claimed identity is real. Self-sign a cert asserting victim@example.com, send one signed message, and it was stored as the encryption target for that address — the user's next Encrypt to the victim went to the attacker. Now requires signerEmailMatch === true and !selfSigned. Both values were already computed and displayed as untrusted in the banner; only the import path ignored them. Tests for `true` explicitly so an undefined match (missing From header) fails closed. Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only Subject and attachment filename; display names, raw addresses, Message-ID, In-Reply-To, References and attachment Content-Type were emitted verbatim, and formatAddress escapes only backslash and quote. In-Reply-To/References/display names are copied from inbound mail when replying or forwarding, so the value is attacker-supplied. Sanitising inside formatHeader covers all 17 call sites by construction; the three headers assembled directly get stripCrlf explicitly. Also adds auth:observe to the manifest. The plugin registers onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363) — without declaring the permission, so under B-09 the session-key wipe would silently stop running. verify-fixes.mjs carries 19 assertions including source checks that fail if either guard is removed or a new unsanitised interpolated header appears. That last one immediately caught the interpolated smime-type Content-Type header, which manual review had dismissed as static. Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This is not safe for real mail yet — sandbox accounts only. Co-Authored-By: Claude Opus 4.8 --- vnc/VNC-CHANGES.md | 6 + vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md | 163 +++ vnc/plugins/smime/README.md | 79 ++ vnc/plugins/smime/manifest.json | 66 ++ vnc/plugins/smime/media/banner.svg | 23 + vnc/plugins/smime/media/icon.svg | 20 + vnc/plugins/smime/package-lock.json | 757 +++++++++++++ vnc/plugins/smime/package.json | 20 + vnc/plugins/smime/src/certificate-utils.js | 217 ++++ vnc/plugins/smime/src/crypto-engine.js | 222 ++++ vnc/plugins/smime/src/index.js | 1087 +++++++++++++++++++ vnc/plugins/smime/src/key-storage.js | 130 +++ vnc/plugins/smime/src/mime-builder.js | 291 +++++ vnc/plugins/smime/src/mime-parse.js | 190 ++++ vnc/plugins/smime/src/node-crypto-shim.js | 12 + vnc/plugins/smime/src/pkcs12.js | 222 ++++ vnc/plugins/smime/src/smime-decrypt.js | 270 +++++ vnc/plugins/smime/src/smime-detect.js | 121 +++ vnc/plugins/smime/src/smime-encrypt.js | 53 + vnc/plugins/smime/src/smime-sign.js | 47 + vnc/plugins/smime/src/smime-verify.js | 164 +++ vnc/plugins/smime/src/util.js | 54 + vnc/plugins/smime/verify-fixes.mjs | 73 ++ 23 files changed, 4287 insertions(+) create mode 100644 vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md create mode 100644 vnc/plugins/smime/README.md create mode 100644 vnc/plugins/smime/manifest.json create mode 100644 vnc/plugins/smime/media/banner.svg create mode 100644 vnc/plugins/smime/media/icon.svg create mode 100644 vnc/plugins/smime/package-lock.json create mode 100644 vnc/plugins/smime/package.json create mode 100644 vnc/plugins/smime/src/certificate-utils.js create mode 100644 vnc/plugins/smime/src/crypto-engine.js create mode 100644 vnc/plugins/smime/src/index.js create mode 100644 vnc/plugins/smime/src/key-storage.js create mode 100644 vnc/plugins/smime/src/mime-builder.js create mode 100644 vnc/plugins/smime/src/mime-parse.js create mode 100644 vnc/plugins/smime/src/node-crypto-shim.js create mode 100644 vnc/plugins/smime/src/pkcs12.js create mode 100644 vnc/plugins/smime/src/smime-decrypt.js create mode 100644 vnc/plugins/smime/src/smime-detect.js create mode 100644 vnc/plugins/smime/src/smime-encrypt.js create mode 100644 vnc/plugins/smime/src/smime-sign.js create mode 100644 vnc/plugins/smime/src/smime-verify.js create mode 100644 vnc/plugins/smime/src/util.js create mode 100644 vnc/plugins/smime/verify-fixes.mjs 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 @@ + + + + + + + + + + + + + + + + + + + + S/MIME + Sign, encrypt, verify & decrypt mail + Your keys never leave the browser + 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 `\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);