@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,048–16,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,048–16,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,048–16,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,048–16,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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 byte–8 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 8–1,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 12–16 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 12–16 octets.");
|
||||
tagLength = Number(tagBytes) * 8;
|
||||
}
|
||||
if (encryptedData.length === 0 || encryptedData.length > MAX_ENCRYPTED_BYTES)
|
||||
throw new Error("PBES2 encrypted payload must contain 1 byte–8 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);
|
||||
}
|
||||
@@ -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 byte–8 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 1–1,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,
|
||||
};
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
import "reflect-metadata";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user