security(smime): fork upstream plugin and fix two audit findings

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 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 10:00:11 +02:00
co-authored by Claude Opus 4.8
parent e9746fcf78
commit f7e487171c
23 changed files with 4287 additions and 0 deletions
+6
View File
@@ -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/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 | `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 | `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. | | 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)_ _(append new rows as you diverge)_
+163
View File
@@ -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 13 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.
### 59
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 79 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 13 are why `S-01` was scheduled before `A-01`. All three would have shipped.
+79
View File
@@ -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.
+66
View File
@@ -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"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" width="1200" height="675">
<defs>
<filter id="grain-smime-b" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" seed="41" stitchTiles="stitch"/>
<feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.16 0"/>
<feComposite in2="SourceGraphic" operator="in"/>
</filter>
<linearGradient id="bg-smime-b" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#64748B"/>
<stop offset="1" stop-color="#0F172A"/>
</linearGradient>
</defs>
<rect width="1200" height="675" fill="url(#bg-smime-b)"/>
<rect width="1200" height="675" fill="white" filter="url(#grain-smime-b)"/>
<g transform="translate(120,200) scale(11.25)" fill="none" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<!-- lucide "shield-check" -->
<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"/>
<path d="m9 12 2 2 4-4"/>
</g>
<text x="460" y="300" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="60" font-weight="700" fill="white" dominant-baseline="middle">S/MIME</text>
<text x="460" y="360" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="24" font-weight="400" fill="white" fill-opacity="0.82" dominant-baseline="middle">Sign, encrypt, verify &amp; decrypt mail</text>
<text x="460" y="396" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" font-size="24" font-weight="400" fill="white" fill-opacity="0.82" dominant-baseline="middle">Your keys never leave the browser</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<defs>
<filter id="grain-smime" x="0" y="0" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="41" stitchTiles="stitch"/>
<feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feComposite in2="SourceGraphic" operator="in"/>
</filter>
<linearGradient id="bg-smime" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#64748B"/>
<stop offset="1" stop-color="#1E293B"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="48" fill="url(#bg-smime)"/>
<rect width="256" height="256" rx="48" fill="white" filter="url(#grain-smime)"/>
<g transform="translate(64,64) scale(5.333)" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- lucide "shield-check" -->
<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"/>
<path d="m9 12 2 2 4-4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+757
View File
@@ -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"
}
}
}
}
+20
View File
@@ -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"
}
}
+217
View File
@@ -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,
};
}
+222
View File
@@ -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' });
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -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());
}
+291
View File
@@ -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);
}
+190
View File
@@ -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)}`;
}
+12
View File
@@ -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 };
+222
View File
@@ -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 };
}
+270
View File
@@ -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,
);
});
}
+121
View File
@@ -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;
}
+53
View File
@@ -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;
}
+47
View File
@@ -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' });
}
+164
View File
@@ -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;
}
+54
View File
@@ -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);
}
+73
View File
@@ -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);