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 {