Release Crypto Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:40:23 +02:00
parent 4b75f819e2
commit 82bb01b13f
37 changed files with 3673 additions and 140 deletions
+579 -25
View File
@@ -1,16 +1,31 @@
import "reflect-metadata";
import {
BasicConstraintsExtension,
AuthorityKeyIdentifierExtension,
ExtendedKeyUsageExtension,
KeyUsageFlags,
KeyUsagesExtension,
PemConverter,
Pkcs10CertificateRequest,
PublicKey,
SubjectAlternativeNameExtension,
SubjectKeyIdentifierExtension,
X509Certificate,
X509Crl,
} from "@peculiar/x509";
import { bytesToBase64Url, bytesToHex } from "@add-ideas/toolbox-helpers";
import {
decryptEncryptedPkcs8,
inspectEncryptedPkcs8,
inspectPkcs8,
reservePbkdf2Work,
type Pbkdf2WorkBudget,
} from "./pbes2";
import {
inspectPkcs12,
recognizesPkcs12,
type Pkcs12BagInventory,
} from "./pkcs12";
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
const MAX_PEM_BLOCKS = 256;
@@ -34,6 +49,31 @@ export interface CryptoInspection {
items: CryptoItem[];
findings: CryptoFinding[];
chain: { child: string; issuer: string; signatureValid: boolean }[];
paths: CertificatePathAnalysis[];
}
export interface CertificatePathLink {
child: string;
issuer: string;
signatureValid: boolean;
issuerIsCa: boolean;
keyCertSignAllowed: boolean;
authorityKeyIdentifierMatched?: boolean;
findings: CryptoFinding[];
}
export interface CertificatePathAnalysis {
leaf: string;
certificates: string[];
status:
| "self-signed-anchor-present"
| "incomplete"
| "ambiguous"
| "loop"
| "invalid";
links: CertificatePathLink[];
findings: CryptoFinding[];
trusted: false;
}
export interface PemBlock {
@@ -199,6 +239,8 @@ async function inspectBlock(
block: PemBlock,
index: number,
now: Date,
password: string | undefined,
pbkdf2Budget: Pbkdf2WorkBudget,
): Promise<CryptoItem> {
if (block.label === "CERTIFICATE" || block.label === "X509 CERTIFICATE")
return inspectCertificate(block.pem, index, now);
@@ -260,12 +302,77 @@ async function inspectBlock(
};
}
if (block.label.includes("PRIVATE KEY")) {
if (block.label === "ENCRYPTED PRIVATE KEY") {
const encryption = inspectEncryptedPkcs8(block.bytes);
if (password !== undefined)
reservePbkdf2Work(
pbkdf2Budget,
encryption.iterations,
`Encrypted private key ${index + 1}`,
);
const decrypted =
password === undefined
? undefined
: await decryptEncryptedPkcs8(block.bytes, password);
try {
return {
id: `private-${index}`,
type: block.label,
title: `Encrypted private key ${index + 1}`,
facts: {
Encrypted: "Yes",
Container: "PKCS #8 EncryptedPrivateKeyInfo",
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
Salt: `${encryption.saltBytes} bytes`,
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
"Private-key algorithm": decrypted
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
: "Password required to inspect",
"Decryption status": decrypted
? "Decrypted and structurally validated in memory"
: "Not attempted",
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
},
findings: [
...(encryption.cipher === "AES-CBC"
? [
{
severity: "warning" as const,
message:
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
},
]
: []),
{
severity: decrypted ? "warning" : "info",
message: decrypted
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
},
],
};
} finally {
decrypted?.bytes.fill(0);
}
}
const key =
block.label === "PRIVATE KEY" ? inspectPkcs8(block.bytes) : undefined;
return {
id: `private-${index}`,
type: block.label,
title: `Private key ${index + 1}`,
facts: {
Encrypted: block.encrypted ? "Yes" : "No",
Container:
block.label === "PRIVATE KEY"
? "PKCS #8 PrivateKeyInfo"
: "Legacy or unsupported private-key container",
...(key
? {
"Private-key algorithm": `${key.algorithm}${key.curve ? ` · ${key.curve}` : ""}`,
}
: {}),
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
},
@@ -274,7 +381,9 @@ async function inspectBlock(
severity: block.encrypted ? "info" : "warning",
message: block.encrypted
? "Encrypted private-key material was identified but not decrypted."
: "Unencrypted private-key material is sensitive. It is not persisted by this app.",
: block.label === "PRIVATE KEY"
? "Unencrypted PKCS #8 private-key material is sensitive. It is structurally inspected but not persisted."
: "This legacy private-key container is fingerprinted but is not imported; convert it to PKCS #8 explicitly outside this tool.",
},
],
};
@@ -390,41 +499,398 @@ async function inspectJson(source: string): Promise<CryptoItem[]> {
return Promise.all(keys.map((key, index) => inspectJwk(key, index)));
}
async function linkCertificates(
function extensionIdentifiers(certificate: X509Certificate): {
authority?: string;
subject?: string;
} {
const authority = certificate.getExtension(AuthorityKeyIdentifierExtension);
const subject = certificate.getExtension(SubjectKeyIdentifierExtension);
return {
...(authority?.keyId ? { authority: authority.keyId.toUpperCase() } : {}),
...(subject?.keyId ? { subject: subject.keyId.toUpperCase() } : {}),
};
}
export async function analyzeCertificatePaths(
items: CryptoItem[],
): Promise<CryptoInspection["chain"]> {
now = new Date(),
): Promise<CertificatePathAnalysis[]> {
const certificates = items.filter(
(item): item is CryptoItem & { certificate: X509Certificate } =>
!!item.certificate,
);
const result: CryptoInspection["chain"] = [];
for (const child of certificates) {
if (child.certificate.subject === child.certificate.issuer) continue;
const issuer = certificates.find(
(candidate) => candidate.certificate.subject === child.certificate.issuer,
);
if (!issuer) continue;
result.push({
child: child.title,
issuer: issuer.title,
signatureValid: await child.certificate
if (certificates.length === 0) return [];
const leaves = certificates.filter(
(candidate) =>
!certificates.some(
(child) =>
child !== candidate &&
child.certificate.issuer === candidate.certificate.subject,
),
);
const starts = leaves.length > 0 ? leaves : certificates;
const paths: CertificatePathAnalysis[] = [];
for (const start of starts) {
const certificatePath = [start];
const links: CertificatePathLink[] = [];
const findings: CryptoFinding[] = [];
const visited = new Set([start.id]);
let current = start;
let status: CertificatePathAnalysis["status"] = "incomplete";
let pathInvalid = false;
for (let depth = 0; depth < 32; depth += 1) {
const certificate = current.certificate;
if (now < certificate.notBefore || now > certificate.notAfter) {
pathInvalid = true;
findings.push({
severity: "error",
message: `${current.title} is outside its certificate validity interval at the selected inspection time.`,
});
}
if (certificate.subject === certificate.issuer) {
const selfSignature = await certificate
.isSelfSigned()
.catch(() => false);
if (!selfSignature) {
pathInvalid = true;
findings.push({
severity: "error",
message: `${current.title} names itself as issuer but its self-signature did not verify.`,
});
}
status = pathInvalid ? "invalid" : "self-signed-anchor-present";
findings.push({
severity: "info",
message:
"A self-signed certificate terminates this explicit-input path, but it is not treated as trusted.",
});
break;
}
const identifiers = extensionIdentifiers(certificate);
const nameCandidates = certificates.filter(
(candidate) =>
candidate !== current &&
candidate.certificate.subject === certificate.issuer,
);
let candidates = nameCandidates;
let authorityMatched: boolean | undefined;
if (identifiers.authority && nameCandidates.length > 0) {
const keyMatches = nameCandidates.filter(
(candidate) =>
extensionIdentifiers(candidate.certificate).subject ===
identifiers.authority,
);
if (keyMatches.length > 0) {
candidates = keyMatches;
authorityMatched = true;
} else if (
nameCandidates.some(
(candidate) =>
extensionIdentifiers(candidate.certificate).subject !== undefined,
)
) {
findings.push({
severity: "error",
message: `${current.title} authority key identifier does not match any same-name issuer candidate.`,
});
status = "invalid";
break;
}
}
if (candidates.length === 0) {
findings.push({
severity: "warning",
message: `No supplied certificate has subject “${certificate.issuer}” for ${current.title}.`,
});
status = pathInvalid ? "invalid" : "incomplete";
break;
}
if (candidates.length > 1) {
findings.push({
severity: "warning",
message: `${current.title} has ${candidates.length} indistinguishable issuer candidates; the path is not guessed.`,
});
status = "ambiguous";
break;
}
const issuer = candidates[0]!;
if (visited.has(issuer.id)) {
findings.push({
severity: "error",
message: `Certificate loop detected when linking ${current.title} to ${issuer.title}.`,
});
status = "loop";
break;
}
const signatureValid = await certificate
.verify({
publicKey: issuer.certificate.publicKey,
signatureOnly: true,
})
.catch(() => false),
.catch(() => false);
const basic = issuer.certificate.getExtension(BasicConstraintsExtension);
const usages = issuer.certificate.getExtension(KeyUsagesExtension);
const issuerIsCa = basic?.ca === true;
const keyCertSignAllowed =
!usages || (usages.usages & KeyUsageFlags.keyCertSign) !== 0;
const linkFindings: CryptoFinding[] = [];
if (!signatureValid)
linkFindings.push({
severity: "error",
message:
"Certificate signature did not verify with this issuer candidate.",
});
if (!issuerIsCa)
linkFindings.push({
severity: "error",
message:
"Issuer certificate does not assert CA=true in Basic Constraints.",
});
if (!keyCertSignAllowed)
linkFindings.push({
severity: "error",
message: "Issuer Key Usage does not permit certificate signing.",
});
const caCertificatesBelow = certificatePath
.slice(1)
.filter(
(item) =>
item.certificate.getExtension(BasicConstraintsExtension)?.ca ===
true,
).length;
if (
basic?.pathLength !== undefined &&
caCertificatesBelow > basic.pathLength
)
linkFindings.push({
severity: "error",
message: `Issuer pathLength ${basic.pathLength} is exceeded by ${caCertificatesBelow} subordinate CA certificate(s).`,
});
if (linkFindings.some((finding) => finding.severity === "error"))
pathInvalid = true;
links.push({
child: current.title,
issuer: issuer.title,
signatureValid,
issuerIsCa,
keyCertSignAllowed,
...(authorityMatched === undefined
? {}
: { authorityKeyIdentifierMatched: authorityMatched }),
findings: linkFindings,
});
findings.push(...linkFindings);
certificatePath.push(issuer);
visited.add(issuer.id);
current = issuer;
if (depth === 31) {
findings.push({
severity: "error",
message: "Certificate path exceeds the 32-certificate bound.",
});
status = "invalid";
}
}
paths.push({
leaf: start.title,
certificates: certificatePath.map((item) => item.title),
status,
links,
findings,
trusted: false,
});
}
return paths;
}
function chainFromPaths(
paths: readonly CertificatePathAnalysis[],
): CryptoInspection["chain"] {
const result: CryptoInspection["chain"] = [];
const seen = new Set<string>();
for (const path of paths) {
for (const link of path.links) {
const key = `${link.child}\0${link.issuer}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
child: link.child,
issuer: link.issuer,
signatureValid: link.signatureValid,
});
}
}
return result;
}
function pfxEncryptionSummary(
encryption: NonNullable<Pkcs12BagInventory["encryption"]>,
): string {
return `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`;
}
async function inspectPkcs12Items(
bytes: Uint8Array,
now: Date,
password: string | undefined,
): Promise<CryptoItem[]> {
const pfx = await inspectPkcs12(bytes, {
...(password === undefined ? {} : { password }),
});
const macStatus =
pfx.mac.status === "verified"
? "Verified with the explicitly supplied password"
: pfx.mac.status === "password-required"
? "Present; explicit password required to verify"
: pfx.mac.status === "unsupported"
? `Present; unsupported digest (${pfx.mac.algorithm ?? "unknown"})`
: "Absent";
const contentSummary = pfx.contents
.map((content) => {
const encryption = content.encryption
? `; ${content.encryption.cipher}-${content.encryption.keyLength}, ${content.encryption.iterations.toLocaleString()} PBKDF2 iterations`
: "";
return `#${content.index + 1} ${content.type}: ${content.state}, ${content.bagCount} bag(s)${encryption}`;
})
.join(" | ");
const envelope: CryptoItem = {
id: "pfx-envelope",
type: "PKCS #12/PFX",
title: "PKCS #12/PFX authenticated safe",
facts: {
Version: String(pfx.version),
"Authenticated-safe content type": pfx.authSafeContentType,
MacData: macStatus,
...(pfx.mac.algorithm ? { "MAC algorithm": pfx.mac.algorithm } : {}),
...(pfx.mac.iterations === undefined
? {}
: { "MAC iterations": pfx.mac.iterations.toLocaleString() }),
Contents: contentSummary || "Empty AuthenticatedSafe",
"SafeBag inventory": `${pfx.bags.length.toLocaleString()} bag(s)`,
Size: `${pfx.bytes.toLocaleString()} bytes`,
"SHA-256 fingerprint": await sha256(buffer(bytes)),
},
findings: [
...pfx.warnings.map((message) => ({
severity: "warning" as const,
message,
})),
...(pfx.mac.status === "password-required"
? [
{
severity: "info" as const,
message:
"Enable password use explicitly to verify MacData and inspect supported encrypted SafeContents/key bags.",
},
]
: []),
{
severity: "warning",
message:
"Private-key bag values are only structurally inspected in page memory. Secret bytes and the password are never included in the report or exported implicitly.",
},
],
};
const bagItems: CryptoItem[] = [];
for (const [index, bag] of pfx.bags.entries()) {
const bagFacts: Record<string, string> = {
"Bag path": bag.path,
"Bag OID": bag.bagOid,
"Friendly name": bag.friendlyName ?? "—",
localKeyId: bag.localKeyId ?? "—",
Encrypted: bag.encrypted ? "Yes" : "No",
State:
bag.state === "inspected"
? "Structurally inspected"
: bag.state === "password-required"
? "Explicit password required"
: "Value deliberately not decoded",
...(bag.encryption
? { Encryption: pfxEncryptionSummary(bag.encryption) }
: {}),
...(bag.key
? {
"Private-key algorithm": `${bag.key.algorithm}${bag.key.curve ? ` · ${bag.key.curve}` : ""}`,
"Private-key container size": `${bag.key.bytes.toLocaleString()} bytes`,
}
: {}),
};
if (bag.certificateBytes) {
let certificate: CryptoItem;
try {
certificate = await inspectCertificate(
bag.certificateBytes,
index,
now,
);
} catch (reason) {
throw new Error(
`${bag.path} contains an invalid X.509 certificate: ${reason instanceof Error ? reason.message : "certificate parsing failed"}`,
{ cause: reason },
);
}
bagItems.push({
...certificate,
id: `pfx-bag-${index}`,
title: bag.friendlyName || certificate.title,
facts: { ...bagFacts, ...certificate.facts },
});
continue;
}
bagItems.push({
id: `pfx-bag-${index}`,
type:
bag.bagType === "private-key" || bag.bagType === "shrouded-private-key"
? "PKCS #12 private-key bag"
: `PKCS #12 ${bag.bagType} bag`,
title:
bag.friendlyName ?? `${bag.bagType.replaceAll("-", " ")} ${index + 1}`,
facts: bagFacts,
findings: [
...(bag.state === "password-required"
? [
{
severity: "info" as const,
message:
"The encrypted bag was inventoried without attempting decryption.",
},
]
: []),
...(bag.key
? [
{
severity: "warning" as const,
message:
"Only private-key container metadata is shown; secret material is not retained in the inspection result.",
},
]
: []),
...(bag.state === "unsupported"
? [
{
severity: "warning" as const,
message:
"This bag type is identified, but its value is not decoded or exported.",
},
]
: []),
],
});
}
return [envelope, ...bagItems];
}
export async function inspectCryptoInput(
input: string | Uint8Array,
now = new Date(),
options: { password?: string } = {},
): Promise<CryptoInspection> {
if (typeof input !== "string" && input.byteLength > MAX_INPUT_BYTES)
throw new Error("Input exceeds the 8 MiB inspection limit.");
const findings: CryptoFinding[] = [];
const pbkdf2Budget: Pbkdf2WorkBudget = { iterations: 0 };
let items: CryptoItem[];
if (typeof input === "string" && input.trimStart().startsWith("{")) {
boundedUtf8Bytes(input);
@@ -433,10 +899,27 @@ export async function inspectCryptoInput(
const blocks = parsePemBlocks(input);
if (!blocks.length)
throw new Error("No supported PEM or JWK/JWKS object was found.");
items = await Promise.all(
blocks.map((block, index) => inspectBlock(block, index, now)),
);
items = [];
for (const [index, block] of blocks.entries())
items.push(
await inspectBlock(block, index, now, options.password, pbkdf2Budget),
);
} else {
if (recognizesPkcs12(input)) {
items = await inspectPkcs12Items(input, now, options.password);
const paths = await analyzeCertificatePaths(items, now);
const chain = chainFromPaths(paths);
if (
items.some((item) => item.certificate) &&
!paths.some((path) => path.status === "self-signed-anchor-present")
)
findings.push({
severity: "info",
message:
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
});
return { items, findings, chain, paths };
}
const candidates: ((bytes: Uint8Array) => Promise<CryptoItem>)[] = [
(bytes) => inspectCertificate(bytes, 0, now),
async (bytes) =>
@@ -449,6 +932,8 @@ export async function inspectCryptoInput(
},
0,
now,
undefined,
pbkdf2Budget,
),
async (bytes) =>
inspectBlock(
@@ -460,31 +945,100 @@ export async function inspectCryptoInput(
},
0,
now,
undefined,
pbkdf2Budget,
),
async (bytes) => {
const encryption = inspectEncryptedPkcs8(bytes);
if (options.password !== undefined)
reservePbkdf2Work(
pbkdf2Budget,
encryption.iterations,
"Encrypted private key 1",
);
const decrypted =
options.password === undefined
? undefined
: await decryptEncryptedPkcs8(bytes, options.password);
try {
return {
id: "private-0",
type: "ENCRYPTED PRIVATE KEY",
title: "Encrypted private key 1",
facts: {
Container: "PKCS #8 EncryptedPrivateKeyInfo",
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
Salt: `${encryption.saltBytes} bytes`,
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
"Private-key algorithm": decrypted
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
: "Password required to inspect",
"Decryption status": decrypted
? "Decrypted and structurally validated in memory"
: "Not attempted",
"SHA-256 fingerprint": await sha256(buffer(bytes)),
},
findings: [
...(encryption.cipher === "AES-CBC"
? [
{
severity: "warning" as const,
message:
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
},
]
: []),
{
severity: decrypted ? "warning" : "info",
message: decrypted
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
},
],
};
} finally {
decrypted?.bytes.fill(0);
}
},
];
let matched: CryptoItem | undefined;
for (const candidate of candidates) {
for (const [candidateIndex, candidate] of candidates.entries()) {
try {
matched = await candidate(input);
break;
} catch {
} catch (reason) {
if (candidateIndex === 3 && options.password) {
let recognized = false;
try {
inspectEncryptedPkcs8(input);
recognized = true;
} catch {
/* this was not PBES2; continue probing */
}
if (recognized) throw reason;
}
/* try the next DER model */
}
}
if (!matched)
throw new Error(
"The binary input is not a supported DER certificate, CSR, or CRL. PKCS #12/PFX is identified but not decrypted in v0.1.",
"The binary input is not a supported DER certificate, CSR, CRL, PBES2 encrypted PKCS #8 key, or PKCS #12/PFX file.",
);
items = [matched];
}
const chain = await linkCertificates(items);
if (items.some((item) => item.certificate) && chain.length === 0)
const paths = await analyzeCertificatePaths(items, now);
const chain = chainFromPaths(paths);
if (
items.some((item) => item.certificate) &&
!paths.some((path) => path.status === "self-signed-anchor-present")
)
findings.push({
severity: "info",
message:
"No complete issuer link was found. No browser or operating-system trust store is consulted.",
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
});
return { items, findings, chain };
return { items, findings, chain, paths };
}
function dnsMatch(pattern: string, hostname: string): boolean {
+637
View File
@@ -0,0 +1,637 @@
import "reflect-metadata";
import { PemConverter, X509Certificate } from "@peculiar/x509";
import {
base64UrlToBytes,
bytesToBase64Url,
secureRandomBytes,
} from "@add-ideas/toolbox-helpers";
import { decryptEncryptedPkcs8 } from "./pbes2";
const MAX_KEY_SOURCE = 8 * 1024 * 1024;
const MAX_MESSAGE = 8 * 1024 * 1024;
const MAX_CIPHERTEXT = 16 * 1024 * 1024;
export type OperationAlgorithm =
| "rsa-pss-sha256"
| "rsa-pkcs1-sha256"
| "ecdsa-p256-sha256"
| "ed25519"
| "rsa-oaep-sha256"
| "aes-gcm-256";
export const OPERATION_ALGORITHMS: ReadonlyArray<{
id: OperationAlgorithm;
label: string;
operations: readonly ("sign" | "verify" | "encrypt" | "decrypt")[];
limit: string;
}> = [
{
id: "rsa-pss-sha256",
label: "RSA-PSS · SHA-256 · 32-byte salt",
operations: ["sign", "verify"],
limit: "RSA modulus 2,04816,384 bits; fixed 32-byte PSS salt.",
},
{
id: "rsa-pkcs1-sha256",
label: "RSASSA-PKCS1-v1_5 · SHA-256",
operations: ["sign", "verify"],
limit: "RSA modulus 2,04816,384 bits.",
},
{
id: "ecdsa-p256-sha256",
label: "ECDSA P-256 · SHA-256",
operations: ["sign", "verify"],
limit: "P-256 keys only; WebCrypto IEEE P1363 signature representation.",
},
{
id: "ed25519",
label: "Ed25519",
operations: ["sign", "verify"],
limit:
"Available only when the current browser WebCrypto implements Ed25519.",
},
{
id: "rsa-oaep-sha256",
label: "RSA-OAEP · SHA-256",
operations: ["encrypt", "decrypt"],
limit: "RSA modulus 2,04816,384 bits; no OAEP label; short messages only.",
},
{
id: "aes-gcm-256",
label: "AES-256-GCM · random 96-bit IV · 128-bit tag",
operations: ["encrypt", "decrypt"],
limit:
"Exactly 256-bit JWK/raw key; fresh WebCrypto IV generated for every encryption.",
},
];
export interface CryptoOperationResult {
operation: "sign" | "verify" | "encrypt" | "decrypt";
algorithm: OperationAlgorithm;
output: string;
valid?: boolean;
facts: Record<string, string>;
}
interface AlgorithmDefinition {
importAlgorithm:
| AlgorithmIdentifier
| RsaHashedImportParams
| EcKeyImportParams
| AesKeyAlgorithm;
operationAlgorithm:
AlgorithmIdentifier | RsaPssParams | EcdsaParams | RsaOaepParams;
expected: "RSA" | "EC" | "Ed25519" | "AES";
}
function definition(algorithm: OperationAlgorithm): AlgorithmDefinition {
switch (algorithm) {
case "rsa-pss-sha256":
return {
importAlgorithm: { name: "RSA-PSS", hash: "SHA-256" },
operationAlgorithm: { name: "RSA-PSS", saltLength: 32 },
expected: "RSA",
};
case "rsa-pkcs1-sha256":
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
operationAlgorithm: { name: "RSASSA-PKCS1-v1_5" },
expected: "RSA",
};
case "ecdsa-p256-sha256":
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-256" },
operationAlgorithm: { name: "ECDSA", hash: "SHA-256" },
expected: "EC",
};
case "ed25519":
return {
importAlgorithm: { name: "Ed25519" },
operationAlgorithm: { name: "Ed25519" },
expected: "Ed25519",
};
case "rsa-oaep-sha256":
return {
importAlgorithm: { name: "RSA-OAEP", hash: "SHA-256" },
operationAlgorithm: { name: "RSA-OAEP" },
expected: "RSA",
};
case "aes-gcm-256":
return {
importAlgorithm: { name: "AES-GCM", length: 256 },
operationAlgorithm: { name: "AES-GCM" },
expected: "AES",
};
}
}
function boundedText(
value: string,
name: string,
maximum = MAX_MESSAGE,
): Uint8Array {
const bytes = new TextEncoder().encode(value);
if (bytes.length > maximum)
throw new Error(
`${name} exceeds ${(maximum / 1024 / 1024).toLocaleString()} MiB.`,
);
return bytes;
}
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function onePem(source: string): { label: string; bytes: Uint8Array } {
const matches = [
...source.matchAll(
/-----BEGIN ([A-Z0-9][A-Z0-9 -]{0,80})-----[\s\S]*?-----END \1-----/gu,
),
];
if (matches.length !== 1)
throw new Error("Operation key input must contain exactly one PEM block.");
let decoded: ArrayBuffer;
try {
decoded = PemConverter.decodeFirst(matches[0]![0]);
} catch {
throw new Error("Operation key PEM has invalid framing or Base64.");
}
return { label: matches[0]![1]!, bytes: new Uint8Array(decoded) };
}
function publicJwk(value: JsonWebKey): JsonWebKey {
if (value.kty === "RSA") return { kty: "RSA", n: value.n, e: value.e };
if (value.kty === "EC")
return { kty: "EC", crv: value.crv, x: value.x, y: value.y };
if (value.kty === "OKP") return { kty: "OKP", crv: value.crv, x: value.x };
return value;
}
function parseJwk(source: string): JsonWebKey {
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error("Operation JWK is not valid JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("Operation JWK must be one JSON object.");
const jwk = parsed as JsonWebKey;
if (typeof jwk.kty !== "string") throw new Error("Operation JWK has no kty.");
return jwk;
}
async function sourceForImport(
source: string,
password: string,
role: "private" | "public",
): Promise<{
format: "pkcs8" | "spki" | "jwk";
keyData: ArrayBuffer | JsonWebKey;
cleanup?: () => void;
}> {
if (boundedText(source, "Key input", MAX_KEY_SOURCE).length === 0)
throw new Error("Key input is required.");
if (source.trimStart().startsWith("{")) {
const jwk = parseJwk(source);
return {
format: "jwk",
keyData: role === "public" ? publicJwk(jwk) : jwk,
};
}
const pem = onePem(source);
if (role === "private") {
if (pem.label === "PRIVATE KEY") {
const keyData = arrayBuffer(pem.bytes);
return {
format: "pkcs8",
keyData,
cleanup: () => {
pem.bytes.fill(0);
new Uint8Array(keyData).fill(0);
},
};
}
if (pem.label === "ENCRYPTED PRIVATE KEY") {
const decrypted = await decryptEncryptedPkcs8(pem.bytes, password);
try {
const keyData = arrayBuffer(decrypted.bytes);
return {
format: "pkcs8",
keyData,
cleanup: () => {
decrypted.bytes.fill(0);
new Uint8Array(keyData).fill(0);
},
};
} catch (error) {
decrypted.bytes.fill(0);
throw error;
}
}
throw new Error(
"Private operations accept PKCS #8 PRIVATE KEY, PBES2 ENCRYPTED PRIVATE KEY, or private JWK. PKCS #1/SEC1 conversion is intentionally not guessed.",
);
}
if (pem.label === "PUBLIC KEY")
return { format: "spki", keyData: arrayBuffer(pem.bytes) };
if (pem.label === "CERTIFICATE" || pem.label === "X509 CERTIFICATE") {
const certificate = new X509Certificate(arrayBuffer(pem.bytes));
return { format: "spki", keyData: certificate.publicKey.rawData };
}
throw new Error(
"Public operations accept SubjectPublicKeyInfo PUBLIC KEY, CERTIFICATE, or public JWK.",
);
}
function assertKeyLimits(
key: CryptoKey,
expected: AlgorithmDefinition["expected"],
) {
if (expected === "RSA") {
const algorithm = key.algorithm as RsaKeyAlgorithm;
if (
algorithm.name.startsWith("RSA") ||
algorithm.name.startsWith("RSASSA")
) {
if (algorithm.modulusLength < 2_048 || algorithm.modulusLength > 16_384)
throw new Error("RSA modulus must contain 2,04816,384 bits.");
return;
}
} else if (expected === "EC") {
const algorithm = key.algorithm as EcKeyAlgorithm;
if (algorithm.name === "ECDSA" && algorithm.namedCurve === "P-256") return;
} else if (expected === "Ed25519") {
if (key.algorithm.name === "Ed25519") return;
} else if (expected === "AES") {
const algorithm = key.algorithm as AesKeyAlgorithm;
if (algorithm.name === "AES-GCM" && algorithm.length === 256) return;
}
throw new Error(
`Imported key does not match the selected ${expected} algorithm limit.`,
);
}
async function importAsymmetric(
source: string,
password: string,
algorithm: OperationAlgorithm,
role: "private" | "public",
usage: KeyUsage,
): Promise<CryptoKey> {
const selected = definition(algorithm);
if (selected.expected === "AES")
throw new Error("Selected operation requires an AES key.");
const input = await sourceForImport(source, password, role);
try {
let key: CryptoKey;
try {
key =
input.format === "jwk"
? await crypto.subtle.importKey(
"jwk",
input.keyData as JsonWebKey,
selected.importAlgorithm,
false,
[usage],
)
: await crypto.subtle.importKey(
input.format,
input.keyData as ArrayBuffer,
selected.importAlgorithm,
false,
[usage],
);
} catch (error) {
throw new Error(
`WebCrypto could not import this ${role} key for the selected algorithm. Check the key container, curve/hash family, password, and key_ops/usages.`,
{ cause: error },
);
}
assertKeyLimits(key, selected.expected);
return key;
} finally {
input.cleanup?.();
}
}
async function importAes(
source: string,
usage: "encrypt" | "decrypt",
): Promise<CryptoKey> {
const trimmed = source.trim();
let format: "jwk" | "raw";
let data: JsonWebKey | ArrayBuffer;
if (trimmed.startsWith("{")) {
format = "jwk";
data = parseJwk(trimmed);
} else {
const encoded = trimmed.startsWith("base64url:")
? trimmed.slice("base64url:".length)
: trimmed;
const bytes = base64UrlToBytes(encoded, { maxOutputBytes: 32 });
if (bytes.length !== 32 || bytesToBase64Url(bytes) !== encoded)
throw new Error(
"Raw AES key must be canonical unpadded Base64url for exactly 32 bytes.",
);
format = "raw";
data = arrayBuffer(bytes);
}
try {
const key =
format === "jwk"
? await crypto.subtle.importKey(
"jwk",
data as JsonWebKey,
{ name: "AES-GCM", length: 256 },
false,
[usage],
)
: await crypto.subtle.importKey(
"raw",
data as ArrayBuffer,
{ name: "AES-GCM", length: 256 },
false,
[usage],
);
assertKeyLimits(key, "AES");
return key;
} catch (error) {
if (error instanceof Error && error.message.includes("Raw AES key"))
throw error;
throw new Error(
"WebCrypto could not import an exact 256-bit AES-GCM key for this operation.",
{ cause: error },
);
}
}
function algorithmLimit(id: OperationAlgorithm): string {
return OPERATION_ALGORITHMS.find((item) => item.id === id)?.limit ?? "";
}
export function generateAesKeySource(): string {
return bytesToBase64Url(secureRandomBytes(32, crypto, 32));
}
export async function signText(
algorithm: OperationAlgorithm,
privateKey: string,
password: string,
message: string,
): Promise<CryptoOperationResult> {
const selected = definition(algorithm);
if (
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
algorithm === "rsa-oaep-sha256"
)
throw new Error("Selected algorithm does not support signing.");
const data = boundedText(message, "Message");
const key = await importAsymmetric(
privateKey,
password,
algorithm,
"private",
"sign",
);
const signature = await crypto.subtle.sign(
selected.operationAlgorithm,
key,
arrayBuffer(data),
);
return {
operation: "sign",
algorithm,
output: bytesToBase64Url(new Uint8Array(signature)),
facts: {
"Message bytes": String(data.length),
"Signature bytes": String(signature.byteLength),
Encoding: "Unpadded Base64url",
Limit: algorithmLimit(algorithm),
},
};
}
export async function verifyText(
algorithm: OperationAlgorithm,
publicKey: string,
message: string,
signature: string,
): Promise<CryptoOperationResult> {
const selected = definition(algorithm);
if (
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
algorithm === "rsa-oaep-sha256"
)
throw new Error("Selected algorithm does not support verification.");
const data = boundedText(message, "Message");
const signatureBytes = base64UrlToBytes(signature.trim(), {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAsymmetric(
publicKey,
"",
algorithm,
"public",
"verify",
);
const valid = await crypto.subtle.verify(
selected.operationAlgorithm,
key,
arrayBuffer(signatureBytes),
arrayBuffer(data),
);
return {
operation: "verify",
algorithm,
output: valid ? "Signature is valid." : "Signature is not valid.",
valid,
facts: {
"Message bytes": String(data.length),
"Signature bytes": String(signatureBytes.length),
Limit: algorithmLimit(algorithm),
},
};
}
export async function encryptText(
algorithm: OperationAlgorithm,
keySource: string,
message: string,
): Promise<CryptoOperationResult> {
const data = boundedText(message, "Plaintext");
if (algorithm === "aes-gcm-256") {
const key = await importAes(keySource, "encrypt");
const iv = secureRandomBytes(12, crypto, 12);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
key,
arrayBuffer(data),
),
);
const output = JSON.stringify(
{
schemaVersion: 1,
algorithm: "AES-256-GCM",
iv: bytesToBase64Url(iv),
ciphertext: bytesToBase64Url(ciphertext),
},
null,
2,
);
return {
operation: "encrypt",
algorithm,
output,
facts: {
"Plaintext bytes": String(data.length),
"Ciphertext and tag bytes": String(ciphertext.length),
Limit: algorithmLimit(algorithm),
},
};
}
if (algorithm !== "rsa-oaep-sha256")
throw new Error("Selected algorithm does not support encryption.");
const key = await importAsymmetric(
keySource,
"",
algorithm,
"public",
"encrypt",
);
const modulusLength = (key.algorithm as RsaKeyAlgorithm).modulusLength;
const maximum = modulusLength / 8 - 2 * 32 - 2;
if (data.length > maximum)
throw new Error(
`RSA-OAEP SHA-256 plaintext is limited to ${maximum} bytes for this key.`,
);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt({ name: "RSA-OAEP" }, key, arrayBuffer(data)),
);
return {
operation: "encrypt",
algorithm,
output: bytesToBase64Url(encrypted),
facts: {
"Plaintext bytes": String(data.length),
"Ciphertext bytes": String(encrypted.length),
"Maximum plaintext bytes": String(maximum),
Encoding: "Unpadded Base64url",
Limit: algorithmLimit(algorithm),
},
};
}
interface AesEnvelope {
schemaVersion: 1;
algorithm: "AES-256-GCM";
iv: string;
ciphertext: string;
}
function parseAesEnvelope(source: string): AesEnvelope {
if (source.length > MAX_CIPHERTEXT * 2)
throw new Error("AES-GCM envelope exceeds the input limit.");
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error(
"AES-GCM ciphertext must be the JSON envelope produced by this tool.",
);
}
const value = parsed as Partial<AesEnvelope>;
if (
!value ||
value.schemaVersion !== 1 ||
value.algorithm !== "AES-256-GCM" ||
typeof value.iv !== "string" ||
typeof value.ciphertext !== "string"
)
throw new Error("AES-GCM envelope fields are incomplete or unsupported.");
return value as AesEnvelope;
}
export async function decryptText(
algorithm: OperationAlgorithm,
keySource: string,
password: string,
ciphertext: string,
): Promise<CryptoOperationResult> {
let decrypted: ArrayBuffer;
let ciphertextBytes: Uint8Array;
if (algorithm === "aes-gcm-256") {
const envelope = parseAesEnvelope(ciphertext);
const iv = base64UrlToBytes(envelope.iv, { maxOutputBytes: 12 });
if (iv.length !== 12 || bytesToBase64Url(iv) !== envelope.iv)
throw new Error(
"AES-GCM envelope IV must be canonical 12-byte Base64url.",
);
ciphertextBytes = base64UrlToBytes(envelope.ciphertext, {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAes(keySource, "decrypt");
try {
decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
key,
arrayBuffer(ciphertextBytes),
);
} catch {
throw new Error(
"AES-GCM authentication failed; no plaintext was released.",
);
}
} else {
if (algorithm !== "rsa-oaep-sha256")
throw new Error("Selected algorithm does not support decryption.");
ciphertextBytes = base64UrlToBytes(ciphertext.trim(), {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAsymmetric(
keySource,
password,
algorithm,
"private",
"decrypt",
);
if (
ciphertextBytes.length !==
(key.algorithm as RsaKeyAlgorithm).modulusLength / 8
)
throw new Error(
"RSA-OAEP ciphertext length does not match the selected key modulus.",
);
try {
decrypted = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
key,
arrayBuffer(ciphertextBytes),
);
} catch {
throw new Error("RSA-OAEP decryption failed; no plaintext was released.");
}
}
let output: string;
try {
output = new TextDecoder("utf-8", { fatal: true }).decode(decrypted);
} catch {
throw new Error(
"Decrypted bytes are not valid UTF-8 text; binary plaintext is not rendered.",
);
}
return {
operation: "decrypt",
algorithm,
output,
facts: {
"Ciphertext bytes": String(ciphertextBytes.length),
"Plaintext bytes": String(decrypted.byteLength),
Limit: algorithmLimit(algorithm),
},
};
}
+418
View File
@@ -0,0 +1,418 @@
import {
BaseBlock,
Integer,
Null,
ObjectIdentifier,
OctetString,
Sequence,
fromBER,
} from "asn1js";
const PBES2 = "1.2.840.113549.1.5.13";
const PBKDF2 = "1.2.840.113549.1.5.12";
const MAX_ENCRYPTED_BYTES = 8 * 1024 * 1024;
const MAX_ITERATIONS = 10_000_000;
export const MAX_TOTAL_PBKDF2_ITERATIONS = 20_000_000;
const PRFS: Record<string, string> = {
"1.2.840.113549.2.7": "SHA-1",
"1.2.840.113549.2.9": "SHA-256",
"1.2.840.113549.2.10": "SHA-384",
"1.2.840.113549.2.11": "SHA-512",
};
const CIPHERS: Record<
string,
{ name: "AES-CBC" | "AES-GCM"; length: 128 | 192 | 256 }
> = {
"2.16.840.1.101.3.4.1.2": { name: "AES-CBC", length: 128 },
"2.16.840.1.101.3.4.1.22": { name: "AES-CBC", length: 192 },
"2.16.840.1.101.3.4.1.42": { name: "AES-CBC", length: 256 },
"2.16.840.1.101.3.4.1.6": { name: "AES-GCM", length: 128 },
"2.16.840.1.101.3.4.1.26": { name: "AES-GCM", length: 192 },
"2.16.840.1.101.3.4.1.46": { name: "AES-GCM", length: 256 },
};
const PRIVATE_KEY_ALGORITHMS: Record<string, string> = {
"1.2.840.113549.1.1.1": "RSA",
"1.2.840.113549.1.1.10": "RSA-PSS",
"1.2.840.10045.2.1": "EC",
"1.3.101.112": "Ed25519",
"1.3.101.113": "Ed448",
"1.3.101.110": "X25519",
"1.3.101.111": "X448",
};
const CURVES: Record<string, string> = {
"1.2.840.10045.3.1.7": "P-256",
"1.3.132.0.34": "P-384",
"1.3.132.0.35": "P-521",
};
export interface Pbkdf2WorkBudget {
iterations: number;
}
export function reservePbkdf2Work(
budget: Pbkdf2WorkBudget,
iterations: number,
label: string,
): void {
if (
!Number.isSafeInteger(budget.iterations) ||
budget.iterations < 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
budget.iterations > MAX_TOTAL_PBKDF2_ITERATIONS - iterations
) {
throw new Error(
`${label} exceeds the ${MAX_TOTAL_PBKDF2_ITERATIONS.toLocaleString()}-iteration aggregate PBKDF2 safety budget. Inspect fewer encrypted objects at once.`,
);
}
budget.iterations += iterations;
}
type Block = BaseBlock;
function asBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function parseDer(bytes: Uint8Array, name: string): Block {
if (bytes.byteLength === 0 || bytes.byteLength > MAX_ENCRYPTED_BYTES)
throw new Error(`${name} must contain 1 byte8 MiB.`);
const parsed = fromBER(asBuffer(bytes));
if (parsed.offset === -1 || parsed.offset !== bytes.byteLength)
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 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 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 integer(block: Block | undefined, name: string): bigint {
if (!(block instanceof Integer))
throw new Error(`${name} must be an ASN.1 INTEGER.`);
return block.toBigInt();
}
export interface Pbes2Inspection {
scheme: "PBES2";
kdf: "PBKDF2";
prf: string;
iterations: number;
saltBytes: number;
cipher: "AES-CBC" | "AES-GCM";
keyLength: 128 | 192 | 256;
ivOrNonceBytes: number;
tagLength?: number;
encryptedBytes: number;
}
interface ParsedPbes2 extends Pbes2Inspection {
salt: Uint8Array;
ivOrNonce: Uint8Array;
encryptedData: Uint8Array;
}
function parsePbes2(bytes: Uint8Array): ParsedPbes2 {
const root = sequence(
parseDer(bytes, "Encrypted PKCS #8"),
"EncryptedPrivateKeyInfo",
);
if (root.length !== 2)
throw new Error(
"EncryptedPrivateKeyInfo must contain an algorithm and encrypted data.",
);
return parsePbes2Algorithm(
root[0],
octets(root[1], "Encrypted PKCS #8 data"),
);
}
function parsePbes2Algorithm(
algorithmBlock: Block | undefined,
encryptedData: Uint8Array,
): ParsedPbes2 {
const algorithm = sequence(algorithmBlock, "Encryption algorithm");
if (
algorithm.length !== 2 ||
oid(algorithm[0], "Encryption algorithm") !== PBES2
)
throw new Error("Only PKCS #5 PBES2 encrypted PKCS #8 is supported.");
const params = sequence(algorithm[1], "PBES2 parameters");
if (params.length !== 2) throw new Error("PBES2 parameters are incomplete.");
const kdf = sequence(params[0], "PBES2 key derivation function");
if (kdf.length !== 2 || oid(kdf[0], "KDF algorithm") !== PBKDF2)
throw new Error("Only PBKDF2 key derivation is supported for PBES2.");
const pbkdf = sequence(kdf[1], "PBKDF2 parameters");
if (pbkdf.length < 2 || pbkdf.length > 4)
throw new Error("PBKDF2 parameters have an unsupported shape.");
const salt = octets(pbkdf[0], "PBKDF2 salt");
if (salt.length < 8 || salt.length > 1_024)
throw new Error("PBKDF2 salt must contain 81,024 bytes.");
const iterationBig = integer(pbkdf[1], "PBKDF2 iteration count");
if (iterationBig < 1n || iterationBig > BigInt(MAX_ITERATIONS))
throw new Error(
`PBKDF2 iteration count must be 1${MAX_ITERATIONS.toLocaleString()}.`,
);
let cursor = 2;
let declaredKeyLength: number | undefined;
if (pbkdf[cursor] instanceof Integer) {
const bytesLong = integer(pbkdf[cursor], "PBKDF2 key length");
if (bytesLong < 1n || bytesLong > 64n)
throw new Error("PBKDF2 key length is outside the supported bound.");
declaredKeyLength = Number(bytesLong) * 8;
cursor += 1;
}
let prf = "SHA-1";
if (pbkdf[cursor]) {
const prfAlgorithm = sequence(pbkdf[cursor], "PBKDF2 PRF");
if (
prfAlgorithm.length < 1 ||
prfAlgorithm.length > 2 ||
(prfAlgorithm[1] !== undefined && !(prfAlgorithm[1] instanceof Null))
)
throw new Error("PBKDF2 PRF parameters must be absent or NULL.");
prf = PRFS[oid(prfAlgorithm[0], "PBKDF2 PRF algorithm")] ?? "";
if (!prf)
throw new Error(
"PBKDF2 PRF is not supported by this WebCrypto workflow.",
);
cursor += 1;
}
if (cursor !== pbkdf.length)
throw new Error("PBKDF2 parameters contain unsupported trailing fields.");
const encryption = sequence(params[1], "PBES2 encryption scheme");
if (encryption.length !== 2)
throw new Error("PBES2 encryption scheme parameters are incomplete.");
const cipher = CIPHERS[oid(encryption[0], "PBES2 cipher")];
if (!cipher)
throw new Error("PBES2 cipher is not a supported AES-CBC/AES-GCM scheme.");
if (declaredKeyLength !== undefined && declaredKeyLength !== cipher.length)
throw new Error(
"PBKDF2 declared key length does not match the AES scheme.",
);
let ivOrNonce: Uint8Array;
let tagLength: number | undefined;
if (cipher.name === "AES-CBC") {
ivOrNonce = octets(encryption[1], "AES-CBC IV");
if (ivOrNonce.length !== 16)
throw new Error("AES-CBC IV must contain 16 bytes.");
} else {
const gcm = sequence(encryption[1], "AES-GCM parameters");
if (gcm.length < 1 || gcm.length > 2)
throw new Error("AES-GCM parameters have an unsupported shape.");
ivOrNonce = octets(gcm[0], "AES-GCM nonce");
if (ivOrNonce.length < 12 || ivOrNonce.length > 16)
throw new Error("AES-GCM nonce must contain 1216 bytes.");
const tagBytes = gcm[1] ? integer(gcm[1], "AES-GCM tag length") : 12n;
if (![12n, 13n, 14n, 15n, 16n].includes(tagBytes))
throw new Error("AES-GCM tag length must contain 1216 octets.");
tagLength = Number(tagBytes) * 8;
}
if (encryptedData.length === 0 || encryptedData.length > MAX_ENCRYPTED_BYTES)
throw new Error("PBES2 encrypted payload must contain 1 byte8 MiB.");
return {
scheme: "PBES2",
kdf: "PBKDF2",
prf,
iterations: Number(iterationBig),
saltBytes: salt.length,
cipher: cipher.name,
keyLength: cipher.length,
ivOrNonceBytes: ivOrNonce.length,
...(tagLength ? { tagLength } : {}),
encryptedBytes: encryptedData.length,
salt,
ivOrNonce,
encryptedData,
};
}
export function inspectEncryptedPkcs8(bytes: Uint8Array): Pbes2Inspection {
const parsed = parsePbes2(bytes);
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
export function inspectPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
): Pbes2Inspection {
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return publicPbes2Inspection(parsed);
}
export interface Pkcs8Inspection {
algorithmOid: string;
algorithm: string;
curve?: string;
bytes: number;
}
export function inspectPkcs8(bytes: Uint8Array): Pkcs8Inspection {
const root = sequence(
parseDer(bytes, "PKCS #8 private key"),
"PrivateKeyInfo",
);
if (root.length < 3) throw new Error("PKCS #8 PrivateKeyInfo is incomplete.");
const version = integer(root[0], "PKCS #8 version");
if (version < 0n || version > 1n)
throw new Error("PKCS #8 version is unsupported.");
const algorithmIdentifier = sequence(root[1], "Private-key algorithm");
const algorithmOid = oid(algorithmIdentifier[0], "Private-key algorithm");
const algorithm = PRIVATE_KEY_ALGORITHMS[algorithmOid] ?? "Unknown";
const curveOid =
algorithm === "EC" && algorithmIdentifier[1] instanceof ObjectIdentifier
? algorithmIdentifier[1].getValue()
: undefined;
// Validates that the privateKey field is present and encoded as octets.
octets(root[2], "PKCS #8 privateKey");
return {
algorithmOid,
algorithm,
...(curveOid ? { curve: CURVES[curveOid] ?? `OID ${curveOid}` } : {}),
bytes: bytes.length,
};
}
export async function decryptEncryptedPkcs8(
bytes: Uint8Array,
password: string,
): Promise<{
bytes: Uint8Array;
inspection: Pbes2Inspection;
key: Pkcs8Inspection;
}> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2(bytes);
const output = await decryptParsedPbes2(parsed, password);
try {
return {
bytes: output,
inspection: publicPbes2Inspection(parsed),
key: inspectPkcs8(output),
};
} catch (error) {
output.fill(0);
throw error;
}
}
export async function decryptPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
password: string,
): Promise<{ bytes: Uint8Array; inspection: Pbes2Inspection }> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return {
bytes: await decryptParsedPbes2(parsed, password),
inspection: publicPbes2Inspection(parsed),
};
}
function publicPbes2Inspection(parsed: ParsedPbes2): Pbes2Inspection {
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
async function decryptParsedPbes2(
parsed: ParsedPbes2,
password: string,
): Promise<Uint8Array> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: asBuffer(parsed.salt),
iterations: parsed.iterations,
hash: parsed.prf,
},
material,
{ name: parsed.cipher, length: parsed.keyLength },
false,
["decrypt"],
);
let decrypted: ArrayBuffer;
try {
decrypted = await crypto.subtle.decrypt(
parsed.cipher === "AES-CBC"
? { name: "AES-CBC", iv: asBuffer(parsed.ivOrNonce) }
: {
name: "AES-GCM",
iv: asBuffer(parsed.ivOrNonce),
tagLength: parsed.tagLength ?? 96,
},
key,
asBuffer(parsed.encryptedData),
);
} catch {
throw new Error(
"PBES2 decryption failed. The password may be wrong, the data may be damaged, or this WebCrypto runtime may not support the parameters.",
);
}
return new Uint8Array(decrypted);
}
+770
View File
@@ -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 byte8 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 11,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,
};
}