|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
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 | +}); |
5 | 19 |
|
6 | 20 | /** |
7 | 21 | * The scheme's wire form, pinned by literal values rather than by recomputing |
@@ -30,3 +44,144 @@ describe('the outbound HTTP signature scheme', () => { |
30 | 44 | expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh')); |
31 | 45 | }); |
32 | 46 | }); |
| 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 | +}); |
0 commit comments