Release Crypto Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 10:40:23 +02:00
parent 4b75f819e2
commit 82bb01b13f
37 changed files with 3673 additions and 140 deletions
+78
View File
@@ -1,4 +1,12 @@
import { describe, expect, it } from "vitest";
import {
AuthorityKeyIdentifierExtension,
BasicConstraintsExtension,
KeyUsageFlags,
KeyUsagesExtension,
SubjectKeyIdentifierExtension,
X509CertificateGenerator,
} from "@peculiar/x509";
import {
checkCertificateHostname,
inspectCryptoInput,
@@ -70,4 +78,74 @@ describe("crypto input inspection", () => {
false,
);
});
it("builds an explicitly untrusted path with signature and CA checks", async () => {
const algorithm = {
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256",
} as const;
const rootKeys = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const root = await X509CertificateGenerator.createSelfSigned(
{
serialNumber: "01",
name: "CN=Local Test Root",
notBefore: new Date("2029-01-01T00:00:00Z"),
notAfter: new Date("2035-01-01T00:00:00Z"),
signingAlgorithm: algorithm,
keys: rootKeys,
extensions: [
new BasicConstraintsExtension(true, 1, true),
new KeyUsagesExtension(
KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign,
true,
),
await SubjectKeyIdentifierExtension.create(rootKeys.publicKey),
],
},
crypto,
);
const leafKeys = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const leaf = await X509CertificateGenerator.create(
{
serialNumber: "02",
subject: "CN=Leaf",
issuer: root.subject,
notBefore: new Date("2029-01-01T00:00:00Z"),
notAfter: new Date("2031-01-01T00:00:00Z"),
signingAlgorithm: algorithm,
publicKey: leafKeys.publicKey,
signingKey: rootKeys.privateKey,
extensions: [
new BasicConstraintsExtension(false, undefined, true),
await AuthorityKeyIdentifierExtension.create(rootKeys.publicKey),
],
},
crypto,
);
const inspection = await inspectCryptoInput(
`${leaf.toString("pem")}\n${root.toString("pem")}`,
new Date("2030-01-01T00:00:00Z"),
);
expect(inspection.paths).toHaveLength(1);
expect(inspection.paths[0]).toMatchObject({
status: "self-signed-anchor-present",
trusted: false,
certificates: ["CN=Leaf", "CN=Local Test Root"],
});
expect(inspection.paths[0]?.links[0]).toMatchObject({
signatureValid: true,
issuerIsCa: true,
keyCertSignAllowed: true,
authorityKeyIdentifierMatched: true,
});
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it, vi } from "vitest";
import { base64UrlToBytes, bytesToBase64Url } from "@add-ideas/toolbox-helpers";
import {
decryptText,
encryptText,
generateAesKeySource,
signText,
verifyText,
} from "../../src/crypto/operations";
import { encryptedPkcs8, encryptedPkcs8Pem } from "../fixtures/pbes2";
describe("concrete WebCrypto operations", () => {
it("round-trips authenticated AES-256-GCM and rejects tampering", async () => {
const key = generateAesKeySource();
expect(key).toMatch(/^[A-Za-z0-9_-]{43}$/u);
const encrypted = await encryptText("aes-gcm-256", key, "private text");
const decrypted = await decryptText(
"aes-gcm-256",
key,
"",
encrypted.output,
);
expect(decrypted.output).toBe("private text");
const envelope = JSON.parse(encrypted.output) as { ciphertext: string };
const tampered = base64UrlToBytes(envelope.ciphertext);
tampered[0] = tampered[0]! ^ 1;
envelope.ciphertext = bytesToBase64Url(tampered);
await expect(
decryptText("aes-gcm-256", key, "", JSON.stringify(envelope)),
).rejects.toThrow(/authentication failed/u);
});
it("signs and verifies ECDSA P-256 messages", async () => {
const pair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const privateJwk = JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.privateKey),
);
const publicJwk = JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.publicKey),
);
const signed = await signText(
"ecdsa-p256-sha256",
privateJwk,
"",
"bounded message",
);
await expect(
verifyText(
"ecdsa-p256-sha256",
publicJwk,
"bounded message",
signed.output,
),
).resolves.toMatchObject({ valid: true });
await expect(
verifyText(
"ecdsa-p256-sha256",
publicJwk,
"changed message",
signed.output,
),
).resolves.toMatchObject({ valid: false });
});
it("wipes both plaintext PKCS #8 buffers after encrypted key import", async () => {
const encrypted = await encryptedPkcs8("operation secret");
const fill = vi.spyOn(Uint8Array.prototype, "fill");
try {
await signText(
"ecdsa-p256-sha256",
encryptedPkcs8Pem(encrypted),
"operation secret",
"bounded message",
);
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(2);
} finally {
fill.mockRestore();
}
});
it("enforces RSA-OAEP message size and round-trips short UTF-8", async () => {
const pair = await crypto.subtle.generateKey(
{
name: "RSA-OAEP",
hash: "SHA-256",
modulusLength: 2048,
publicExponent: Uint8Array.of(1, 0, 1),
},
true,
["encrypt", "decrypt"],
);
const privateJwk = JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.privateKey),
);
const publicJwk = JSON.stringify(
await crypto.subtle.exportKey("jwk", pair.publicKey),
);
const encrypted = await encryptText(
"rsa-oaep-sha256",
publicJwk,
"short secret",
);
await expect(
decryptText("rsa-oaep-sha256", privateJwk, "", encrypted.output),
).resolves.toMatchObject({ output: "short secret" });
await expect(
encryptText("rsa-oaep-sha256", publicJwk, "x".repeat(191)),
).rejects.toThrow(/limited to 190 bytes/u);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from "vitest";
import {
MAX_TOTAL_PBKDF2_ITERATIONS,
decryptEncryptedPkcs8,
inspectEncryptedPkcs8,
reservePbkdf2Work,
} from "../../src/crypto/pbes2";
import { inspectCryptoInput } from "../../src/crypto/inspection";
import {
encryptedPbes2Payload,
encryptedPkcs8,
encryptedPkcs8Pem as pem,
} from "../fixtures/pbes2";
describe("PBES2 encrypted PKCS #8", () => {
it("enforces one aggregate PBKDF2 work budget", () => {
const budget = { iterations: 0 };
reservePbkdf2Work(budget, 10_000_000, "first object");
reservePbkdf2Work(budget, 10_000_000, "second object");
expect(budget.iterations).toBe(MAX_TOTAL_PBKDF2_ITERATIONS);
expect(() => reservePbkdf2Work(budget, 1, "third object")).toThrow(
/aggregate PBKDF2 safety budget/u,
);
});
it("inspects supported parameters and decrypts only with the password", async () => {
const bytes = await encryptedPkcs8("correct horse");
expect(inspectEncryptedPkcs8(bytes)).toMatchObject({
scheme: "PBES2",
kdf: "PBKDF2",
prf: "SHA-256",
iterations: 12_000,
cipher: "AES-CBC",
keyLength: 256,
});
await expect(decryptEncryptedPkcs8(bytes, "wrong battery")).rejects.toThrow(
/decryption failed/u,
);
const decrypted = await decryptEncryptedPkcs8(bytes, "correct horse");
expect(decrypted.key).toMatchObject({ algorithm: "EC", curve: "P-256" });
decrypted.bytes.fill(0);
});
it("integrates password-gated decryption into PEM inspection", async () => {
const bytes = await encryptedPkcs8("local secret");
const withoutPassword = await inspectCryptoInput(pem(bytes));
expect(withoutPassword.items[0]?.facts["Decryption status"]).toBe(
"Not attempted",
);
const withPassword = await inspectCryptoInput(pem(bytes), new Date(), {
password: "local secret",
});
expect(withPassword.items[0]?.facts["Private-key algorithm"]).toContain(
"EC",
);
});
it("wipes decrypted private-key bytes after PEM and DER inspection", async () => {
const bytes = await encryptedPkcs8("ephemeral secret");
const fill = vi.spyOn(Uint8Array.prototype, "fill");
try {
await inspectCryptoInput(pem(bytes), new Date(), {
password: "ephemeral secret",
});
await inspectCryptoInput(bytes, new Date(), {
password: "ephemeral secret",
});
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(2);
} finally {
fill.mockRestore();
}
});
it("wipes decrypted bytes when the plaintext is not valid PKCS #8", async () => {
const bytes = await encryptedPbes2Payload(
"wrong structure",
new TextEncoder().encode("valid AES-CBC padding; invalid PKCS #8"),
);
const fill = vi.spyOn(Uint8Array.prototype, "fill");
try {
await expect(
decryptEncryptedPkcs8(bytes, "wrong structure"),
).rejects.toThrow(/PKCS #8|DER|SEQUENCE/u);
expect(fill.mock.calls.filter(([value]) => value === 0)).toHaveLength(1);
} finally {
fill.mockRestore();
}
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { inspectCryptoInput } from "../../src/crypto/inspection";
import { inspectPkcs12 } from "../../src/crypto/pkcs12";
import {
EMPTY_PASSWORD_PFX_BASE64,
LEGACY_PFX_BASE64,
MODERN_PFX_PASSWORD,
pfxFixture,
} from "../fixtures/pkcs12";
describe("bounded PKCS #12 inspection", () => {
it("inventories supported content without silently using a password", async () => {
const result = await inspectPkcs12(pfxFixture());
expect(result.mac).toMatchObject({
present: true,
status: "password-required",
algorithm: "SHA-256",
iterations: 2_048,
});
expect(result.contents).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "encryptedData",
state: "password-required",
}),
expect.objectContaining({ type: "data", state: "parsed" }),
]),
);
expect(result.bags).toEqual(
expect.arrayContaining([
expect.objectContaining({
bagType: "shrouded-private-key",
friendlyName: "Local test identity",
state: "password-required",
}),
]),
);
});
it("verifies MacData and decrypts PBES2 SafeContents/key bags", async () => {
const result = await inspectPkcs12(pfxFixture(), {
password: MODERN_PFX_PASSWORD,
});
expect(result.mac.status).toBe("verified");
expect(result.contents.every((content) => content.state === "parsed")).toBe(
true,
);
expect(result.bags).toEqual(
expect.arrayContaining([
expect.objectContaining({
bagType: "certificate",
friendlyName: "Local test identity",
}),
expect.objectContaining({
bagType: "shrouded-private-key",
state: "inspected",
key: expect.objectContaining({ algorithm: "RSA" }),
}),
]),
);
});
it("distinguishes explicit empty passwords from password omission", async () => {
const bytes = pfxFixture(EMPTY_PASSWORD_PFX_BASE64);
expect((await inspectPkcs12(bytes)).mac.status).toBe("password-required");
const inspected = await inspectPkcs12(bytes, { password: "" });
expect(inspected.mac.status).toBe("verified");
expect(
inspected.bags.some(
(bag) =>
bag.bagType === "shrouded-private-key" &&
bag.state === "inspected" &&
bag.key?.algorithm === "RSA",
),
).toBe(true);
});
it("fails closed for wrong passwords, damaged nesting and legacy PBE", async () => {
await expect(
inspectPkcs12(pfxFixture(), { password: "wrong-password" }),
).rejects.toThrow(/password is incorrect|MacData\/authSafe/u);
const truncated = pfxFixture().slice(0, -1);
await expect(inspectPkcs12(truncated)).rejects.toThrow(
/malformed DER|trailing bytes/u,
);
await expect(
inspectPkcs12(pfxFixture(LEGACY_PFX_BASE64), {
password: MODERN_PFX_PASSWORD,
}),
).rejects.toThrow(/legacy PKCS #12 PBE/u);
await expect(
inspectPkcs12(pfxFixture(), { password: "x".repeat(4_097) }),
).rejects.toThrow(/at most 4,096 UTF-16 units/u);
});
it("integrates bag and certificate inventory without reporting secrets", async () => {
const result = await inspectCryptoInput(pfxFixture(), new Date(), {
password: MODERN_PFX_PASSWORD,
});
expect(result.items[0]).toMatchObject({
type: "PKCS #12/PFX",
facts: expect.objectContaining({
MacData: expect.stringMatching(/Verified/u),
}),
});
expect(result.items.some((item) => item.certificate)).toBe(true);
expect(
result.items.some((item) =>
item.facts["Private-key algorithm"]?.includes("RSA"),
),
).toBe(true);
const reportSurface = JSON.stringify(
result.items.map(({ id, type, title, facts, findings }) => ({
id,
type,
title,
facts,
findings,
})),
);
expect(reportSurface).not.toContain(MODERN_PFX_PASSWORD);
expect(reportSurface).not.toContain("PRIVATE KEY-----");
});
});