import { BaseBlock, BmpString, Integer, ObjectIdentifier, OctetString, Sequence, Set as AsnSet, Utf8String, fromBER, } from "asn1js"; import { decryptEncryptedPkcs8, decryptPbes2Payload, inspectEncryptedPkcs8, inspectPbes2Payload, inspectPkcs8, reservePbkdf2Work, type Pbkdf2WorkBudget, type Pbes2Inspection, type Pkcs8Inspection, } from "./pbes2"; const MAX_INPUT_BYTES = 8 * 1024 * 1024; const MAX_CONTENT_INFOS = 64; const MAX_BAGS = 512; const MAX_NESTING_DEPTH = 4; const MAX_ATTRIBUTES = 32; const MAX_MAC_ITERATIONS = 50_000; const MAX_PASSWORD_UNITS = 4_096; const CMS_DATA = "1.2.840.113549.1.7.1"; const CMS_ENCRYPTED_DATA = "1.2.840.113549.1.7.6"; const PBES2 = "1.2.840.113549.1.5.13"; const LEGACY_PKCS12_PBE_PREFIX = "1.2.840.113549.1.12.1."; const KEY_BAG = "1.2.840.113549.1.12.10.1.1"; const SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2"; const CERT_BAG = "1.2.840.113549.1.12.10.1.3"; const CRL_BAG = "1.2.840.113549.1.12.10.1.4"; const SECRET_BAG = "1.2.840.113549.1.12.10.1.5"; const SAFE_CONTENTS_BAG = "1.2.840.113549.1.12.10.1.6"; const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1"; const FRIENDLY_NAME = "1.2.840.113549.1.9.20"; const LOCAL_KEY_ID = "1.2.840.113549.1.9.21"; type Block = BaseBlock; const DIGESTS: Record< string, { name: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; u: number; v: number } > = { "1.3.14.3.2.26": { name: "SHA-1", u: 20, v: 64 }, "2.16.840.1.101.3.4.2.1": { name: "SHA-256", u: 32, v: 64 }, "2.16.840.1.101.3.4.2.2": { name: "SHA-384", u: 48, v: 128 }, "2.16.840.1.101.3.4.2.3": { name: "SHA-512", u: 64, v: 128 }, }; export interface Pkcs12MacInspection { present: boolean; status: "absent" | "password-required" | "verified" | "unsupported"; algorithm?: string; algorithmOid?: string; iterations?: number; saltBytes?: number; } export interface Pkcs12ContentInventory { index: number; type: "data" | "encryptedData"; state: "parsed" | "password-required"; encryption?: Pbes2Inspection; bagCount: number; } export interface Pkcs12BagInventory { path: string; bagType: | "private-key" | "shrouded-private-key" | "certificate" | "safe-contents" | "crl" | "secret" | "unknown"; bagOid: string; friendlyName?: string; localKeyId?: string; encrypted: boolean; state: "inspected" | "password-required" | "unsupported"; key?: Pkcs8Inspection; encryption?: Pbes2Inspection; certificateBytes?: Uint8Array; } export interface Pkcs12Inspection { recognized: true; version: 3; bytes: number; authSafeContentType: typeof CMS_DATA; mac: Pkcs12MacInspection; contents: Pkcs12ContentInventory[]; bags: Pkcs12BagInventory[]; warnings: string[]; } interface ParsedMacData { digest: Uint8Array; salt: Uint8Array; algorithmOid: string; algorithm?: (typeof DIGESTS)[string]; iterations: number; } interface InventoryContext { password?: string; bags: Pkcs12BagInventory[]; warnings: string[]; pbkdf2Budget: Pbkdf2WorkBudget; } function asBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice( bytes.byteOffset, bytes.byteOffset + bytes.byteLength, ) as ArrayBuffer; } function derBytes(block: Block): Uint8Array { return new Uint8Array(block.toBER(false)); } function parseDer(bytes: Uint8Array, name: string): Block { if (bytes.length === 0 || bytes.length > MAX_INPUT_BYTES) throw new Error(`${name} must contain 1 byte–8 MiB.`); const parsed = fromBER(asBuffer(bytes)); if (parsed.offset === -1 || parsed.offset !== bytes.length) throw new Error(`${name} is malformed DER or contains trailing bytes.`); const canonical = new Uint8Array(parsed.result.toBER(false)); if ( canonical.length !== bytes.length || canonical.some((value, index) => value !== bytes[index]) ) throw new Error(`${name} must use canonical definite-length DER.`); return parsed.result; } function sequence(block: Block | undefined, name: string): Block[] { if (!(block instanceof Sequence)) throw new Error(`${name} must be an ASN.1 SEQUENCE.`); return block.valueBlock.value; } function set(block: Block | undefined, name: string): Block[] { if (!(block instanceof AsnSet)) throw new Error(`${name} must be an ASN.1 SET.`); return block.valueBlock.value; } function oid(block: Block | undefined, name: string): string { if (!(block instanceof ObjectIdentifier)) throw new Error(`${name} must be an ASN.1 object identifier.`); return block.getValue(); } function integer(block: Block | undefined, name: string): bigint { if (!(block instanceof Integer)) throw new Error(`${name} must be an ASN.1 INTEGER.`); return block.toBigInt(); } function octets(block: Block | undefined, name: string): Uint8Array { if (!(block instanceof OctetString)) throw new Error(`${name} must be an ASN.1 OCTET STRING.`); return new Uint8Array(block.getValue()); } function explicit(block: Block | undefined, tag: number, name: string): Block { if ( !block || block.idBlock.tagClass !== 3 || block.idBlock.tagNumber !== tag || !block.idBlock.isConstructed ) throw new Error(`${name} must be an explicit [${tag}] value.`); const constructed = block.valueBlock as Block["valueBlock"] & { value?: Block[]; }; if (!Array.isArray(constructed.value) || constructed.value.length !== 1) throw new Error(`${name} must contain exactly one value.`); return constructed.value[0]!; } function implicitOctets( block: Block | undefined, tag: number, name: string, ): Uint8Array { if ( !block || block.idBlock.tagClass !== 3 || block.idBlock.tagNumber !== tag || block.idBlock.isConstructed ) throw new Error(`${name} must be a primitive implicit [${tag}] value.`); const primitive = block.valueBlock as Block["valueBlock"] & { valueHexView?: Uint8Array; }; return new Uint8Array(primitive.valueHexView ?? new Uint8Array()); } function boundedPassword(password: string): void { if (password.length > MAX_PASSWORD_UNITS) throw new Error( `PKCS #12 password must contain at most ${MAX_PASSWORD_UNITS.toLocaleString()} UTF-16 units.`, ); } function parseContentInfo(block: Block, name: string): [string, Block] { const fields = sequence(block, name); if (fields.length !== 2) throw new Error(`${name} must contain a content type and [0] content.`); return [oid(fields[0], `${name} content type`), explicit(fields[1], 0, name)]; } function parseMacData(block: Block | undefined): ParsedMacData { const fields = sequence(block, "PFX MacData"); if (fields.length < 2 || fields.length > 3) throw new Error("PFX MacData has an unsupported shape."); const digestInfo = sequence(fields[0], "PFX MacData DigestInfo"); if (digestInfo.length !== 2) throw new Error("PFX MacData DigestInfo is incomplete."); const algorithmIdentifier = sequence( digestInfo[0], "PFX MacData digest algorithm", ); if (algorithmIdentifier.length < 1 || algorithmIdentifier.length > 2) throw new Error("PFX MacData digest AlgorithmIdentifier is malformed."); const algorithmOid = oid( algorithmIdentifier[0], "PFX MacData digest algorithm", ); const iterationsBig = fields[2] ? integer(fields[2], "PFX MacData iterations") : 1n; if (iterationsBig < 1n || iterationsBig > BigInt(MAX_MAC_ITERATIONS)) throw new Error( `PFX MacData iterations must be 1–${MAX_MAC_ITERATIONS.toLocaleString()}.`, ); const salt = octets(fields[1], "PFX MacData salt"); if (salt.length < 1 || salt.length > 1_024) throw new Error("PFX MacData salt must contain 1–1,024 bytes."); const digest = octets(digestInfo[1], "PFX MacData digest"); const algorithm = DIGESTS[algorithmOid]; if (algorithm && digest.length !== algorithm.u) throw new Error( `PFX MacData ${algorithm.name} digest must contain ${algorithm.u} bytes.`, ); return { digest, salt, algorithmOid, ...(algorithm ? { algorithm } : {}), iterations: Number(iterationsBig), }; } function pkcs12PasswordBytes(password: string): Uint8Array { const output = new Uint8Array((password.length + 1) * 2); for (let index = 0; index < password.length; index += 1) { const unit = password.charCodeAt(index); output[index * 2] = unit >>> 8; output[index * 2 + 1] = unit & 0xff; } return output; } function repeatToMultiple(source: Uint8Array, multiple: number): Uint8Array { if (source.length === 0) return source; const length = multiple * Math.ceil(source.length / multiple); return Uint8Array.from( { length }, (_, index) => source[index % source.length]!, ); } async function pkcs12MacKey( password: string, salt: Uint8Array, iterations: number, digest: NonNullable, ): Promise { // RFC 7292 Appendix B, diversifier ID 3 (MAC material). const diversifier = new Uint8Array(digest.v).fill(3); const passwordBytes = pkcs12PasswordBytes(password); const saltBlock = repeatToMultiple(salt, digest.v); const passwordBlock = repeatToMultiple(passwordBytes, digest.v); const state = new Uint8Array(saltBlock.length + passwordBlock.length); state.set(saltBlock); state.set(passwordBlock, saltBlock.length); const source = new Uint8Array(diversifier.length + state.length); source.set(diversifier); source.set(state, diversifier.length); let derived = new Uint8Array(); const blocks = Math.ceil(digest.u / digest.u); for (let blockIndex = 0; blockIndex < blocks; blockIndex += 1) { let a = new Uint8Array(await crypto.subtle.digest(digest.name, source)); for (let round = 1; round < iterations; round += 1) a = new Uint8Array(await crypto.subtle.digest(digest.name, a)); const next = new Uint8Array(derived.length + a.length); next.set(derived); next.set(a, derived.length); derived = next; if (state.length > 0) { const b = Uint8Array.from( { length: digest.v }, (_, index) => a[index % a.length]!, ); for (let offset = 0; offset < state.length; offset += digest.v) { let carry = 1; for (let index = digest.v - 1; index >= 0; index -= 1) { const position = offset + index; const sum = state[position]! + b[index]! + carry; state[position] = sum & 0xff; carry = sum >>> 8; } } source.set(state, diversifier.length); } } passwordBytes.fill(0); state.fill(0); source.fill(0); return derived.slice(0, digest.u); } function equalConstantTime(left: Uint8Array, right: Uint8Array): boolean { let difference = left.length ^ right.length; const length = Math.max(left.length, right.length); for (let index = 0; index < length; index += 1) difference |= (left[index] ?? 0) ^ (right[index] ?? 0); return difference === 0; } async function verifyMacData( mac: ParsedMacData, authSafeBytes: Uint8Array, password: string, ): Promise { if (!mac.algorithm) throw new Error( `PFX MacData digest ${mac.algorithmOid} is not supported by this WebCrypto workflow.`, ); const keyBytes = await pkcs12MacKey( password, mac.salt, mac.iterations, mac.algorithm, ); try { const key = await crypto.subtle.importKey( "raw", asBuffer(keyBytes), { name: "HMAC", hash: mac.algorithm.name }, false, ["sign"], ); const actual = new Uint8Array( await crypto.subtle.sign("HMAC", key, asBuffer(authSafeBytes)), ); if (!equalConstantTime(actual, mac.digest)) throw new Error( "PKCS #12 password is incorrect, or MacData/authSafe bytes are damaged.", ); } finally { keyBytes.fill(0); } } function parseAttributes( block: Block | undefined, path: string, ): Pick { if (!block) return {}; const attributes = set(block, `${path} attributes`); if (attributes.length > MAX_ATTRIBUTES) throw new Error(`${path} has more than ${MAX_ATTRIBUTES} attributes.`); let friendlyName: string | undefined; let localKeyId: string | undefined; for (const attribute of attributes) { const fields = sequence(attribute, `${path} attribute`); if (fields.length !== 2) throw new Error(`${path} contains a malformed bag attribute.`); const attributeOid = oid(fields[0], `${path} attribute type`); const values = set(fields[1], `${path} attribute values`); if (values.length !== 1) throw new Error(`${path} bag attributes must contain one value.`); const value = values[0]!; if (attributeOid === FRIENDLY_NAME) { if (!(value instanceof BmpString) && !(value instanceof Utf8String)) throw new Error( `${path} friendlyName must be BMPString or UTF8String.`, ); const candidate = value.getValue(); if (candidate.length > 256) throw new Error(`${path} friendlyName exceeds 256 UTF-16 units.`); friendlyName = candidate; } else if (attributeOid === LOCAL_KEY_ID) { const candidate = octets(value, `${path} localKeyId`); if (candidate.length > 64) throw new Error(`${path} localKeyId exceeds 64 bytes.`); localKeyId = Array.from(candidate, (byte) => byte.toString(16).padStart(2, "0"), ) .join("") .toUpperCase(); } } return { ...(friendlyName === undefined ? {} : { friendlyName }), ...(localKeyId === undefined ? {} : { localKeyId }), }; } function addBag(context: InventoryContext, bag: Pkcs12BagInventory): void { if (context.bags.length >= MAX_BAGS) throw new Error(`PFX contains more than ${MAX_BAGS} SafeBags.`); context.bags.push(bag); } async function inspectSafeContents( bytes: Uint8Array, context: InventoryContext, prefix: string, depth: number, ): Promise { if (depth > MAX_NESTING_DEPTH) throw new Error( `PFX SafeContents nesting exceeds ${MAX_NESTING_DEPTH} levels.`, ); const bags = sequence( parseDer(bytes, `${prefix} SafeContents`), "SafeContents", ); const before = context.bags.length; for (const [index, block] of bags.entries()) { const path = `${prefix}/bag[${index}]`; const fields = sequence(block, path); if (fields.length < 2 || fields.length > 3) throw new Error(`${path} has an unsupported SafeBag shape.`); const bagOid = oid(fields[0], `${path} bagId`); const bagValue = explicit(fields[1], 0, `${path} bagValue`); const attributes = parseAttributes(fields[2], path); if (bagOid === KEY_BAG) { const keyBytes = derBytes(bagValue); try { addBag(context, { path, bagType: "private-key", bagOid, ...attributes, encrypted: false, state: "inspected", key: inspectPkcs8(keyBytes), }); } finally { keyBytes.fill(0); } continue; } if (bagOid === SHROUDED_KEY_BAG) { const encryptedBytes = derBytes(bagValue); let encryption: Pbes2Inspection; try { encryption = inspectEncryptedPkcs8(encryptedBytes); } catch (reason) { throw new Error( `${path} uses a legacy or unsupported shrouded-key PBE: ${reason instanceof Error ? reason.message : "unsupported algorithm"}`, { cause: reason }, ); } if (context.password === undefined) { addBag(context, { path, bagType: "shrouded-private-key", bagOid, ...attributes, encrypted: true, state: "password-required", encryption, }); } else { reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, path); let decrypted: Awaited>; try { decrypted = await decryptEncryptedPkcs8( encryptedBytes, context.password, ); } catch (reason) { throw new Error( `${path} could not decrypt its shrouded PKCS #8 key: ${reason instanceof Error ? reason.message : "decryption failed"}`, { cause: reason }, ); } try { addBag(context, { path, bagType: "shrouded-private-key", bagOid, ...attributes, encrypted: true, state: "inspected", encryption, key: decrypted.key, }); } finally { decrypted.bytes.fill(0); } } continue; } if (bagOid === CERT_BAG) { const certFields = sequence(bagValue, `${path} CertBag`); if (certFields.length !== 2) throw new Error(`${path} CertBag has an unsupported shape.`); const certId = oid(certFields[0], `${path} certificate type`); if (certId !== X509_CERTIFICATE) throw new Error(`${path} certificate type ${certId} is unsupported.`); addBag(context, { path, bagType: "certificate", bagOid, ...attributes, encrypted: false, state: "inspected", certificateBytes: octets( explicit(certFields[1], 0, `${path} certificate value`), `${path} X.509 certificate`, ), }); continue; } if (bagOid === SAFE_CONTENTS_BAG) { addBag(context, { path, bagType: "safe-contents", bagOid, ...attributes, encrypted: false, state: "inspected", }); const nestedBytes = derBytes(bagValue); try { await inspectSafeContents(nestedBytes, context, path, depth + 1); } finally { nestedBytes.fill(0); } continue; } const bagType = bagOid === CRL_BAG ? "crl" : bagOid === SECRET_BAG ? "secret" : "unknown"; addBag(context, { path, bagType, bagOid, ...attributes, encrypted: false, state: "unsupported", }); context.warnings.push( `${path} (${bagType}, OID ${bagOid}) is inventoried but its value is deliberately not decoded or exported.`, ); } return context.bags.length - before; } async function inspectAuthenticatedSafe( bytes: Uint8Array, context: InventoryContext, ): Promise { const blocks = sequence( parseDer(bytes, "PFX AuthenticatedSafe"), "AuthenticatedSafe", ); if (blocks.length > MAX_CONTENT_INFOS) throw new Error( `PFX AuthenticatedSafe contains more than ${MAX_CONTENT_INFOS} ContentInfo values.`, ); const contents: Pkcs12ContentInventory[] = []; for (const [index, block] of blocks.entries()) { const [contentType, content] = parseContentInfo( block, `AuthenticatedSafe[${index}]`, ); const prefix = `content[${index}]`; if (contentType === CMS_DATA) { const bagCount = await inspectSafeContents( octets(content, `${prefix} data`), context, prefix, 0, ); contents.push({ index, type: "data", state: "parsed", bagCount }); continue; } if (contentType !== CMS_ENCRYPTED_DATA) throw new Error( `AuthenticatedSafe[${index}] content type ${contentType} is unsupported; only data and encryptedData are accepted.`, ); const encryptedData = sequence(content, `${prefix} EncryptedData`); if (encryptedData.length < 2 || encryptedData.length > 3) throw new Error(`${prefix} EncryptedData has an unsupported shape.`); if (integer(encryptedData[0], `${prefix} EncryptedData version`) !== 0n) throw new Error(`${prefix} EncryptedData version must be 0.`); const encryptedContentInfo = sequence( encryptedData[1], `${prefix} EncryptedContentInfo`, ); if (encryptedContentInfo.length !== 3) throw new Error(`${prefix} EncryptedContentInfo is incomplete.`); if ( oid(encryptedContentInfo[0], `${prefix} encrypted content type`) !== CMS_DATA ) throw new Error(`${prefix} encrypted content must contain CMS data.`); const algorithmIdentifier = derBytes(encryptedContentInfo[1]!); const algorithmFields = sequence( encryptedContentInfo[1], `${prefix} encryption algorithm`, ); const algorithmOid = oid( algorithmFields[0], `${prefix} encryption algorithm`, ); if (algorithmOid !== PBES2) { const family = algorithmOid.startsWith(LEGACY_PKCS12_PBE_PREFIX) ? "legacy PKCS #12 PBE" : "unsupported encryption"; throw new Error( `${prefix} uses ${family} OID ${algorithmOid}; only PBES2/PBKDF2 with AES-CBC or AES-GCM is supported.`, ); } const encryptedBytes = implicitOctets( encryptedContentInfo[2], 0, `${prefix} encryptedContent`, ); const encryption = inspectPbes2Payload(algorithmIdentifier, encryptedBytes); if (context.password === undefined) { contents.push({ index, type: "encryptedData", state: "password-required", encryption, bagCount: 0, }); continue; } let plaintext: Uint8Array | undefined; try { reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, prefix); plaintext = ( await decryptPbes2Payload( algorithmIdentifier, encryptedBytes, context.password, ) ).bytes; const bagCount = await inspectSafeContents(plaintext, context, prefix, 0); contents.push({ index, type: "encryptedData", state: "parsed", encryption, bagCount, }); } catch (reason) { throw new Error( `${prefix} PBES2 content could not be decrypted and parsed: ${reason instanceof Error ? reason.message : "decryption failed"}`, { cause: reason }, ); } finally { plaintext?.fill(0); } } return contents; } export function recognizesPkcs12(bytes: Uint8Array): boolean { try { const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX"); return ( root.length >= 2 && root.length <= 3 && integer(root[0], "PFX version") === 3n && sequence(root[1], "PFX authSafe ContentInfo").length >= 1 ); } catch { return false; } } export async function inspectPkcs12( bytes: Uint8Array, options: { password?: string } = {}, ): Promise { if (options.password !== undefined) boundedPassword(options.password); const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX"); if (root.length < 2 || root.length > 3) throw new Error("PFX must contain version, authSafe and optional MacData."); if (integer(root[0], "PFX version") !== 3n) throw new Error("PFX version must be 3."); const [authSafeContentType, authSafeContent] = parseContentInfo( root[1]!, "PFX authSafe ContentInfo", ); if (authSafeContentType !== CMS_DATA) throw new Error( `PFX authSafe content type ${authSafeContentType} is unsupported; RFC 7292 requires CMS data here.`, ); const authSafeBytes = octets(authSafeContent, "PFX authSafe data"); let mac: Pkcs12MacInspection; if (!root[2]) { mac = { present: false, status: "absent" }; } else { const parsedMac = parseMacData(root[2]); if (options.password === undefined) { mac = { present: true, status: parsedMac.algorithm ? "password-required" : "unsupported", algorithm: parsedMac.algorithm?.name ?? `OID ${parsedMac.algorithmOid}`, algorithmOid: parsedMac.algorithmOid, iterations: parsedMac.iterations, saltBytes: parsedMac.salt.length, }; } else { await verifyMacData(parsedMac, authSafeBytes, options.password); mac = { present: true, status: "verified", algorithm: parsedMac.algorithm!.name, algorithmOid: parsedMac.algorithmOid, iterations: parsedMac.iterations, saltBytes: parsedMac.salt.length, }; } } const context: InventoryContext = { ...(options.password === undefined ? {} : { password: options.password }), bags: [], warnings: [], pbkdf2Budget: { iterations: 0 }, }; if (!root[2]) context.warnings.push( "MacData is absent, so the AuthenticatedSafe has no verified password/integrity check.", ); const contents = await inspectAuthenticatedSafe(authSafeBytes, context); return { recognized: true, version: 3, bytes: bytes.length, authSafeContentType: CMS_DATA, mac, contents, bags: context.bags, warnings: context.warnings, }; }