Skip to content

Commit 7b0a9c9

Browse files
feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated (#22803)
Fixes #22769 Clause-②: yes The inbound flow hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) now accepts a second, versioned form of `x-objectstack-signature`. Its timestamp is inside the signed material and is checked against a fixed five-minute tolerance window. The body-only form is still accepted and now logs a deprecation signal. Our senders are unchanged. This is the additive scheme from triage's answer (`6105617130`). Refusing body-only signatures (the cutover) is the maintainer's decision and is not in this PR. ## What changes - **One definition, in core.** `packages/core/src/security/http-signature.ts` gains three things beside `signHttpBody`: - `signHttpBodyAt(body, secret, timestampSeconds)` returns `t=SECONDS,v1=HEX`. HEX is the lowercase hex HMAC-SHA256, under the secret, of the string `SECONDS`, a full stop, and then the raw body bytes (the Stripe shape). - `HTTP_SIGNATURE_TOLERANCE_SECONDS = 300`. It is a named constant, ⛔ not a configuration key. - `verifyHttpSignature(body, secret, header)` returns `HttpSignatureVerdict`: `{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason: 'absent' | 'malformed' | 'mismatch' | 'outside-window' }`. It is the receiver's half of both forms. It re-signs through `signHttpBody` / `signHttpBodyAt` and compares with `timingSafeEqual`, so no receiver holds a second copy of the HMAC input. The window is symmetric: a `t` more than 300 s ahead of the clock is refused too. - All three, and the type, are exported from `@objectstack/core` (`src/security/index.ts`). This is an additive public surface. - **trigger-api verifies through it.** `ApiTrigger.handleRequest` and the exported `verifySignature` (same signature, still returns `boolean`) call `verifyHttpSignature`. The local `createHmac` copy of the signed material is gone. Every refusal is the existing `401` envelope `{ success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } }`, the status PR #22779 declares for the inbound-hook member, and nothing is enqueued. - **Deprecation signal on the body-only form:** one `warn` line per armed hook (`ArmedHook.bodyOnlyDeprecationLogged`). It names the flow and the hook, spells out the timestamped form, and states the window. Re-arming the hook (stop/start, which the engine does on a flow update) logs it once more. A sender that keeps posting cannot flood the log. - `packages/triggers/trigger-api/vitest.config.ts` aliases bare `@objectstack/core` to source, with an anchored `find`, the same way `service-queue` does. Without the alias, the new value import would make these pins judge core's `dist/`, and `check:test-source-alias` would refuse the unregistered import. - The route-ledger note (`trigger-api-route-ledger.ts`) names both forms. - ⛔ Not touched: `service-messaging`'s outbox, `service-automation`'s `http` node, `packages/spec`, `packages/lint`, docs, and `trigger-api/src/plugin.ts`. ## Why this shape - **One header, with the form told apart by its prefix** (`sha256=` against `t=`). The self-hosted mount already passes the one header through unchanged, so `plugin.ts` needs no change. PR #22779's transport-neutral member says "Anything a later signing scheme adds (a timestamp, for one) arrives in the same forwarded request, so this signature gains no field for it", and this shape keeps that true. A separate timestamp header would need a mount change and a new `handleRequest` input, and this card's scope excludes both. ADR-0041 names "GitHub/Stripe style", and this is the Stripe grammar. - **A strict grammar, with no fallback.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a stale `t`, it is refused. It never drops back to the body-only check. The grammar is: a canonical decimal `t` of at most 15 digits, one 64-digit lowercase hex `v1`, and exactly those two elements in that order. Verification re-signs the value and compares the whole thing, so any other spelling is refused. - **Nothing speculative:** no multiple `v1` entries for key rotation, no `v0`, no configuration key, no new header. ## Tests On HEAD `ffb30285d`, after merging `origin/main` `c74d84399` and rebuilding (`pnpm --filter '@objectstack/trigger-api...' --filter '@objectstack/objectql...' build`): - `pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2`: Test Files 93 passed, Tests 2362 passed. - `pnpm --filter @objectstack/trigger-api test`: Test Files 2 passed, Tests 38 passed. - `pnpm --filter @objectstack/core --filter @objectstack/trigger-api typecheck`: both `Done`. Core's `check:test-typecheck` held its ledger: 4 files, 4 errors, 4 pinned signatures, no growth. trigger-api compiles against core's rebuilt `.d.ts`, and `verifyHttpSignature` exists only in the new build. The pins (every refusal asserts the whole `401 INVALID_SIGNATURE` envelope and that nothing was enqueued): - `api-trigger.test.ts`, "the timestamped signature form": the sender's value is computed with `createHmac` straight from the recipe, not through core. - A fresh timestamped post is accepted (`202`, enqueued, the flow runs, no deprecation line). - A replay outside the window is refused: the exact post that was accepted is sent again once the clock is 301 s later. - A stale timestamp that the HMAC genuinely covers is refused, and so is a future one past the window. The two edges are accepted. - A stale timestamp carried with a valid body-only signature is refused, in both the `v1=` and the `sha256=` spelling. - Malformed values are refused with the same envelope a bad signature gets. - A wrong secret and a different body are refused. - CONTROL: a body-only post is still accepted, and the deprecation line is logged once per armed hook. - `http-signature.test.ts`: literal-value pins of the wire form, the window edges, the replay, no fallback, the malformed set, and trimming. A constant-time pin wraps `node:crypto`'s `timingSafeEqual` and asserts that it makes the one decision for every well-formed value of either form. **Ablations.** Each mutation went through `node scripts/ablation-replace.mjs` in WRAP mode. Every one landed (anchor 1 to 0, blob changed) and was restored: the blob equals the HEAD blob and `git diff HEAD` is empty. A script `trap` restored on EXIT, INT and TERM. Both suites resolve the subject from source: core's test imports it relatively, and trigger-api through the alias. So no `dist/` leg applies, and the trigger-api reds below show the alias reaching source. Runs on `115cd41f4`, except A3b on `9cb44814e`. Neither source file has changed since. | # | Mutation | Red | |---|---|---| | A1 | window check disabled | core: edges, replay · trigger-api: replay, stale/future, `verifySignature` stale | | A2 | `v1` signs the body alone, not `t.body` | core: literal pins, "signs the timestamp", no-fallback · trigger-api: fresh, replay, stale, no-fallback, CONTROL, `verifySignature` | | A3b | a `t=` value falls back to a `sha256` element | core: no-fallback · trigger-api: "stale timestamp with a valid body-only signature" | | A4 | `timingSafeEqual` replaced by `===` | core: constant-time pin | | A5 | the once-per-hook flag never set | trigger-api: CONTROL (two lines, not one) | | A7 | header lower-cased before parsing | core: malformed set · trigger-api: malformed envelope | The first A3 run left core green, which showed that core's suite did not cover the `sha256`-spelled downgrade. `9cb44814e` added that case, and A3b turned core red as well. **Gates.** The list was derived on this branch at `ffb30285d` with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which gave 65 commands. Each command's exit code was recorded to disk before any pipe. `--ran` reconciled them: "Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN." All 65 exited 0 on `ffb30285d`, including `check:test-source-alias` ("73 packages with tests scanned; 60 registered …") and `check:nul-bytes`. - Read on that run: the `dist/`-reading gates (`check:dts-closure`, `check:sourcemap-no-sources-content`, `check:published-files`) swept core and trigger-api as rebuilt at this head. Other packages' `dist/` came from an earlier build of this branch. - `check:dual-build-cjs-loads` was re-run after the workspace `dist/` had been rebuilt at this head: "107 published require entry point(s) across 66 package(s) load". **Lint (narrowed, not the repo scan).** I ran eslint over this diff's files on `ffb30285d`, with no inline config. - Which files are linted is eslint's own answer, not mine: for each changed path, `ESLint#isPathIgnored` and `calculateConfigForFile` say whether it is linted. 7 of 8 are; the changeset `.md` is not. - The count comes from the results array: "files linted (from results): 7; errors 0; warnings 0". - The narrowing cannot hide a verdict on an untouched file. `eslint.config.mjs` never enables type-aware linting (its own header: "no `parserOptions.project`, no typed `@typescript-eslint` rules"), so no untouched file's verdict depends on these edits. The full `pnpm lint` is CI's. ## Acceptance notes None of these is filed. Each is noted for the seat. - **ADR-0138 D7** (Proposed; `docs/adr/0138-…md`, "Partner webhooks are served today by the signed inbound-hook channel (`verifySignature`), which verifies an HMAC and has no timestamp or replay window") goes stale for the versioned form once this lands. This is a Tier H surface and is not edited here. Carrier: whoever next amends ADR-0138, alongside its F1 follow-up. - **ADR-0012 §"What plugin-webhooks already provides"** lists an `X-Objectstack-Timestamp` header. No sender in this tree sends it, and no code reads it. This is pre-existing and was not changed. - **PR #22779** (`domain:spec`, open): the `handleInboundHook` TSDoc describes only the body-only form. Its refusal status agrees with this PR (`401 INVALID_SIGNATURE`). Whichever lands second may widen that sentence. ⛔ Not edited here. - **Every sender-facing recipe in the tree names only the body-only form:** `skills/objectstack-automation/SKILL.md` §Inbound webhook, `packages/lint` `rule-explanations.ts` (`flow-api-trigger-secret-missing`) and the `validate-flow-trigger-readiness.ts` hint, the `examples/app-showcase` inbound-flow comment, and `docs/qa/platform-checklist/areas/automation.json`. None of them becomes false, because body-only is still accepted. They do steer an author to the deprecated form, so that question goes to the seat in the report. - **The deprecation signal reaches the operator, not the sender.** A sender-facing signal (a response header) would need the mount to forward headers, which is a `plugin.ts` change and is out of scope. - **Signing at enqueue cannot carry a window.** The outbox signs once at enqueue and persists the value, and a redelivery replays it (`redeliver-guard.ts` header; `webhooks.mdx` §6.1). A sender adopting the timestamped form must sign at send time, or every retry older than 300 s fails. This is recorded for the cutover decision. --- _Generated by [Claude Code](https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 85edd61 commit 7b0a9c9

8 files changed

Lines changed: 522 additions & 19 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/trigger-api': minor
4+
---
5+
6+
feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form
7+
8+
Clause-②: yes
9+
10+
- **The new form.** `x-objectstack-signature: t=<unix seconds>,v1=<hex>`, where the hex is the lowercase HMAC-SHA256 of `<t>.<raw body>` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed.
11+
- **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued.
12+
- **Body-only signatures keep working.** `sha256=<hex HMAC-SHA256 of the raw body>` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post.
13+
- **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms.
14+
- **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today.
15+
16+
**To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=<t>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`.

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

Lines changed: 159 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,146 @@ 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+
// in either spelling.
119+
expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},v1=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'mismatch' });
120+
expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},sha256=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'malformed' });
121+
// A genuine value whose timestamp was moved into the window.
122+
const stale = signHttpBodyAt(BODY, SECRET, NOW - 3600);
123+
const moved = stale.replace(`t=${NOW - 3600},`, `t=${NOW},`);
124+
expect(moved).not.toBe(stale);
125+
expect(verifyHttpSignature(BODY, SECRET, moved)).toEqual({ valid: false, reason: 'mismatch' });
126+
});
127+
128+
it('refuses the wrong secret and the wrong body in the timestamped form', () => {
129+
expect(verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, 'another-secret', NOW)))
130+
.toEqual({ valid: false, reason: 'mismatch' });
131+
expect(verifyHttpSignature(`${BODY} `, SECRET, signHttpBodyAt(BODY, SECRET, NOW)))
132+
.toEqual({ valid: false, reason: 'mismatch' });
133+
});
134+
135+
it('refuses every value that claims the timestamped form without its exact shape', () => {
136+
const hex = signHttpBodyAt(BODY, SECRET, NOW).split(',v1=')[1]!;
137+
for (const malformed of [
138+
`t=${NOW}`,
139+
`t=${NOW},v1=`,
140+
`t=${NOW},v1=${hex.slice(1)}`,
141+
`t=${NOW},v1=${hex.toUpperCase()}`,
142+
`v1=${hex},t=${NOW}`,
143+
`t=0${NOW},v1=${hex}`,
144+
`t=+${NOW},v1=${hex}`,
145+
`t=${NOW}.5,v1=${hex}`,
146+
`t=${NOW},v1=${hex},v0=${hex}`,
147+
`t=${NOW},v1=${hex},v1=${hex}`,
148+
`t = ${NOW}, v1 = ${hex}`,
149+
`t=${NOW},sha256=${hex}`,
150+
`T=${NOW},V1=${hex}`,
151+
'garbage',
152+
]) {
153+
expect(verifyHttpSignature(BODY, SECRET, malformed), malformed).toEqual({ valid: false, reason: 'malformed' });
154+
}
155+
});
156+
157+
it('still accepts the body-only form, as body-only, and refuses a wrong one', () => {
158+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' });
159+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, 'another-secret'))).toEqual({ valid: false, reason: 'mismatch' });
160+
expect(verifyHttpSignature(BODY, SECRET, 'sha256=zz')).toEqual({ valid: false, reason: 'mismatch' });
161+
// The body-only form signs no time, so the clock never refuses it.
162+
vi.setSystemTime((NOW + 10 * 365 * 24 * 3600) * 1000);
163+
expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' });
164+
});
165+
166+
it('trims the header, and reads a missing or blank one as absent', () => {
167+
expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBodyAt(BODY, SECRET, NOW)} `)).toEqual({ valid: true, scheme: 'v1' });
168+
expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBody(BODY, SECRET)}\t`)).toEqual({ valid: true, scheme: 'body-only' });
169+
for (const absent of [undefined, '', ' ']) {
170+
expect(verifyHttpSignature(BODY, SECRET, absent)).toEqual({ valid: false, reason: 'absent' });
171+
}
172+
});
173+
174+
it('decides every well-formed value of either form by a constant-time comparison', () => {
175+
const compare = vi.mocked(timingSafeEqual);
176+
const cases: Array<[string, boolean]> = [
177+
[signHttpBodyAt(BODY, SECRET, NOW), true],
178+
[signHttpBodyAt(BODY, 'another-secret', NOW), false],
179+
[signHttpBody(BODY, SECRET), true],
180+
[signHttpBody(BODY, 'another-secret'), false],
181+
];
182+
for (const [header, valid] of cases) {
183+
compare.mockClear();
184+
expect(verifyHttpSignature(BODY, SECRET, header).valid, header).toBe(valid);
185+
expect(compare, header).toHaveBeenCalledTimes(1);
186+
expect(compare.mock.results[0]!.value, header).toBe(valid);
187+
}
188+
});
189+
});

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

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

3-
import { createHmac } from 'node:crypto';
3+
import { createHmac, timingSafeEqual } from 'node:crypto';
44

55
/**
66
* The outbound HTTP signature scheme — the ONE definition every ObjectStack
@@ -22,11 +22,55 @@ import { createHmac } from 'node:crypto';
2222
*
2323
* What the scheme does NOT own is WHICH bytes are the body — each sender signs
2424
* the serialization it actually sends.
25+
*
26+
* ## The timestamped form, and the receiver's side
27+
*
28+
* The body-only value signs no time, so a captured request verifies forever: a
29+
* replay of it is indistinguishable from the original. The header therefore
30+
* has a second, versioned form, {@link signHttpBodyAt}:
31+
* `t=<unix seconds>,v1=<hex>`, where the hex is the HMAC-SHA256 of
32+
* `<t>.<body>` — the timestamp is INSIDE the signed material, so it cannot be
33+
* moved without the secret, and a receiver refuses a `t` outside
34+
* {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} of its own clock. The two forms
35+
* share the one header and are told apart by their prefix (`sha256=` against
36+
* `t=`), so a receiver needs no second header and a sender changes one value.
37+
*
38+
* {@link verifyHttpSignature} is the receiver's half of both forms, and the
39+
* only one: it re-signs with the functions above and compares in constant
40+
* time, so the material a receiver checks is, by construction, the material a
41+
* sender signs. Our own senders still send the body-only form — a receiver
42+
* outside ObjectStack verifies exactly that today, and switching them would
43+
* break it — so the body-only form stays accepted; refusing it is a decision
44+
* of its own, not this definition's.
2545
*/
2646

2747
/** Header carrying the HMAC-SHA256 signature of the request body. */
2848
export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature';
2949

50+
/**
51+
* How far, in seconds, a timestamped signature's `t` may sit from the
52+
* receiver's clock, in either direction, and still verify. Five minutes: wide
53+
* enough for ordinary clock skew and a sender's retry, narrow enough that a
54+
* captured request stops verifying soon after it was sent. Fixed, not
55+
* configured — the window is part of the scheme every sender targets.
56+
*/
57+
export const HTTP_SIGNATURE_TOLERANCE_SECONDS = 300;
58+
59+
/** The body-only form's prefix — what {@link signHttpBody} produces. */
60+
const BODY_ONLY_PREFIX = 'sha256=';
61+
62+
/**
63+
* The timestamped form, exactly as {@link signHttpBodyAt} writes it: a
64+
* canonical decimal `t` (no sign, no leading zero) and one 64-digit lowercase
65+
* hex `v1`. Anything else claiming the form is malformed, never re-read.
66+
*/
67+
const TIMESTAMPED_FORM = /^t=(0|[1-9][0-9]{0,14}),v1=[0-9a-f]{64}$/;
68+
69+
/** The lowercase hex HMAC-SHA256 of `material` — the one HMAC both forms use. */
70+
function hmacSha256Hex(material: string, secret: string): string {
71+
return createHmac('sha256', secret).update(material).digest('hex');
72+
}
73+
3074
/**
3175
* Compute the {@link HTTP_SIGNATURE_HEADER} value for a body: `sha256=<hex>` of
3276
* `HMAC-SHA256(body, secret)`.
@@ -35,5 +79,90 @@ export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature';
3579
* anyway); the `secret` argument is NOT.
3680
*/
3781
export function signHttpBody(body: string, secret: string): string {
38-
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
82+
return `${BODY_ONLY_PREFIX}${hmacSha256Hex(body, secret)}`;
83+
}
84+
85+
/**
86+
* Compute the timestamped {@link HTTP_SIGNATURE_HEADER} value for a body sent
87+
* at `timestampSeconds` (Unix time, whole seconds): `t=<timestampSeconds>,v1=<hex>`
88+
* where the hex is `HMAC-SHA256(<timestampSeconds>.<body>, secret)`.
89+
*
90+
* Sign at send time, not at enqueue: a receiver refuses the value once its
91+
* clock is more than {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} from `t`, so a
92+
* value persisted and replayed later stops verifying — which is the point.
93+
*
94+
* @throws RangeError when `timestampSeconds` is not a non-negative safe integer.
95+
*/
96+
export function signHttpBodyAt(body: string, secret: string, timestampSeconds: number): string {
97+
if (!Number.isSafeInteger(timestampSeconds) || timestampSeconds < 0) {
98+
throw new RangeError(
99+
`signHttpBodyAt: timestampSeconds must be a non-negative whole number of seconds since the Unix epoch, ` +
100+
`got ${String(timestampSeconds)} — pass Math.floor(Date.now() / 1000).`,
101+
);
102+
}
103+
return `t=${timestampSeconds},v1=${hmacSha256Hex(`${timestampSeconds}.${body}`, secret)}`;
104+
}
105+
106+
/**
107+
* What {@link verifyHttpSignature} concluded. A receiver answers every
108+
* `valid: false` alike; `reason` is for its own tests and logs, never for the
109+
* sender. `scheme` says which form verified, so a receiver can signal the
110+
* body-only form's deprecation.
111+
*
112+
* - `absent` — no header, or a blank one.
113+
* - `malformed` — neither form's shape.
114+
* - `mismatch` — the shape is right, and the HMAC is not the secret's for
115+
* this body (and, for the timestamped form, this `t`).
116+
* - `outside-window` — a timestamped value whose HMAC verified, with a `t`
117+
* more than {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} from the receiver's
118+
* clock: a replay, or a sender clock that is wrong.
119+
*/
120+
export type HttpSignatureVerdict =
121+
| { readonly valid: true; readonly scheme: 'v1' | 'body-only' }
122+
| { readonly valid: false; readonly reason: 'absent' | 'malformed' | 'mismatch' | 'outside-window' };
123+
124+
/** Constant-time compare of two equal-length strings (a length difference is not secret). */
125+
function constantTimeEqual(expected: string, received: string): boolean {
126+
const a = Buffer.from(expected, 'utf8');
127+
const b = Buffer.from(received, 'utf8');
128+
if (a.length !== b.length) return false;
129+
return timingSafeEqual(a, b);
130+
}
131+
132+
/**
133+
* Verify an inbound {@link HTTP_SIGNATURE_HEADER} value against the exact bytes
134+
* of the body received and the shared secret, in either form:
135+
*
136+
* - `sha256=<hex>` — the body-only form, verified by re-signing with
137+
* {@link signHttpBody}.
138+
* - `t=<seconds>,v1=<hex>` — the timestamped form, verified by re-signing
139+
* with {@link signHttpBodyAt} at the `t` it carries, and then refused unless
140+
* `t` is within {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} of this clock. A
141+
* value that claims this form is judged by it alone: it never falls back to
142+
* the body-only form.
143+
*
144+
* The header is trimmed first. Both comparisons are constant-time.
145+
*/
146+
export function verifyHttpSignature(body: string, secret: string, header: string | undefined): HttpSignatureVerdict {
147+
const value = header?.trim();
148+
if (!value) return { valid: false, reason: 'absent' };
149+
150+
if (value.startsWith(BODY_ONLY_PREFIX)) {
151+
return constantTimeEqual(signHttpBody(body, secret), value)
152+
? { valid: true, scheme: 'body-only' }
153+
: { valid: false, reason: 'mismatch' };
154+
}
155+
156+
const form = TIMESTAMPED_FORM.exec(value);
157+
if (!form) return { valid: false, reason: 'malformed' };
158+
// At most fifteen digits, so always a safe integer.
159+
const timestampSeconds = Number(form[1]);
160+
if (!constantTimeEqual(signHttpBodyAt(body, secret, timestampSeconds), value)) {
161+
return { valid: false, reason: 'mismatch' };
162+
}
163+
const nowSeconds = Math.floor(Date.now() / 1000);
164+
if (Math.abs(nowSeconds - timestampSeconds) > HTTP_SIGNATURE_TOLERANCE_SECONDS) {
165+
return { valid: false, reason: 'outside-window' };
166+
}
167+
return { valid: true, scheme: 'v1' };
39168
}

‎packages/core/src/security/index.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,17 @@ export {
5151
// The outbound HTTP signature scheme (`X-Objectstack-Signature`) — one
5252
// definition shared by the messaging outbox and the flow `http` node's inline
5353
// arm, which cannot import each other; `@objectstack/service-messaging`
54-
// re-exports both names unchanged.
55-
export { HTTP_SIGNATURE_HEADER, signHttpBody } from './http-signature.js';
54+
// re-exports both names unchanged. Its timestamped form and the receiver's
55+
// verifier for both forms live beside it (`@objectstack/trigger-api`'s
56+
// inbound hook verifies through `verifyHttpSignature`).
57+
export {
58+
HTTP_SIGNATURE_HEADER,
59+
HTTP_SIGNATURE_TOLERANCE_SECONDS,
60+
signHttpBody,
61+
signHttpBodyAt,
62+
verifyHttpSignature,
63+
type HttpSignatureVerdict,
64+
} from './http-signature.js';
5665

5766
// [#12699 / ADR-0131 D7] The ONE fail-closed reader of the mounted `org-scoping`
5867
// service's per-deployment keys — shared by the engine's schema registry

0 commit comments

Comments
 (0)