/** * TASK-0130 — unit: shared AES-256-GCM secret envelope. * * Known-vector (independent construction via node:crypto), roundtrip, * empty string, key derivation (hex-raw vs sha256), tamper detection. */ import { createCipheriv, createHash, randomBytes } from "node:crypto"; import { describe, expect, it } from "vitest"; import { aes256gcmDecrypt, aes256gcmEncrypt, hmacSha256hex, keyFromSecret, randomToken, safeEqual, sha256hex } from "./crypto.js"; const HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const OPAQUE_KEY = "test-master-key-0123456789abcdef"; describe("shared aes256gcm envelope", () => { it("roundtrips (opaque key → sha256 derivation)", () => { const plain = "whsec_super-secret-signing-key-123"; expect(aes256gcmDecrypt(aes256gcmEncrypt(plain, OPAQUE_KEY), OPAQUE_KEY)).toBe(plain); }); it("KNOWN VECTOR: a ciphertext built independently decrypts", () => { // Build the envelope OUTSIDE the implementation (fixed IV) and prove // aes256gcmDecrypt reads exactly base64(iv || tag || ct). const key = createHash("sha256").update(OPAQUE_KEY, "utf8").digest(); const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const ct = Buffer.concat([cipher.update("known-vector", "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); const envelope = Buffer.concat([iv, tag, ct]).toString("base64"); expect(aes256gcmDecrypt(envelope, OPAQUE_KEY)).toBe("known-vector"); }); it("a 64-hex-char key is used RAW (not hashed)", () => { expect(keyFromSecret(HEX_KEY).equals(Buffer.from(HEX_KEY, "hex"))).toBe(true); expect(keyFromSecret(OPAQUE_KEY).equals(createHash("sha256").update(OPAQUE_KEY).digest())).toBe(true); // A ciphertext under the raw hex key must NOT decrypt under a different // (non-hex) key, and the hex key must roundtrip. const enc = aes256gcmEncrypt("hex-parity", HEX_KEY); expect(aes256gcmDecrypt(enc, HEX_KEY)).toBe("hex-parity"); expect(() => aes256gcmDecrypt(enc, OPAQUE_KEY)).toThrow(); }); it("handles empty strings", () => { expect(aes256gcmDecrypt(aes256gcmEncrypt("", OPAQUE_KEY), OPAQUE_KEY)).toBe(""); }); it("rejects non-32-byte key buffers", () => { expect(() => aes256gcmEncrypt("x", Buffer.from("short"))).toThrow(/32 bytes/); }); }); describe("tamper detection (GCM auth tag)", () => { const enc = aes256gcmEncrypt("sensitive-webhook-secret", HEX_KEY); const raw = () => Buffer.from(enc, "base64"); it("flipping a ciphertext byte throws", () => { const buf = raw(); buf[buf.length - 1]! ^= 0x01; expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow(); }); it("corrupting the tag throws", () => { const buf = raw(); buf[13]! ^= 0xff; // inside the tag (bytes 12..27) expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow(); }); it("corrupting the IV throws", () => { const buf = raw(); buf[5]! ^= 0x01; expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow(); }); it("short / malformed input throws cleanly", () => { expect(() => aes256gcmDecrypt("aW52YWxpZA==", HEX_KEY)).toThrow(); }); }); describe("hashing + token helpers", () => { it("sha256hex / hmacSha256hex produce 64-hex digests", () => { expect(sha256hex("abc")).toMatch(/^[0-9a-f]{64}$/); expect(hmacSha256hex("k", "m")).toMatch(/^[0-9a-f]{64}$/); }); it("randomToken is URL-safe and length-bounded", () => { const t = randomToken(32); expect(t).toMatch(/^[A-Za-z0-9_-]+$/); expect(t.length).toBeGreaterThanOrEqual(40); }); it("safeEqual is constant-shape (length mismatch false)", () => { expect(safeEqual("abc", "abc")).toBe(true); expect(safeEqual("abc", "abd")).toBe(false); expect(safeEqual("abc", "abcd")).toBe(false); }); });