@@ -0,0 +1,770 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user