From 8a2ef5a6c5d737d132c1c6a7e356f32365263e97 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 14:29:29 +0100 Subject: [PATCH] feat: integrate webcrypto-liner for legacy algorithm support in S/MIME handling --- .env.example | 2 +- lib/smime/crypto-engine.ts | 76 +++++++++++++++ lib/smime/pkcs12-import.ts | 20 ++-- lib/smime/smime-decrypt.ts | 8 +- package-lock.json | 186 +++++++++++++++++++++++++++++++++++++ package.json | 1 + 6 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 lib/smime/crypto-engine.ts diff --git a/.env.example b/.env.example index eaea9c85..8bbbf2cb 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,7 @@ JMAP_SERVER_URL=https://your-jmap-server.com # ============================================================================= # Hostname the server binds to (default: 0.0.0.0) -# Set to "::" for IPv6 or "[::]" for dual-stack support. +# Set to "::" for dual-stack # HOSTNAME=0.0.0.0 # Port the server listens on (default: 3000) diff --git a/lib/smime/crypto-engine.ts b/lib/smime/crypto-engine.ts new file mode 100644 index 00000000..0879e477 --- /dev/null +++ b/lib/smime/crypto-engine.ts @@ -0,0 +1,76 @@ +/** + * Crypto engine backed by webcrypto-liner for legacy algorithm support. + * + * webcrypto-liner extends the native Web Crypto API with algorithms + * like DES-EDE3-CBC (3DES) that are commonly found in S/MIME messages + * and PKCS#12 files produced by legacy clients (Outlook, Thunderbird, etc.). + * + * Native Web Crypto calls are passed through to the real implementation; + * liner only intercepts algorithms that the browser doesn't natively support. + */ + +import * as pkijs from 'pkijs'; + +// webcrypto-liner exports a Crypto constructor at runtime that extends native +// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only +// expose the type alias, so we import the module dynamically and cast. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const liner = require('webcrypto-liner') as { + Crypto: { new (): Crypto }; + setCrypto: (subtle: SubtleCrypto) => void; + nativeCrypto: Crypto | Record; +}; + +let linerEngine: pkijs.CryptoEngine | null = null; +let linerCryptoInstance: Crypto | null = null; + +function ensureLiner() { + if (!linerCryptoInstance) { + // In Node.js, webcrypto-liner can't auto-detect the native crypto + // (it looks for self.crypto which doesn't exist). Feed it manually + // so that native algorithms (RSA, AES, etc.) stay hardware-accelerated + // and only truly missing algorithms (3DES) use the software fallback. + if ( + typeof liner.nativeCrypto?.getRandomValues !== 'function' && + typeof globalThis.crypto?.subtle !== 'undefined' + ) { + liner.setCrypto(globalThis.crypto.subtle); + } + linerCryptoInstance = new liner.Crypto(); + } + if (!linerEngine) { + linerEngine = new pkijs.CryptoEngine({ + crypto: linerCryptoInstance, + subtle: linerCryptoInstance.subtle, + name: 'webcrypto-liner', + }); + } +} + +/** Get a PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */ +export function getLinerCryptoEngine(): pkijs.CryptoEngine { + ensureLiner(); + return linerEngine!; +} + +/** + * Run an async operation with the global PKI.js engine set to webcrypto-liner, + * then restore the previous engine afterwards. + * + * Required for operations that use the global engine internally + * (e.g. PFX.parseInternalValues for PKCS#12 import). + */ +export async function withLinerEngine(fn: () => Promise): Promise { + ensureLiner(); + + // Save the current global engine so we can restore it + const prev = pkijs.getEngine(); + + pkijs.setEngine('webcrypto-liner', linerCryptoInstance!, linerEngine!); + try { + return await fn(); + } finally { + // Restore the previous engine + pkijs.setEngine(prev.name, prev.crypto as unknown as pkijs.CryptoEngine); + } +} diff --git a/lib/smime/pkcs12-import.ts b/lib/smime/pkcs12-import.ts index 38968302..324a32d0 100644 --- a/lib/smime/pkcs12-import.ts +++ b/lib/smime/pkcs12-import.ts @@ -6,6 +6,7 @@ import { classifyCapabilities, } from './certificate-utils'; import type { SmimeKeyRecord, Pkcs12ImportResult } from './types'; +import { withLinerEngine } from './crypto-engine'; const KDF_ITERATIONS = 600_000; const AES_KEY_LENGTH = 256; @@ -39,9 +40,12 @@ export async function importPkcs12( // PKIjs handles MAC verification internally during parseInternalValues } - // Parse internal values - await pfx.parseInternalValues({ - password: stringToAB(p12Passphrase), + // Use webcrypto-liner as the global engine for 3DES support. + // Many PKCS#12 files use pbeWithSHAAnd3-KeyTripleDES-CBC internally. + await withLinerEngine(async () => { + await pfx.parseInternalValues({ + password: stringToAB(p12Passphrase), + }); }); // Extract certificates and private key from parsed PKCS#12 @@ -63,7 +67,9 @@ export async function importPkcs12( } return {}; }); - await authSafe.parseInternalValues({ safeContents: safeContentsParams }); + await withLinerEngine(async () => { + await authSafe.parseInternalValues({ safeContents: safeContentsParams }); + }); for (const safeContent of authSafe.parsedValue.safeContents) { const sc = safeContent.value ?? safeContent.parsedValue; @@ -115,8 +121,10 @@ export async function importPkcs12( privateKeyInfo = shroudedBag.parsedValue; } else { // Decrypt shrouded key bag to get private key info - await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise }).parseInternalValues({ - password: stringToAB(p12Passphrase), + await withLinerEngine(async () => { + await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise }).parseInternalValues({ + password: stringToAB(p12Passphrase), + }); }); if (shroudedBag.parsedValue) { privateKeyInfo = shroudedBag.parsedValue; diff --git a/lib/smime/smime-decrypt.ts b/lib/smime/smime-decrypt.ts index 42d613d7..b4e4b17e 100644 --- a/lib/smime/smime-decrypt.ts +++ b/lib/smime/smime-decrypt.ts @@ -8,6 +8,7 @@ import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; import type { SmimeKeyRecord } from './types'; +import { getLinerCryptoEngine } from './crypto-engine'; export interface DecryptionInput { /** Raw CMS EnvelopedData bytes (DER) */ @@ -359,11 +360,8 @@ async function decryptWithKey( const certAsn1 = asn1js.fromBER(keyRecord.certificate); const cert = new pkijs.Certificate({ schema: certAsn1.result }); - const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - }); + // Use webcrypto-liner engine for legacy algorithm support (e.g. 3DES) + const cryptoEngine = getLinerCryptoEngine(); const result = await envelopedData.decrypt( recipientIndex, diff --git a/package-lock.json b/package-lock.json index 29a0cbbe..ae68c527 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^19.2.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "webcrypto-liner": "^1.4.3", "zustand": "^5.0.9" }, "devDependencies": { @@ -2375,6 +2376,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "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/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -2761,6 +2785,44 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "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/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4075,6 +4137,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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.7", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", @@ -4154,6 +4222,12 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -4177,6 +4251,12 @@ "node": "18 || 20 || >=22" } }, + "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/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -4376,6 +4456,17 @@ "dev": true, "license": "MIT" }, + "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/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4600,6 +4691,16 @@ "node": ">=6" } }, + "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4660,6 +4761,20 @@ "dev": true, "license": "ISC" }, + "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/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5663,6 +5778,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5693,6 +5818,17 @@ "hermes-estree": "0.25.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/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -5812,6 +5948,12 @@ "node": ">=8" } }, + "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/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -6779,6 +6921,18 @@ "node": ">=4" } }, + "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/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -8805,6 +8959,38 @@ "node": ">=18" } }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, + "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" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index c80f97ae..fe43fab4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "react-dom": "^19.2.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", + "webcrypto-liner": "^1.4.3", "zustand": "^5.0.9" }, "devDependencies": {