forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathhmac.test.ts
More file actions
470 lines (439 loc) · 14.6 KB
/
Copy pathhmac.test.ts
File metadata and controls
470 lines (439 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
import crypto from "node:crypto";
import { describe, it, expect, afterEach, vi } from "vitest";
import { canonicalPayload, computeSignature, verifyHmac } from "./hmac.js";
const NOW = 1_700_000_000;
const nowSec = (): number => NOW;
function sign(
secret: string,
method: string,
path: string,
ts: number,
body: string,
): string {
return `sha256=${computeSignature(secret, canonicalPayload(method, path, String(ts), body))}`;
}
describe("canonicalPayload", () => {
it("formats METHOD|path|ts|sha256(body) with uppercase method", () => {
const c = canonicalPayload("post", "/webhooks/deploy", "123", "hello");
expect(c.startsWith("POST|/webhooks/deploy|123|")).toBe(true);
// sha256("hello")
expect(
c.endsWith(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
),
).toBe(true);
});
});
describe("computeSignature", () => {
it("produces deterministic hex of length 64 for sha256", () => {
const sig = computeSignature("secret", "POST|/x|1|abc");
expect(sig).toMatch(/^[0-9a-f]{64}$/);
// Deterministic: same inputs → same output.
expect(computeSignature("secret", "POST|/x|1|abc")).toBe(sig);
});
it("differs across secrets (rotate primary vs secondary)", () => {
const canonical = canonicalPayload("POST", "/x", "1", "body");
const primary = computeSignature("primary-key", canonical);
const rotate = computeSignature("rotate-key", canonical);
expect(primary).not.toBe(rotate);
expect(primary).toMatch(/^[0-9a-f]{64}$/);
expect(rotate).toMatch(/^[0-9a-f]{64}$/);
});
it("differs when any canonical field changes", () => {
const a = computeSignature("k", canonicalPayload("POST", "/x", "1", "b"));
const b = computeSignature("k", canonicalPayload("POST", "/x", "2", "b"));
const c = computeSignature("k", canonicalPayload("POST", "/y", "1", "b"));
const d = computeSignature("k", canonicalPayload("GET", "/x", "1", "b"));
const e = computeSignature("k", canonicalPayload("POST", "/x", "1", "B"));
expect(new Set([a, b, c, d, e]).size).toBe(5);
});
it("handles empty body and empty path (hex, fixed length)", () => {
const sig = computeSignature("k", canonicalPayload("POST", "", "0", ""));
expect(sig).toMatch(/^[0-9a-f]{64}$/);
});
});
describe("verifyHmac", () => {
const secret = "primary-key";
const body = '{"ok":true}';
const path = "/webhooks/deploy";
const method = "POST";
const sig = sign(secret, method, path, NOW, body);
it("accepts a valid signature within skew", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(true);
});
it("accepts signatures with no sha256= prefix", () => {
const raw = sig.replace(/^sha256=/, "");
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: raw,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(true);
});
it("rejects a stale timestamp", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW - 1000),
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("stale");
});
it("rejects a future timestamp beyond skew", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW + 1000),
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("stale");
});
it("rejects a non-integer (float) timestamp with invalid-timestamp", () => {
const r = verifyHmac({
method,
path,
timestamp: "1700000000.5",
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("invalid-timestamp");
});
it("rejects a non-numeric timestamp with invalid-timestamp", () => {
const r = verifyHmac({
method,
path,
timestamp: "not-a-number",
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("invalid-timestamp");
});
it("rejects a wrong signature", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "sha256=deadbeef",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("bad-signature");
});
it("rejects with missing-timestamp when only timestamp is absent", () => {
const r = verifyHmac({
method,
path,
timestamp: "",
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("missing-timestamp");
});
it("rejects with missing-signature when only signature is absent", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("missing-signature");
});
it("rejects with missing-headers when both are absent (legacy code retained)", () => {
const r = verifyHmac({
method,
path,
timestamp: "",
body,
signatureHeader: "",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("missing-headers");
});
it("accepts a bare hex signature without the sha256= prefix (lenient by design)", () => {
const raw = sig.replace(/^sha256=/, "");
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: raw,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(true);
});
it("signals timing-safe compare shape check: mismatched-length hex → bad-signature, not invalid-format", () => {
// Half-length valid hex. The signature-format regex accepts any
// even-length hex string; the timingSafeEqual shape-check inside
// the loop returns false (length mismatch) and we fall through
// with bad-signature rather than surfacing a compare error.
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "sha256=abcdef",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("bad-signature");
});
it("accepts the secondary key during rotation", () => {
const oldSecret = "old-key";
const newSecret = "new-key";
const oldSig = sign(oldSecret, method, path, NOW, body);
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: oldSig,
secrets: [newSecret, oldSecret],
nowSec,
});
expect(r.ok).toBe(true);
});
it("rejects when neither rotation key matches", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: sign("third-key", method, path, NOW, body),
secrets: ["k1", "k2"],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("bad-signature");
});
it("rejects malformed hex in signature with invalid-signature-format", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "sha256=zzzz",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("invalid-signature-format");
});
it("rejects odd-length hex in signature with invalid-signature-format", () => {
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "sha256=abc",
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("invalid-signature-format");
});
it("trims whitespace from the signature header before verifying", () => {
// Simulate a sender that accidentally smuggled whitespace into the
// header (jq `$(...)` trailing newline is a classic offender).
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: ` ${sig}\n`,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(true);
});
it("trims whitespace from the timestamp header", () => {
const r = verifyHmac({
method,
path,
timestamp: ` ${NOW}\n`,
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
});
expect(r.ok).toBe(true);
});
describe("compare-error classification (F3.2)", () => {
// Regression: previously both length-mismatch (expected, noisy) and
// genuinely unexpected crypto-layer errors (rare, page-worthy) were
// logged at debug, making it impossible to alert on real breakage
// without being drowned by happy-path rejection noise. We now split
// them so operators can page on `HMAC_COMPARE_UNEXPECTED_ERROR`.
function captureLogger(): {
logger: {
debug: (msg: string, meta?: unknown) => void;
info: (msg: string, meta?: unknown) => void;
warn: (msg: string, meta?: unknown) => void;
error: (msg: string, meta?: unknown) => void;
};
debugCalls: Array<{ msg: string; meta?: unknown }>;
warnCalls: Array<{ msg: string; meta?: unknown }>;
} {
const debugCalls: Array<{ msg: string; meta?: unknown }> = [];
const warnCalls: Array<{ msg: string; meta?: unknown }> = [];
return {
logger: {
debug: (msg, meta) => {
debugCalls.push({ msg, meta });
},
info: () => {},
warn: (msg, meta) => {
warnCalls.push({ msg, meta });
},
error: () => {},
},
debugCalls,
warnCalls,
};
}
it("logs length-mismatch at debug (not warn) — no pager spam on malformed input", () => {
// Force a length-mismatch path by passing a validly-shaped hex
// signature (even-length, all hex) that's shorter than the
// computed expected length. The inner timingSafeEqual call is
// guarded by `providedHex.length === expected.length`, so it
// returns false without throwing — the catch branch doesn't fire
// at all in that path, which is correct.
//
// To actually exercise the catch branch with a length mismatch,
// we use a provided signature whose even-length shape passes the
// regex but decodes to a different length than expected. The
// length guard short-circuits for sig lengths != expected, so we
// must construct a scenario where Buffer.from triggers throwing
// behavior. In practice the primary path for length-mismatch is
// a direct call to timingSafeEqual with mismatched Buffers — we
// simulate that by forcing the comparison via secrets rotation.
//
// Simplest deterministic test: short even-hex → length guard
// short-circuits, returns bad-signature, no catch fires. Verify
// warnCalls is empty (we never surfaced HMAC_COMPARE_UNEXPECTED_ERROR).
const { logger: cap, warnCalls } = captureLogger();
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: "sha256=abcdef",
secrets: [secret],
nowSec,
logger: cap,
});
expect(r.ok).toBe(false);
expect(r.reason).toBe("bad-signature");
// Malformed/short hex must NOT surface HMAC_COMPARE_UNEXPECTED_ERROR.
expect(
warnCalls.some(
(c) =>
typeof c.meta === "object" &&
c.meta !== null &&
"errorId" in c.meta &&
(c.meta as { errorId?: string }).errorId ===
"HMAC_COMPARE_UNEXPECTED_ERROR",
),
).toBe(false);
});
// Guard: if a crypto spy throws mid-test and we forget to restore,
// downstream tests would see the injected error. `restoreAllMocks`
// on the spy set up by `vi.spyOn` below auto-reverts.
afterEach(() => {
vi.restoreAllMocks();
});
it("logs unexpected crypto errors at warn with stable errorId", () => {
// Simulate a crypto-layer failure. Our regex + even-length check
// filters malformed hex before reaching timingSafeEqual, so the
// only way to reach the catch-with-non-length-mismatch branch
// from public API is via an unexpected runtime error (OOM,
// platform quirk). We force that via `vi.spyOn` with auto-restore
// — safer than manually reassigning a global and relying on
// try/finally to clean up (a thrown assertion inside the try
// would leak the stub to every subsequent test).
const syntheticError = new Error("simulated crypto failure (OOM)");
// Capture the spy so we can assert it actually intercepted the call.
// `hmac.ts` uses `import crypto from "node:crypto"` and invokes
// `crypto.timingSafeEqual(...)` via property access on the default
// namespace — both this test and the module see the same namespace
// object, so `vi.spyOn` rebinds the property the module reads at
// call time. If the module ever switches to a destructured
// `import { timingSafeEqual }`, the spy would silently miss; the
// `toHaveBeenCalled` assertion below is the tripwire for that.
const spy = vi
.spyOn(crypto, "timingSafeEqual")
.mockImplementationOnce(() => {
throw syntheticError;
});
const { logger: cap, warnCalls } = captureLogger();
const r = verifyHmac({
method,
path,
timestamp: String(NOW),
body,
signatureHeader: sig,
secrets: [secret],
nowSec,
logger: cap,
});
expect(r.ok).toBe(false);
// After the synthetic crypto failure, the loop falls through to
// bad-signature (no secret matched).
expect(r.reason).toBe("bad-signature");
// Tripwire: prove the spy actually intercepted. A green test with
// zero spy invocations would mean we never exercised the catch
// branch (e.g. the module switched to a destructured import and
// kept the original binding).
expect(spy).toHaveBeenCalled();
const unexpectedCall = warnCalls.find(
(c) =>
typeof c.meta === "object" &&
c.meta !== null &&
"errorId" in c.meta &&
(c.meta as { errorId?: string }).errorId ===
"HMAC_COMPARE_UNEXPECTED_ERROR",
);
expect(unexpectedCall).toBeDefined();
expect(unexpectedCall!.msg).toBe("hmac.verify.compare-error");
});
});
});