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:
co-authored by
Claude Opus 4.8
parent
e9746fcf78
commit
f7e487171c
@@ -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)_
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# S/MIME plugin security audit — `bulwarkmail/plugins/smime`
|
||||||
|
|
||||||
|
**Task:** `S-01` · **Date:** 2026-08-04 · **Auditor:** Claude Opus 4.8 (findings independently verified against host source)
|
||||||
|
**Subject:** `github.com/bulwarkmail/plugins` @ `91085a3`, `smime/` — 2,935 lines of source across 13 modules
|
||||||
|
**Runs as:** privileged (same-origin) tier — full DOM, network, IndexedDB and WebCrypto access; handles users' private keys
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Do not ship as-is. Fork and fix findings 1–3 first.**
|
||||||
|
|
||||||
|
This is not malicious code and shows no sign of a backdoor. The cryptographic primitives are handled competently — PBKDF2-SHA256 at 600k iterations, AES-256-GCM wrapping, every private-key import non-extractable, no network egress anywhere in the bundle. The problems are **trust-model and input-validation gaps**, all fixable, two of them in a handful of lines.
|
||||||
|
|
||||||
|
The single most reassuring property: **there is no exfiltration path.** Across all 13 modules there is no `fetch`, `XMLHttpRequest`, `WebSocket`, `sendBeacon`, `new Image`, `.src =`, `EventSource`, dynamic `import()`, or URL literal of any kind. This matters more than usual because privileged tier is same-origin — the manifest's lack of `http:fetch` would *not* have constrained it, so the absence had to be verified in code rather than inferred from permissions.
|
||||||
|
|
||||||
|
## Remediation status (updated 2026-08-04)
|
||||||
|
|
||||||
|
Forked to `vnc/plugins/smime/` — **source only; the upstream zip was deliberately not vendored** (see Supply chain below).
|
||||||
|
|
||||||
|
| # | Status |
|
||||||
|
|---|---|
|
||||||
|
| 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` |
|
||||||
|
| 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly |
|
||||||
|
| — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` |
|
||||||
|
| 2 · Unauthenticated CBC on decrypt | ⛔ **Open — gate before real mail.** Still accepts unauthenticated CBC |
|
||||||
|
| 4, 5, 6, 7, 8, 9 | ⛔ Open |
|
||||||
|
|
||||||
|
Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — 19 assertions, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, and source assertions that fail if either guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught `smime-type=${input.smimeType}` (`mime-builder.js:216`), which manual review had dismissed as a static string.
|
||||||
|
|
||||||
|
**Still not safe for real mail** — finding 2 is unfixed. Suitable only for a throwaway sandbox account.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| # | Severity | Finding | Location |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **HIGH** | Certificate substitution via auto-import of unvalidated self-signed certs | `index.js:361-383` |
|
||||||
|
| 2 | **HIGH** | Unauthenticated CBC ciphers accepted on decrypt (EFAIL precondition) | `smime-decrypt.js:258-269`, `crypto-engine.js:114-135` |
|
||||||
|
| 3 | **MED-HIGH** | CRLF header injection — escaping covers 2 of ~6 insertion points | `mime-builder.js:115-139` |
|
||||||
|
| 4 | **MEDIUM** | Unlocked key handles persisted to durable IndexedDB, not memory | `key-storage.js:106-109` |
|
||||||
|
| 5 | **MEDIUM** | MIME parser: unbounded recursion + no size caps (DoS) | `mime-parse.js:47-50`, `smime-detect.js:88-109` |
|
||||||
|
| 6 | **MEDIUM** | `RSAES-PKCS1-v1_5` decrypt via JS polyfill (Marvin-class oracle surface) | `pkcs12.js:196-201`, `smime-decrypt.js:51-65` |
|
||||||
|
| 7 | LOW | Signer-cert lookup falls back to a blind heuristic for SKI-addressed CMS | `smime-verify.js:158-161` |
|
||||||
|
| 8 | LOW | `classifyCapabilities` defaults `canSign`/`canEncrypt` to true when KU/EKU absent | `certificate-utils.js:170-191` |
|
||||||
|
| 9 | LOW | PKCS#12 passphrase via `charCodeAt` — mangles non-Latin1 passphrases | `pkcs12.js:18-23` |
|
||||||
|
|
||||||
|
### 1. Certificate substitution — HIGH
|
||||||
|
|
||||||
|
`smime-verify.js:44` verifies with **`checkChain: false`** — cryptographic signature only, no trust anchor. This is a defensible design choice, and the module correctly computes both `selfSigned` (line 81) and `signerEmailMatch` (lines 74-77), returning them for the UI banner.
|
||||||
|
|
||||||
|
**But `maybeAutoImportSigner` ignores both:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// index.js:361-364
|
||||||
|
async function maybeAutoImportSigner(status) {
|
||||||
|
if (settings().autoImportSignerCerts === false) return;
|
||||||
|
const cert = status && status.signerCert;
|
||||||
|
if (!cert || !status.signatureValid || !cert.email) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
It then calls `savePublicCert(...)`, storing the certificate as the **encryption target** for the email address the certificate claims. `autoImportSignerCerts` defaults to **true**.
|
||||||
|
|
||||||
|
`certificate-utils.js:134-167` collects the identity email from either the legacy Subject `E=` attribute or the SAN `rfc822Name`, treating both as equally authoritative — and for a self-signed cert both are entirely self-asserted.
|
||||||
|
|
||||||
|
**Attack:** generate a self-signed certificate asserting `victim@vnc.biz`, sign any message with it, send it to the user. On open, the signature verifies (it is internally consistent), the cert is silently stored as the encryption key for `victim@vnc.biz`. A later user-initiated "Encrypt" to that address encrypts to the attacker's key instead of the recipient's. The user sees an encrypted-send confirmation; the legitimate recipient cannot read it, and anyone holding the attacker's key who obtains the ciphertext can.
|
||||||
|
|
||||||
|
**Fix (small — the data is already computed):** require `!selfSigned` **and** `signerEmailMatch === true` before auto-import; once `S-06`'s trust store exists, require chain validation instead. Ideally require explicit user confirmation before a certificate becomes an encryption target.
|
||||||
|
|
||||||
|
### 2. Unauthenticated CBC on decrypt — HIGH
|
||||||
|
|
||||||
|
**There is no content-encryption-algorithm allowlist anywhere in the decrypt path** (verified by exhaustive grep — no reference to `contentEncryptionAlgorithm`, `GCM`, or `CBC` in `smime-decrypt.js`). The decrypt call passes attacker-supplied CMS straight to pkijs:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// smime-decrypt.js:262-263
|
||||||
|
return withLinerEngine(async () => {
|
||||||
|
const cryptoEngine = getLinerCryptoEngine();
|
||||||
|
```
|
||||||
|
|
||||||
|
And that engine deliberately widens the accepted set to legacy unauthenticated ciphers:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// crypto-engine.js:121-123
|
||||||
|
case OID_DES_EDE3_CBC: return { name: 'DES-EDE3-CBC', length: 192 };
|
||||||
|
case OID_DES_CBC: return { name: 'DES-CBC', length: 64 };
|
||||||
|
case OID_RC2_CBC: return { name: 'RC2-CBC', length: 128 };
|
||||||
|
```
|
||||||
|
|
||||||
|
CMS `EnvelopedData` carries no MAC; only AEAD modes provide integrity. Native AES-CBC is likewise accepted, since nothing inspects the algorithm at all. Decrypted bytes are returned to the renderer with no authenticity gate — the precondition for EFAIL direct-exfiltration and CBC-gadget attacks. End-to-end exploitability additionally depends on the host's HTML sanitiser blocking external resource loads, which is a separate control and should not be the only one.
|
||||||
|
|
||||||
|
**Fix:** allowlist AEAD content encryption (AES-GCM) on decrypt. If legacy CBC must be supported for old archived mail, gate it behind an explicit per-message user opt-in and never render its output as HTML.
|
||||||
|
|
||||||
|
### 3. CRLF header injection — MED-HIGH
|
||||||
|
|
||||||
|
`encodeHeaderValue` (`mime-builder.js:141-152`) does neutralise CR/LF — but only as a side effect of Q-encoding, and it is applied to just **Subject** and **attachment filename**. It is not applied to display names, raw addresses, `Message-ID`, `In-Reply-To`, `References`, or attachment `Content-Type`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// mime-builder.js:115-121 — escapes only backslash and quote
|
||||||
|
function formatAddress(addr) {
|
||||||
|
if (addr.name) {
|
||||||
|
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
return `"${escaped}" <${addr.email}>`;
|
||||||
|
}
|
||||||
|
return addr.email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`formatHeader` (123-139) performs line *folding* only — no sanitisation. A display name containing `\r\n` is emitted verbatim into the header block.
|
||||||
|
|
||||||
|
**Remote path:** `In-Reply-To` / `References` / display names are typically copied from an inbound message when replying or forwarding, so the injected value is attacker-supplied. Impact ranges from spoofed `Reply-To` to added recipients, depending on whether the submission path passes explicit `envelopeRecipients` (the host's `sendRawEmail` accepts them; whether the plugin supplies them should be confirmed).
|
||||||
|
|
||||||
|
**Fix:** strip or encode CR/LF in one place — inside `formatHeader` — so every header value is covered by construction rather than per-call-site.
|
||||||
|
|
||||||
|
### 4. Unlocked keys persisted to disk — MEDIUM
|
||||||
|
|
||||||
|
`key-storage.js:106-109` writes unlocked, non-extractable `CryptoKey` handles into the durable `smime-plugin-store` IndexedDB. Raw key material is never exposed (non-extractable survives structured clone), but the *usable handle* is on disk and survives tab close and browser restart unless wiped.
|
||||||
|
|
||||||
|
The wipe hooks are real — `onAfterLogout` and `onAccountSwitch` exist in our host at `lib/plugin-hooks.ts:362-363` (an earlier reviewer flagged these as possibly fictional; they are not). Two caveats stand:
|
||||||
|
|
||||||
|
- The `lockOnLogout` setting (default true) lets a user disable the wipe entirely.
|
||||||
|
- If the wipe does not run — crash, killed tab, failed transaction — the handle persists, and anyone with the browser profile can decrypt mail **without the passphrase**.
|
||||||
|
|
||||||
|
This also contradicts the host's own plugin guidance in `WRITING_PLUGINS.md` §8 ("in memory only"), and it is weaker than the native implementation this replaced, which kept unlocked keys in an in-memory `Map`. The trade buys cross-iframe sharing between the settings slot and the background hooks.
|
||||||
|
|
||||||
|
### 5–9
|
||||||
|
|
||||||
|
Recursion in `parseEntity` (`mime-parse.js:47-50`) and both `smime-detect.js` walkers (88-109) is depth-unbounded; `binaryString` (`mime-parse.js:28-32`) concatenates byte-by-byte with no size cap. ReDoS specifically was checked across every regex and is **clean** — all linear. Finding 6 is an accepted-risk interop trade needing documentation rather than removal. Findings 7–9 are correctness/robustness issues.
|
||||||
|
|
||||||
|
## Verified clean
|
||||||
|
|
||||||
|
| Area | Result |
|
||||||
|
|---|---|
|
||||||
|
| Network egress | **None** across all 13 modules |
|
||||||
|
| `eval` / `new Function` / dynamic code | None |
|
||||||
|
| XSS / DOM injection | No `innerHTML`, `document.write`, or `dangerouslySetInnerHTML`; cert fields render as React text children (auto-escaped) |
|
||||||
|
| Passphrase handling | Local scope only; never persisted to storage, `api.storage`, cookies, or logs |
|
||||||
|
| `api.storage.*` (plaintext localStorage) | Non-secret prefs and verification metadata only — no keys, no passphrases |
|
||||||
|
| Key wrapping | PBKDF2-SHA256 600k → AES-256-GCM; 32-byte salt, 12-byte IV |
|
||||||
|
| Key extractability | `extractable: false` at all five `importKey` sites |
|
||||||
|
| Encrypt path | Hardcoded AES-GCM + RSA-OAEP/SHA-256 — no downgrade negotiation, no RC2/DES reachable |
|
||||||
|
| Polyfill scoping | `sign`/`encrypt`/`verify` use `nativeEngine()` exclusively; the JS polyfill is never used for signing or randomness |
|
||||||
|
| Engine restore | `withLinerEngine` uses `try/finally` — no leaked global polyfill state |
|
||||||
|
| Fingerprints | SHA-256 (not SHA-1) |
|
||||||
|
| Backdoors / hardcoded keys / TODO markers | None |
|
||||||
|
|
||||||
|
## Supply-chain findings
|
||||||
|
|
||||||
|
**Do not ship the marketplace zip.** `smime.zip` in the repo contains a single 1.77 MB bundled `index.js` — not readable source — and its manifest reads **1.0.1** while the repo source is **1.0.2**. The shipped artifact is stale relative to the code, so auditing `src/` would not audit what that zip installs.
|
||||||
|
|
||||||
|
**Build from source ourselves** via the repo's own `npm run package` (esbuild bundle of `src/` plus pkijs/asn1js/pvtsutils/webcrypto-liner). That way the artifact corresponds to the audited code and we control it.
|
||||||
|
|
||||||
|
## Interaction with our own changes
|
||||||
|
|
||||||
|
- **`B-04` is compatible.** The plugin declares `email:render-takeover` and `email:send`, which our gate requires for `onRenderEmailBody` and `onComposeSend`. It will load correctly.
|
||||||
|
- **`B-09` carries a real hazard here.** The plugin registers `onAfterLogout` and `onAccountSwitch` but its manifest requests **no `auth:observe`**. If `B-09` gates auth hooks on that permission, **the session-key wipe silently stops running** — turning finding 4 from a caveat into a live exposure. This is a concrete instance of the over-gating risk noted in `B-09`, and it is security-relevant: add the permission to the fork's manifest, or exempt these hooks deliberately.
|
||||||
|
- **`B-01` is exercised by this bundle** — 1.77 MB of minified pkijs/webcrypto-liner will trip the pattern scanner, which is exactly the case the override exists for.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
1. Fork `smime/` into `vnc/plugins/smime/` — **do not** vendor the upstream zip.
|
||||||
|
2. Fix findings **1, 2, 3** before any user imports a key. Findings 1 and 3 are small, localised changes; finding 2 is an allowlist.
|
||||||
|
3. Add `auth:observe` to the forked manifest so the wipe survives `B-09`.
|
||||||
|
4. Document findings 4 and 6 as accepted risks with rationale, or fix 4 by moving session keys back to memory and accepting the cross-iframe cost.
|
||||||
|
5. Re-audit the diff after fixes, then proceed to the self-signed-certificate spike.
|
||||||
|
|
||||||
|
Findings 1–3 are why `S-01` was scheduled before `A-01`. All three would have shipped.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & 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 |
@@ -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 |
Generated
+757
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user