771 lines
24 KiB
TypeScript
771 lines
24 KiB
TypeScript
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<ParsedMacData["algorithm"]>,
|
||
): Promise<Uint8Array> {
|
||
// 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<void> {
|
||
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<Pkcs12BagInventory, "friendlyName" | "localKeyId"> {
|
||
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<number> {
|
||
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<ReturnType<typeof decryptEncryptedPkcs8>>;
|
||
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<Pkcs12ContentInventory[]> {
|
||
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<Pkcs12Inspection> {
|
||
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,
|
||
};
|
||
}
|