1
7import { createCipheriv, createHash, randomBytes } from "node:crypto";
8import { describe, expect, it } from "vitest";
9import {
10 aes256gcmDecrypt,
11 aes256gcmEncrypt,
12 hmacSha256hex,
13 keyFromSecret,
14 randomToken,
15 safeEqual,
16 sha256hex
17} from "./crypto.js";
18
19const HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
20const OPAQUE_KEY = "test-master-key-0123456789abcdef";
21
22describe("shared aes256gcm envelope", () => {
23 it("roundtrips (opaque key → sha256 derivation)", () => {
24 const plain = "whsec_super-secret-signing-key-123";
25 expect(aes256gcmDecrypt(aes256gcmEncrypt(plain, OPAQUE_KEY), OPAQUE_KEY)).toBe(plain);
26 });
27
28 it("KNOWN VECTOR: a ciphertext built independently decrypts", () => {
29
30
31 const key = createHash("sha256").update(OPAQUE_KEY, "utf8").digest();
32 const iv = randomBytes(12);
33 const cipher = createCipheriv("aes-256-gcm", key, iv);
34 const ct = Buffer.concat([cipher.update("known-vector", "utf8"), cipher.final()]);
35 const tag = cipher.getAuthTag();
36 const envelope = Buffer.concat([iv, tag, ct]).toString("base64");
37 expect(aes256gcmDecrypt(envelope, OPAQUE_KEY)).toBe("known-vector");
38 });
39
40 it("a 64-hex-char key is used RAW (not hashed)", () => {
41 expect(keyFromSecret(HEX_KEY).equals(Buffer.from(HEX_KEY, "hex"))).toBe(true);
42 expect(keyFromSecret(OPAQUE_KEY).equals(createHash("sha256").update(OPAQUE_KEY).digest())).toBe(true);
43
44
45 const enc = aes256gcmEncrypt("hex-parity", HEX_KEY);
46 expect(aes256gcmDecrypt(enc, HEX_KEY)).toBe("hex-parity");
47 expect(() => aes256gcmDecrypt(enc, OPAQUE_KEY)).toThrow();
48 });
49
50 it("handles empty strings", () => {
51 expect(aes256gcmDecrypt(aes256gcmEncrypt("", OPAQUE_KEY), OPAQUE_KEY)).toBe("");
52 });
53
54 it("rejects non-32-byte key buffers", () => {
55 expect(() => aes256gcmEncrypt("x", Buffer.from("short"))).toThrow(/32 bytes/);
56 });
57});
58
59describe("tamper detection (GCM auth tag)", () => {
60 const enc = aes256gcmEncrypt("sensitive-webhook-secret", HEX_KEY);
61 const raw = () => Buffer.from(enc, "base64");
62
63 it("flipping a ciphertext byte throws", () => {
64 const buf = raw();
65 buf[buf.length - 1]! ^= 0x01;
66 expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow();
67 });
68
69 it("corrupting the tag throws", () => {
70 const buf = raw();
71 buf[13]! ^= 0xff;
72 expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow();
73 });
74
75 it("corrupting the IV throws", () => {
76 const buf = raw();
77 buf[5]! ^= 0x01;
78 expect(() => aes256gcmDecrypt(buf.toString("base64"), HEX_KEY)).toThrow();
79 });
80
81 it("short / malformed input throws cleanly", () => {
82 expect(() => aes256gcmDecrypt("aW52YWxpZA==", HEX_KEY)).toThrow();
83 });
84});
85
86describe("hashing + token helpers", () => {
87 it("sha256hex / hmacSha256hex produce 64-hex digests", () => {
88 expect(sha256hex("abc")).toMatch(/^[0-9a-f]{64}$/);
89 expect(hmacSha256hex("k", "m")).toMatch(/^[0-9a-f]{64}$/);
90 });
91
92 it("randomToken is URL-safe and length-bounded", () => {
93 const t = randomToken(32);
94 expect(t).toMatch(/^[A-Za-z0-9_-]+$/);
95 expect(t.length).toBeGreaterThanOrEqual(40);
96 });
97
98 it("safeEqual is constant-shape (length mismatch false)", () => {
99 expect(safeEqual("abc", "abc")).toBe(true);
100 expect(safeEqual("abc", "abd")).toBe(false);
101 expect(safeEqual("abc", "abcd")).toBe(false);
102 });
103});
104