Release Crypto Tools 0.1.0

This commit is contained in:
2026-09-01 02:44:39 +02:00
commit 4b75f819e2
57 changed files with 9159 additions and 0 deletions
+537
View File
@@ -0,0 +1,537 @@
import "reflect-metadata";
import {
BasicConstraintsExtension,
ExtendedKeyUsageExtension,
KeyUsagesExtension,
PemConverter,
Pkcs10CertificateRequest,
PublicKey,
SubjectAlternativeNameExtension,
X509Certificate,
X509Crl,
} from "@peculiar/x509";
import { bytesToBase64Url, bytesToHex } from "@add-ideas/toolbox-helpers";
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 }[];
}
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,
): 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")) {
return {
id: `private-${index}`,
type: block.label,
title: `Private key ${index + 1}`,
facts: {
Encrypted: block.encrypted ? "Yes" : "No",
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."
: "Unencrypted private-key material is sensitive. It is not persisted by this app.",
},
],
};
}
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)));
}
async function linkCertificates(
items: CryptoItem[],
): Promise<CryptoInspection["chain"]> {
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
.verify({
publicKey: issuer.certificate.publicKey,
signatureOnly: true,
})
.catch(() => false),
});
}
return result;
}
export async function inspectCryptoInput(
input: string | Uint8Array,
now = new Date(),
): Promise<CryptoInspection> {
if (typeof input !== "string" && input.byteLength > MAX_INPUT_BYTES)
throw new Error("Input exceeds the 8 MiB inspection limit.");
const findings: CryptoFinding[] = [];
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 = await Promise.all(
blocks.map((block, index) => inspectBlock(block, index, now)),
);
} else {
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,
),
async (bytes) =>
inspectBlock(
{
label: "X509 CRL",
pem: PemConverter.encode(buffer(bytes), "X509 CRL"),
bytes,
encrypted: false,
},
0,
now,
),
];
let matched: CryptoItem | undefined;
for (const candidate of candidates) {
try {
matched = await candidate(input);
break;
} catch {
/* 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.",
);
items = [matched];
}
const chain = await linkCertificates(items);
if (items.some((item) => item.certificate) && chain.length === 0)
findings.push({
severity: "info",
message:
"No complete issuer link was found. No browser or operating-system trust store is consulted.",
});
return { items, findings, chain };
}
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.`,
};
}