Skip to content

Commit 115cd41

Browse files
committed
test(core,trigger-api): pin the timestamped signature form, its window, and the body-only control
Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8a6e59 commit 115cd41

2 files changed

Lines changed: 294 additions & 3 deletions

File tree

‎packages/core/src/security/http-signature.test.ts‎

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
4-
import { HTTP_SIGNATURE_HEADER, signHttpBody } from '../index.js';
3+
import { timingSafeEqual } from 'node:crypto';
4+
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
5+
import {
6+
HTTP_SIGNATURE_HEADER,
7+
HTTP_SIGNATURE_TOLERANCE_SECONDS,
8+
signHttpBody,
9+
signHttpBodyAt,
10+
verifyHttpSignature,
11+
} from '../index.js';
12+
13+
// The constant-time pin below counts calls to `timingSafeEqual`; the wrapper
14+
// keeps its behaviour byte for byte.
15+
vi.mock('node:crypto', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('node:crypto')>();
17+
return { ...actual, timingSafeEqual: vi.fn(actual.timingSafeEqual) };
18+
});
519

620
/**
721
* The scheme's wire form, pinned by literal values rather than by recomputing
@@ -30,3 +44,144 @@ describe('the outbound HTTP signature scheme', () => {
3044
expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh'));
3145
});
3246
});
47+
48+
/**
49+
* The timestamped form: the same literal-value discipline as above. Every
50+
* receiver of a `t=…,v1=…` value — ours and anyone else's — relies on these
51+
* exact bytes, and on the timestamp being inside the signed material.
52+
*/
53+
describe('the timestamped form of the HTTP signature scheme', () => {
54+
it('is t=<seconds>,v1= plus the lowercase hex HMAC-SHA256 of "<t>.<body>"', () => {
55+
expect(signHttpBodyAt('{"a":1}', 'shh', 1760000000)).toBe(
56+
't=1760000000,v1=f5f3d98fe723d34c35649871bfa9eb4530a1b2f197e96e6e8c1b95a984e3d46d',
57+
);
58+
expect(signHttpBodyAt('', 'flow-hook-secret', 0)).toBe(
59+
't=0,v1=6433f1bb31a3f1b7ddbc61b4e6361758e607526ac9953cb6a91389500ffefad0',
60+
);
61+
});
62+
63+
it('signs the timestamp: the same body at another second is another signature', () => {
64+
const at = signHttpBodyAt('{"a":1}', 'shh', 1760000000).split(',v1=')[1];
65+
const next = signHttpBodyAt('{"a":1}', 'shh', 1760000001).split(',v1=')[1];
66+
expect(next).not.toBe(at);
67+
// And it is not the body-only HMAC with a timestamp beside it.
68+
expect(at).not.toBe(signHttpBody('{"a":1}', 'shh').slice('sha256='.length));
69+
});
70+
71+
it('refuses a timestamp that is not a non-negative whole number of seconds', () => {
72+
for (const bad of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) {
73+
expect(() => signHttpBodyAt('{}', 'shh', bad)).toThrow(RangeError);
74+
}
75+
});
76+
77+
it('has a fixed five-minute tolerance window', () => {
78+
expect(HTTP_SIGNATURE_TOLERANCE_SECONDS).toBe(300);
79+
});
80+
});
81+
82+
describe('verifyHttpSignature — the receiver\'s half of both forms', () => {
83+
const NOW = 1760000000;
84+
const BODY = '{"event":"paid","id":"evt_1"}';
85+
const SECRET = 'pin-verify-secret-7c1e';
86+
87+
beforeEach(() => {
88+
vi.useFakeTimers({ toFake: ['Date'] });
89+
vi.setSystemTime(NOW * 1000);
90+
vi.mocked(timingSafeEqual).mockClear();
91+
});
92+
afterEach(() => {
93+
vi.useRealTimers();
94+
});
95+
96+
it('accepts a fresh timestamped value, as the v1 form', () => {
97+
expect(verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, SECRET, NOW))).toEqual({ valid: true, scheme: 'v1' });
98+
});
99+
100+
it('accepts a timestamp at either edge of the window, and refuses one second past either edge', () => {
101+
const at = (t: number) => verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, SECRET, t));
102+
expect(at(NOW - HTTP_SIGNATURE_TOLERANCE_SECONDS)).toEqual({ valid: true, scheme: 'v1' });
103+
expect(at(NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS)).toEqual({ valid: true, scheme: 'v1' });
104+
expect(at(NOW - HTTP_SIGNATURE_TOLERANCE_SECONDS - 1)).toEqual({ valid: false, reason: 'outside-window' });
105+
expect(at(NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS + 1)).toEqual({ valid: false, reason: 'outside-window' });
106+
});
107+
108+
it('refuses a value it accepted once the clock has moved past the window — a replay', () => {
109+
const header = signHttpBodyAt(BODY, SECRET, NOW);
110+
expect(verifyHttpSignature(BODY, SECRET, header)).toEqual({ valid: true, scheme: 'v1' });
111+
vi.setSystemTime((NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS + 1) * 1000);
112+
expect(verifyHttpSignature(BODY, SECRET, header)).toEqual({ valid: false, reason: 'outside-window' });
113+
});
114+
115+
it('refuses a timestamp the HMAC does not cover — never a fallback to the body-only form', () => {
116+
const bodyOnlyHex = signHttpBody(BODY, SECRET).slice('sha256='.length);
117+
// A captured body-only signature, re-labelled with a fresh timestamp.
118+
expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},v1=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'mismatch' });
119+
// A genuine value whose timestamp was moved into the window.
120+
const stale = signHttpBodyAt(BODY, SECRET, NOW - 3600);
121+
const moved = stale.replace(`t=${NOW - 3600},`, `t=${NOW},`);
122+
expect(moved).not.toBe(stale);
123+
expect(verifyHttpSignature(BODY, SECRET, moved)).toEqual({ valid: false, reason: 'mismatch' });
124+
});
125+
126+
it('refuses the wrong secret and the wrong body in the timestamped form', () => {
127+
expect(verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, 'another-secret', NOW)))
128+
.toEqual({ valid: false, reason: 'mismatch' });
129+
expect(verifyHttpSignature(`${BODY} `, SECRET, signHttpBodyAt(BODY, SECRET, NOW)))
130+
.toEqual({ valid: false, reason: 'mismatch' });
131+
});
132+
133+
it('refuses every value that claims the timestamped form without its exact shape', () => {
134+
const hex = signHttpBodyAt(BODY, SECRET, NOW).split(',v1=')[1]!;
135+
for (const malformed of [
136+
`t=${NOW}`,
137+
`t=${NOW},v1=`,
138+
`t=${NOW},v1=${hex.slice(1)}`,
139+
`t=${NOW},v1=${hex.toUpperCase()}`,
140+
`v1=${hex},t=${NOW}`,
141+
`t=0${NOW},v1=${hex}`,
142+
`t=+${NOW},v1=${hex}`,
143+
`t=${NOW}.5,v1=${hex}`,
144+
`t=${NOW},v1=${hex},v0=${hex}`,
145+
`t=${NOW},v1=${hex},v1=${hex}`,
146+
`t = ${NOW}, v1 = ${hex}`,
147+
`t=${NOW},sha256=${hex}`,
148+
`T=${NOW},V1=${hex}`,
149+
'garbage',
150+
]) {
151+
expect(verifyHttpSignature(BODY, SECRET, malformed), malformed).toEqual({ valid: false, reason: 'malformed' });
152+
}
153+
});
154+
155+
it('still accepts the body-only form, as body-only, and refuses a wrong one', () => {
156+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' });
157+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, 'another-secret'))).toEqual({ valid: false, reason: 'mismatch' });
158+
expect(verifyHttpSignature(BODY, SECRET, 'sha256=zz')).toEqual({ valid: false, reason: 'mismatch' });
159+
// The body-only form signs no time, so the clock never refuses it.
160+
vi.setSystemTime((NOW + 10 * 365 * 24 * 3600) * 1000);
161+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' });
162+
});
163+
164+
it('trims the header, and reads a missing or blank one as absent', () => {
165+
expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBodyAt(BODY, SECRET, NOW)} `)).toEqual({ valid: true, scheme: 'v1' });
166+
expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBody(BODY, SECRET)}\t`)).toEqual({ valid: true, scheme: 'body-only' });
167+
for (const absent of [undefined, '', ' ']) {
168+
expect(verifyHttpSignature(BODY, SECRET, absent)).toEqual({ valid: false, reason: 'absent' });
169+
}
170+
});
171+
172+
it('decides every well-formed value of either form by a constant-time comparison', () => {
173+
const compare = vi.mocked(timingSafeEqual);
174+
const cases: Array<[string, boolean]> = [
175+
[signHttpBodyAt(BODY, SECRET, NOW), true],
176+
[signHttpBodyAt(BODY, 'another-secret', NOW), false],
177+
[signHttpBody(BODY, SECRET), true],
178+
[signHttpBody(BODY, 'another-secret'), false],
179+
];
180+
for (const [header, valid] of cases) {
181+
compare.mockClear();
182+
expect(verifyHttpSignature(BODY, SECRET, header).valid, header).toBe(valid);
183+
expect(compare, header).toHaveBeenCalledTimes(1);
184+
expect(compare.mock.results[0]!.value, header).toBe(valid);
185+
}
186+
});
187+
});

‎packages/triggers/trigger-api/src/api-trigger.test.ts‎

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { createHmac } from 'node:crypto';
4-
import { describe, it, expect, beforeEach, vi } from 'vitest';
4+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
55
import { ApiTrigger, verifySignature } from './api-trigger.js';
66
import type { QueueServiceSurface } from './api-trigger.js';
77

@@ -221,3 +221,139 @@ describe('ApiTrigger — a secret read at verification time', () => {
221221
expect(reads).toBe(0);
222222
});
223223
});
224+
225+
/**
226+
* The timestamped signature form beside the body-only one. A third-party
227+
* sender computes it on its own, so the helper below derives it with
228+
* `createHmac` directly from the published recipe rather than through
229+
* `@objectstack/core`: these pins check the receiver against the wire form,
230+
* not against the function it verifies with.
231+
*
232+
* x-objectstack-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
233+
*
234+
* Every refusal asserts the whole bad-signature envelope — `401`, code
235+
* `INVALID_SIGNATURE` — and that nothing reached the queue.
236+
*/
237+
describe('ApiTrigger — the timestamped signature form', () => {
238+
const NOW = 1760000000;
239+
const WINDOW = 300;
240+
const SECRET = 'pin-trigger-v1-3f9a';
241+
const BODY = JSON.stringify({ event: 'invoice.paid', id: 'evt_77' });
242+
const REFUSED = { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } };
243+
244+
function sigV1(secret: string, body: string, t: number): string {
245+
return `t=${t},v1=` + createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest('hex');
246+
}
247+
248+
let queue: ReturnType<typeof makeFakeQueue>;
249+
let log: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
250+
let trigger: ApiTrigger;
251+
let runs: any[];
252+
253+
beforeEach(() => {
254+
vi.useFakeTimers({ toFake: ['Date'] });
255+
vi.setSystemTime(NOW * 1000);
256+
queue = makeFakeQueue();
257+
log = { info: vi.fn(), warn: vi.fn() };
258+
trigger = new ApiTrigger(() => queue, log as any);
259+
runs = [];
260+
trigger.start({ flowName: 'invoice_intake', config: { hookId: 'hk_v1', secret: SECRET } }, async (ctx) => { runs.push(ctx); });
261+
});
262+
afterEach(() => {
263+
vi.useRealTimers();
264+
});
265+
266+
const post = (signatureHeader: string | undefined, rawBody = BODY) =>
267+
trigger.handleRequest({ flowName: 'invoice_intake', hookId: 'hk_v1', rawBody, signatureHeader });
268+
269+
/** The deprecation lines this trigger has logged so far. */
270+
const deprecationLines = () =>
271+
log.warn.mock.calls.map((c) => String(c[0])).filter((line) => line.includes('deprecated'));
272+
273+
it('accepts a fresh timestamped post: 202, enqueued, the flow runs — and no deprecation line', async () => {
274+
const res = await post(sigV1(SECRET, BODY, NOW));
275+
expect(res.status).toBe(202);
276+
expect(res.body).toEqual({ accepted: true, messageId: 'msg_1' });
277+
expect(await queue.deliver()).toBe(1);
278+
expect(runs[0].record).toEqual({ event: 'invoice.paid', id: 'evt_77' });
279+
expect(deprecationLines()).toEqual([]);
280+
});
281+
282+
it('refuses a replay outside the window: the post it accepted, sent again later, is a 401 and is not enqueued', async () => {
283+
const header = sigV1(SECRET, BODY, NOW);
284+
expect((await post(header)).status).toBe(202);
285+
expect(queue.published).toHaveLength(1);
286+
287+
vi.setSystemTime((NOW + WINDOW + 1) * 1000);
288+
expect(await post(header)).toEqual(REFUSED);
289+
expect(queue.published).toHaveLength(1);
290+
});
291+
292+
it('refuses a stale timestamp the HMAC genuinely covers, and a future one past the window; accepts the edges', async () => {
293+
expect(await post(sigV1(SECRET, BODY, NOW - WINDOW - 1))).toEqual(REFUSED);
294+
expect(await post(sigV1(SECRET, BODY, NOW + WINDOW + 1))).toEqual(REFUSED);
295+
expect(queue.published).toEqual([]);
296+
expect((await post(sigV1(SECRET, BODY, NOW - WINDOW))).status).toBe(202);
297+
expect((await post(sigV1(SECRET, BODY, NOW + WINDOW))).status).toBe(202);
298+
});
299+
300+
it('refuses a stale timestamp carried with a valid BODY-ONLY signature — a timestamp claims the versioned form, with no fallback', async () => {
301+
const bodyOnlyHex = sig(SECRET, BODY).slice('sha256='.length);
302+
for (const t of [NOW - WINDOW - 1, NOW - 3600, NOW]) {
303+
expect(await post(`t=${t},v1=${bodyOnlyHex}`), `v1 at ${t}`).toEqual(REFUSED);
304+
expect(await post(`t=${t},sha256=${bodyOnlyHex}`), `sha256 at ${t}`).toEqual(REFUSED);
305+
}
306+
expect(queue.published).toEqual([]);
307+
expect(deprecationLines()).toEqual([]);
308+
});
309+
310+
it('refuses a malformed timestamped value with the same envelope a bad signature gets', async () => {
311+
const hex = sigV1(SECRET, BODY, NOW).split(',v1=')[1]!;
312+
for (const malformed of [
313+
`t=${NOW}`,
314+
`t=${NOW},v1=${hex.slice(2)}`,
315+
`t=${NOW},v1=${hex.toUpperCase()}`,
316+
`v1=${hex},t=${NOW}`,
317+
`t=0${NOW},v1=${hex}`,
318+
`t=abc,v1=${hex}`,
319+
`t=${NOW},v1=${hex},v0=${hex}`,
320+
]) {
321+
expect(await post(malformed), malformed).toEqual(REFUSED);
322+
}
323+
// The same envelope as a plain bad signature.
324+
expect(await post(sig('not-the-secret', BODY))).toEqual(REFUSED);
325+
expect(queue.published).toEqual([]);
326+
});
327+
328+
it('refuses the timestamped form under the wrong secret, or over a body other than the one received', async () => {
329+
expect(await post(sigV1('not-the-secret', BODY, NOW))).toEqual(REFUSED);
330+
expect(await post(sigV1(SECRET, BODY, NOW), `${BODY}\n`)).toEqual(REFUSED);
331+
expect(queue.published).toEqual([]);
332+
});
333+
334+
it('CONTROL: still accepts a body-only signature, and logs its deprecation once per armed hook, naming the flow and the hook', async () => {
335+
expect((await post(sig(SECRET, BODY))).status).toBe(202);
336+
expect((await post(sig(SECRET, BODY))).status).toBe(202);
337+
expect(queue.published).toHaveLength(2);
338+
339+
const lines = deprecationLines();
340+
expect(lines).toHaveLength(1);
341+
expect(lines[0]).toContain("flow 'invoice_intake'");
342+
expect(lines[0]).toContain("hook 'hk_v1'");
343+
expect(lines[0]).toContain('t=<unix seconds>,v1=');
344+
345+
// A timestamped post adds nothing to it; re-arming the hook says it once more.
346+
expect((await post(sigV1(SECRET, BODY, NOW))).status).toBe(202);
347+
expect(deprecationLines()).toHaveLength(1);
348+
trigger.stop('invoice_intake');
349+
trigger.start({ flowName: 'invoice_intake', config: { hookId: 'hk_v1', secret: SECRET } }, async () => {});
350+
expect((await post(sig(SECRET, BODY))).status).toBe(202);
351+
expect(deprecationLines()).toHaveLength(2);
352+
});
353+
354+
it('verifySignature, the exported check, accepts both forms and refuses a stale one', () => {
355+
expect(verifySignature(SECRET, BODY, sigV1(SECRET, BODY, NOW))).toBe(true);
356+
expect(verifySignature(SECRET, BODY, sig(SECRET, BODY))).toBe(true);
357+
expect(verifySignature(SECRET, BODY, sigV1(SECRET, BODY, NOW - WINDOW - 1))).toBe(false);
358+
});
359+
});

0 commit comments

Comments
 (0)