+579
-25
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user