feat: enhance certificate extraction and legacy PBE support in crypto engine

This commit is contained in:
Linus Rath
2026-03-21 02:48:12 +01:00
parent 439a4dbe8a
commit 2d834213ee
2 changed files with 209 additions and 10 deletions
+27 -7
View File
@@ -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);
}
}
}
}
+182 -3
View File
@@ -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<string, never>;
};
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<Uint8Array> {
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<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
): Promise<ArrayBuffer> {
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',