From 2d834213ee11c0db06d713180174cc74f25e6ba0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 02:48:12 +0100 Subject: [PATCH] feat: enhance certificate extraction and legacy PBE support in crypto engine --- lib/smime/certificate-utils.ts | 34 ++++-- lib/smime/crypto-engine.ts | 185 ++++++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 10 deletions(-) diff --git a/lib/smime/certificate-utils.ts b/lib/smime/certificate-utils.ts index 74cea609..ddfc7baf 100644 --- a/lib/smime/certificate-utils.ts +++ b/lib/smime/certificate-utils.ts @@ -171,13 +171,33 @@ function extractEmailAddresses(cert: pkijs.Certificate): string[] { // From SubjectAlternativeName const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN); - if (sanExt?.parsedValue) { - const san = sanExt.parsedValue as pkijs.GeneralNames; - for (const name of san.names) { - // type 1 = rfc822Name - if (name.type === 1 && typeof name.value === 'string') { - if (!emails.includes(name.value)) { - emails.push(name.value); + if (sanExt) { + let names: pkijs.GeneralName[] | undefined; + + // parsedValue may be a GeneralNames with .names, or a raw ASN.1 object + const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined; + if (pv?.names) { + names = pv.names; + } else if (sanExt.extnValue) { + // Manually parse the extension value as a SEQUENCE OF GeneralName + try { + const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView); + if (sanAsn1.offset !== -1) { + const gn = new pkijs.GeneralNames({ schema: sanAsn1.result }); + names = gn.names; + } + } catch { + // Malformed SAN — skip gracefully + } + } + + if (names) { + for (const name of names) { + // type 1 = rfc822Name + if (name.type === 1 && typeof name.value === 'string') { + if (!emails.includes(name.value)) { + emails.push(name.value); + } } } } diff --git a/lib/smime/crypto-engine.ts b/lib/smime/crypto-engine.ts index 0879e477..96e6eace 100644 --- a/lib/smime/crypto-engine.ts +++ b/lib/smime/crypto-engine.ts @@ -7,21 +7,200 @@ * * Native Web Crypto calls are passed through to the real implementation; * liner only intercepts algorithms that the browser doesn't natively support. + * + * Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only + * handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use + * legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We + * extend CryptoEngine to handle those via RFC 7292 Appendix B key + * derivation + webcrypto-liner's DES-EDE3-CBC support. */ +import * as asn1js from 'asn1js'; 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. +// Import the ES module build directly — the package's "browser" field points +// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.). // eslint-disable-next-line @typescript-eslint/no-require-imports -const liner = require('webcrypto-liner') as { +const liner = require('webcrypto-liner/build/index.es.js') as { Crypto: { new (): Crypto }; setCrypto: (subtle: SubtleCrypto) => void; nativeCrypto: Crypto | Record; }; -let linerEngine: pkijs.CryptoEngine | null = null; +// ── PKCS#12 legacy PBE OIDs ────────────────────────────────────────── +const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC +const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC +const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC +const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC + +const LEGACY_PBE_OIDS = new Set([ + PBE_SHA1_3DES_3KEY, + PBE_SHA1_3DES_2KEY, + PBE_SHA1_RC2_128, + PBE_SHA1_RC2_40, +]); + +/** Algorithm config for each legacy PBE OID. */ +function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } { + 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. + * + * @param password BMP-encoded password (with trailing 0x00 0x00) + * @param salt raw salt bytes + * @param iterations PBKDF iteration count + * @param id 1 = key material, 2 = IV, 3 = MAC key + * @param needed number of bytes to derive + */ +async function pkcs12KDF( + password: Uint8Array, + salt: Uint8Array, + iterations: number, + id: number, + needed: number, +): Promise { + const v = 64; // SHA-1 block size + const u = 20; // SHA-1 output size + + // Step 1: diversifier D = v bytes of 'id' + const D = new Uint8Array(v); + D.fill(id); + + // Step 2: fill S from salt, padded/repeated to v-byte boundary + 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]; + + // Step 3: fill P from password, padded/repeated to v-byte boundary + 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]; + + // I = S || P + 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++) { + // Aj = Hash^iterations(D || 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) { + // Build B by repeating A to fill v bytes + const B = new Uint8Array(v); + for (let j = 0; j < v; j++) B[j] = A[j % u]; + + // I[j] = (I[j] + B + 1) mod 2^v for each v-byte block + 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: ArrayBuffer): Uint8Array { + const passView = new Uint8Array(password); + // If already BMP-encoded (even length, every odd byte is 0x00 for ASCII), + // or empty, use as-is. Otherwise convert char codes to big-endian UCS-2. + // pkijs passes the password as a raw ArrayBuffer of char codes. + 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]; + } + // trailing 0x00 0x00 + bmp[bmp.length - 2] = 0; + bmp[bmp.length - 1] = 0; + return bmp; +} + +/** + * Extended CryptoEngine that handles legacy PKCS#12 PBE algorithms. + * Falls through to the base CryptoEngine for everything else. + */ +class Pkcs12CryptoEngine extends pkijs.CryptoEngine { + async decryptEncryptedContentInfo( + parameters: Parameters[0], + ): Promise { + const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId; + + if (!LEGACY_PBE_OIDS.has(oid)) { + // Delegate to base CryptoEngine (handles PBES2) + return super.decryptEncryptedContentInfo(parameters); + } + + const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams; + if (!algParams) { + throw new Error('Missing PBE algorithm parameters'); + } + + // Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER } + const paramAsn1 = asn1js.fromBER(algParams.toBER(false)); + if (paramAsn1.offset === -1) { + throw new Error('Invalid PBE parameters ASN.1'); + } + const seq = paramAsn1.result as asn1js.Sequence; + const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView); + const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec; + + const { keyLen, ivLen, algName } = pbeConfig(oid); + const bmpPassword = passwordToBMP(parameters.password); + + // Derive key (id=1) and IV (id=2) using PKCS#12 KDF + const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen); + const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen); + + // Import key via webcrypto-liner (supports DES-EDE3-CBC) + const cryptoKey = await this.importKey( + 'raw', + new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength) as unknown as BufferSource, + { name: algName, length: keyLen * 8 } as Algorithm, + false, + ['decrypt'], + ); + + // Decrypt + const ciphertext = parameters.encryptedContentInfo.getEncryptedContent(); + return this.decrypt( + { name: algName, iv: ivBytes } as Algorithm, + cryptoKey, + ciphertext, + ); + } +} + +let linerEngine: Pkcs12CryptoEngine | null = null; let linerCryptoInstance: Crypto | null = null; function ensureLiner() { @@ -39,7 +218,7 @@ function ensureLiner() { linerCryptoInstance = new liner.Crypto(); } if (!linerEngine) { - linerEngine = new pkijs.CryptoEngine({ + linerEngine = new Pkcs12CryptoEngine({ crypto: linerCryptoInstance, subtle: linerCryptoInstance.subtle, name: 'webcrypto-liner',