Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/22769-versioned-http-signature.md
Original file line number Diff line number Diff line change
@@ -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=<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.
- **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=<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.
- **`@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=<t>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`.
161 changes: 159 additions & 2 deletions packages/core/src/security/http-signature.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:crypto')>();
return { ...actual, timingSafeEqual: vi.fn(actual.timingSafeEqual) };
});

/**
* The scheme's wire form, pinned by literal values rather than by recomputing
Expand Down Expand Up @@ -30,3 +44,146 @@ 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=<seconds>,v1= plus the lowercase hex HMAC-SHA256 of "<t>.<body>"', () => {
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,
// 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},`);
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);
}
});
});
133 changes: 131 additions & 2 deletions packages/core/src/security/http-signature.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=<unix seconds>,v1=<hex>`, where the hex is the HMAC-SHA256 of
* `<t>.<body>` — 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=<hex>` of
* `HMAC-SHA256(body, secret)`.
Expand All @@ -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=<timestampSeconds>,v1=<hex>`
* where the hex is `HMAC-SHA256(<timestampSeconds>.<body>, 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=<hex>` — the body-only form, verified by re-signing with
* {@link signHttpBody}.
* - `t=<seconds>,v1=<hex>` — 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' };
}
13 changes: 11 additions & 2 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading