import { describe, expect, test } from "bun:test"; import { decryptSecret, encryptSecret, hashPassword, isLegacySecret, passwordNeedsRehash, verifyPassword, verifyPasswordAgainstNobody, } from "./crypto"; const MASTER = "correct horse battery staple"; /** Produces a ciphertext the way the pre-AUTH-A10 code did: AES-GCM under SHA-256(master), no AAD, bare base64. */ async function legacyEncrypt(masterKey: string, plaintext: string): Promise<{ ciphertext: string; iv: string }> { const encoder = new TextEncoder(); const digest = await crypto.subtle.digest("SHA-256", encoder.encode(masterKey)); const key = await crypto.subtle.importKey("raw", digest, "AES-GCM", false, ["encrypt"]); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(plaintext)), ); return { ciphertext: btoa(String.fromCharCode(...encrypted)), iv: btoa(String.fromCharCode(...iv)) }; } describe("encryptSecret / decryptSecret (AUTH-A10)", () => { test("round-trips under a per-purpose key with AAD", async () => { const context = { purpose: "totp", aad: "user:u1" }; const sealed = await encryptSecret(MASTER, "JBSWY3DPEHPK3PXP", context); expect(isLegacySecret(sealed.ciphertext)).toBe(false); expect(await decryptSecret(MASTER, sealed.ciphertext, sealed.iv, context)).toBe("JBSWY3DPEHPK3PXP"); }); test("a ciphertext moved to another row (different AAD) does not decrypt", async () => { const sealed = await encryptSecret(MASTER, "seed", { purpose: "totp", aad: "user:u1" }); await expect( decryptSecret(MASTER, sealed.ciphertext, sealed.iv, { purpose: "totp", aad: "user:u2" }), ).rejects.toThrow(); }); test("a ciphertext for one purpose does not decrypt under another purpose's key", async () => { const sealed = await encryptSecret(MASTER, "seed", { purpose: "totp" }); await expect(decryptSecret(MASTER, sealed.ciphertext, sealed.iv, { purpose: "repo-secret" })).rejects.toThrow(); }); test("legacy ciphertexts (no version prefix) still decrypt and are reported as legacy", async () => { const legacy = await legacyEncrypt(MASTER, "old-secret"); expect(isLegacySecret(legacy.ciphertext)).toBe(true); expect(await decryptSecret(MASTER, legacy.ciphertext, legacy.iv, { purpose: "totp", aad: "user:u1" })).toBe( "old-secret", ); }); }); describe("password hashing (AUTH-A4, AUTH-A5)", () => { test("new hashes use the current cost and do not need a rehash", async () => { const stored = await hashPassword("hunter22"); expect(stored.startsWith("pbkdf2$100000$")).toBe(true); expect(passwordNeedsRehash(stored)).toBe(false); expect(await verifyPassword("hunter22", stored)).toBe(true); expect(await verifyPassword("hunter23", stored)).toBe(false); }); test("hashes made at a lower iteration count are flagged for rehash but still verify", async () => { const [, , salt, hash] = (await hashPassword("pw")).split("$"); const downgraded = `pbkdf2$50000$${salt}$${hash}`; expect(passwordNeedsRehash(downgraded)).toBe(true); expect(passwordNeedsRehash("argon2$x$y$z")).toBe(true); }); test("refuses costs the Workers runtime cannot compute instead of throwing", async () => { const [, , salt, hash] = (await hashPassword("pw")).split("$"); expect(await verifyPassword("pw", `pbkdf2$600000$${salt}$${hash}`)).toBe(false); expect(await verifyPassword("pw", `pbkdf2$abc$${salt}$${hash}`)).toBe(false); }); test("the dummy verification always fails", async () => { expect(await verifyPasswordAgainstNobody("anything")).toBe(false); }); }); describe("empty master key (DEPLOY-P5)", () => { test("sealing and opening refuse an empty or blank SECRETS_KEY instead of deriving from it", async () => { await expect(encryptSecret("", "seed")).rejects.toThrow("SECRETS_KEY"); await expect(encryptSecret(" ", "seed")).rejects.toThrow("SECRETS_KEY"); const sealed = await encryptSecret(MASTER, "seed"); await expect(decryptSecret("", sealed.ciphertext, sealed.iv)).rejects.toThrow("SECRETS_KEY"); await expect(decryptSecret("", "bGVnYWN5", sealed.iv)).rejects.toThrow("SECRETS_KEY"); }); });