91 lines · 4.2 KB
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();
  });