Skip to content

Commit 1482879

Browse files
committed
fix(service-automation): sign the http node's inline call with the one scheme, and refuse a secret that did not resolve
The outbound HTTP signature scheme (signHttpBody, HTTP_SIGNATURE_HEADER) moves to @objectstack/core, the runtime floor both senders stand on; service-messaging re-exports the same bindings under its published names. The flow http node's inline arm, and the durable arm's no-outbox fallback that degrades to it, now send X-Objectstack-Signature over the exact bytes they hand fetch (the empty body when there is none). A non-empty signingSecret that renders to nothing at run time refuses the node instead of sending unsigned; an authored '' still sends unsigned on purpose, on every arm. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
1 parent b9a7d91 commit 1482879

7 files changed

Lines changed: 150 additions & 25 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { HTTP_SIGNATURE_HEADER, signHttpBody } from '../index.js';
5+
6+
/**
7+
* The scheme's wire form, pinned by literal values rather than by recomputing
8+
* an HMAC here: a test that rebuilt the value with the same `createHmac` call
9+
* would agree with any change made to both. Every sender and every receiver of
10+
* `X-Objectstack-Signature` relies on exactly these bytes.
11+
*/
12+
describe('the outbound HTTP signature scheme', () => {
13+
it('is carried in X-Objectstack-Signature', () => {
14+
expect(HTTP_SIGNATURE_HEADER).toBe('X-Objectstack-Signature');
15+
});
16+
17+
it('signs the empty body a bodyless request carries', () => {
18+
expect(signHttpBody('', 'flow-hook-secret')).toBe(
19+
'sha256=28c9179fd9763c0e7d41dc5241d9d77607270f8912e3b7692426f677bd5187f7',
20+
);
21+
});
22+
23+
it('is sha256= plus the lowercase hex HMAC-SHA256 of the exact body bytes', () => {
24+
expect(signHttpBody('{"a":1}', 'shh')).toBe(
25+
'sha256=dfb8cf3fc9778c70386e30f5e0776d37f9ee9c8756d3cbd7df0902150644358d',
26+
);
27+
// One byte of difference in the body is a different signature — the
28+
// receiver verifies over what it received, so a sender must sign what
29+
// it sends, not an equivalent re-serialization.
30+
expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh'));
31+
});
32+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { createHmac } from 'node:crypto';
4+
5+
/**
6+
* The outbound HTTP signature scheme — the ONE definition every ObjectStack
7+
* sender signs with and every receiver verifies against.
8+
*
9+
* A request carries {@link HTTP_SIGNATURE_HEADER} whose value is
10+
* {@link signHttpBody} of the exact bytes of its body under the shared secret:
11+
* `sha256=` followed by the lowercase hex HMAC-SHA256. A request with no body is
12+
* signed over the empty string, which is what its receiver reads.
13+
*
14+
* It lives in `@objectstack/core` because two senders that cannot import each
15+
* other at runtime both sign with it: `@objectstack/service-messaging`'s durable
16+
* HTTP outbox (which signs at enqueue) and `@objectstack/service-automation`'s
17+
* flow `http` node, whose inline arm — and its durable arm's fallback when no
18+
* outbox is wired — calls `fetch` itself. `service-messaging` re-exports both
19+
* names unchanged, so its published surface still carries them. ⛔ Never a
20+
* second copy of this HMAC input anywhere: two copies are how one key comes to
21+
* mean two things on two arms.
22+
*
23+
* What the scheme does NOT own is WHICH bytes are the body — each sender signs
24+
* the serialization it actually sends.
25+
*/
26+
27+
/** Header carrying the HMAC-SHA256 signature of the request body. */
28+
export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature';
29+
30+
/**
31+
* Compute the {@link HTTP_SIGNATURE_HEADER} value for a body: `sha256=<hex>` of
32+
* `HMAC-SHA256(body, secret)`.
33+
*
34+
* The output is safe to persist (it is handed to the receiver on the wire
35+
* anyway); the `secret` argument is NOT.
36+
*/
37+
export function signHttpBody(body: string, secret: string): string {
38+
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
39+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export {
4848
type VerifyIntegrityResult,
4949
} from './plugin-artifact-integrity.js';
5050

51+
// The outbound HTTP signature scheme (`X-Objectstack-Signature`) — one
52+
// definition shared by the messaging outbox and the flow `http` node's inline
53+
// 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';
56+
5157
// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
5258
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
5359
// The kernel never received a plugin's config to validate — factories close

‎packages/services/service-automation/src/builtin/http-node-signing.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
MemoryHttpOutbox,
99
HttpDispatcher,
1010
signHttpBody,
11+
HTTP_SIGNATURE_HEADER,
1112
} from '@objectstack/service-messaging';
13+
import { signHttpBody as coreSignHttpBody, HTTP_SIGNATURE_HEADER as CORE_HTTP_SIGNATURE_HEADER } from '@objectstack/core';
1214
import { AutomationEngine } from '../engine.js';
1315
import { registerHttpNodes } from './http-nodes.js';
1416

@@ -315,3 +317,16 @@ describe('http node signing — durable arm with the outbox wired', () => {
315317
expect(received).toHaveLength(0);
316318
});
317319
});
320+
321+
/**
322+
* One scheme, not two copies: the signer the node uses (`@objectstack/core`'s)
323+
* and the one `@objectstack/service-messaging` publishes to receivers are the
324+
* SAME binding, not two implementations that happen to agree today.
325+
*/
326+
describe('http signature scheme — one binding', () => {
327+
it("service-messaging's published signer and header are @objectstack/core's own", () => {
328+
expect(signHttpBody).toBe(coreSignHttpBody);
329+
expect(HTTP_SIGNATURE_HEADER).toBe(CORE_HTTP_SIGNATURE_HEADER);
330+
expect(HTTP_SIGNATURE_HEADER.toLowerCase()).toBe(SIGNATURE_HEADER_LC);
331+
});
332+
});

‎packages/services/service-automation/src/builtin/http-nodes.ts‎

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

3+
import { HTTP_SIGNATURE_HEADER, signHttpBody } from '@objectstack/core';
34
import type { PluginContext } from '@objectstack/core';
45
import { defineActionDescriptor, HttpConfigSchema } from '@objectstack/spec/automation';
56
import type { HttpConfigParsed } from '@objectstack/spec/automation';
@@ -32,6 +33,14 @@ import { parseNodeConfig } from './parse-config.js';
3233
* path; that descriptor key was retired in #6748 — a suspending HTTP node
3334
* would declare `supportsPause: true` plus a `resumeAuthority` and return
3435
* `suspend: true`, which is the mechanism the engine actually enforces.)
36+
*
37+
* `signingSecret` means `X-Objectstack-Signature` on EVERY arm, with ONE scheme
38+
* (`@objectstack/core`'s `signHttpBody`): the outbox signs the body it will
39+
* POST, and the inline call — including the durable arm's no-outbox fallback —
40+
* signs the exact bytes it hands `fetch`, the empty string when there is no
41+
* body. An authored `''` sends unsigned on purpose, on every arm; a non-empty
42+
* value that renders to nothing at run time refuses the node rather than let
43+
* the call leave unsigned.
3544
*/
3645

3746
/** Structural view of `service-messaging`'s HTTP outbox surface (ADR-0018 M3). */
@@ -122,6 +131,21 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
122131
const timeoutMs = cfg.timeoutMs;
123132
const signingSecret = cfg.signingSecret;
124133

134+
// A secret the author SET that rendered to nothing — a `{token}`
135+
// with no value in this run — cannot sign, and sending anyway is
136+
// the one outcome the key exists to prevent: the receiver gets a
137+
// request it cannot authenticate while the run reports success.
138+
// Only an authored `''` means "unsigned on purpose" (the outbox
139+
// reads it the same way). A non-string result never reaches here:
140+
// the contract parse above already refused it, naming the key.
141+
if (typeof raw.signingSecret === 'string' && raw.signingSecret !== '' && !signingSecret) {
142+
return refuseNode(
143+
`http '${node.id}': config.signingSecret is set but resolved to no value in this run, so the ` +
144+
`request cannot carry ${HTTP_SIGNATURE_HEADER} and was not sent. Give the value its template ` +
145+
`reads to the run, or author signingSecret: '' to send unsigned on purpose.`,
146+
);
147+
}
148+
125149
// ── Durable mode: enqueue onto the messaging HTTP outbox ──────────
126150
if (durable) {
127151
const messaging = getMessaging();
@@ -208,7 +232,8 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
208232
return { success: false, error: `http (durable) failed to enqueue: ${(err as Error).message}` };
209233
}
210234
}
211-
// No outbox available — degrade to a best-effort inline call.
235+
// No outbox available — degrade to a best-effort inline call,
236+
// which signs exactly as the outbox would have.
212237
ctx.logger.warn(
213238
`[http] node '${node.id}' requested durable delivery but no messaging HTTP outbox is wired; falling back to inline fetch`,
214239
);
@@ -222,11 +247,19 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
222247
const reads = /^(GET|HEAD|OPTIONS)$/i.test(method);
223248
const controller = new AbortController();
224249
const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
250+
// The exact bytes this arm sends, and therefore the bytes it signs.
251+
// They are this arm's own serialization, not the outbox's
252+
// `deliveryBody`; a receiver verifies over what it received, so each
253+
// arm signs what it sends. No body is signed as the empty string.
254+
const requestBody = body !== undefined && body !== null ? JSON.stringify(body) : undefined;
255+
const requestHeaders = signingSecret
256+
? { ...headers, [HTTP_SIGNATURE_HEADER]: signHttpBody(requestBody ?? '', signingSecret) }
257+
: headers;
225258
try {
226259
const response = await fetch(url, {
227260
method,
228-
headers,
229-
body: body !== undefined && body !== null ? JSON.stringify(body) : undefined,
261+
headers: requestHeaders,
262+
body: requestBody,
230263
signal: controller.signal,
231264
});
232265
const data = await readBody(response);

‎packages/services/service-messaging/src/http-sender.ts‎

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

3-
import { createHmac, randomUUID } from 'node:crypto';
3+
import { randomUUID } from 'node:crypto';
4+
import { HTTP_SIGNATURE_HEADER, signHttpBody } from '@objectstack/core';
45
import type { HttpAckResult, HttpDelivery } from './http-outbox.js';
56

67
/**
@@ -10,19 +11,26 @@ import type { HttpAckResult, HttpDelivery } from './http-outbox.js';
1011
* stateless attempt (`sendOnce`) plus the retry-schedule classifier
1112
* (`classifyAttempt`). The dispatcher owns claim/ack; this module owns the wire.
1213
*
13-
* It also owns both halves of the HMAC contract — {@link deliveryBody} (the exact
14-
* bytes that are signed AND sent) and {@link signBody} (how they are signed) — so
15-
* the enqueue-time signer and the send-time transport cannot drift into signing
16-
* one string and posting another. See {@link HttpDelivery.signature} for why the
17-
* signature is computed once, at enqueue, instead of re-derived at send time from
18-
* a secret carried on the row.
14+
* It also owns the outbox's half of the HMAC contract — {@link deliveryBody}, the
15+
* exact bytes that are signed AND sent — so the enqueue-time signer and the
16+
* send-time transport cannot drift into signing one string and posting another.
17+
* HOW those bytes are signed is not this module's: the scheme is
18+
* `@objectstack/core`'s `signHttpBody` / `HTTP_SIGNATURE_HEADER`, shared with the
19+
* flow `http` node's inline arm, and re-exported below under this module's
20+
* historical names. See {@link HttpDelivery.signature} for why the signature is
21+
* computed once, at enqueue, instead of re-derived at send time from a secret
22+
* carried on the row.
1923
*/
2024

2125
/** Default per-request timeout. */
2226
export const DEFAULT_HTTP_TIMEOUT_MS = 15_000;
2327

24-
/** Header carrying the HMAC-SHA256 signature of the request body. */
25-
export const SIGNATURE_HEADER = 'X-Objectstack-Signature';
28+
/**
29+
* The signature header and the signer — the SAME bindings `@objectstack/core`
30+
* exports, never a second implementation: the outbox and the flow `http` node's
31+
* inline arm must mean one thing by one `signingSecret`.
32+
*/
33+
export { HTTP_SIGNATURE_HEADER as SIGNATURE_HEADER, signHttpBody as signBody };
2634

2735
/**
2836
* The exact request body for a delivery — the bytes that get POSTed, and
@@ -39,17 +47,6 @@ export function deliveryBody(payload: unknown): string {
3947
return typeof payload === 'string' ? payload : JSON.stringify(payload ?? null);
4048
}
4149

42-
/**
43-
* Compute the `X-Objectstack-Signature` value for a body: `sha256=<hex>` of
44-
* `HMAC-SHA256(body, secret)`.
45-
*
46-
* The output is safe to persist (it is handed to the receiver on the wire
47-
* anyway); the `secret` argument is NOT — see {@link HttpDelivery.signature}.
48-
*/
49-
export function signBody(body: string, secret: string): string {
50-
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
51-
}
52-
5350
/** Truncate response bodies to keep storage cost predictable. */
5451
const RESPONSE_BODY_CAP = 16 * 1024;
5552

@@ -105,7 +102,7 @@ export async function sendOnce(
105102
// signing secret is not on the row for this attempt — or any other — to
106103
// carry. #7722.
107104
if (delivery.signature) {
108-
headers[SIGNATURE_HEADER] = delivery.signature;
105+
headers[HTTP_SIGNATURE_HEADER] = delivery.signature;
109106
}
110107

111108
const timeoutMs = delivery.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;

‎packages/services/service-messaging/src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,10 @@ export {
173173
newDeliveryId as newHttpDeliveryId,
174174
DEFAULT_HTTP_TIMEOUT_MS,
175175
// The signing pair (#7722) — exported so a receiver-side verifier (or a
176-
// test) recomputes the HMAC over exactly the bytes the sender signed.
176+
// test) recomputes the HMAC over exactly the bytes the sender signed. The
177+
// signer and the header are `@objectstack/core`'s own bindings
178+
// (`signHttpBody` / `HTTP_SIGNATURE_HEADER`), re-exported under the same
179+
// names: one scheme for the outbox and the flow `http` node's inline arm.
177180
deliveryBody as httpDeliveryBody,
178181
signBody as signHttpBody,
179182
SIGNATURE_HEADER as HTTP_SIGNATURE_HEADER,

0 commit comments

Comments
 (0)