1092 lines
36 KiB
TypeScript
1092 lines
36 KiB
TypeScript
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;
|
|
|
|
export interface CryptoFinding {
|
|
severity: "info" | "warning" | "error";
|
|
message: string;
|
|
}
|
|
|
|
export interface CryptoItem {
|
|
id: string;
|
|
type: string;
|
|
title: string;
|
|
facts: Record<string, string>;
|
|
findings: CryptoFinding[];
|
|
certificate?: X509Certificate;
|
|
dnsNames?: string[];
|
|
}
|
|
|
|
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 {
|
|
label: string;
|
|
pem: string;
|
|
bytes: Uint8Array;
|
|
encrypted: boolean;
|
|
}
|
|
|
|
const PEM_PATTERN =
|
|
/-----BEGIN ([A-Z0-9][A-Z0-9 -]{0,80})-----([\s\S]*?)-----END \1-----/gu;
|
|
|
|
function buffer(bytes: Uint8Array): ArrayBuffer {
|
|
return bytes.buffer.slice(
|
|
bytes.byteOffset,
|
|
bytes.byteOffset + bytes.byteLength,
|
|
) as ArrayBuffer;
|
|
}
|
|
|
|
function boundedUtf8Bytes(value: string): number {
|
|
const size = new TextEncoder().encode(value).byteLength;
|
|
if (size > MAX_INPUT_BYTES)
|
|
throw new Error("Input exceeds the 8 MiB inspection limit.");
|
|
return size;
|
|
}
|
|
|
|
export function parsePemBlocks(source: string): PemBlock[] {
|
|
boundedUtf8Bytes(source);
|
|
const blocks: PemBlock[] = [];
|
|
for (const match of source.matchAll(PEM_PATTERN)) {
|
|
if (blocks.length >= MAX_PEM_BLOCKS)
|
|
throw new Error(`Input contains more than ${MAX_PEM_BLOCKS} PEM blocks.`);
|
|
const label = match[1]!;
|
|
const body = match[2]!;
|
|
const encrypted =
|
|
/(?:^|\n)(?:Proc-Type:\s*4,ENCRYPTED|DEK-Info:)/iu.test(body) ||
|
|
label === "ENCRYPTED PRIVATE KEY";
|
|
let rawData: ArrayBuffer;
|
|
try {
|
|
rawData = PemConverter.decodeFirst(match[0]);
|
|
} catch {
|
|
throw new Error(`The ${label} PEM block has invalid Base64 or framing.`);
|
|
}
|
|
const bytes = new Uint8Array(rawData);
|
|
if (bytes.byteLength > MAX_INPUT_BYTES)
|
|
throw new Error(`The ${label} block exceeds the 8 MiB limit.`);
|
|
blocks.push({ label, pem: match[0], bytes, encrypted });
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
async function sha256(bytes: BufferSource): Promise<string> {
|
|
return (
|
|
bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)))
|
|
.toUpperCase()
|
|
.match(/.{2}/gu)
|
|
?.join(":") ?? ""
|
|
);
|
|
}
|
|
|
|
function algorithmName(algorithm: Algorithm): string {
|
|
const details = algorithm as Algorithm & {
|
|
namedCurve?: string;
|
|
hash?: { name?: string };
|
|
modulusLength?: number;
|
|
};
|
|
return [
|
|
details.name,
|
|
details.namedCurve,
|
|
details.modulusLength ? `${details.modulusLength} bit` : "",
|
|
details.hash?.name,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
}
|
|
|
|
function validityFindings(
|
|
certificate: X509Certificate,
|
|
now: Date,
|
|
): CryptoFinding[] {
|
|
if (now < certificate.notBefore)
|
|
return [
|
|
{
|
|
severity: "warning",
|
|
message: `Not valid before ${certificate.notBefore.toISOString()}.`,
|
|
},
|
|
];
|
|
if (now > certificate.notAfter)
|
|
return [
|
|
{
|
|
severity: "error",
|
|
message: `Expired on ${certificate.notAfter.toISOString()}.`,
|
|
},
|
|
];
|
|
const days = Math.ceil(
|
|
(certificate.notAfter.getTime() - now.getTime()) / 86_400_000,
|
|
);
|
|
return days <= 30
|
|
? [
|
|
{
|
|
severity: "warning",
|
|
message: `Expires in ${days} day${days === 1 ? "" : "s"}.`,
|
|
},
|
|
]
|
|
: [];
|
|
}
|
|
|
|
function extensionFacts(certificate: X509Certificate): Record<string, string> {
|
|
const facts: Record<string, string> = {};
|
|
const basic = certificate.getExtension(BasicConstraintsExtension);
|
|
if (basic)
|
|
facts["Basic constraints"] = basic.ca
|
|
? `Certificate authority${basic.pathLength === undefined ? "" : `; path length ${basic.pathLength}`}`
|
|
: "End entity";
|
|
const usages = certificate.getExtension(KeyUsagesExtension);
|
|
if (usages) facts["Key usage bits"] = `0x${usages.usages.toString(16)}`;
|
|
const extended = certificate.getExtension(ExtendedKeyUsageExtension);
|
|
if (extended) facts["Extended key usages"] = extended.usages.join(", ");
|
|
facts.Extensions = String(certificate.extensions.length);
|
|
return facts;
|
|
}
|
|
|
|
async function inspectCertificate(
|
|
pemOrBytes: string | Uint8Array,
|
|
index: number,
|
|
now: Date,
|
|
): Promise<CryptoItem> {
|
|
const certificate = new X509Certificate(
|
|
typeof pemOrBytes === "string" ? pemOrBytes : buffer(pemOrBytes),
|
|
);
|
|
const san = certificate.getExtension(SubjectAlternativeNameExtension);
|
|
const dnsNames =
|
|
san?.names.items
|
|
.filter((name) => name.type === "dns")
|
|
.map((name) => name.value) ?? [];
|
|
const selfSigned = await certificate.isSelfSigned().catch(() => false);
|
|
return {
|
|
id: `certificate-${index}`,
|
|
type: "X.509 certificate",
|
|
title: certificate.subject || `Certificate ${index + 1}`,
|
|
facts: {
|
|
Subject: certificate.subject || "(empty)",
|
|
Issuer: certificate.issuer || "(empty)",
|
|
Serial: certificate.serialNumber,
|
|
"Valid from": certificate.notBefore.toISOString(),
|
|
"Valid until": certificate.notAfter.toISOString(),
|
|
"Public key": algorithmName(certificate.publicKey.algorithm),
|
|
"Signature algorithm": algorithmName(certificate.signatureAlgorithm),
|
|
"SHA-256 fingerprint": await sha256(certificate.rawData),
|
|
"DNS names": dnsNames.join(", ") || "—",
|
|
"Self-signed": selfSigned
|
|
? "Yes (signature verified)"
|
|
: "No or unverifiable",
|
|
...extensionFacts(certificate),
|
|
},
|
|
findings: validityFindings(certificate, now),
|
|
certificate,
|
|
dnsNames,
|
|
};
|
|
}
|
|
|
|
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);
|
|
if (
|
|
block.label === "CERTIFICATE REQUEST" ||
|
|
block.label === "NEW CERTIFICATE REQUEST"
|
|
) {
|
|
const request = new Pkcs10CertificateRequest(block.pem);
|
|
return {
|
|
id: `csr-${index}`,
|
|
type: "PKCS #10 certificate request",
|
|
title: request.subject || `Certificate request ${index + 1}`,
|
|
facts: {
|
|
Subject: request.subject || "(empty)",
|
|
"Public key": algorithmName(request.publicKey.algorithm),
|
|
"Signature algorithm": algorithmName(request.signatureAlgorithm),
|
|
Extensions: String(request.extensions.length),
|
|
"Signature valid": (await request.verify()) ? "Yes" : "No",
|
|
"SHA-256 fingerprint": await sha256(request.rawData),
|
|
},
|
|
findings: [],
|
|
};
|
|
}
|
|
if (block.label === "X509 CRL") {
|
|
const crl = new X509Crl(block.pem);
|
|
return {
|
|
id: `crl-${index}`,
|
|
type: "X.509 certificate revocation list",
|
|
title: crl.issuer || `CRL ${index + 1}`,
|
|
facts: {
|
|
Issuer: crl.issuer,
|
|
"This update": crl.thisUpdate.toISOString(),
|
|
"Next update": crl.nextUpdate?.toISOString() ?? "—",
|
|
"Revoked entries": String(crl.entries.length),
|
|
"SHA-256 fingerprint": await sha256(crl.rawData),
|
|
},
|
|
findings:
|
|
crl.nextUpdate && crl.nextUpdate < now
|
|
? [
|
|
{
|
|
severity: "warning",
|
|
message: "The CRL next-update time has passed.",
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
if (block.label === "PUBLIC KEY") {
|
|
const key = new PublicKey(block.pem);
|
|
return {
|
|
id: `public-${index}`,
|
|
type: "SubjectPublicKeyInfo",
|
|
title: `Public key ${index + 1}`,
|
|
facts: {
|
|
Algorithm: algorithmName(key.algorithm),
|
|
"SHA-256 SPKI fingerprint": await sha256(key.rawData),
|
|
},
|
|
findings: [],
|
|
};
|
|
}
|
|
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)),
|
|
},
|
|
findings: [
|
|
{
|
|
severity: block.encrypted ? "info" : "warning",
|
|
message: block.encrypted
|
|
? "Encrypted private-key material was identified but not decrypted."
|
|
: 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.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return {
|
|
id: `pem-${index}`,
|
|
type: block.label,
|
|
title: `${block.label} ${index + 1}`,
|
|
facts: {
|
|
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
|
|
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
|
|
},
|
|
findings: [
|
|
{
|
|
severity: "warning",
|
|
message:
|
|
"This PEM object is identified but its internal structure is not supported in v0.1.",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
type ExtendedJsonWebKey = JsonWebKey & {
|
|
kid?: string;
|
|
use?: string;
|
|
key_ops?: string[];
|
|
};
|
|
type JsonWebKeySet = { keys: ExtendedJsonWebKey[] };
|
|
|
|
function isJwks(value: unknown): value is JsonWebKeySet {
|
|
return (
|
|
!!value &&
|
|
typeof value === "object" &&
|
|
Array.isArray((value as { keys?: unknown }).keys)
|
|
);
|
|
}
|
|
|
|
function isJwk(value: unknown): value is ExtendedJsonWebKey {
|
|
return (
|
|
!!value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value) &&
|
|
typeof (value as { kty?: unknown }).kty === "string"
|
|
);
|
|
}
|
|
|
|
function thumbprintMembers(key: ExtendedJsonWebKey): Record<string, string> {
|
|
if (key.kty === "RSA" && key.e && key.n)
|
|
return { e: key.e, kty: key.kty, n: key.n };
|
|
if (key.kty === "EC" && key.crv && key.x && key.y)
|
|
return { crv: key.crv, kty: key.kty, x: key.x, y: key.y };
|
|
if (key.kty === "OKP" && key.crv && key.x)
|
|
return { crv: key.crv, kty: key.kty, x: key.x };
|
|
if (key.kty === "oct" && key.k) return { k: key.k, kty: key.kty };
|
|
throw new Error(
|
|
`JWK ${key.kid ?? "without a kid"} lacks the RFC 7638 members for ${key.kty ?? "an unknown key type"}.`,
|
|
);
|
|
}
|
|
|
|
async function inspectJwk(
|
|
key: ExtendedJsonWebKey,
|
|
index: number,
|
|
): Promise<CryptoItem> {
|
|
const canonical = JSON.stringify(thumbprintMembers(key));
|
|
const digest = new Uint8Array(
|
|
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)),
|
|
);
|
|
const privateMembers = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"].filter(
|
|
(name) => name in key,
|
|
);
|
|
return {
|
|
id: `jwk-${index}`,
|
|
type: "JSON Web Key",
|
|
title: key.kid || `${key.kty ?? "Unknown"} key ${index + 1}`,
|
|
facts: {
|
|
Type: key.kty ?? "—",
|
|
Curve: key.crv ?? "—",
|
|
Algorithm: key.alg ?? "—",
|
|
Use: key.use ?? "—",
|
|
Operations: key.key_ops?.join(", ") ?? "—",
|
|
"RFC 7638 SHA-256 thumbprint": bytesToBase64Url(digest, false),
|
|
"Contains private material": privateMembers.length
|
|
? `Yes (${privateMembers.join(", ")})`
|
|
: "No",
|
|
},
|
|
findings: privateMembers.length
|
|
? [
|
|
{
|
|
severity: "warning",
|
|
message:
|
|
"This JWK contains private or symmetric key material. It remains in memory only.",
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
async function inspectJson(source: string): Promise<CryptoItem[]> {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(source);
|
|
} catch {
|
|
throw new Error("JSON input is not valid JSON.");
|
|
}
|
|
const candidates: unknown[] = isJwks(parsed) ? parsed.keys : [parsed];
|
|
if (!candidates.every(isJwk))
|
|
throw new Error(
|
|
"JSON input must be a JWK or a JWKS containing only JWK objects.",
|
|
);
|
|
const keys = candidates;
|
|
if (keys.length > 1_000)
|
|
throw new Error("JWKS contains more than 1,000 keys.");
|
|
return Promise.all(keys.map((key, index) => inspectJwk(key, index)));
|
|
}
|
|
|
|
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[],
|
|
now = new Date(),
|
|
): Promise<CertificatePathAnalysis[]> {
|
|
const certificates = items.filter(
|
|
(item): item is CryptoItem & { certificate: X509Certificate } =>
|
|
!!item.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);
|
|
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);
|
|
items = await inspectJson(input);
|
|
} else if (typeof input === "string") {
|
|
const blocks = parsePemBlocks(input);
|
|
if (!blocks.length)
|
|
throw new Error("No supported PEM or JWK/JWKS object was found.");
|
|
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) =>
|
|
inspectBlock(
|
|
{
|
|
label: "CERTIFICATE REQUEST",
|
|
pem: PemConverter.encode(buffer(bytes), "CERTIFICATE REQUEST"),
|
|
bytes,
|
|
encrypted: false,
|
|
},
|
|
0,
|
|
now,
|
|
undefined,
|
|
pbkdf2Budget,
|
|
),
|
|
async (bytes) =>
|
|
inspectBlock(
|
|
{
|
|
label: "X509 CRL",
|
|
pem: PemConverter.encode(buffer(bytes), "X509 CRL"),
|
|
bytes,
|
|
encrypted: false,
|
|
},
|
|
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 [candidateIndex, candidate] of candidates.entries()) {
|
|
try {
|
|
matched = await candidate(input);
|
|
break;
|
|
} 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, CRL, PBES2 encrypted PKCS #8 key, or PKCS #12/PFX file.",
|
|
);
|
|
items = [matched];
|
|
}
|
|
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 };
|
|
}
|
|
|
|
function dnsMatch(pattern: string, hostname: string): boolean {
|
|
const left = pattern.toLowerCase().replace(/\.$/u, "");
|
|
const right = hostname.toLowerCase().replace(/\.$/u, "");
|
|
if (!left.includes("*")) return left === right;
|
|
if (!left.startsWith("*.") || left.slice(2).includes("*")) return false;
|
|
const suffix = left.slice(1);
|
|
return (
|
|
right.endsWith(suffix) && right.split(".").length === left.split(".").length
|
|
);
|
|
}
|
|
|
|
export function checkCertificateHostname(
|
|
item: CryptoItem,
|
|
hostname: string,
|
|
): { valid: boolean; message: string } {
|
|
const candidate = hostname.trim().replace(/\.$/u, "");
|
|
const validHostname =
|
|
candidate.length > 0 &&
|
|
candidate.length <= 253 &&
|
|
candidate
|
|
.split(".")
|
|
.every(
|
|
(label) =>
|
|
label.length > 0 &&
|
|
label.length <= 63 &&
|
|
/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u.test(label),
|
|
);
|
|
if (!validHostname)
|
|
return {
|
|
valid: false,
|
|
message: "Enter a valid ASCII DNS hostname for this local check.",
|
|
};
|
|
if (!item.certificate)
|
|
return { valid: false, message: "Select a certificate." };
|
|
if (!item.dnsNames?.length)
|
|
return {
|
|
valid: false,
|
|
message:
|
|
"The certificate has no DNS subject-alternative names; legacy Common Name fallback is not used.",
|
|
};
|
|
const match = item.dnsNames.find((name) => dnsMatch(name, candidate));
|
|
return match
|
|
? { valid: true, message: `${candidate} matches ${match}.` }
|
|
: {
|
|
valid: false,
|
|
message: `${candidate} does not match any DNS subject-alternative name.`,
|
|
};
|
|
}
|