From b8a6e593bd1c223e678e48274b2f1369b1684263 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:50:25 +0000 Subject: [PATCH 1/3] feat(core,trigger-api): timestamped X-Objectstack-Signature form with a tolerance window, verified through core Adds the versioned form of the one HTTP signature scheme beside the body-only one, in packages/core/src/security/http-signature.ts: t=,v1=.">, refused once t is more than HTTP_SIGNATURE_TOLERANCE_SECONDS (300) from the receiver's clock. verifyHttpSignature is the receiver's half of both forms; it re-signs through signHttpBody / signHttpBodyAt and compares in constant time. trigger-api's inbound hook verifies through it instead of computing its own copy of the HMAC input. Body-only signatures stay accepted, with a warn line once per armed hook naming the flow and the hook. Our senders are unchanged. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude --- packages/core/src/security/http-signature.ts | 133 +++++++++++++++++- packages/core/src/security/index.ts | 13 +- .../triggers/trigger-api/src/api-trigger.ts | 53 +++++-- .../src/trigger-api-route-ledger.ts | 5 +- .../triggers/trigger-api/vitest.config.ts | 22 +++ 5 files changed, 210 insertions(+), 16 deletions(-) diff --git a/packages/core/src/security/http-signature.ts b/packages/core/src/security/http-signature.ts index fc4426fc62c..caf8967d54a 100644 --- a/packages/core/src/security/http-signature.ts +++ b/packages/core/src/security/http-signature.ts @@ -1,6 +1,6 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { createHmac } from 'node:crypto'; +import { createHmac, timingSafeEqual } from 'node:crypto'; /** * The outbound HTTP signature scheme — the ONE definition every ObjectStack @@ -22,11 +22,55 @@ import { createHmac } from 'node:crypto'; * * What the scheme does NOT own is WHICH bytes are the body — each sender signs * the serialization it actually sends. + * + * ## The timestamped form, and the receiver's side + * + * The body-only value signs no time, so a captured request verifies forever: a + * replay of it is indistinguishable from the original. The header therefore + * has a second, versioned form, {@link signHttpBodyAt}: + * `t=,v1=`, where the hex is the HMAC-SHA256 of + * `.` — the timestamp is INSIDE the signed material, so it cannot be + * moved without the secret, and a receiver refuses a `t` outside + * {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} of its own clock. The two forms + * share the one header and are told apart by their prefix (`sha256=` against + * `t=`), so a receiver needs no second header and a sender changes one value. + * + * {@link verifyHttpSignature} is the receiver's half of both forms, and the + * only one: it re-signs with the functions above and compares in constant + * time, so the material a receiver checks is, by construction, the material a + * sender signs. Our own senders still send the body-only form — a receiver + * outside ObjectStack verifies exactly that today, and switching them would + * break it — so the body-only form stays accepted; refusing it is a decision + * of its own, not this definition's. */ /** Header carrying the HMAC-SHA256 signature of the request body. */ export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature'; +/** + * How far, in seconds, a timestamped signature's `t` may sit from the + * receiver's clock, in either direction, and still verify. Five minutes: wide + * enough for ordinary clock skew and a sender's retry, narrow enough that a + * captured request stops verifying soon after it was sent. Fixed, not + * configured — the window is part of the scheme every sender targets. + */ +export const HTTP_SIGNATURE_TOLERANCE_SECONDS = 300; + +/** The body-only form's prefix — what {@link signHttpBody} produces. */ +const BODY_ONLY_PREFIX = 'sha256='; + +/** + * The timestamped form, exactly as {@link signHttpBodyAt} writes it: a + * canonical decimal `t` (no sign, no leading zero) and one 64-digit lowercase + * hex `v1`. Anything else claiming the form is malformed, never re-read. + */ +const TIMESTAMPED_FORM = /^t=(0|[1-9][0-9]{0,14}),v1=[0-9a-f]{64}$/; + +/** The lowercase hex HMAC-SHA256 of `material` — the one HMAC both forms use. */ +function hmacSha256Hex(material: string, secret: string): string { + return createHmac('sha256', secret).update(material).digest('hex'); +} + /** * Compute the {@link HTTP_SIGNATURE_HEADER} value for a body: `sha256=` of * `HMAC-SHA256(body, secret)`. @@ -35,5 +79,90 @@ export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature'; * anyway); the `secret` argument is NOT. */ export function signHttpBody(body: string, secret: string): string { - return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; + return `${BODY_ONLY_PREFIX}${hmacSha256Hex(body, secret)}`; +} + +/** + * Compute the timestamped {@link HTTP_SIGNATURE_HEADER} value for a body sent + * at `timestampSeconds` (Unix time, whole seconds): `t=,v1=` + * where the hex is `HMAC-SHA256(., secret)`. + * + * Sign at send time, not at enqueue: a receiver refuses the value once its + * clock is more than {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} from `t`, so a + * value persisted and replayed later stops verifying — which is the point. + * + * @throws RangeError when `timestampSeconds` is not a non-negative safe integer. + */ +export function signHttpBodyAt(body: string, secret: string, timestampSeconds: number): string { + if (!Number.isSafeInteger(timestampSeconds) || timestampSeconds < 0) { + throw new RangeError( + `signHttpBodyAt: timestampSeconds must be a non-negative whole number of seconds since the Unix epoch, ` + + `got ${String(timestampSeconds)} — pass Math.floor(Date.now() / 1000).`, + ); + } + return `t=${timestampSeconds},v1=${hmacSha256Hex(`${timestampSeconds}.${body}`, secret)}`; +} + +/** + * What {@link verifyHttpSignature} concluded. A receiver answers every + * `valid: false` alike; `reason` is for its own tests and logs, never for the + * sender. `scheme` says which form verified, so a receiver can signal the + * body-only form's deprecation. + * + * - `absent` — no header, or a blank one. + * - `malformed` — neither form's shape. + * - `mismatch` — the shape is right, and the HMAC is not the secret's for + * this body (and, for the timestamped form, this `t`). + * - `outside-window` — a timestamped value whose HMAC verified, with a `t` + * more than {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} from the receiver's + * clock: a replay, or a sender clock that is wrong. + */ +export type HttpSignatureVerdict = + | { readonly valid: true; readonly scheme: 'v1' | 'body-only' } + | { readonly valid: false; readonly reason: 'absent' | 'malformed' | 'mismatch' | 'outside-window' }; + +/** Constant-time compare of two equal-length strings (a length difference is not secret). */ +function constantTimeEqual(expected: string, received: string): boolean { + const a = Buffer.from(expected, 'utf8'); + const b = Buffer.from(received, 'utf8'); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +/** + * Verify an inbound {@link HTTP_SIGNATURE_HEADER} value against the exact bytes + * of the body received and the shared secret, in either form: + * + * - `sha256=` — the body-only form, verified by re-signing with + * {@link signHttpBody}. + * - `t=,v1=` — the timestamped form, verified by re-signing + * with {@link signHttpBodyAt} at the `t` it carries, and then refused unless + * `t` is within {@link HTTP_SIGNATURE_TOLERANCE_SECONDS} of this clock. A + * value that claims this form is judged by it alone: it never falls back to + * the body-only form. + * + * The header is trimmed first. Both comparisons are constant-time. + */ +export function verifyHttpSignature(body: string, secret: string, header: string | undefined): HttpSignatureVerdict { + const value = header?.trim(); + if (!value) return { valid: false, reason: 'absent' }; + + if (value.startsWith(BODY_ONLY_PREFIX)) { + return constantTimeEqual(signHttpBody(body, secret), value) + ? { valid: true, scheme: 'body-only' } + : { valid: false, reason: 'mismatch' }; + } + + const form = TIMESTAMPED_FORM.exec(value); + if (!form) return { valid: false, reason: 'malformed' }; + // At most fifteen digits, so always a safe integer. + const timestampSeconds = Number(form[1]); + if (!constantTimeEqual(signHttpBodyAt(body, secret, timestampSeconds), value)) { + return { valid: false, reason: 'mismatch' }; + } + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - timestampSeconds) > HTTP_SIGNATURE_TOLERANCE_SECONDS) { + return { valid: false, reason: 'outside-window' }; + } + return { valid: true, scheme: 'v1' }; } diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index d32a892f2f2..f02b613f262 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -51,8 +51,17 @@ export { // The outbound HTTP signature scheme (`X-Objectstack-Signature`) — one // definition shared by the messaging outbox and the flow `http` node's inline // arm, which cannot import each other; `@objectstack/service-messaging` -// re-exports both names unchanged. -export { HTTP_SIGNATURE_HEADER, signHttpBody } from './http-signature.js'; +// re-exports both names unchanged. Its timestamped form and the receiver's +// verifier for both forms live beside it (`@objectstack/trigger-api`'s +// inbound hook verifies through `verifyHttpSignature`). +export { + HTTP_SIGNATURE_HEADER, + HTTP_SIGNATURE_TOLERANCE_SECONDS, + signHttpBody, + signHttpBodyAt, + verifyHttpSignature, + type HttpSignatureVerdict, +} from './http-signature.js'; // [#12699 / ADR-0131 D7] The ONE fail-closed reader of the mounted `org-scoping` // service's per-deployment keys — shared by the engine's schema registry diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index 0b845e08bc4..85e344ecb3c 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { timingSafeEqual } from 'node:crypto'; +import { HTTP_SIGNATURE_TOLERANCE_SECONDS, verifyHttpSignature } from '@objectstack/core'; import type { AutomationContext } from '@objectstack/spec/contracts'; /** @@ -76,6 +77,12 @@ interface ArmedHook { secret: HookSecret; queue: string; callback: (ctx: AutomationContext) => Promise; + /** + * Whether this arming has already logged that it accepted a body-only + * signature. The deprecation line is said once per armed hook, never once + * per post, so a sender that keeps posting cannot flood the log with it. + */ + bodyOnlyDeprecationLogged: boolean; } /** The trimmed, non-blank string `value` holds, else `undefined`. */ @@ -92,14 +99,25 @@ function safeEqual(a: string, b: string): boolean { } /** - * GitHub/Stripe-style HMAC verification: the sender computes - * `sha256=` with the shared per-flow secret and - * sends it as `x-objectstack-signature`. + * GitHub/Stripe-style HMAC verification of `x-objectstack-signature` against + * the shared per-flow secret, through `@objectstack/core`'s + * `verifyHttpSignature` — the one definition of the scheme every ObjectStack + * sender signs with, so this receiver holds no copy of the signed material. + * Two forms verify: + * + * - `t=,v1=`, the HMAC of `.`: the timestamp + * is signed, and a `t` more than `HTTP_SIGNATURE_TOLERANCE_SECONDS` (five + * minutes) from this clock is refused, so a captured post stops verifying. + * - `sha256=`, the HMAC of the raw body alone. Still accepted, so no + * existing sender breaks, and deprecated: it signs no time, so a captured + * post verifies forever. {@link ApiTrigger.handleRequest} logs it once per + * armed hook. + * + * A value that claims the timestamped form is judged by it alone, and every + * refusal is the same `false`. */ export function verifySignature(secret: string, rawBody: string, header: string | undefined): boolean { - if (!header) return false; - const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); - return safeEqual(expected, header.trim()); + return verifyHttpSignature(rawBody, secret, header).valid; } /** @@ -113,7 +131,8 @@ export function verifySignature(secret: string, rawBody: string, header: string * * POST /api/v1/automation/hooks/:flowName/:hookId * → 404 unknown flow / wrong hookId - * → 401 missing/bad HMAC signature + * → 401 missing/bad HMAC signature (a timestamped one outside its + * window included — see {@link verifySignature}) * → 400 non-JSON body * → 202 { accepted, messageId } — queued; a consumer executes the flow * @@ -173,7 +192,9 @@ export class ApiTrigger implements FlowTrigger { const queue = `${QUEUE_PREFIX}:${binding.flowName}`; const secret: HookSecret = resolve ? { kind: 'resolved', resolve } : { kind: 'literal', secret: literal! }; - const hook: ArmedHook = { flowName: binding.flowName, hookId, secret, queue, callback }; + const hook: ArmedHook = { + flowName: binding.flowName, hookId, secret, queue, callback, bodyOnlyDeprecationLogged: false, + }; this.hooks.set(binding.flowName, hook); const q = this.getQueue(); @@ -244,9 +265,21 @@ export class ApiTrigger implements FlowTrigger { body: { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The hook secret is unavailable.' } }, }; } - if (!verifySignature(secret, input.rawBody, input.signatureHeader)) { + const verdict = verifyHttpSignature(input.rawBody, secret, input.signatureHeader); + if (!verdict.valid) { return { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } }; } + if (verdict.scheme === 'body-only' && !hook.bodyOnlyDeprecationLogged) { + hook.bodyOnlyDeprecationLogged = true; + this.logger.warn( + `[trigger-api] flow '${hook.flowName}' hook '${hook.hookId}' accepted a post signed with the deprecated ` + + `body-only x-objectstack-signature (sha256=). It signs no time, so a ` + + `captured post can be replayed and still verify. Sign with the timestamped form instead: ` + + `t=,v1=.">, refused once t is more than ` + + `${HTTP_SIGNATURE_TOLERANCE_SECONDS}s from this server's clock (@objectstack/core signHttpBodyAt). ` + + `Logged once per armed hook.`, + ); + } let payload: Record; try { diff --git a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts index 11714220abf..802b6da0387 100644 --- a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts +++ b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts @@ -115,8 +115,9 @@ export const TRIGGER_API_ROUTE_LEDGER: readonly TriggerApiRouteLedgerEntry[] = [ family: 'inbound-hooks', disposition: 'server-only', note: - 'the door a third-party sender posts through, not an SDK call: GitHub/Stripe-style HMAC over the raw body ' - + '(`x-objectstack-signature`, constant-time) against the flow start-node `secret`, then ENQUEUE + 202 — the flow ' + 'the door a third-party sender posts through, not an SDK call: GitHub/Stripe-style HMAC over the raw body, or over ' + + 'a signed timestamp and the raw body checked against a tolerance window (`x-objectstack-signature`, ' + + 'constant-time, through core\'s `verifyHttpSignature`) against the flow start-node `secret`, then ENQUEUE + 202 — the flow ' + 'runs on the queue consumer, never in-band (ADR-0041 §5). Measured, not assumed: `@objectstack/client`\'s whole ' + '`automation` namespace targets the dispatcher domain `/api/v1/automation`, and no client method builds a ' + '`/automation/hooks/*` URL. Not `public` either — that disposition means an anonymous BROWSER surface (public ' diff --git a/packages/triggers/trigger-api/vitest.config.ts b/packages/triggers/trigger-api/vitest.config.ts index 5e0591efc44..5ba660de5ba 100644 --- a/packages/triggers/trigger-api/vitest.config.ts +++ b/packages/triggers/trigger-api/vitest.config.ts @@ -5,6 +5,7 @@ // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ test: { @@ -16,4 +17,25 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, }, + resolve: { + // ARRAY form with an ANCHORED `find`, deliberately. The object form matches + // by PREFIX, so a bare key whose replacement is a FILE also swallows that + // package's subpaths and resolves them to `.../index.ts/` — + // ENOTDIR at run time, in a config that reads as correct. + // `scripts/check-test-source-alias.mjs` is the authority on the rule. + alias: [ + // `api-trigger.ts` verifies every inbound post through + // @objectstack/core's `verifyHttpSignature`, the one definition of the + // signature scheme. Without this entry the specifier resolves through + // core's `exports` to its `dist/`, so the signature pins here would be a + // verdict on another package's BUILD STATE rather than on the source in + // this checkout — and the dangerous direction is the quiet one: an + // unbuilt change to the verifier leaves these pins green against stale + // bytes. + { + find: /^@objectstack\/core$/, + replacement: path.resolve(__dirname, '../../core/src/index.ts'), + }, + ], + }, }); From 115cd41f4d3d2148100a59066061e63ee23664b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:51:51 +0000 Subject: [PATCH 2/3] 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 --- .../core/src/security/http-signature.test.ts | 159 +++++++++++++++++- .../trigger-api/src/api-trigger.test.ts | 138 ++++++++++++++- 2 files changed, 294 insertions(+), 3 deletions(-) diff --git a/packages/core/src/security/http-signature.test.ts b/packages/core/src/security/http-signature.test.ts index 2a3d871f5f4..845bdc2dae7 100644 --- a/packages/core/src/security/http-signature.test.ts +++ b/packages/core/src/security/http-signature.test.ts @@ -1,7 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; -import { HTTP_SIGNATURE_HEADER, signHttpBody } from '../index.js'; +import { timingSafeEqual } from 'node:crypto'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { + HTTP_SIGNATURE_HEADER, + HTTP_SIGNATURE_TOLERANCE_SECONDS, + signHttpBody, + signHttpBodyAt, + verifyHttpSignature, +} from '../index.js'; + +// The constant-time pin below counts calls to `timingSafeEqual`; the wrapper +// keeps its behaviour byte for byte. +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, timingSafeEqual: vi.fn(actual.timingSafeEqual) }; +}); /** * The scheme's wire form, pinned by literal values rather than by recomputing @@ -30,3 +44,144 @@ describe('the outbound HTTP signature scheme', () => { expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh')); }); }); + +/** + * The timestamped form: the same literal-value discipline as above. Every + * receiver of a `t=…,v1=…` value — ours and anyone else's — relies on these + * exact bytes, and on the timestamp being inside the signed material. + */ +describe('the timestamped form of the HTTP signature scheme', () => { + it('is t=,v1= plus the lowercase hex HMAC-SHA256 of "."', () => { + expect(signHttpBodyAt('{"a":1}', 'shh', 1760000000)).toBe( + 't=1760000000,v1=f5f3d98fe723d34c35649871bfa9eb4530a1b2f197e96e6e8c1b95a984e3d46d', + ); + expect(signHttpBodyAt('', 'flow-hook-secret', 0)).toBe( + 't=0,v1=6433f1bb31a3f1b7ddbc61b4e6361758e607526ac9953cb6a91389500ffefad0', + ); + }); + + it('signs the timestamp: the same body at another second is another signature', () => { + const at = signHttpBodyAt('{"a":1}', 'shh', 1760000000).split(',v1=')[1]; + const next = signHttpBodyAt('{"a":1}', 'shh', 1760000001).split(',v1=')[1]; + expect(next).not.toBe(at); + // And it is not the body-only HMAC with a timestamp beside it. + expect(at).not.toBe(signHttpBody('{"a":1}', 'shh').slice('sha256='.length)); + }); + + it('refuses a timestamp that is not a non-negative whole number of seconds', () => { + for (const bad of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => signHttpBodyAt('{}', 'shh', bad)).toThrow(RangeError); + } + }); + + it('has a fixed five-minute tolerance window', () => { + expect(HTTP_SIGNATURE_TOLERANCE_SECONDS).toBe(300); + }); +}); + +describe('verifyHttpSignature — the receiver\'s half of both forms', () => { + const NOW = 1760000000; + const BODY = '{"event":"paid","id":"evt_1"}'; + const SECRET = 'pin-verify-secret-7c1e'; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW * 1000); + vi.mocked(timingSafeEqual).mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('accepts a fresh timestamped value, as the v1 form', () => { + expect(verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, SECRET, NOW))).toEqual({ valid: true, scheme: 'v1' }); + }); + + it('accepts a timestamp at either edge of the window, and refuses one second past either edge', () => { + const at = (t: number) => verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, SECRET, t)); + expect(at(NOW - HTTP_SIGNATURE_TOLERANCE_SECONDS)).toEqual({ valid: true, scheme: 'v1' }); + expect(at(NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS)).toEqual({ valid: true, scheme: 'v1' }); + expect(at(NOW - HTTP_SIGNATURE_TOLERANCE_SECONDS - 1)).toEqual({ valid: false, reason: 'outside-window' }); + expect(at(NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS + 1)).toEqual({ valid: false, reason: 'outside-window' }); + }); + + it('refuses a value it accepted once the clock has moved past the window — a replay', () => { + const header = signHttpBodyAt(BODY, SECRET, NOW); + expect(verifyHttpSignature(BODY, SECRET, header)).toEqual({ valid: true, scheme: 'v1' }); + vi.setSystemTime((NOW + HTTP_SIGNATURE_TOLERANCE_SECONDS + 1) * 1000); + expect(verifyHttpSignature(BODY, SECRET, header)).toEqual({ valid: false, reason: 'outside-window' }); + }); + + it('refuses a timestamp the HMAC does not cover — never a fallback to the body-only form', () => { + const bodyOnlyHex = signHttpBody(BODY, SECRET).slice('sha256='.length); + // A captured body-only signature, re-labelled with a fresh timestamp. + expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},v1=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'mismatch' }); + // A genuine value whose timestamp was moved into the window. + const stale = signHttpBodyAt(BODY, SECRET, NOW - 3600); + const moved = stale.replace(`t=${NOW - 3600},`, `t=${NOW},`); + expect(moved).not.toBe(stale); + expect(verifyHttpSignature(BODY, SECRET, moved)).toEqual({ valid: false, reason: 'mismatch' }); + }); + + it('refuses the wrong secret and the wrong body in the timestamped form', () => { + expect(verifyHttpSignature(BODY, SECRET, signHttpBodyAt(BODY, 'another-secret', NOW))) + .toEqual({ valid: false, reason: 'mismatch' }); + expect(verifyHttpSignature(`${BODY} `, SECRET, signHttpBodyAt(BODY, SECRET, NOW))) + .toEqual({ valid: false, reason: 'mismatch' }); + }); + + it('refuses every value that claims the timestamped form without its exact shape', () => { + const hex = signHttpBodyAt(BODY, SECRET, NOW).split(',v1=')[1]!; + for (const malformed of [ + `t=${NOW}`, + `t=${NOW},v1=`, + `t=${NOW},v1=${hex.slice(1)}`, + `t=${NOW},v1=${hex.toUpperCase()}`, + `v1=${hex},t=${NOW}`, + `t=0${NOW},v1=${hex}`, + `t=+${NOW},v1=${hex}`, + `t=${NOW}.5,v1=${hex}`, + `t=${NOW},v1=${hex},v0=${hex}`, + `t=${NOW},v1=${hex},v1=${hex}`, + `t = ${NOW}, v1 = ${hex}`, + `t=${NOW},sha256=${hex}`, + `T=${NOW},V1=${hex}`, + 'garbage', + ]) { + expect(verifyHttpSignature(BODY, SECRET, malformed), malformed).toEqual({ valid: false, reason: 'malformed' }); + } + }); + + it('still accepts the body-only form, as body-only, and refuses a wrong one', () => { + expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' }); + expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, 'another-secret'))).toEqual({ valid: false, reason: 'mismatch' }); + expect(verifyHttpSignature(BODY, SECRET, 'sha256=zz')).toEqual({ valid: false, reason: 'mismatch' }); + // The body-only form signs no time, so the clock never refuses it. + vi.setSystemTime((NOW + 10 * 365 * 24 * 3600) * 1000); + expect(verifyHttpSignature(BODY, SECRET, signHttpBody(BODY, SECRET))).toEqual({ valid: true, scheme: 'body-only' }); + }); + + it('trims the header, and reads a missing or blank one as absent', () => { + expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBodyAt(BODY, SECRET, NOW)} `)).toEqual({ valid: true, scheme: 'v1' }); + expect(verifyHttpSignature(BODY, SECRET, ` ${signHttpBody(BODY, SECRET)}\t`)).toEqual({ valid: true, scheme: 'body-only' }); + for (const absent of [undefined, '', ' ']) { + expect(verifyHttpSignature(BODY, SECRET, absent)).toEqual({ valid: false, reason: 'absent' }); + } + }); + + it('decides every well-formed value of either form by a constant-time comparison', () => { + const compare = vi.mocked(timingSafeEqual); + const cases: Array<[string, boolean]> = [ + [signHttpBodyAt(BODY, SECRET, NOW), true], + [signHttpBodyAt(BODY, 'another-secret', NOW), false], + [signHttpBody(BODY, SECRET), true], + [signHttpBody(BODY, 'another-secret'), false], + ]; + for (const [header, valid] of cases) { + compare.mockClear(); + expect(verifyHttpSignature(BODY, SECRET, header).valid, header).toBe(valid); + expect(compare, header).toHaveBeenCalledTimes(1); + expect(compare.mock.results[0]!.value, header).toBe(valid); + } + }); +}); diff --git a/packages/triggers/trigger-api/src/api-trigger.test.ts b/packages/triggers/trigger-api/src/api-trigger.test.ts index ea0e3e35fab..ff7cdd049e8 100644 --- a/packages/triggers/trigger-api/src/api-trigger.test.ts +++ b/packages/triggers/trigger-api/src/api-trigger.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { createHmac } from 'node:crypto'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ApiTrigger, verifySignature } from './api-trigger.js'; import type { QueueServiceSurface } from './api-trigger.js'; @@ -221,3 +221,139 @@ describe('ApiTrigger — a secret read at verification time', () => { expect(reads).toBe(0); }); }); + +/** + * The timestamped signature form beside the body-only one. A third-party + * sender computes it on its own, so the helper below derives it with + * `createHmac` directly from the published recipe rather than through + * `@objectstack/core`: these pins check the receiver against the wire form, + * not against the function it verifies with. + * + * x-objectstack-signature: t=,v1=."> + * + * Every refusal asserts the whole bad-signature envelope — `401`, code + * `INVALID_SIGNATURE` — and that nothing reached the queue. + */ +describe('ApiTrigger — the timestamped signature form', () => { + const NOW = 1760000000; + const WINDOW = 300; + const SECRET = 'pin-trigger-v1-3f9a'; + const BODY = JSON.stringify({ event: 'invoice.paid', id: 'evt_77' }); + const REFUSED = { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } }; + + function sigV1(secret: string, body: string, t: number): string { + return `t=${t},v1=` + createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest('hex'); + } + + let queue: ReturnType; + let log: { info: ReturnType; warn: ReturnType }; + let trigger: ApiTrigger; + let runs: any[]; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW * 1000); + queue = makeFakeQueue(); + log = { info: vi.fn(), warn: vi.fn() }; + trigger = new ApiTrigger(() => queue, log as any); + runs = []; + trigger.start({ flowName: 'invoice_intake', config: { hookId: 'hk_v1', secret: SECRET } }, async (ctx) => { runs.push(ctx); }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const post = (signatureHeader: string | undefined, rawBody = BODY) => + trigger.handleRequest({ flowName: 'invoice_intake', hookId: 'hk_v1', rawBody, signatureHeader }); + + /** The deprecation lines this trigger has logged so far. */ + const deprecationLines = () => + log.warn.mock.calls.map((c) => String(c[0])).filter((line) => line.includes('deprecated')); + + it('accepts a fresh timestamped post: 202, enqueued, the flow runs — and no deprecation line', async () => { + const res = await post(sigV1(SECRET, BODY, NOW)); + expect(res.status).toBe(202); + expect(res.body).toEqual({ accepted: true, messageId: 'msg_1' }); + expect(await queue.deliver()).toBe(1); + expect(runs[0].record).toEqual({ event: 'invoice.paid', id: 'evt_77' }); + expect(deprecationLines()).toEqual([]); + }); + + it('refuses a replay outside the window: the post it accepted, sent again later, is a 401 and is not enqueued', async () => { + const header = sigV1(SECRET, BODY, NOW); + expect((await post(header)).status).toBe(202); + expect(queue.published).toHaveLength(1); + + vi.setSystemTime((NOW + WINDOW + 1) * 1000); + expect(await post(header)).toEqual(REFUSED); + expect(queue.published).toHaveLength(1); + }); + + it('refuses a stale timestamp the HMAC genuinely covers, and a future one past the window; accepts the edges', async () => { + expect(await post(sigV1(SECRET, BODY, NOW - WINDOW - 1))).toEqual(REFUSED); + expect(await post(sigV1(SECRET, BODY, NOW + WINDOW + 1))).toEqual(REFUSED); + expect(queue.published).toEqual([]); + expect((await post(sigV1(SECRET, BODY, NOW - WINDOW))).status).toBe(202); + expect((await post(sigV1(SECRET, BODY, NOW + WINDOW))).status).toBe(202); + }); + + it('refuses a stale timestamp carried with a valid BODY-ONLY signature — a timestamp claims the versioned form, with no fallback', async () => { + const bodyOnlyHex = sig(SECRET, BODY).slice('sha256='.length); + for (const t of [NOW - WINDOW - 1, NOW - 3600, NOW]) { + expect(await post(`t=${t},v1=${bodyOnlyHex}`), `v1 at ${t}`).toEqual(REFUSED); + expect(await post(`t=${t},sha256=${bodyOnlyHex}`), `sha256 at ${t}`).toEqual(REFUSED); + } + expect(queue.published).toEqual([]); + expect(deprecationLines()).toEqual([]); + }); + + it('refuses a malformed timestamped value with the same envelope a bad signature gets', async () => { + const hex = sigV1(SECRET, BODY, NOW).split(',v1=')[1]!; + for (const malformed of [ + `t=${NOW}`, + `t=${NOW},v1=${hex.slice(2)}`, + `t=${NOW},v1=${hex.toUpperCase()}`, + `v1=${hex},t=${NOW}`, + `t=0${NOW},v1=${hex}`, + `t=abc,v1=${hex}`, + `t=${NOW},v1=${hex},v0=${hex}`, + ]) { + expect(await post(malformed), malformed).toEqual(REFUSED); + } + // The same envelope as a plain bad signature. + expect(await post(sig('not-the-secret', BODY))).toEqual(REFUSED); + expect(queue.published).toEqual([]); + }); + + it('refuses the timestamped form under the wrong secret, or over a body other than the one received', async () => { + expect(await post(sigV1('not-the-secret', BODY, NOW))).toEqual(REFUSED); + expect(await post(sigV1(SECRET, BODY, NOW), `${BODY}\n`)).toEqual(REFUSED); + expect(queue.published).toEqual([]); + }); + + it('CONTROL: still accepts a body-only signature, and logs its deprecation once per armed hook, naming the flow and the hook', async () => { + expect((await post(sig(SECRET, BODY))).status).toBe(202); + expect((await post(sig(SECRET, BODY))).status).toBe(202); + expect(queue.published).toHaveLength(2); + + const lines = deprecationLines(); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("flow 'invoice_intake'"); + expect(lines[0]).toContain("hook 'hk_v1'"); + expect(lines[0]).toContain('t=,v1='); + + // A timestamped post adds nothing to it; re-arming the hook says it once more. + expect((await post(sigV1(SECRET, BODY, NOW))).status).toBe(202); + expect(deprecationLines()).toHaveLength(1); + trigger.stop('invoice_intake'); + trigger.start({ flowName: 'invoice_intake', config: { hookId: 'hk_v1', secret: SECRET } }, async () => {}); + expect((await post(sig(SECRET, BODY))).status).toBe(202); + expect(deprecationLines()).toHaveLength(2); + }); + + it('verifySignature, the exported check, accepts both forms and refuses a stale one', () => { + expect(verifySignature(SECRET, BODY, sigV1(SECRET, BODY, NOW))).toBe(true); + expect(verifySignature(SECRET, BODY, sig(SECRET, BODY))).toBe(true); + expect(verifySignature(SECRET, BODY, sigV1(SECRET, BODY, NOW - WINDOW - 1))).toBe(false); + }); +}); From 9cb44814e9918f8868bb8d87b900a536eaaee7ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:05:46 +0000 Subject: [PATCH 3/3] chore(changeset): timestamped HTTP signature form; pin the sha256-spelled downgrade in core Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude --- .changeset/22769-versioned-http-signature.md | 16 ++++++++++++++++ .../core/src/security/http-signature.test.ts | 4 +++- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/22769-versioned-http-signature.md diff --git a/.changeset/22769-versioned-http-signature.md b/.changeset/22769-versioned-http-signature.md new file mode 100644 index 00000000000..d039ad05187 --- /dev/null +++ b/.changeset/22769-versioned-http-signature.md @@ -0,0 +1,16 @@ +--- +'@objectstack/core': minor +'@objectstack/trigger-api': minor +--- + +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 + +Clause-②: yes + +- **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` 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. +- **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. +- **Body-only signatures keep working.** `sha256=` 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. +- **`@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. +- **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. + +**To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. diff --git a/packages/core/src/security/http-signature.test.ts b/packages/core/src/security/http-signature.test.ts index 845bdc2dae7..a91a12cccf3 100644 --- a/packages/core/src/security/http-signature.test.ts +++ b/packages/core/src/security/http-signature.test.ts @@ -114,8 +114,10 @@ describe('verifyHttpSignature — the receiver\'s half of both forms', () => { it('refuses a timestamp the HMAC does not cover — never a fallback to the body-only form', () => { const bodyOnlyHex = signHttpBody(BODY, SECRET).slice('sha256='.length); - // A captured body-only signature, re-labelled with a fresh timestamp. + // A captured body-only signature, re-labelled with a fresh timestamp, + // in either spelling. expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},v1=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'mismatch' }); + expect(verifyHttpSignature(BODY, SECRET, `t=${NOW},sha256=${bodyOnlyHex}`)).toEqual({ valid: false, reason: 'malformed' }); // A genuine value whose timestamp was moved into the window. const stale = signHttpBodyAt(BODY, SECRET, NOW - 3600); const moved = stale.replace(`t=${NOW - 3600},`, `t=${NOW},`);