@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-----");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user