Skip to content

Commit 9cb4481

Browse files
committed
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 <noreply@anthropic.com>
1 parent 115cd41 commit 9cb4481

2 files changed

Lines changed: 19 additions & 1 deletion

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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,10 @@ describe('verifyHttpSignature — the receiver\'s half of both forms', () => {
114114

115115
it('refuses a timestamp the HMAC does not cover — never a fallback to the body-only form', () => {
116116
const bodyOnlyHex = signHttpBody(BODY, SECRET).slice('sha256='.length);
117-
// A captured body-only signature, re-labelled with a fresh timestamp.
117+
// A captured body-only signature, re-labelled with a fresh timestamp,
118+
// in either spelling.
118119
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' });
119121
// A genuine value whose timestamp was moved into the window.
120122
const stale = signHttpBodyAt(BODY, SECRET, NOW - 3600);
121123
const moved = stale.replace(`t=${NOW - 3600},`, `t=${NOW},`);

0 commit comments

Comments
 (0)