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
+217
View File
@@ -0,0 +1,217 @@
import { useMemo, useState } from "react";
import {
decryptText,
encryptText,
generateAesKeySource,
OPERATION_ALGORITHMS,
signText,
verifyText,
type CryptoOperationResult,
type OperationAlgorithm,
} from "../crypto/operations";
type Operation = CryptoOperationResult["operation"];
const DEFAULT_ALGORITHM: Record<Operation, OperationAlgorithm> = {
sign: "rsa-pss-sha256",
verify: "rsa-pss-sha256",
encrypt: "aes-gcm-256",
decrypt: "aes-gcm-256",
};
export function OperationsWorkspace() {
const [operation, setOperation] = useState<Operation>("encrypt");
const [algorithm, setAlgorithm] = useState<OperationAlgorithm>("aes-gcm-256");
const [keySource, setKeySource] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("Local-only example");
const [artifact, setArtifact] = useState("");
const [result, setResult] = useState<CryptoOperationResult>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const algorithms = useMemo(
() =>
OPERATION_ALGORITHMS.filter((candidate) =>
candidate.operations.includes(operation),
),
[operation],
);
const selected = OPERATION_ALGORITHMS.find(
(candidate) => candidate.id === algorithm,
);
const chooseOperation = (next: Operation) => {
setOperation(next);
setAlgorithm(DEFAULT_ALGORITHM[next]);
setError("");
};
const execute = async () => {
setBusy(true);
setError("");
try {
const next =
operation === "sign"
? await signText(algorithm, keySource, password, message)
: operation === "verify"
? await verifyText(algorithm, keySource, message, artifact)
: operation === "encrypt"
? await encryptText(algorithm, keySource, message)
: await decryptText(algorithm, keySource, password, artifact);
setResult(next);
if (operation === "sign" || operation === "encrypt")
setArtifact(next.output);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Cryptographic operation failed.",
);
} finally {
setBusy(false);
}
};
const isPrivateOperation = operation === "sign" || operation === "decrypt";
const artifactLabel =
operation === "verify" ? "Signature · unpadded Base64url" : "Ciphertext";
return (
<section className="panel workspace" aria-labelledby="operations-heading">
<div className="panel-heading">
<div>
<p className="eyebrow">Concrete WebCrypto operations</p>
<h2 id="operations-heading">Sign, verify, encrypt or decrypt</h2>
</div>
<span className="privacy-pill">Explicit limits</span>
</div>
<p className="notice">
Inputs, passwords and plaintext stay in this page. Successful results
remain visible while a new operation runs; private material is never
included in downloads or inspection reports.
</p>
<div className="operation-tabs" role="group" aria-label="Operation">
{(["sign", "verify", "encrypt", "decrypt"] as const).map((name) => (
<button
type="button"
aria-pressed={operation === name}
key={name}
onClick={() => chooseOperation(name)}
>
{name[0]!.toUpperCase() + name.slice(1)}
</button>
))}
</div>
<div className="operation-grid">
<label className="field">
<span>Algorithm profile</span>
<select
value={algorithm}
onChange={(event) =>
setAlgorithm(event.target.value as OperationAlgorithm)
}
>
{algorithms.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.label}
</option>
))}
</select>
</label>
<div className="field limit-card">
<span>Enforced profile</span>
<p>{selected?.limit}</p>
</div>
</div>
<label className="field">
<span>
{algorithm === "aes-gcm-256"
? "AES key · 32-byte unpadded Base64url or oct JWK"
: isPrivateOperation
? "Private key · PKCS #8 PEM, PBES2 PEM or JWK"
: "Public key · SPKI PEM, certificate PEM or JWK"}
</span>
<textarea
value={keySource}
onChange={(event) => setKeySource(event.target.value)}
spellCheck={false}
aria-label="Operation key"
/>
</label>
{algorithm === "aes-gcm-256" && (
<div className="actions">
<button
type="button"
onClick={() => setKeySource(generateAesKeySource())}
>
Generate 256-bit AES key
</button>
<span className="muted">Copy and store the key before leaving.</span>
</div>
)}
{isPrivateOperation && algorithm !== "aes-gcm-256" && (
<label className="field">
<span>PBES2 password · used only when the key is encrypted</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
/>
</label>
)}
{operation !== "decrypt" && (
<label className="field">
<span>{operation === "encrypt" ? "Plaintext" : "Message"}</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
/>
</label>
)}
{(operation === "verify" || operation === "decrypt") && (
<label className="field">
<span>{artifactLabel}</span>
<textarea
value={artifact}
onChange={(event) => setArtifact(event.target.value)}
spellCheck={false}
/>
</label>
)}
<div className="actions">
<button
type="button"
className="primary"
onClick={() => void execute()}
disabled={busy}
>
{busy
? `${operation[0]!.toUpperCase() + operation.slice(1)}ing…`
: `${operation[0]!.toUpperCase() + operation.slice(1)} locally`}
</button>
<span className="muted">8 MiB text/key input hard limit</span>
</div>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
{result && (
<article className="operation-result" aria-live="polite">
<p className="eyebrow">Last successful operation</p>
<pre>{result.output}</pre>
<dl className="facts">
{Object.entries(result.facts).map(([name, value]) => (
<div key={name}>
<dt>{name}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</article>
)}
</section>
);
}
+121 -41
View File
@@ -4,6 +4,7 @@ import {
inspectCryptoInput,
type CryptoInspection,
} from "../crypto/inspection";
import { OperationsWorkspace } from "./OperationsWorkspace";
const example = `{
"kty": "EC",
@@ -22,6 +23,7 @@ function reportJson(inspection: CryptoInspection): string {
"Explicit inputs only; browser and operating-system trust stores were not used.",
findings: inspection.findings,
chain: inspection.chain,
paths: inspection.paths,
items: inspection.items.map(({ id, type, title, facts, findings }) => ({
id,
type,
@@ -51,8 +53,11 @@ export function Workbench() {
const [inspection, setInspection] = useState<CryptoInspection>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [inspectionPassword, setInspectionPassword] = useState("");
const [useInspectionPassword, setUseInspectionPassword] = useState(false);
const [hostname, setHostname] = useState("example.com");
const generation = useRef(0);
const currentInput = useRef<string | Uint8Array>(example);
const certificates = useMemo(
() => inspection?.items.filter((item) => item.certificate) ?? [],
[inspection],
@@ -65,12 +70,14 @@ export function Workbench() {
[certificates, hostname],
);
const run = async (input: string | Uint8Array = source) => {
const run = async (input: string | Uint8Array = currentInput.current) => {
const current = ++generation.current;
setBusy(true);
setError("");
try {
const next = await inspectCryptoInput(input);
const next = await inspectCryptoInput(input, new Date(), {
...(useInspectionPassword ? { password: inspectionPassword } : {}),
});
if (generation.current === current) setInspection(next);
} catch (reason) {
if (generation.current === current)
@@ -88,16 +95,20 @@ export function Workbench() {
setError("File exceeds the 8 MiB inspection limit.");
return;
}
setSource(
`Selected binary input: ${file.name} (${file.size.toLocaleString()} bytes)`,
);
const textual =
/(?:json|jwk|jwks|pem|crt|cer|csr|key)$/iu.test(file.name) ||
file.type.startsWith("text/") ||
file.type === "application/json";
await run(
textual ? await file.text() : new Uint8Array(await file.arrayBuffer()),
const input = textual
? await file.text()
: new Uint8Array(await file.arrayBuffer());
currentInput.current = input;
setSource(
textual
? input.toString()
: `Selected binary input: ${file.name} (${file.size.toLocaleString()} bytes)`,
);
await run(input);
};
return (
@@ -131,9 +142,39 @@ export function Workbench() {
<textarea
aria-label="Cryptographic input"
value={source}
onChange={(event) => setSource(event.target.value)}
onChange={(event) => {
setSource(event.target.value);
currentInput.current = event.target.value;
}}
spellCheck={false}
/>
<label className="field">
<span>
PBES2 / PKCS #12 password · used only after explicit opt-in and
never included in the report
</span>
<input
type="password"
value={inspectionPassword}
onChange={(event) => setInspectionPassword(event.target.value)}
autoComplete="off"
disabled={!useInspectionPassword}
/>
</label>
<label className="password-consent">
<input
type="checkbox"
checked={useInspectionPassword}
onChange={(event) => {
setUseInspectionPassword(event.target.checked);
if (!event.target.checked) setInspectionPassword("");
}}
/>
<span>
Use this password in memory for this inspection (a blank value
explicitly means an empty PKCS #12 password)
</span>
</label>
<div className="actions">
<button
type="button"
@@ -212,41 +253,71 @@ export function Workbench() {
))}
</div>
</section>
{inspection.chain.length > 0 && (
{inspection.paths.length > 0 && (
<section
className="panel workspace"
aria-labelledby="chain-heading"
>
<div>
<p className="eyebrow">Explicit path</p>
<h2 id="chain-heading">Issuer signatures</h2>
<p className="eyebrow">Explicit-input path analysis</p>
<h2 id="chain-heading">Certificate paths</h2>
</div>
<div className="item-list">
{inspection.paths.map((path) => (
<article className="crypto-item" key={path.leaf}>
<header className="panel-heading">
<div>
<p className="eyebrow">{path.status}</p>
<h3>{path.leaf}</h3>
</div>
<span className="privacy-pill">Not trusted</span>
</header>
<p className="path-line">{path.certificates.join(" → ")}</p>
{path.links.length > 0 && (
<table>
<thead>
<tr>
<th>Certificate</th>
<th>Issuer</th>
<th>Signature</th>
<th>CA / keyCertSign</th>
</tr>
</thead>
<tbody>
{path.links.map((link) => (
<tr key={`${link.child}-${link.issuer}`}>
<td>{link.child}</td>
<td>{link.issuer}</td>
<td>
{link.signatureValid ? "Valid" : "Invalid"}
</td>
<td>
{link.issuerIsCa ? "CA" : "Not CA"} ·{" "}
{link.keyCertSignAllowed
? "permitted"
: "not permitted"}
</td>
</tr>
))}
</tbody>
</table>
)}
{path.findings.map((finding) => (
<p
className={`finding finding-${finding.severity}`}
key={finding.message}
>
{finding.message}
</p>
))}
</article>
))}
</div>
<table>
<thead>
<tr>
<th>Certificate</th>
<th>Issuer</th>
<th>Signature</th>
</tr>
</thead>
<tbody>
{inspection.chain.map((link) => (
<tr key={`${link.child}-${link.issuer}`}>
<td>{link.child}</td>
<td>{link.issuer}</td>
<td>
{link.signatureValid
? "Valid"
: "Invalid / unsupported"}
</td>
</tr>
))}
</tbody>
</table>
<p className="muted">
A valid signature link alone does not establish trust, purpose,
revocation status, name validity, or complete RFC 5280 path
validation.
Issuer DN, AKI/SKI, signatures, CA constraints, keyCertSign,
approximate path length and validity are checked. A successful
explicit-input path still does not establish trust, policy,
purpose or revocation status.
</p>
</section>
)}
@@ -282,19 +353,28 @@ export function Workbench() {
)}
</>
)}
<OperationsWorkspace />
<section className="panel workspace" aria-labelledby="boundary-heading">
<div>
<p className="eyebrow">v0.1 boundary</p>
<p className="eyebrow">Deliberate boundary</p>
<h2 id="boundary-heading">What this release does not claim</h2>
</div>
<ul className="compact-list">
<li>
Private keys are identified and fingerprinted, never persisted,
generated, or silently decrypted.
Private keys are never persisted or silently decrypted. PBES2 import
requires an explicit password and decrypted bytes remain in page
memory only.
</li>
<li>
PKCS #12/PFX, encrypted PKCS #8, CMS/JWS signature workflows and
certificate issuance are deferred.
PKCS #12 parses bounded AuthenticatedSafe/SafeContents structures,
verifies supported MacData, and decrypts only modern
PBES2/PBKDF2/AES content. Legacy PKCS #12 PBE, unknown CMS content,
PKCS #1/SEC1, CMS/JWS and certificate issuance are not implemented.
</li>
<li>
Operations use only the listed, fixed WebCrypto profiles. They do
not auto-detect algorithms, hash files, stream large inputs or claim
protocol-level interoperability beyond their displayed encoding.
</li>
<li>
No online OCSP, CRL download, Certificate Transparency query,
+579 -25
View File
@@ -1,16 +1,31 @@
import "reflect-metadata";
import {
BasicConstraintsExtension,
AuthorityKeyIdentifierExtension,
ExtendedKeyUsageExtension,
KeyUsageFlags,
KeyUsagesExtension,
PemConverter,
Pkcs10CertificateRequest,
PublicKey,
SubjectAlternativeNameExtension,
SubjectKeyIdentifierExtension,
X509Certificate,
X509Crl,
} from "@peculiar/x509";
import { bytesToBase64Url, bytesToHex } from "@add-ideas/toolbox-helpers";
import {
decryptEncryptedPkcs8,
inspectEncryptedPkcs8,
inspectPkcs8,
reservePbkdf2Work,
type Pbkdf2WorkBudget,
} from "./pbes2";
import {
inspectPkcs12,
recognizesPkcs12,
type Pkcs12BagInventory,
} from "./pkcs12";
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
const MAX_PEM_BLOCKS = 256;
@@ -34,6 +49,31 @@ export interface CryptoInspection {
items: CryptoItem[];
findings: CryptoFinding[];
chain: { child: string; issuer: string; signatureValid: boolean }[];
paths: CertificatePathAnalysis[];
}
export interface CertificatePathLink {
child: string;
issuer: string;
signatureValid: boolean;
issuerIsCa: boolean;
keyCertSignAllowed: boolean;
authorityKeyIdentifierMatched?: boolean;
findings: CryptoFinding[];
}
export interface CertificatePathAnalysis {
leaf: string;
certificates: string[];
status:
| "self-signed-anchor-present"
| "incomplete"
| "ambiguous"
| "loop"
| "invalid";
links: CertificatePathLink[];
findings: CryptoFinding[];
trusted: false;
}
export interface PemBlock {
@@ -199,6 +239,8 @@ async function inspectBlock(
block: PemBlock,
index: number,
now: Date,
password: string | undefined,
pbkdf2Budget: Pbkdf2WorkBudget,
): Promise<CryptoItem> {
if (block.label === "CERTIFICATE" || block.label === "X509 CERTIFICATE")
return inspectCertificate(block.pem, index, now);
@@ -260,12 +302,77 @@ async function inspectBlock(
};
}
if (block.label.includes("PRIVATE KEY")) {
if (block.label === "ENCRYPTED PRIVATE KEY") {
const encryption = inspectEncryptedPkcs8(block.bytes);
if (password !== undefined)
reservePbkdf2Work(
pbkdf2Budget,
encryption.iterations,
`Encrypted private key ${index + 1}`,
);
const decrypted =
password === undefined
? undefined
: await decryptEncryptedPkcs8(block.bytes, password);
try {
return {
id: `private-${index}`,
type: block.label,
title: `Encrypted private key ${index + 1}`,
facts: {
Encrypted: "Yes",
Container: "PKCS #8 EncryptedPrivateKeyInfo",
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
Salt: `${encryption.saltBytes} bytes`,
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
"Private-key algorithm": decrypted
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
: "Password required to inspect",
"Decryption status": decrypted
? "Decrypted and structurally validated in memory"
: "Not attempted",
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
},
findings: [
...(encryption.cipher === "AES-CBC"
? [
{
severity: "warning" as const,
message:
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
},
]
: []),
{
severity: decrypted ? "warning" : "info",
message: decrypted
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
},
],
};
} finally {
decrypted?.bytes.fill(0);
}
}
const key =
block.label === "PRIVATE KEY" ? inspectPkcs8(block.bytes) : undefined;
return {
id: `private-${index}`,
type: block.label,
title: `Private key ${index + 1}`,
facts: {
Encrypted: block.encrypted ? "Yes" : "No",
Container:
block.label === "PRIVATE KEY"
? "PKCS #8 PrivateKeyInfo"
: "Legacy or unsupported private-key container",
...(key
? {
"Private-key algorithm": `${key.algorithm}${key.curve ? ` · ${key.curve}` : ""}`,
}
: {}),
Size: `${block.bytes.byteLength.toLocaleString()} bytes`,
"SHA-256 fingerprint": await sha256(buffer(block.bytes)),
},
@@ -274,7 +381,9 @@ async function inspectBlock(
severity: block.encrypted ? "info" : "warning",
message: block.encrypted
? "Encrypted private-key material was identified but not decrypted."
: "Unencrypted private-key material is sensitive. It is not persisted by this app.",
: block.label === "PRIVATE KEY"
? "Unencrypted PKCS #8 private-key material is sensitive. It is structurally inspected but not persisted."
: "This legacy private-key container is fingerprinted but is not imported; convert it to PKCS #8 explicitly outside this tool.",
},
],
};
@@ -390,41 +499,398 @@ async function inspectJson(source: string): Promise<CryptoItem[]> {
return Promise.all(keys.map((key, index) => inspectJwk(key, index)));
}
async function linkCertificates(
function extensionIdentifiers(certificate: X509Certificate): {
authority?: string;
subject?: string;
} {
const authority = certificate.getExtension(AuthorityKeyIdentifierExtension);
const subject = certificate.getExtension(SubjectKeyIdentifierExtension);
return {
...(authority?.keyId ? { authority: authority.keyId.toUpperCase() } : {}),
...(subject?.keyId ? { subject: subject.keyId.toUpperCase() } : {}),
};
}
export async function analyzeCertificatePaths(
items: CryptoItem[],
): Promise<CryptoInspection["chain"]> {
now = new Date(),
): Promise<CertificatePathAnalysis[]> {
const certificates = items.filter(
(item): item is CryptoItem & { certificate: X509Certificate } =>
!!item.certificate,
);
const result: CryptoInspection["chain"] = [];
for (const child of certificates) {
if (child.certificate.subject === child.certificate.issuer) continue;
const issuer = certificates.find(
(candidate) => candidate.certificate.subject === child.certificate.issuer,
);
if (!issuer) continue;
result.push({
child: child.title,
issuer: issuer.title,
signatureValid: await child.certificate
if (certificates.length === 0) return [];
const leaves = certificates.filter(
(candidate) =>
!certificates.some(
(child) =>
child !== candidate &&
child.certificate.issuer === candidate.certificate.subject,
),
);
const starts = leaves.length > 0 ? leaves : certificates;
const paths: CertificatePathAnalysis[] = [];
for (const start of starts) {
const certificatePath = [start];
const links: CertificatePathLink[] = [];
const findings: CryptoFinding[] = [];
const visited = new Set([start.id]);
let current = start;
let status: CertificatePathAnalysis["status"] = "incomplete";
let pathInvalid = false;
for (let depth = 0; depth < 32; depth += 1) {
const certificate = current.certificate;
if (now < certificate.notBefore || now > certificate.notAfter) {
pathInvalid = true;
findings.push({
severity: "error",
message: `${current.title} is outside its certificate validity interval at the selected inspection time.`,
});
}
if (certificate.subject === certificate.issuer) {
const selfSignature = await certificate
.isSelfSigned()
.catch(() => false);
if (!selfSignature) {
pathInvalid = true;
findings.push({
severity: "error",
message: `${current.title} names itself as issuer but its self-signature did not verify.`,
});
}
status = pathInvalid ? "invalid" : "self-signed-anchor-present";
findings.push({
severity: "info",
message:
"A self-signed certificate terminates this explicit-input path, but it is not treated as trusted.",
});
break;
}
const identifiers = extensionIdentifiers(certificate);
const nameCandidates = certificates.filter(
(candidate) =>
candidate !== current &&
candidate.certificate.subject === certificate.issuer,
);
let candidates = nameCandidates;
let authorityMatched: boolean | undefined;
if (identifiers.authority && nameCandidates.length > 0) {
const keyMatches = nameCandidates.filter(
(candidate) =>
extensionIdentifiers(candidate.certificate).subject ===
identifiers.authority,
);
if (keyMatches.length > 0) {
candidates = keyMatches;
authorityMatched = true;
} else if (
nameCandidates.some(
(candidate) =>
extensionIdentifiers(candidate.certificate).subject !== undefined,
)
) {
findings.push({
severity: "error",
message: `${current.title} authority key identifier does not match any same-name issuer candidate.`,
});
status = "invalid";
break;
}
}
if (candidates.length === 0) {
findings.push({
severity: "warning",
message: `No supplied certificate has subject “${certificate.issuer}” for ${current.title}.`,
});
status = pathInvalid ? "invalid" : "incomplete";
break;
}
if (candidates.length > 1) {
findings.push({
severity: "warning",
message: `${current.title} has ${candidates.length} indistinguishable issuer candidates; the path is not guessed.`,
});
status = "ambiguous";
break;
}
const issuer = candidates[0]!;
if (visited.has(issuer.id)) {
findings.push({
severity: "error",
message: `Certificate loop detected when linking ${current.title} to ${issuer.title}.`,
});
status = "loop";
break;
}
const signatureValid = await certificate
.verify({
publicKey: issuer.certificate.publicKey,
signatureOnly: true,
})
.catch(() => false),
.catch(() => false);
const basic = issuer.certificate.getExtension(BasicConstraintsExtension);
const usages = issuer.certificate.getExtension(KeyUsagesExtension);
const issuerIsCa = basic?.ca === true;
const keyCertSignAllowed =
!usages || (usages.usages & KeyUsageFlags.keyCertSign) !== 0;
const linkFindings: CryptoFinding[] = [];
if (!signatureValid)
linkFindings.push({
severity: "error",
message:
"Certificate signature did not verify with this issuer candidate.",
});
if (!issuerIsCa)
linkFindings.push({
severity: "error",
message:
"Issuer certificate does not assert CA=true in Basic Constraints.",
});
if (!keyCertSignAllowed)
linkFindings.push({
severity: "error",
message: "Issuer Key Usage does not permit certificate signing.",
});
const caCertificatesBelow = certificatePath
.slice(1)
.filter(
(item) =>
item.certificate.getExtension(BasicConstraintsExtension)?.ca ===
true,
).length;
if (
basic?.pathLength !== undefined &&
caCertificatesBelow > basic.pathLength
)
linkFindings.push({
severity: "error",
message: `Issuer pathLength ${basic.pathLength} is exceeded by ${caCertificatesBelow} subordinate CA certificate(s).`,
});
if (linkFindings.some((finding) => finding.severity === "error"))
pathInvalid = true;
links.push({
child: current.title,
issuer: issuer.title,
signatureValid,
issuerIsCa,
keyCertSignAllowed,
...(authorityMatched === undefined
? {}
: { authorityKeyIdentifierMatched: authorityMatched }),
findings: linkFindings,
});
findings.push(...linkFindings);
certificatePath.push(issuer);
visited.add(issuer.id);
current = issuer;
if (depth === 31) {
findings.push({
severity: "error",
message: "Certificate path exceeds the 32-certificate bound.",
});
status = "invalid";
}
}
paths.push({
leaf: start.title,
certificates: certificatePath.map((item) => item.title),
status,
links,
findings,
trusted: false,
});
}
return paths;
}
function chainFromPaths(
paths: readonly CertificatePathAnalysis[],
): CryptoInspection["chain"] {
const result: CryptoInspection["chain"] = [];
const seen = new Set<string>();
for (const path of paths) {
for (const link of path.links) {
const key = `${link.child}\0${link.issuer}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({
child: link.child,
issuer: link.issuer,
signatureValid: link.signatureValid,
});
}
}
return result;
}
function pfxEncryptionSummary(
encryption: NonNullable<Pkcs12BagInventory["encryption"]>,
): string {
return `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`;
}
async function inspectPkcs12Items(
bytes: Uint8Array,
now: Date,
password: string | undefined,
): Promise<CryptoItem[]> {
const pfx = await inspectPkcs12(bytes, {
...(password === undefined ? {} : { password }),
});
const macStatus =
pfx.mac.status === "verified"
? "Verified with the explicitly supplied password"
: pfx.mac.status === "password-required"
? "Present; explicit password required to verify"
: pfx.mac.status === "unsupported"
? `Present; unsupported digest (${pfx.mac.algorithm ?? "unknown"})`
: "Absent";
const contentSummary = pfx.contents
.map((content) => {
const encryption = content.encryption
? `; ${content.encryption.cipher}-${content.encryption.keyLength}, ${content.encryption.iterations.toLocaleString()} PBKDF2 iterations`
: "";
return `#${content.index + 1} ${content.type}: ${content.state}, ${content.bagCount} bag(s)${encryption}`;
})
.join(" | ");
const envelope: CryptoItem = {
id: "pfx-envelope",
type: "PKCS #12/PFX",
title: "PKCS #12/PFX authenticated safe",
facts: {
Version: String(pfx.version),
"Authenticated-safe content type": pfx.authSafeContentType,
MacData: macStatus,
...(pfx.mac.algorithm ? { "MAC algorithm": pfx.mac.algorithm } : {}),
...(pfx.mac.iterations === undefined
? {}
: { "MAC iterations": pfx.mac.iterations.toLocaleString() }),
Contents: contentSummary || "Empty AuthenticatedSafe",
"SafeBag inventory": `${pfx.bags.length.toLocaleString()} bag(s)`,
Size: `${pfx.bytes.toLocaleString()} bytes`,
"SHA-256 fingerprint": await sha256(buffer(bytes)),
},
findings: [
...pfx.warnings.map((message) => ({
severity: "warning" as const,
message,
})),
...(pfx.mac.status === "password-required"
? [
{
severity: "info" as const,
message:
"Enable password use explicitly to verify MacData and inspect supported encrypted SafeContents/key bags.",
},
]
: []),
{
severity: "warning",
message:
"Private-key bag values are only structurally inspected in page memory. Secret bytes and the password are never included in the report or exported implicitly.",
},
],
};
const bagItems: CryptoItem[] = [];
for (const [index, bag] of pfx.bags.entries()) {
const bagFacts: Record<string, string> = {
"Bag path": bag.path,
"Bag OID": bag.bagOid,
"Friendly name": bag.friendlyName ?? "—",
localKeyId: bag.localKeyId ?? "—",
Encrypted: bag.encrypted ? "Yes" : "No",
State:
bag.state === "inspected"
? "Structurally inspected"
: bag.state === "password-required"
? "Explicit password required"
: "Value deliberately not decoded",
...(bag.encryption
? { Encryption: pfxEncryptionSummary(bag.encryption) }
: {}),
...(bag.key
? {
"Private-key algorithm": `${bag.key.algorithm}${bag.key.curve ? ` · ${bag.key.curve}` : ""}`,
"Private-key container size": `${bag.key.bytes.toLocaleString()} bytes`,
}
: {}),
};
if (bag.certificateBytes) {
let certificate: CryptoItem;
try {
certificate = await inspectCertificate(
bag.certificateBytes,
index,
now,
);
} catch (reason) {
throw new Error(
`${bag.path} contains an invalid X.509 certificate: ${reason instanceof Error ? reason.message : "certificate parsing failed"}`,
{ cause: reason },
);
}
bagItems.push({
...certificate,
id: `pfx-bag-${index}`,
title: bag.friendlyName || certificate.title,
facts: { ...bagFacts, ...certificate.facts },
});
continue;
}
bagItems.push({
id: `pfx-bag-${index}`,
type:
bag.bagType === "private-key" || bag.bagType === "shrouded-private-key"
? "PKCS #12 private-key bag"
: `PKCS #12 ${bag.bagType} bag`,
title:
bag.friendlyName ?? `${bag.bagType.replaceAll("-", " ")} ${index + 1}`,
facts: bagFacts,
findings: [
...(bag.state === "password-required"
? [
{
severity: "info" as const,
message:
"The encrypted bag was inventoried without attempting decryption.",
},
]
: []),
...(bag.key
? [
{
severity: "warning" as const,
message:
"Only private-key container metadata is shown; secret material is not retained in the inspection result.",
},
]
: []),
...(bag.state === "unsupported"
? [
{
severity: "warning" as const,
message:
"This bag type is identified, but its value is not decoded or exported.",
},
]
: []),
],
});
}
return [envelope, ...bagItems];
}
export async function inspectCryptoInput(
input: string | Uint8Array,
now = new Date(),
options: { password?: string } = {},
): Promise<CryptoInspection> {
if (typeof input !== "string" && input.byteLength > MAX_INPUT_BYTES)
throw new Error("Input exceeds the 8 MiB inspection limit.");
const findings: CryptoFinding[] = [];
const pbkdf2Budget: Pbkdf2WorkBudget = { iterations: 0 };
let items: CryptoItem[];
if (typeof input === "string" && input.trimStart().startsWith("{")) {
boundedUtf8Bytes(input);
@@ -433,10 +899,27 @@ export async function inspectCryptoInput(
const blocks = parsePemBlocks(input);
if (!blocks.length)
throw new Error("No supported PEM or JWK/JWKS object was found.");
items = await Promise.all(
blocks.map((block, index) => inspectBlock(block, index, now)),
);
items = [];
for (const [index, block] of blocks.entries())
items.push(
await inspectBlock(block, index, now, options.password, pbkdf2Budget),
);
} else {
if (recognizesPkcs12(input)) {
items = await inspectPkcs12Items(input, now, options.password);
const paths = await analyzeCertificatePaths(items, now);
const chain = chainFromPaths(paths);
if (
items.some((item) => item.certificate) &&
!paths.some((path) => path.status === "self-signed-anchor-present")
)
findings.push({
severity: "info",
message:
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
});
return { items, findings, chain, paths };
}
const candidates: ((bytes: Uint8Array) => Promise<CryptoItem>)[] = [
(bytes) => inspectCertificate(bytes, 0, now),
async (bytes) =>
@@ -449,6 +932,8 @@ export async function inspectCryptoInput(
},
0,
now,
undefined,
pbkdf2Budget,
),
async (bytes) =>
inspectBlock(
@@ -460,31 +945,100 @@ export async function inspectCryptoInput(
},
0,
now,
undefined,
pbkdf2Budget,
),
async (bytes) => {
const encryption = inspectEncryptedPkcs8(bytes);
if (options.password !== undefined)
reservePbkdf2Work(
pbkdf2Budget,
encryption.iterations,
"Encrypted private key 1",
);
const decrypted =
options.password === undefined
? undefined
: await decryptEncryptedPkcs8(bytes, options.password);
try {
return {
id: "private-0",
type: "ENCRYPTED PRIVATE KEY",
title: "Encrypted private key 1",
facts: {
Container: "PKCS #8 EncryptedPrivateKeyInfo",
Encryption: `${encryption.scheme} · ${encryption.kdf} ${encryption.prf} · ${encryption.iterations.toLocaleString()} iterations · ${encryption.cipher}-${encryption.keyLength}`,
Salt: `${encryption.saltBytes} bytes`,
"IV / nonce": `${encryption.ivOrNonceBytes} bytes`,
"Encrypted size": `${encryption.encryptedBytes.toLocaleString()} bytes`,
"Private-key algorithm": decrypted
? `${decrypted.key.algorithm}${decrypted.key.curve ? ` · ${decrypted.key.curve}` : ""}`
: "Password required to inspect",
"Decryption status": decrypted
? "Decrypted and structurally validated in memory"
: "Not attempted",
"SHA-256 fingerprint": await sha256(buffer(bytes)),
},
findings: [
...(encryption.cipher === "AES-CBC"
? [
{
severity: "warning" as const,
message:
"AES-CBC PBES2 does not authenticate the ciphertext; a successful padding/structure check is not an integrity guarantee.",
},
]
: []),
{
severity: decrypted ? "warning" : "info",
message: decrypted
? "The PBES2 key was decrypted only in page memory. Private-key material is sensitive and was not added to the report."
: "PBES2 parameters were inspected without decryption. Enter a password explicitly to validate/import the key in memory.",
},
],
};
} finally {
decrypted?.bytes.fill(0);
}
},
];
let matched: CryptoItem | undefined;
for (const candidate of candidates) {
for (const [candidateIndex, candidate] of candidates.entries()) {
try {
matched = await candidate(input);
break;
} catch {
} catch (reason) {
if (candidateIndex === 3 && options.password) {
let recognized = false;
try {
inspectEncryptedPkcs8(input);
recognized = true;
} catch {
/* this was not PBES2; continue probing */
}
if (recognized) throw reason;
}
/* try the next DER model */
}
}
if (!matched)
throw new Error(
"The binary input is not a supported DER certificate, CSR, or CRL. PKCS #12/PFX is identified but not decrypted in v0.1.",
"The binary input is not a supported DER certificate, CSR, CRL, PBES2 encrypted PKCS #8 key, or PKCS #12/PFX file.",
);
items = [matched];
}
const chain = await linkCertificates(items);
if (items.some((item) => item.certificate) && chain.length === 0)
const paths = await analyzeCertificatePaths(items, now);
const chain = chainFromPaths(paths);
if (
items.some((item) => item.certificate) &&
!paths.some((path) => path.status === "self-signed-anchor-present")
)
findings.push({
severity: "info",
message:
"No complete issuer link was found. No browser or operating-system trust store is consulted.",
"No supplied path terminates at a valid self-signed certificate. No browser or operating-system trust store is consulted.",
});
return { items, findings, chain };
return { items, findings, chain, paths };
}
function dnsMatch(pattern: string, hostname: string): boolean {
+637
View File
@@ -0,0 +1,637 @@
import "reflect-metadata";
import { PemConverter, X509Certificate } from "@peculiar/x509";
import {
base64UrlToBytes,
bytesToBase64Url,
secureRandomBytes,
} from "@add-ideas/toolbox-helpers";
import { decryptEncryptedPkcs8 } from "./pbes2";
const MAX_KEY_SOURCE = 8 * 1024 * 1024;
const MAX_MESSAGE = 8 * 1024 * 1024;
const MAX_CIPHERTEXT = 16 * 1024 * 1024;
export type OperationAlgorithm =
| "rsa-pss-sha256"
| "rsa-pkcs1-sha256"
| "ecdsa-p256-sha256"
| "ed25519"
| "rsa-oaep-sha256"
| "aes-gcm-256";
export const OPERATION_ALGORITHMS: ReadonlyArray<{
id: OperationAlgorithm;
label: string;
operations: readonly ("sign" | "verify" | "encrypt" | "decrypt")[];
limit: string;
}> = [
{
id: "rsa-pss-sha256",
label: "RSA-PSS · SHA-256 · 32-byte salt",
operations: ["sign", "verify"],
limit: "RSA modulus 2,04816,384 bits; fixed 32-byte PSS salt.",
},
{
id: "rsa-pkcs1-sha256",
label: "RSASSA-PKCS1-v1_5 · SHA-256",
operations: ["sign", "verify"],
limit: "RSA modulus 2,04816,384 bits.",
},
{
id: "ecdsa-p256-sha256",
label: "ECDSA P-256 · SHA-256",
operations: ["sign", "verify"],
limit: "P-256 keys only; WebCrypto IEEE P1363 signature representation.",
},
{
id: "ed25519",
label: "Ed25519",
operations: ["sign", "verify"],
limit:
"Available only when the current browser WebCrypto implements Ed25519.",
},
{
id: "rsa-oaep-sha256",
label: "RSA-OAEP · SHA-256",
operations: ["encrypt", "decrypt"],
limit: "RSA modulus 2,04816,384 bits; no OAEP label; short messages only.",
},
{
id: "aes-gcm-256",
label: "AES-256-GCM · random 96-bit IV · 128-bit tag",
operations: ["encrypt", "decrypt"],
limit:
"Exactly 256-bit JWK/raw key; fresh WebCrypto IV generated for every encryption.",
},
];
export interface CryptoOperationResult {
operation: "sign" | "verify" | "encrypt" | "decrypt";
algorithm: OperationAlgorithm;
output: string;
valid?: boolean;
facts: Record<string, string>;
}
interface AlgorithmDefinition {
importAlgorithm:
| AlgorithmIdentifier
| RsaHashedImportParams
| EcKeyImportParams
| AesKeyAlgorithm;
operationAlgorithm:
AlgorithmIdentifier | RsaPssParams | EcdsaParams | RsaOaepParams;
expected: "RSA" | "EC" | "Ed25519" | "AES";
}
function definition(algorithm: OperationAlgorithm): AlgorithmDefinition {
switch (algorithm) {
case "rsa-pss-sha256":
return {
importAlgorithm: { name: "RSA-PSS", hash: "SHA-256" },
operationAlgorithm: { name: "RSA-PSS", saltLength: 32 },
expected: "RSA",
};
case "rsa-pkcs1-sha256":
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
operationAlgorithm: { name: "RSASSA-PKCS1-v1_5" },
expected: "RSA",
};
case "ecdsa-p256-sha256":
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-256" },
operationAlgorithm: { name: "ECDSA", hash: "SHA-256" },
expected: "EC",
};
case "ed25519":
return {
importAlgorithm: { name: "Ed25519" },
operationAlgorithm: { name: "Ed25519" },
expected: "Ed25519",
};
case "rsa-oaep-sha256":
return {
importAlgorithm: { name: "RSA-OAEP", hash: "SHA-256" },
operationAlgorithm: { name: "RSA-OAEP" },
expected: "RSA",
};
case "aes-gcm-256":
return {
importAlgorithm: { name: "AES-GCM", length: 256 },
operationAlgorithm: { name: "AES-GCM" },
expected: "AES",
};
}
}
function boundedText(
value: string,
name: string,
maximum = MAX_MESSAGE,
): Uint8Array {
const bytes = new TextEncoder().encode(value);
if (bytes.length > maximum)
throw new Error(
`${name} exceeds ${(maximum / 1024 / 1024).toLocaleString()} MiB.`,
);
return bytes;
}
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function onePem(source: string): { label: string; bytes: Uint8Array } {
const matches = [
...source.matchAll(
/-----BEGIN ([A-Z0-9][A-Z0-9 -]{0,80})-----[\s\S]*?-----END \1-----/gu,
),
];
if (matches.length !== 1)
throw new Error("Operation key input must contain exactly one PEM block.");
let decoded: ArrayBuffer;
try {
decoded = PemConverter.decodeFirst(matches[0]![0]);
} catch {
throw new Error("Operation key PEM has invalid framing or Base64.");
}
return { label: matches[0]![1]!, bytes: new Uint8Array(decoded) };
}
function publicJwk(value: JsonWebKey): JsonWebKey {
if (value.kty === "RSA") return { kty: "RSA", n: value.n, e: value.e };
if (value.kty === "EC")
return { kty: "EC", crv: value.crv, x: value.x, y: value.y };
if (value.kty === "OKP") return { kty: "OKP", crv: value.crv, x: value.x };
return value;
}
function parseJwk(source: string): JsonWebKey {
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error("Operation JWK is not valid JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error("Operation JWK must be one JSON object.");
const jwk = parsed as JsonWebKey;
if (typeof jwk.kty !== "string") throw new Error("Operation JWK has no kty.");
return jwk;
}
async function sourceForImport(
source: string,
password: string,
role: "private" | "public",
): Promise<{
format: "pkcs8" | "spki" | "jwk";
keyData: ArrayBuffer | JsonWebKey;
cleanup?: () => void;
}> {
if (boundedText(source, "Key input", MAX_KEY_SOURCE).length === 0)
throw new Error("Key input is required.");
if (source.trimStart().startsWith("{")) {
const jwk = parseJwk(source);
return {
format: "jwk",
keyData: role === "public" ? publicJwk(jwk) : jwk,
};
}
const pem = onePem(source);
if (role === "private") {
if (pem.label === "PRIVATE KEY") {
const keyData = arrayBuffer(pem.bytes);
return {
format: "pkcs8",
keyData,
cleanup: () => {
pem.bytes.fill(0);
new Uint8Array(keyData).fill(0);
},
};
}
if (pem.label === "ENCRYPTED PRIVATE KEY") {
const decrypted = await decryptEncryptedPkcs8(pem.bytes, password);
try {
const keyData = arrayBuffer(decrypted.bytes);
return {
format: "pkcs8",
keyData,
cleanup: () => {
decrypted.bytes.fill(0);
new Uint8Array(keyData).fill(0);
},
};
} catch (error) {
decrypted.bytes.fill(0);
throw error;
}
}
throw new Error(
"Private operations accept PKCS #8 PRIVATE KEY, PBES2 ENCRYPTED PRIVATE KEY, or private JWK. PKCS #1/SEC1 conversion is intentionally not guessed.",
);
}
if (pem.label === "PUBLIC KEY")
return { format: "spki", keyData: arrayBuffer(pem.bytes) };
if (pem.label === "CERTIFICATE" || pem.label === "X509 CERTIFICATE") {
const certificate = new X509Certificate(arrayBuffer(pem.bytes));
return { format: "spki", keyData: certificate.publicKey.rawData };
}
throw new Error(
"Public operations accept SubjectPublicKeyInfo PUBLIC KEY, CERTIFICATE, or public JWK.",
);
}
function assertKeyLimits(
key: CryptoKey,
expected: AlgorithmDefinition["expected"],
) {
if (expected === "RSA") {
const algorithm = key.algorithm as RsaKeyAlgorithm;
if (
algorithm.name.startsWith("RSA") ||
algorithm.name.startsWith("RSASSA")
) {
if (algorithm.modulusLength < 2_048 || algorithm.modulusLength > 16_384)
throw new Error("RSA modulus must contain 2,04816,384 bits.");
return;
}
} else if (expected === "EC") {
const algorithm = key.algorithm as EcKeyAlgorithm;
if (algorithm.name === "ECDSA" && algorithm.namedCurve === "P-256") return;
} else if (expected === "Ed25519") {
if (key.algorithm.name === "Ed25519") return;
} else if (expected === "AES") {
const algorithm = key.algorithm as AesKeyAlgorithm;
if (algorithm.name === "AES-GCM" && algorithm.length === 256) return;
}
throw new Error(
`Imported key does not match the selected ${expected} algorithm limit.`,
);
}
async function importAsymmetric(
source: string,
password: string,
algorithm: OperationAlgorithm,
role: "private" | "public",
usage: KeyUsage,
): Promise<CryptoKey> {
const selected = definition(algorithm);
if (selected.expected === "AES")
throw new Error("Selected operation requires an AES key.");
const input = await sourceForImport(source, password, role);
try {
let key: CryptoKey;
try {
key =
input.format === "jwk"
? await crypto.subtle.importKey(
"jwk",
input.keyData as JsonWebKey,
selected.importAlgorithm,
false,
[usage],
)
: await crypto.subtle.importKey(
input.format,
input.keyData as ArrayBuffer,
selected.importAlgorithm,
false,
[usage],
);
} catch (error) {
throw new Error(
`WebCrypto could not import this ${role} key for the selected algorithm. Check the key container, curve/hash family, password, and key_ops/usages.`,
{ cause: error },
);
}
assertKeyLimits(key, selected.expected);
return key;
} finally {
input.cleanup?.();
}
}
async function importAes(
source: string,
usage: "encrypt" | "decrypt",
): Promise<CryptoKey> {
const trimmed = source.trim();
let format: "jwk" | "raw";
let data: JsonWebKey | ArrayBuffer;
if (trimmed.startsWith("{")) {
format = "jwk";
data = parseJwk(trimmed);
} else {
const encoded = trimmed.startsWith("base64url:")
? trimmed.slice("base64url:".length)
: trimmed;
const bytes = base64UrlToBytes(encoded, { maxOutputBytes: 32 });
if (bytes.length !== 32 || bytesToBase64Url(bytes) !== encoded)
throw new Error(
"Raw AES key must be canonical unpadded Base64url for exactly 32 bytes.",
);
format = "raw";
data = arrayBuffer(bytes);
}
try {
const key =
format === "jwk"
? await crypto.subtle.importKey(
"jwk",
data as JsonWebKey,
{ name: "AES-GCM", length: 256 },
false,
[usage],
)
: await crypto.subtle.importKey(
"raw",
data as ArrayBuffer,
{ name: "AES-GCM", length: 256 },
false,
[usage],
);
assertKeyLimits(key, "AES");
return key;
} catch (error) {
if (error instanceof Error && error.message.includes("Raw AES key"))
throw error;
throw new Error(
"WebCrypto could not import an exact 256-bit AES-GCM key for this operation.",
{ cause: error },
);
}
}
function algorithmLimit(id: OperationAlgorithm): string {
return OPERATION_ALGORITHMS.find((item) => item.id === id)?.limit ?? "";
}
export function generateAesKeySource(): string {
return bytesToBase64Url(secureRandomBytes(32, crypto, 32));
}
export async function signText(
algorithm: OperationAlgorithm,
privateKey: string,
password: string,
message: string,
): Promise<CryptoOperationResult> {
const selected = definition(algorithm);
if (
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
algorithm === "rsa-oaep-sha256"
)
throw new Error("Selected algorithm does not support signing.");
const data = boundedText(message, "Message");
const key = await importAsymmetric(
privateKey,
password,
algorithm,
"private",
"sign",
);
const signature = await crypto.subtle.sign(
selected.operationAlgorithm,
key,
arrayBuffer(data),
);
return {
operation: "sign",
algorithm,
output: bytesToBase64Url(new Uint8Array(signature)),
facts: {
"Message bytes": String(data.length),
"Signature bytes": String(signature.byteLength),
Encoding: "Unpadded Base64url",
Limit: algorithmLimit(algorithm),
},
};
}
export async function verifyText(
algorithm: OperationAlgorithm,
publicKey: string,
message: string,
signature: string,
): Promise<CryptoOperationResult> {
const selected = definition(algorithm);
if (
!["RSA", "EC", "Ed25519"].includes(selected.expected) ||
algorithm === "rsa-oaep-sha256"
)
throw new Error("Selected algorithm does not support verification.");
const data = boundedText(message, "Message");
const signatureBytes = base64UrlToBytes(signature.trim(), {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAsymmetric(
publicKey,
"",
algorithm,
"public",
"verify",
);
const valid = await crypto.subtle.verify(
selected.operationAlgorithm,
key,
arrayBuffer(signatureBytes),
arrayBuffer(data),
);
return {
operation: "verify",
algorithm,
output: valid ? "Signature is valid." : "Signature is not valid.",
valid,
facts: {
"Message bytes": String(data.length),
"Signature bytes": String(signatureBytes.length),
Limit: algorithmLimit(algorithm),
},
};
}
export async function encryptText(
algorithm: OperationAlgorithm,
keySource: string,
message: string,
): Promise<CryptoOperationResult> {
const data = boundedText(message, "Plaintext");
if (algorithm === "aes-gcm-256") {
const key = await importAes(keySource, "encrypt");
const iv = secureRandomBytes(12, crypto, 12);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
key,
arrayBuffer(data),
),
);
const output = JSON.stringify(
{
schemaVersion: 1,
algorithm: "AES-256-GCM",
iv: bytesToBase64Url(iv),
ciphertext: bytesToBase64Url(ciphertext),
},
null,
2,
);
return {
operation: "encrypt",
algorithm,
output,
facts: {
"Plaintext bytes": String(data.length),
"Ciphertext and tag bytes": String(ciphertext.length),
Limit: algorithmLimit(algorithm),
},
};
}
if (algorithm !== "rsa-oaep-sha256")
throw new Error("Selected algorithm does not support encryption.");
const key = await importAsymmetric(
keySource,
"",
algorithm,
"public",
"encrypt",
);
const modulusLength = (key.algorithm as RsaKeyAlgorithm).modulusLength;
const maximum = modulusLength / 8 - 2 * 32 - 2;
if (data.length > maximum)
throw new Error(
`RSA-OAEP SHA-256 plaintext is limited to ${maximum} bytes for this key.`,
);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt({ name: "RSA-OAEP" }, key, arrayBuffer(data)),
);
return {
operation: "encrypt",
algorithm,
output: bytesToBase64Url(encrypted),
facts: {
"Plaintext bytes": String(data.length),
"Ciphertext bytes": String(encrypted.length),
"Maximum plaintext bytes": String(maximum),
Encoding: "Unpadded Base64url",
Limit: algorithmLimit(algorithm),
},
};
}
interface AesEnvelope {
schemaVersion: 1;
algorithm: "AES-256-GCM";
iv: string;
ciphertext: string;
}
function parseAesEnvelope(source: string): AesEnvelope {
if (source.length > MAX_CIPHERTEXT * 2)
throw new Error("AES-GCM envelope exceeds the input limit.");
let parsed: unknown;
try {
parsed = JSON.parse(source);
} catch {
throw new Error(
"AES-GCM ciphertext must be the JSON envelope produced by this tool.",
);
}
const value = parsed as Partial<AesEnvelope>;
if (
!value ||
value.schemaVersion !== 1 ||
value.algorithm !== "AES-256-GCM" ||
typeof value.iv !== "string" ||
typeof value.ciphertext !== "string"
)
throw new Error("AES-GCM envelope fields are incomplete or unsupported.");
return value as AesEnvelope;
}
export async function decryptText(
algorithm: OperationAlgorithm,
keySource: string,
password: string,
ciphertext: string,
): Promise<CryptoOperationResult> {
let decrypted: ArrayBuffer;
let ciphertextBytes: Uint8Array;
if (algorithm === "aes-gcm-256") {
const envelope = parseAesEnvelope(ciphertext);
const iv = base64UrlToBytes(envelope.iv, { maxOutputBytes: 12 });
if (iv.length !== 12 || bytesToBase64Url(iv) !== envelope.iv)
throw new Error(
"AES-GCM envelope IV must be canonical 12-byte Base64url.",
);
ciphertextBytes = base64UrlToBytes(envelope.ciphertext, {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAes(keySource, "decrypt");
try {
decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: arrayBuffer(iv), tagLength: 128 },
key,
arrayBuffer(ciphertextBytes),
);
} catch {
throw new Error(
"AES-GCM authentication failed; no plaintext was released.",
);
}
} else {
if (algorithm !== "rsa-oaep-sha256")
throw new Error("Selected algorithm does not support decryption.");
ciphertextBytes = base64UrlToBytes(ciphertext.trim(), {
maxOutputBytes: MAX_CIPHERTEXT,
});
const key = await importAsymmetric(
keySource,
password,
algorithm,
"private",
"decrypt",
);
if (
ciphertextBytes.length !==
(key.algorithm as RsaKeyAlgorithm).modulusLength / 8
)
throw new Error(
"RSA-OAEP ciphertext length does not match the selected key modulus.",
);
try {
decrypted = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
key,
arrayBuffer(ciphertextBytes),
);
} catch {
throw new Error("RSA-OAEP decryption failed; no plaintext was released.");
}
}
let output: string;
try {
output = new TextDecoder("utf-8", { fatal: true }).decode(decrypted);
} catch {
throw new Error(
"Decrypted bytes are not valid UTF-8 text; binary plaintext is not rendered.",
);
}
return {
operation: "decrypt",
algorithm,
output,
facts: {
"Ciphertext bytes": String(ciphertextBytes.length),
"Plaintext bytes": String(decrypted.byteLength),
Limit: algorithmLimit(algorithm),
},
};
}
+418
View File
@@ -0,0 +1,418 @@
import {
BaseBlock,
Integer,
Null,
ObjectIdentifier,
OctetString,
Sequence,
fromBER,
} from "asn1js";
const PBES2 = "1.2.840.113549.1.5.13";
const PBKDF2 = "1.2.840.113549.1.5.12";
const MAX_ENCRYPTED_BYTES = 8 * 1024 * 1024;
const MAX_ITERATIONS = 10_000_000;
export const MAX_TOTAL_PBKDF2_ITERATIONS = 20_000_000;
const PRFS: Record<string, string> = {
"1.2.840.113549.2.7": "SHA-1",
"1.2.840.113549.2.9": "SHA-256",
"1.2.840.113549.2.10": "SHA-384",
"1.2.840.113549.2.11": "SHA-512",
};
const CIPHERS: Record<
string,
{ name: "AES-CBC" | "AES-GCM"; length: 128 | 192 | 256 }
> = {
"2.16.840.1.101.3.4.1.2": { name: "AES-CBC", length: 128 },
"2.16.840.1.101.3.4.1.22": { name: "AES-CBC", length: 192 },
"2.16.840.1.101.3.4.1.42": { name: "AES-CBC", length: 256 },
"2.16.840.1.101.3.4.1.6": { name: "AES-GCM", length: 128 },
"2.16.840.1.101.3.4.1.26": { name: "AES-GCM", length: 192 },
"2.16.840.1.101.3.4.1.46": { name: "AES-GCM", length: 256 },
};
const PRIVATE_KEY_ALGORITHMS: Record<string, string> = {
"1.2.840.113549.1.1.1": "RSA",
"1.2.840.113549.1.1.10": "RSA-PSS",
"1.2.840.10045.2.1": "EC",
"1.3.101.112": "Ed25519",
"1.3.101.113": "Ed448",
"1.3.101.110": "X25519",
"1.3.101.111": "X448",
};
const CURVES: Record<string, string> = {
"1.2.840.10045.3.1.7": "P-256",
"1.3.132.0.34": "P-384",
"1.3.132.0.35": "P-521",
};
export interface Pbkdf2WorkBudget {
iterations: number;
}
export function reservePbkdf2Work(
budget: Pbkdf2WorkBudget,
iterations: number,
label: string,
): void {
if (
!Number.isSafeInteger(budget.iterations) ||
budget.iterations < 0 ||
!Number.isSafeInteger(iterations) ||
iterations < 1 ||
budget.iterations > MAX_TOTAL_PBKDF2_ITERATIONS - iterations
) {
throw new Error(
`${label} exceeds the ${MAX_TOTAL_PBKDF2_ITERATIONS.toLocaleString()}-iteration aggregate PBKDF2 safety budget. Inspect fewer encrypted objects at once.`,
);
}
budget.iterations += iterations;
}
type Block = BaseBlock;
function asBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function parseDer(bytes: Uint8Array, name: string): Block {
if (bytes.byteLength === 0 || bytes.byteLength > MAX_ENCRYPTED_BYTES)
throw new Error(`${name} must contain 1 byte8 MiB.`);
const parsed = fromBER(asBuffer(bytes));
if (parsed.offset === -1 || parsed.offset !== bytes.byteLength)
throw new Error(`${name} is malformed DER or contains trailing bytes.`);
const canonical = new Uint8Array(parsed.result.toBER(false));
if (
canonical.length !== bytes.length ||
canonical.some((value, index) => value !== bytes[index])
)
throw new Error(`${name} must use canonical definite-length DER.`);
return parsed.result;
}
function sequence(block: Block | undefined, name: string): Block[] {
if (!(block instanceof Sequence))
throw new Error(`${name} must be an ASN.1 SEQUENCE.`);
return block.valueBlock.value;
}
function oid(block: Block | undefined, name: string): string {
if (!(block instanceof ObjectIdentifier))
throw new Error(`${name} must be an ASN.1 object identifier.`);
return block.getValue();
}
function octets(block: Block | undefined, name: string): Uint8Array {
if (!(block instanceof OctetString))
throw new Error(`${name} must be an ASN.1 OCTET STRING.`);
return new Uint8Array(block.getValue());
}
function integer(block: Block | undefined, name: string): bigint {
if (!(block instanceof Integer))
throw new Error(`${name} must be an ASN.1 INTEGER.`);
return block.toBigInt();
}
export interface Pbes2Inspection {
scheme: "PBES2";
kdf: "PBKDF2";
prf: string;
iterations: number;
saltBytes: number;
cipher: "AES-CBC" | "AES-GCM";
keyLength: 128 | 192 | 256;
ivOrNonceBytes: number;
tagLength?: number;
encryptedBytes: number;
}
interface ParsedPbes2 extends Pbes2Inspection {
salt: Uint8Array;
ivOrNonce: Uint8Array;
encryptedData: Uint8Array;
}
function parsePbes2(bytes: Uint8Array): ParsedPbes2 {
const root = sequence(
parseDer(bytes, "Encrypted PKCS #8"),
"EncryptedPrivateKeyInfo",
);
if (root.length !== 2)
throw new Error(
"EncryptedPrivateKeyInfo must contain an algorithm and encrypted data.",
);
return parsePbes2Algorithm(
root[0],
octets(root[1], "Encrypted PKCS #8 data"),
);
}
function parsePbes2Algorithm(
algorithmBlock: Block | undefined,
encryptedData: Uint8Array,
): ParsedPbes2 {
const algorithm = sequence(algorithmBlock, "Encryption algorithm");
if (
algorithm.length !== 2 ||
oid(algorithm[0], "Encryption algorithm") !== PBES2
)
throw new Error("Only PKCS #5 PBES2 encrypted PKCS #8 is supported.");
const params = sequence(algorithm[1], "PBES2 parameters");
if (params.length !== 2) throw new Error("PBES2 parameters are incomplete.");
const kdf = sequence(params[0], "PBES2 key derivation function");
if (kdf.length !== 2 || oid(kdf[0], "KDF algorithm") !== PBKDF2)
throw new Error("Only PBKDF2 key derivation is supported for PBES2.");
const pbkdf = sequence(kdf[1], "PBKDF2 parameters");
if (pbkdf.length < 2 || pbkdf.length > 4)
throw new Error("PBKDF2 parameters have an unsupported shape.");
const salt = octets(pbkdf[0], "PBKDF2 salt");
if (salt.length < 8 || salt.length > 1_024)
throw new Error("PBKDF2 salt must contain 81,024 bytes.");
const iterationBig = integer(pbkdf[1], "PBKDF2 iteration count");
if (iterationBig < 1n || iterationBig > BigInt(MAX_ITERATIONS))
throw new Error(
`PBKDF2 iteration count must be 1${MAX_ITERATIONS.toLocaleString()}.`,
);
let cursor = 2;
let declaredKeyLength: number | undefined;
if (pbkdf[cursor] instanceof Integer) {
const bytesLong = integer(pbkdf[cursor], "PBKDF2 key length");
if (bytesLong < 1n || bytesLong > 64n)
throw new Error("PBKDF2 key length is outside the supported bound.");
declaredKeyLength = Number(bytesLong) * 8;
cursor += 1;
}
let prf = "SHA-1";
if (pbkdf[cursor]) {
const prfAlgorithm = sequence(pbkdf[cursor], "PBKDF2 PRF");
if (
prfAlgorithm.length < 1 ||
prfAlgorithm.length > 2 ||
(prfAlgorithm[1] !== undefined && !(prfAlgorithm[1] instanceof Null))
)
throw new Error("PBKDF2 PRF parameters must be absent or NULL.");
prf = PRFS[oid(prfAlgorithm[0], "PBKDF2 PRF algorithm")] ?? "";
if (!prf)
throw new Error(
"PBKDF2 PRF is not supported by this WebCrypto workflow.",
);
cursor += 1;
}
if (cursor !== pbkdf.length)
throw new Error("PBKDF2 parameters contain unsupported trailing fields.");
const encryption = sequence(params[1], "PBES2 encryption scheme");
if (encryption.length !== 2)
throw new Error("PBES2 encryption scheme parameters are incomplete.");
const cipher = CIPHERS[oid(encryption[0], "PBES2 cipher")];
if (!cipher)
throw new Error("PBES2 cipher is not a supported AES-CBC/AES-GCM scheme.");
if (declaredKeyLength !== undefined && declaredKeyLength !== cipher.length)
throw new Error(
"PBKDF2 declared key length does not match the AES scheme.",
);
let ivOrNonce: Uint8Array;
let tagLength: number | undefined;
if (cipher.name === "AES-CBC") {
ivOrNonce = octets(encryption[1], "AES-CBC IV");
if (ivOrNonce.length !== 16)
throw new Error("AES-CBC IV must contain 16 bytes.");
} else {
const gcm = sequence(encryption[1], "AES-GCM parameters");
if (gcm.length < 1 || gcm.length > 2)
throw new Error("AES-GCM parameters have an unsupported shape.");
ivOrNonce = octets(gcm[0], "AES-GCM nonce");
if (ivOrNonce.length < 12 || ivOrNonce.length > 16)
throw new Error("AES-GCM nonce must contain 1216 bytes.");
const tagBytes = gcm[1] ? integer(gcm[1], "AES-GCM tag length") : 12n;
if (![12n, 13n, 14n, 15n, 16n].includes(tagBytes))
throw new Error("AES-GCM tag length must contain 1216 octets.");
tagLength = Number(tagBytes) * 8;
}
if (encryptedData.length === 0 || encryptedData.length > MAX_ENCRYPTED_BYTES)
throw new Error("PBES2 encrypted payload must contain 1 byte8 MiB.");
return {
scheme: "PBES2",
kdf: "PBKDF2",
prf,
iterations: Number(iterationBig),
saltBytes: salt.length,
cipher: cipher.name,
keyLength: cipher.length,
ivOrNonceBytes: ivOrNonce.length,
...(tagLength ? { tagLength } : {}),
encryptedBytes: encryptedData.length,
salt,
ivOrNonce,
encryptedData,
};
}
export function inspectEncryptedPkcs8(bytes: Uint8Array): Pbes2Inspection {
const parsed = parsePbes2(bytes);
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
export function inspectPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
): Pbes2Inspection {
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return publicPbes2Inspection(parsed);
}
export interface Pkcs8Inspection {
algorithmOid: string;
algorithm: string;
curve?: string;
bytes: number;
}
export function inspectPkcs8(bytes: Uint8Array): Pkcs8Inspection {
const root = sequence(
parseDer(bytes, "PKCS #8 private key"),
"PrivateKeyInfo",
);
if (root.length < 3) throw new Error("PKCS #8 PrivateKeyInfo is incomplete.");
const version = integer(root[0], "PKCS #8 version");
if (version < 0n || version > 1n)
throw new Error("PKCS #8 version is unsupported.");
const algorithmIdentifier = sequence(root[1], "Private-key algorithm");
const algorithmOid = oid(algorithmIdentifier[0], "Private-key algorithm");
const algorithm = PRIVATE_KEY_ALGORITHMS[algorithmOid] ?? "Unknown";
const curveOid =
algorithm === "EC" && algorithmIdentifier[1] instanceof ObjectIdentifier
? algorithmIdentifier[1].getValue()
: undefined;
// Validates that the privateKey field is present and encoded as octets.
octets(root[2], "PKCS #8 privateKey");
return {
algorithmOid,
algorithm,
...(curveOid ? { curve: CURVES[curveOid] ?? `OID ${curveOid}` } : {}),
bytes: bytes.length,
};
}
export async function decryptEncryptedPkcs8(
bytes: Uint8Array,
password: string,
): Promise<{
bytes: Uint8Array;
inspection: Pbes2Inspection;
key: Pkcs8Inspection;
}> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2(bytes);
const output = await decryptParsedPbes2(parsed, password);
try {
return {
bytes: output,
inspection: publicPbes2Inspection(parsed),
key: inspectPkcs8(output),
};
} catch (error) {
output.fill(0);
throw error;
}
}
export async function decryptPbes2Payload(
algorithmIdentifier: Uint8Array,
encryptedData: Uint8Array,
password: string,
): Promise<{ bytes: Uint8Array; inspection: Pbes2Inspection }> {
if (password.length > 100_000)
throw new Error("Password must contain at most 100,000 UTF-16 units.");
const parsed = parsePbes2Algorithm(
parseDer(algorithmIdentifier, "PBES2 AlgorithmIdentifier"),
encryptedData,
);
return {
bytes: await decryptParsedPbes2(parsed, password),
inspection: publicPbes2Inspection(parsed),
};
}
function publicPbes2Inspection(parsed: ParsedPbes2): Pbes2Inspection {
return {
scheme: parsed.scheme,
kdf: parsed.kdf,
prf: parsed.prf,
iterations: parsed.iterations,
saltBytes: parsed.saltBytes,
cipher: parsed.cipher,
keyLength: parsed.keyLength,
ivOrNonceBytes: parsed.ivOrNonceBytes,
...(parsed.tagLength === undefined ? {} : { tagLength: parsed.tagLength }),
encryptedBytes: parsed.encryptedBytes,
};
}
async function decryptParsedPbes2(
parsed: ParsedPbes2,
password: string,
): Promise<Uint8Array> {
const material = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"],
);
const key = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: asBuffer(parsed.salt),
iterations: parsed.iterations,
hash: parsed.prf,
},
material,
{ name: parsed.cipher, length: parsed.keyLength },
false,
["decrypt"],
);
let decrypted: ArrayBuffer;
try {
decrypted = await crypto.subtle.decrypt(
parsed.cipher === "AES-CBC"
? { name: "AES-CBC", iv: asBuffer(parsed.ivOrNonce) }
: {
name: "AES-GCM",
iv: asBuffer(parsed.ivOrNonce),
tagLength: parsed.tagLength ?? 96,
},
key,
asBuffer(parsed.encryptedData),
);
} catch {
throw new Error(
"PBES2 decryption failed. The password may be wrong, the data may be damaged, or this WebCrypto runtime may not support the parameters.",
);
}
return new Uint8Array(decrypted);
}
+770
View File
@@ -0,0 +1,770 @@
import {
BaseBlock,
BmpString,
Integer,
ObjectIdentifier,
OctetString,
Sequence,
Set as AsnSet,
Utf8String,
fromBER,
} from "asn1js";
import {
decryptEncryptedPkcs8,
decryptPbes2Payload,
inspectEncryptedPkcs8,
inspectPbes2Payload,
inspectPkcs8,
reservePbkdf2Work,
type Pbkdf2WorkBudget,
type Pbes2Inspection,
type Pkcs8Inspection,
} from "./pbes2";
const MAX_INPUT_BYTES = 8 * 1024 * 1024;
const MAX_CONTENT_INFOS = 64;
const MAX_BAGS = 512;
const MAX_NESTING_DEPTH = 4;
const MAX_ATTRIBUTES = 32;
const MAX_MAC_ITERATIONS = 50_000;
const MAX_PASSWORD_UNITS = 4_096;
const CMS_DATA = "1.2.840.113549.1.7.1";
const CMS_ENCRYPTED_DATA = "1.2.840.113549.1.7.6";
const PBES2 = "1.2.840.113549.1.5.13";
const LEGACY_PKCS12_PBE_PREFIX = "1.2.840.113549.1.12.1.";
const KEY_BAG = "1.2.840.113549.1.12.10.1.1";
const SHROUDED_KEY_BAG = "1.2.840.113549.1.12.10.1.2";
const CERT_BAG = "1.2.840.113549.1.12.10.1.3";
const CRL_BAG = "1.2.840.113549.1.12.10.1.4";
const SECRET_BAG = "1.2.840.113549.1.12.10.1.5";
const SAFE_CONTENTS_BAG = "1.2.840.113549.1.12.10.1.6";
const X509_CERTIFICATE = "1.2.840.113549.1.9.22.1";
const FRIENDLY_NAME = "1.2.840.113549.1.9.20";
const LOCAL_KEY_ID = "1.2.840.113549.1.9.21";
type Block = BaseBlock;
const DIGESTS: Record<
string,
{ name: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; u: number; v: number }
> = {
"1.3.14.3.2.26": { name: "SHA-1", u: 20, v: 64 },
"2.16.840.1.101.3.4.2.1": { name: "SHA-256", u: 32, v: 64 },
"2.16.840.1.101.3.4.2.2": { name: "SHA-384", u: 48, v: 128 },
"2.16.840.1.101.3.4.2.3": { name: "SHA-512", u: 64, v: 128 },
};
export interface Pkcs12MacInspection {
present: boolean;
status: "absent" | "password-required" | "verified" | "unsupported";
algorithm?: string;
algorithmOid?: string;
iterations?: number;
saltBytes?: number;
}
export interface Pkcs12ContentInventory {
index: number;
type: "data" | "encryptedData";
state: "parsed" | "password-required";
encryption?: Pbes2Inspection;
bagCount: number;
}
export interface Pkcs12BagInventory {
path: string;
bagType:
| "private-key"
| "shrouded-private-key"
| "certificate"
| "safe-contents"
| "crl"
| "secret"
| "unknown";
bagOid: string;
friendlyName?: string;
localKeyId?: string;
encrypted: boolean;
state: "inspected" | "password-required" | "unsupported";
key?: Pkcs8Inspection;
encryption?: Pbes2Inspection;
certificateBytes?: Uint8Array;
}
export interface Pkcs12Inspection {
recognized: true;
version: 3;
bytes: number;
authSafeContentType: typeof CMS_DATA;
mac: Pkcs12MacInspection;
contents: Pkcs12ContentInventory[];
bags: Pkcs12BagInventory[];
warnings: string[];
}
interface ParsedMacData {
digest: Uint8Array;
salt: Uint8Array;
algorithmOid: string;
algorithm?: (typeof DIGESTS)[string];
iterations: number;
}
interface InventoryContext {
password?: string;
bags: Pkcs12BagInventory[];
warnings: string[];
pbkdf2Budget: Pbkdf2WorkBudget;
}
function asBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
}
function derBytes(block: Block): Uint8Array {
return new Uint8Array(block.toBER(false));
}
function parseDer(bytes: Uint8Array, name: string): Block {
if (bytes.length === 0 || bytes.length > MAX_INPUT_BYTES)
throw new Error(`${name} must contain 1 byte8 MiB.`);
const parsed = fromBER(asBuffer(bytes));
if (parsed.offset === -1 || parsed.offset !== bytes.length)
throw new Error(`${name} is malformed DER or contains trailing bytes.`);
const canonical = new Uint8Array(parsed.result.toBER(false));
if (
canonical.length !== bytes.length ||
canonical.some((value, index) => value !== bytes[index])
)
throw new Error(`${name} must use canonical definite-length DER.`);
return parsed.result;
}
function sequence(block: Block | undefined, name: string): Block[] {
if (!(block instanceof Sequence))
throw new Error(`${name} must be an ASN.1 SEQUENCE.`);
return block.valueBlock.value;
}
function set(block: Block | undefined, name: string): Block[] {
if (!(block instanceof AsnSet))
throw new Error(`${name} must be an ASN.1 SET.`);
return block.valueBlock.value;
}
function oid(block: Block | undefined, name: string): string {
if (!(block instanceof ObjectIdentifier))
throw new Error(`${name} must be an ASN.1 object identifier.`);
return block.getValue();
}
function integer(block: Block | undefined, name: string): bigint {
if (!(block instanceof Integer))
throw new Error(`${name} must be an ASN.1 INTEGER.`);
return block.toBigInt();
}
function octets(block: Block | undefined, name: string): Uint8Array {
if (!(block instanceof OctetString))
throw new Error(`${name} must be an ASN.1 OCTET STRING.`);
return new Uint8Array(block.getValue());
}
function explicit(block: Block | undefined, tag: number, name: string): Block {
if (
!block ||
block.idBlock.tagClass !== 3 ||
block.idBlock.tagNumber !== tag ||
!block.idBlock.isConstructed
)
throw new Error(`${name} must be an explicit [${tag}] value.`);
const constructed = block.valueBlock as Block["valueBlock"] & {
value?: Block[];
};
if (!Array.isArray(constructed.value) || constructed.value.length !== 1)
throw new Error(`${name} must contain exactly one value.`);
return constructed.value[0]!;
}
function implicitOctets(
block: Block | undefined,
tag: number,
name: string,
): Uint8Array {
if (
!block ||
block.idBlock.tagClass !== 3 ||
block.idBlock.tagNumber !== tag ||
block.idBlock.isConstructed
)
throw new Error(`${name} must be a primitive implicit [${tag}] value.`);
const primitive = block.valueBlock as Block["valueBlock"] & {
valueHexView?: Uint8Array;
};
return new Uint8Array(primitive.valueHexView ?? new Uint8Array());
}
function boundedPassword(password: string): void {
if (password.length > MAX_PASSWORD_UNITS)
throw new Error(
`PKCS #12 password must contain at most ${MAX_PASSWORD_UNITS.toLocaleString()} UTF-16 units.`,
);
}
function parseContentInfo(block: Block, name: string): [string, Block] {
const fields = sequence(block, name);
if (fields.length !== 2)
throw new Error(`${name} must contain a content type and [0] content.`);
return [oid(fields[0], `${name} content type`), explicit(fields[1], 0, name)];
}
function parseMacData(block: Block | undefined): ParsedMacData {
const fields = sequence(block, "PFX MacData");
if (fields.length < 2 || fields.length > 3)
throw new Error("PFX MacData has an unsupported shape.");
const digestInfo = sequence(fields[0], "PFX MacData DigestInfo");
if (digestInfo.length !== 2)
throw new Error("PFX MacData DigestInfo is incomplete.");
const algorithmIdentifier = sequence(
digestInfo[0],
"PFX MacData digest algorithm",
);
if (algorithmIdentifier.length < 1 || algorithmIdentifier.length > 2)
throw new Error("PFX MacData digest AlgorithmIdentifier is malformed.");
const algorithmOid = oid(
algorithmIdentifier[0],
"PFX MacData digest algorithm",
);
const iterationsBig = fields[2]
? integer(fields[2], "PFX MacData iterations")
: 1n;
if (iterationsBig < 1n || iterationsBig > BigInt(MAX_MAC_ITERATIONS))
throw new Error(
`PFX MacData iterations must be 1${MAX_MAC_ITERATIONS.toLocaleString()}.`,
);
const salt = octets(fields[1], "PFX MacData salt");
if (salt.length < 1 || salt.length > 1_024)
throw new Error("PFX MacData salt must contain 11,024 bytes.");
const digest = octets(digestInfo[1], "PFX MacData digest");
const algorithm = DIGESTS[algorithmOid];
if (algorithm && digest.length !== algorithm.u)
throw new Error(
`PFX MacData ${algorithm.name} digest must contain ${algorithm.u} bytes.`,
);
return {
digest,
salt,
algorithmOid,
...(algorithm ? { algorithm } : {}),
iterations: Number(iterationsBig),
};
}
function pkcs12PasswordBytes(password: string): Uint8Array {
const output = new Uint8Array((password.length + 1) * 2);
for (let index = 0; index < password.length; index += 1) {
const unit = password.charCodeAt(index);
output[index * 2] = unit >>> 8;
output[index * 2 + 1] = unit & 0xff;
}
return output;
}
function repeatToMultiple(source: Uint8Array, multiple: number): Uint8Array {
if (source.length === 0) return source;
const length = multiple * Math.ceil(source.length / multiple);
return Uint8Array.from(
{ length },
(_, index) => source[index % source.length]!,
);
}
async function pkcs12MacKey(
password: string,
salt: Uint8Array,
iterations: number,
digest: NonNullable<ParsedMacData["algorithm"]>,
): Promise<Uint8Array> {
// RFC 7292 Appendix B, diversifier ID 3 (MAC material).
const diversifier = new Uint8Array(digest.v).fill(3);
const passwordBytes = pkcs12PasswordBytes(password);
const saltBlock = repeatToMultiple(salt, digest.v);
const passwordBlock = repeatToMultiple(passwordBytes, digest.v);
const state = new Uint8Array(saltBlock.length + passwordBlock.length);
state.set(saltBlock);
state.set(passwordBlock, saltBlock.length);
const source = new Uint8Array(diversifier.length + state.length);
source.set(diversifier);
source.set(state, diversifier.length);
let derived = new Uint8Array();
const blocks = Math.ceil(digest.u / digest.u);
for (let blockIndex = 0; blockIndex < blocks; blockIndex += 1) {
let a = new Uint8Array(await crypto.subtle.digest(digest.name, source));
for (let round = 1; round < iterations; round += 1)
a = new Uint8Array(await crypto.subtle.digest(digest.name, a));
const next = new Uint8Array(derived.length + a.length);
next.set(derived);
next.set(a, derived.length);
derived = next;
if (state.length > 0) {
const b = Uint8Array.from(
{ length: digest.v },
(_, index) => a[index % a.length]!,
);
for (let offset = 0; offset < state.length; offset += digest.v) {
let carry = 1;
for (let index = digest.v - 1; index >= 0; index -= 1) {
const position = offset + index;
const sum = state[position]! + b[index]! + carry;
state[position] = sum & 0xff;
carry = sum >>> 8;
}
}
source.set(state, diversifier.length);
}
}
passwordBytes.fill(0);
state.fill(0);
source.fill(0);
return derived.slice(0, digest.u);
}
function equalConstantTime(left: Uint8Array, right: Uint8Array): boolean {
let difference = left.length ^ right.length;
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1)
difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
return difference === 0;
}
async function verifyMacData(
mac: ParsedMacData,
authSafeBytes: Uint8Array,
password: string,
): Promise<void> {
if (!mac.algorithm)
throw new Error(
`PFX MacData digest ${mac.algorithmOid} is not supported by this WebCrypto workflow.`,
);
const keyBytes = await pkcs12MacKey(
password,
mac.salt,
mac.iterations,
mac.algorithm,
);
try {
const key = await crypto.subtle.importKey(
"raw",
asBuffer(keyBytes),
{ name: "HMAC", hash: mac.algorithm.name },
false,
["sign"],
);
const actual = new Uint8Array(
await crypto.subtle.sign("HMAC", key, asBuffer(authSafeBytes)),
);
if (!equalConstantTime(actual, mac.digest))
throw new Error(
"PKCS #12 password is incorrect, or MacData/authSafe bytes are damaged.",
);
} finally {
keyBytes.fill(0);
}
}
function parseAttributes(
block: Block | undefined,
path: string,
): Pick<Pkcs12BagInventory, "friendlyName" | "localKeyId"> {
if (!block) return {};
const attributes = set(block, `${path} attributes`);
if (attributes.length > MAX_ATTRIBUTES)
throw new Error(`${path} has more than ${MAX_ATTRIBUTES} attributes.`);
let friendlyName: string | undefined;
let localKeyId: string | undefined;
for (const attribute of attributes) {
const fields = sequence(attribute, `${path} attribute`);
if (fields.length !== 2)
throw new Error(`${path} contains a malformed bag attribute.`);
const attributeOid = oid(fields[0], `${path} attribute type`);
const values = set(fields[1], `${path} attribute values`);
if (values.length !== 1)
throw new Error(`${path} bag attributes must contain one value.`);
const value = values[0]!;
if (attributeOid === FRIENDLY_NAME) {
if (!(value instanceof BmpString) && !(value instanceof Utf8String))
throw new Error(
`${path} friendlyName must be BMPString or UTF8String.`,
);
const candidate = value.getValue();
if (candidate.length > 256)
throw new Error(`${path} friendlyName exceeds 256 UTF-16 units.`);
friendlyName = candidate;
} else if (attributeOid === LOCAL_KEY_ID) {
const candidate = octets(value, `${path} localKeyId`);
if (candidate.length > 64)
throw new Error(`${path} localKeyId exceeds 64 bytes.`);
localKeyId = Array.from(candidate, (byte) =>
byte.toString(16).padStart(2, "0"),
)
.join("")
.toUpperCase();
}
}
return {
...(friendlyName === undefined ? {} : { friendlyName }),
...(localKeyId === undefined ? {} : { localKeyId }),
};
}
function addBag(context: InventoryContext, bag: Pkcs12BagInventory): void {
if (context.bags.length >= MAX_BAGS)
throw new Error(`PFX contains more than ${MAX_BAGS} SafeBags.`);
context.bags.push(bag);
}
async function inspectSafeContents(
bytes: Uint8Array,
context: InventoryContext,
prefix: string,
depth: number,
): Promise<number> {
if (depth > MAX_NESTING_DEPTH)
throw new Error(
`PFX SafeContents nesting exceeds ${MAX_NESTING_DEPTH} levels.`,
);
const bags = sequence(
parseDer(bytes, `${prefix} SafeContents`),
"SafeContents",
);
const before = context.bags.length;
for (const [index, block] of bags.entries()) {
const path = `${prefix}/bag[${index}]`;
const fields = sequence(block, path);
if (fields.length < 2 || fields.length > 3)
throw new Error(`${path} has an unsupported SafeBag shape.`);
const bagOid = oid(fields[0], `${path} bagId`);
const bagValue = explicit(fields[1], 0, `${path} bagValue`);
const attributes = parseAttributes(fields[2], path);
if (bagOid === KEY_BAG) {
const keyBytes = derBytes(bagValue);
try {
addBag(context, {
path,
bagType: "private-key",
bagOid,
...attributes,
encrypted: false,
state: "inspected",
key: inspectPkcs8(keyBytes),
});
} finally {
keyBytes.fill(0);
}
continue;
}
if (bagOid === SHROUDED_KEY_BAG) {
const encryptedBytes = derBytes(bagValue);
let encryption: Pbes2Inspection;
try {
encryption = inspectEncryptedPkcs8(encryptedBytes);
} catch (reason) {
throw new Error(
`${path} uses a legacy or unsupported shrouded-key PBE: ${reason instanceof Error ? reason.message : "unsupported algorithm"}`,
{ cause: reason },
);
}
if (context.password === undefined) {
addBag(context, {
path,
bagType: "shrouded-private-key",
bagOid,
...attributes,
encrypted: true,
state: "password-required",
encryption,
});
} else {
reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, path);
let decrypted: Awaited<ReturnType<typeof decryptEncryptedPkcs8>>;
try {
decrypted = await decryptEncryptedPkcs8(
encryptedBytes,
context.password,
);
} catch (reason) {
throw new Error(
`${path} could not decrypt its shrouded PKCS #8 key: ${reason instanceof Error ? reason.message : "decryption failed"}`,
{ cause: reason },
);
}
try {
addBag(context, {
path,
bagType: "shrouded-private-key",
bagOid,
...attributes,
encrypted: true,
state: "inspected",
encryption,
key: decrypted.key,
});
} finally {
decrypted.bytes.fill(0);
}
}
continue;
}
if (bagOid === CERT_BAG) {
const certFields = sequence(bagValue, `${path} CertBag`);
if (certFields.length !== 2)
throw new Error(`${path} CertBag has an unsupported shape.`);
const certId = oid(certFields[0], `${path} certificate type`);
if (certId !== X509_CERTIFICATE)
throw new Error(`${path} certificate type ${certId} is unsupported.`);
addBag(context, {
path,
bagType: "certificate",
bagOid,
...attributes,
encrypted: false,
state: "inspected",
certificateBytes: octets(
explicit(certFields[1], 0, `${path} certificate value`),
`${path} X.509 certificate`,
),
});
continue;
}
if (bagOid === SAFE_CONTENTS_BAG) {
addBag(context, {
path,
bagType: "safe-contents",
bagOid,
...attributes,
encrypted: false,
state: "inspected",
});
const nestedBytes = derBytes(bagValue);
try {
await inspectSafeContents(nestedBytes, context, path, depth + 1);
} finally {
nestedBytes.fill(0);
}
continue;
}
const bagType =
bagOid === CRL_BAG ? "crl" : bagOid === SECRET_BAG ? "secret" : "unknown";
addBag(context, {
path,
bagType,
bagOid,
...attributes,
encrypted: false,
state: "unsupported",
});
context.warnings.push(
`${path} (${bagType}, OID ${bagOid}) is inventoried but its value is deliberately not decoded or exported.`,
);
}
return context.bags.length - before;
}
async function inspectAuthenticatedSafe(
bytes: Uint8Array,
context: InventoryContext,
): Promise<Pkcs12ContentInventory[]> {
const blocks = sequence(
parseDer(bytes, "PFX AuthenticatedSafe"),
"AuthenticatedSafe",
);
if (blocks.length > MAX_CONTENT_INFOS)
throw new Error(
`PFX AuthenticatedSafe contains more than ${MAX_CONTENT_INFOS} ContentInfo values.`,
);
const contents: Pkcs12ContentInventory[] = [];
for (const [index, block] of blocks.entries()) {
const [contentType, content] = parseContentInfo(
block,
`AuthenticatedSafe[${index}]`,
);
const prefix = `content[${index}]`;
if (contentType === CMS_DATA) {
const bagCount = await inspectSafeContents(
octets(content, `${prefix} data`),
context,
prefix,
0,
);
contents.push({ index, type: "data", state: "parsed", bagCount });
continue;
}
if (contentType !== CMS_ENCRYPTED_DATA)
throw new Error(
`AuthenticatedSafe[${index}] content type ${contentType} is unsupported; only data and encryptedData are accepted.`,
);
const encryptedData = sequence(content, `${prefix} EncryptedData`);
if (encryptedData.length < 2 || encryptedData.length > 3)
throw new Error(`${prefix} EncryptedData has an unsupported shape.`);
if (integer(encryptedData[0], `${prefix} EncryptedData version`) !== 0n)
throw new Error(`${prefix} EncryptedData version must be 0.`);
const encryptedContentInfo = sequence(
encryptedData[1],
`${prefix} EncryptedContentInfo`,
);
if (encryptedContentInfo.length !== 3)
throw new Error(`${prefix} EncryptedContentInfo is incomplete.`);
if (
oid(encryptedContentInfo[0], `${prefix} encrypted content type`) !==
CMS_DATA
)
throw new Error(`${prefix} encrypted content must contain CMS data.`);
const algorithmIdentifier = derBytes(encryptedContentInfo[1]!);
const algorithmFields = sequence(
encryptedContentInfo[1],
`${prefix} encryption algorithm`,
);
const algorithmOid = oid(
algorithmFields[0],
`${prefix} encryption algorithm`,
);
if (algorithmOid !== PBES2) {
const family = algorithmOid.startsWith(LEGACY_PKCS12_PBE_PREFIX)
? "legacy PKCS #12 PBE"
: "unsupported encryption";
throw new Error(
`${prefix} uses ${family} OID ${algorithmOid}; only PBES2/PBKDF2 with AES-CBC or AES-GCM is supported.`,
);
}
const encryptedBytes = implicitOctets(
encryptedContentInfo[2],
0,
`${prefix} encryptedContent`,
);
const encryption = inspectPbes2Payload(algorithmIdentifier, encryptedBytes);
if (context.password === undefined) {
contents.push({
index,
type: "encryptedData",
state: "password-required",
encryption,
bagCount: 0,
});
continue;
}
let plaintext: Uint8Array | undefined;
try {
reservePbkdf2Work(context.pbkdf2Budget, encryption.iterations, prefix);
plaintext = (
await decryptPbes2Payload(
algorithmIdentifier,
encryptedBytes,
context.password,
)
).bytes;
const bagCount = await inspectSafeContents(plaintext, context, prefix, 0);
contents.push({
index,
type: "encryptedData",
state: "parsed",
encryption,
bagCount,
});
} catch (reason) {
throw new Error(
`${prefix} PBES2 content could not be decrypted and parsed: ${reason instanceof Error ? reason.message : "decryption failed"}`,
{ cause: reason },
);
} finally {
plaintext?.fill(0);
}
}
return contents;
}
export function recognizesPkcs12(bytes: Uint8Array): boolean {
try {
const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX");
return (
root.length >= 2 &&
root.length <= 3 &&
integer(root[0], "PFX version") === 3n &&
sequence(root[1], "PFX authSafe ContentInfo").length >= 1
);
} catch {
return false;
}
}
export async function inspectPkcs12(
bytes: Uint8Array,
options: { password?: string } = {},
): Promise<Pkcs12Inspection> {
if (options.password !== undefined) boundedPassword(options.password);
const root = sequence(parseDer(bytes, "PKCS #12/PFX"), "PFX");
if (root.length < 2 || root.length > 3)
throw new Error("PFX must contain version, authSafe and optional MacData.");
if (integer(root[0], "PFX version") !== 3n)
throw new Error("PFX version must be 3.");
const [authSafeContentType, authSafeContent] = parseContentInfo(
root[1]!,
"PFX authSafe ContentInfo",
);
if (authSafeContentType !== CMS_DATA)
throw new Error(
`PFX authSafe content type ${authSafeContentType} is unsupported; RFC 7292 requires CMS data here.`,
);
const authSafeBytes = octets(authSafeContent, "PFX authSafe data");
let mac: Pkcs12MacInspection;
if (!root[2]) {
mac = { present: false, status: "absent" };
} else {
const parsedMac = parseMacData(root[2]);
if (options.password === undefined) {
mac = {
present: true,
status: parsedMac.algorithm ? "password-required" : "unsupported",
algorithm: parsedMac.algorithm?.name ?? `OID ${parsedMac.algorithmOid}`,
algorithmOid: parsedMac.algorithmOid,
iterations: parsedMac.iterations,
saltBytes: parsedMac.salt.length,
};
} else {
await verifyMacData(parsedMac, authSafeBytes, options.password);
mac = {
present: true,
status: "verified",
algorithm: parsedMac.algorithm!.name,
algorithmOid: parsedMac.algorithmOid,
iterations: parsedMac.iterations,
saltBytes: parsedMac.salt.length,
};
}
}
const context: InventoryContext = {
...(options.password === undefined ? {} : { password: options.password }),
bags: [],
warnings: [],
pbkdf2Budget: { iterations: 0 },
};
if (!root[2])
context.warnings.push(
"MacData is absent, so the AuthenticatedSafe has no verified password/integrity check.",
);
const contents = await inspectAuthenticatedSafe(authSafeBytes, context);
return {
recognized: true,
version: 3,
bytes: bytes.length,
authSafeContentType: CMS_DATA,
mac,
contents,
bags: context.bags,
warnings: context.warnings,
};
}
+54
View File
@@ -179,6 +179,20 @@ textarea {
font-size: 0.76rem;
font-weight: 750;
}
.password-consent {
display: flex;
align-items: start;
gap: 0.55rem;
color: var(--toolbox-muted);
font-size: 0.82rem;
line-height: 1.4;
}
.password-consent input {
width: 1rem;
height: 1rem;
margin-top: 0.08rem;
accent-color: var(--toolbox-accent);
}
.muted {
color: var(--toolbox-muted);
}
@@ -319,6 +333,46 @@ textarea {
padding-left: 1.25rem;
line-height: 1.6;
}
.operation-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
}
.operation-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.operation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 0.8rem;
}
.limit-card p {
min-height: 2.55rem;
margin: 0;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface-soft);
line-height: 1.45;
}
.operation-result {
display: grid;
gap: 0.75rem;
padding: 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.72rem;
background: var(--toolbox-surface-soft);
}
.operation-result pre,
.path-line {
margin: 0;
overflow: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
table {
width: 100%;
border-collapse: collapse;
+1
View File
@@ -1,3 +1,4 @@
import "reflect-metadata";
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
+45 -3
View File
@@ -3,12 +3,20 @@
"schemaVersion": 1,
"id": "de.add-ideas.crypto-tools",
"name": "Crypto Tools",
"version": "0.1.0",
"description": "Inspect keys, certificates and signatures locally in the browser.",
"version": "0.2.0",
"description": "Inspect keys, certificates, PKCS #12 and signatures locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["security", "cryptography", "developer"],
"tags": ["x509", "certificate", "jwk", "signature", "crypto"],
"tags": [
"x509",
"certificate",
"pkcs12",
"pfx",
"jwk",
"signature",
"crypto"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,6 +29,40 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/pem-certificate-chain",
"extensions": [".pem", ".crt", ".cer", ".csr", ".key"]
},
{
"mediaType": "application/pkix-cert",
"extensions": [".der", ".cer"]
},
{
"mediaType": "application/pkcs12",
"extensions": [".p12", ".pfx"]
},
{
"mediaType": "application/json",
"extensions": [".json", ".jwk", ".jwks"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "text/plain",
"extensions": [".pem", ".txt"]
}
]
},
"capabilities": {
"required": [],
"optional": ["web-crypto"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";