diff --git a/.changeset/20628-http-node-signs-both-arms.md b/.changeset/20628-http-node-signs-both-arms.md new file mode 100644 index 00000000000..e3d1d81cf75 --- /dev/null +++ b/.changeset/20628-http-node-signs-both-arms.md @@ -0,0 +1,17 @@ +--- +"@objectstack/core": minor +"@objectstack/service-automation": patch +"@objectstack/service-messaging": patch +--- + +**A flow `http` node's `signingSecret` now signs the request on every arm, with one scheme, and a secret that does not resolve refuses the node instead of letting the request leave unsigned.** + +`signingSecret` is declared as "HMAC-SHA256 secret → X-Objectstack-Signature", with no arm named. Only the durable arm honoured it, because only the messaging outbox signed. The default inline request, and a `durable: true` node on a host with no messaging HTTP outbox (which degrades to that inline request), were sent without the header while the run reported success. + +- `@objectstack/core`: **new exports** `signHttpBody(body, secret)` and `HTTP_SIGNATURE_HEADER`, the outbound HTTP signature scheme: `X-Objectstack-Signature: sha256=`, where a request with no body is signed over the empty string. They were `@objectstack/service-messaging`'s own, and they moved here so a sender with no outbox can sign with the same code. +- `@objectstack/service-messaging`: `signHttpBody` and `HTTP_SIGNATURE_HEADER` are still exported under the same names. They are now re-exports of the `@objectstack/core` bindings, not a second implementation. Delivery rows and the headers the outbox sends are unchanged. +- `@objectstack/service-automation`: the `http` node's inline request carries `X-Objectstack-Signature` whenever `signingSecret` is set. It is computed over the exact body the node sends (its JSON serialization of `config.body`, or the empty string when there is none), so a receiver that verifies with `signHttpBody` over the bytes it received accepts it on every arm. + - A non-empty `signingSecret` that renders to nothing at run time now fails the node with a guard refusal naming `config.signingSecret`, and nothing is sent. This covers a `{token}` with no value in the run, or one that renders the empty string. The refusal is on every arm, including the outbox arm, which used to enqueue such a delivery unsigned. A fault edge does not route it. The fix is to give the run the value the template reads. + - An authored `signingSecret: ''` still sends unsigned on purpose, on every arm. + +Clause-②: yes (widening) — two new exports on `@objectstack/core`'s root. Nothing is removed or renamed on any package. The one newly refused case is a node whose authored secret did not resolve, which the published contract already said signs. diff --git a/packages/core/src/security/http-signature.test.ts b/packages/core/src/security/http-signature.test.ts new file mode 100644 index 00000000000..2a3d871f5f4 --- /dev/null +++ b/packages/core/src/security/http-signature.test.ts @@ -0,0 +1,32 @@ +// 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'; + +/** + * The scheme's wire form, pinned by literal values rather than by recomputing + * an HMAC here: a test that rebuilt the value with the same `createHmac` call + * would agree with any change made to both. Every sender and every receiver of + * `X-Objectstack-Signature` relies on exactly these bytes. + */ +describe('the outbound HTTP signature scheme', () => { + it('is carried in X-Objectstack-Signature', () => { + expect(HTTP_SIGNATURE_HEADER).toBe('X-Objectstack-Signature'); + }); + + it('signs the empty body a bodyless request carries', () => { + expect(signHttpBody('', 'flow-hook-secret')).toBe( + 'sha256=28c9179fd9763c0e7d41dc5241d9d77607270f8912e3b7692426f677bd5187f7', + ); + }); + + it('is sha256= plus the lowercase hex HMAC-SHA256 of the exact body bytes', () => { + expect(signHttpBody('{"a":1}', 'shh')).toBe( + 'sha256=dfb8cf3fc9778c70386e30f5e0776d37f9ee9c8756d3cbd7df0902150644358d', + ); + // One byte of difference in the body is a different signature — the + // receiver verifies over what it received, so a sender must sign what + // it sends, not an equivalent re-serialization. + expect(signHttpBody('{"a": 1}', 'shh')).not.toBe(signHttpBody('{"a":1}', 'shh')); + }); +}); diff --git a/packages/core/src/security/http-signature.ts b/packages/core/src/security/http-signature.ts new file mode 100644 index 00000000000..fc4426fc62c --- /dev/null +++ b/packages/core/src/security/http-signature.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { createHmac } from 'node:crypto'; + +/** + * The outbound HTTP signature scheme — the ONE definition every ObjectStack + * sender signs with and every receiver verifies against. + * + * A request carries {@link HTTP_SIGNATURE_HEADER} whose value is + * {@link signHttpBody} of the exact bytes of its body under the shared secret: + * `sha256=` followed by the lowercase hex HMAC-SHA256. A request with no body is + * signed over the empty string, which is what its receiver reads. + * + * It lives in `@objectstack/core` because two senders that cannot import each + * other at runtime both sign with it: `@objectstack/service-messaging`'s durable + * HTTP outbox (which signs at enqueue) and `@objectstack/service-automation`'s + * flow `http` node, whose inline arm — and its durable arm's fallback when no + * outbox is wired — calls `fetch` itself. `service-messaging` re-exports both + * names unchanged, so its published surface still carries them. ⛔ Never a + * second copy of this HMAC input anywhere: two copies are how one key comes to + * mean two things on two arms. + * + * What the scheme does NOT own is WHICH bytes are the body — each sender signs + * the serialization it actually sends. + */ + +/** Header carrying the HMAC-SHA256 signature of the request body. */ +export const HTTP_SIGNATURE_HEADER = 'X-Objectstack-Signature'; + +/** + * Compute the {@link HTTP_SIGNATURE_HEADER} value for a body: `sha256=` of + * `HMAC-SHA256(body, secret)`. + * + * The output is safe to persist (it is handed to the receiver on the wire + * anyway); the `secret` argument is NOT. + */ +export function signHttpBody(body: string, secret: string): string { + return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; +} diff --git a/packages/core/src/security/index.ts b/packages/core/src/security/index.ts index bd50811e7cd..b1238235b97 100644 --- a/packages/core/src/security/index.ts +++ b/packages/core/src/security/index.ts @@ -48,6 +48,12 @@ export { type VerifyIntegrityResult, } from './plugin-artifact-integrity.js'; +// 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'; + // `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on // 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7). // The kernel never received a plugin's config to validate — factories close diff --git a/packages/services/service-automation/src/builtin/http-node-signing.test.ts b/packages/services/service-automation/src/builtin/http-node-signing.test.ts new file mode 100644 index 00000000000..0439baa6d14 --- /dev/null +++ b/packages/services/service-automation/src/builtin/http-node-signing.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { + MessagingService, + MemoryHttpOutbox, + HttpDispatcher, + signHttpBody, + HTTP_SIGNATURE_HEADER, +} from '@objectstack/service-messaging'; +import { signHttpBody as coreSignHttpBody, HTTP_SIGNATURE_HEADER as CORE_HTTP_SIGNATURE_HEADER } from '@objectstack/core'; +import { AutomationEngine } from '../engine.js'; +import { registerHttpNodes } from './http-nodes.js'; + +/** + * `signingSecret` on a flow `http` node means `X-Objectstack-Signature` on the + * wire, on EVERY arm the node can take. + * + * The key is declared without qualification — `HttpConfigSchema.signingSecret` + * and the descriptor's `configSchema` both read "HMAC-SHA256 secret → + * X-Objectstack-Signature" — yet only the durable arm used to hand it to + * anything that signs (the messaging outbox). The inline arm, and the durable + * arm's no-outbox fallback that degrades to it, built their `fetch` from + * `headers` alone: the request went out unsigned and the run said + * `success: true`. + * + * Every assertion here is made by a REAL local receiver, on the bytes and + * headers it actually received, and a signature counts only if it verifies with + * the published scheme (`signHttpBody` over the received body) — the check a + * real receiving endpoint makes. A mocked `fetch` would pin what the node + * INTENDED to send; the receiver pins what arrived. + * + * The refusal cases pin the other half: a secret the author set that did not + * resolve at run time must stop the call, never let it leave unsigned. An + * explicitly empty `signingSecret: ''` is NOT that case — it is the documented + * way to send unsigned on purpose, and it behaves the same on every arm. + */ + +const SECRET = 'flow-hook-secret'; + +/** + * `sha256=` + hex HMAC-SHA256 of the EMPTY body under {@link SECRET}. Pinned as + * a literal so no arm can drift to signing something other than the empty body + * a bodyless request actually carries (`'{}'`, `'null'`, …) while still + * agreeing with a helper that drifted alongside it. + */ +const EMPTY_BODY_SIGNATURE = 'sha256=28c9179fd9763c0e7d41dc5241d9d77607270f8912e3b7692426f677bd5187f7'; + +const SIGNATURE_HEADER_LC = 'x-objectstack-signature'; + +interface Received { + method: string; + headers: Record; + body: string; +} + +let server: Server; +let baseUrl: string; +const received: Received[] = []; + +beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + received.push({ + method: req.method ?? '', + headers: req.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + received.length = 0; +}); + +function silentLogger(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +/** An engine with the `http` node wired against the given `messaging` service (or none). */ +function engineWith(messaging?: unknown): AutomationEngine { + const engine = new AutomationEngine(silentLogger()); + registerHttpNodes(engine, { + logger: silentLogger(), + getService: (name: string) => (name === 'messaging' ? messaging : undefined), + } as any); + return engine; +} + +function flow(config: Record) { + return { + name: 'signed_callout', + label: 'Signed callout', + type: 'autolaunched' as const, + variables: [{ name: 'signing_key', type: 'text' as const, isInput: true }], + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'call', type: 'http' as const, label: 'Call', config }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; +} + +async function run(engine: AutomationEngine, config: Record, params?: Record) { + engine.registerFlow('signed_callout', flow(config)); + return engine.execute('signed_callout', params ? ({ params } as any) : undefined); +} + +/** The one received request, asserting there was exactly one. */ +function only(): Received { + expect(received).toHaveLength(1); + return received[0]; +} + +/** The signature a receiver would compute over what it received, with the shared secret. */ +function expectVerifies(req: Received, secret: string): void { + const sent = req.headers[SIGNATURE_HEADER_LC]; + expect(sent, 'no X-Objectstack-Signature arrived').toBeTypeOf('string'); + expect(sent).toBe(signHttpBody(req.body, secret)); +} + +/** + * The two arms that call `fetch` in-process: the default request/response arm, + * and `durable: true` on a host with no messaging HTTP outbox, which degrades + * to that same inline call. The fallback is driven twice — no `messaging` + * service at all, and a real `MessagingService` with no outbox wired — because + * those are the two compositions that take it. + */ +const INLINE_ARMS: Array<{ arm: string; durable: boolean; messaging: () => unknown }> = [ + { arm: 'inline (default)', durable: false, messaging: () => undefined }, + { arm: 'durable, no messaging service', durable: true, messaging: () => undefined }, + { + arm: 'durable, messaging with no HTTP outbox', + durable: true, + messaging: () => new MessagingService({ logger: silentLogger() }), + }, +]; + +describe.each(INLINE_ARMS)('http node signing — $arm arm', ({ durable, messaging }) => { + const base = () => ({ url: `${baseUrl}/hook`, ...(durable ? { durable: true } : {}) }); + + it('signs a JSON body: the receiver verifies the signature over the bytes it received', async () => { + const result = await run(engineWith(messaging()), { + ...base(), + method: 'POST', + headers: { 'X-Control': 'kept' }, + signingSecret: SECRET, + body: { order: 42, lines: ['a', 'b'] }, + }); + + expect(result.success).toBe(true); + const req = only(); + expect(req.headers['x-control']).toBe('kept'); + expect(JSON.parse(req.body)).toEqual({ order: 42, lines: ['a', 'b'] }); + expectVerifies(req, SECRET); + }); + + it('signs the exact serialization it sends — a string body goes out JSON-encoded, and that is what is signed', async () => { + const result = await run(engineWith(messaging()), { + ...base(), + method: 'POST', + signingSecret: SECRET, + body: 'hello', + }); + + expect(result.success).toBe(true); + const req = only(); + // The inline arm's own serialization (`JSON.stringify`), which is NOT + // the outbox's `deliveryBody` (that one sends a string verbatim). Each + // arm signs what it sends; the receiver only ever sees the bytes. + expect(req.body).toBe('"hello"'); + expectVerifies(req, SECRET); + }); + + it('signs a GET with no body over the empty body the receiver sees', async () => { + const result = await run(engineWith(messaging()), { ...base(), method: 'GET', signingSecret: SECRET }); + + expect(result.success).toBe(true); + const req = only(); + expect(req.method).toBe('GET'); + expect(req.body).toBe(''); + expect(req.headers[SIGNATURE_HEADER_LC]).toBe(EMPTY_BODY_SIGNATURE); + expectVerifies(req, SECRET); + }); + + it('signs with a secret that arrives through a template', async () => { + const result = await run( + engineWith(messaging()), + { ...base(), method: 'POST', signingSecret: '{signing_key}', body: { a: 1 } }, + { signing_key: SECRET }, + ); + + expect(result.success).toBe(true); + expectVerifies(only(), SECRET); + }); + + it('sends NO signature header when the node sets no signingSecret', async () => { + const result = await run(engineWith(messaging()), { ...base(), method: 'POST', body: { a: 1 } }); + + expect(result.success).toBe(true); + expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined(); + }); + + it("sends NO signature header for an explicitly empty signingSecret: '' — the unsigned-on-purpose spelling", async () => { + const result = await run(engineWith(messaging()), { + ...base(), + method: 'POST', + signingSecret: '', + body: { a: 1 }, + }); + + expect(result.success).toBe(true); + expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined(); + }); + + it('REFUSES, and sends nothing, when a templated secret resolves to nothing', async () => { + // `signing_key` is declared but not supplied: the whole-token template + // resolves to no value at all. + const result = await run(engineWith(messaging()), { + ...base(), + method: 'POST', + signingSecret: '{signing_key}', + body: { a: 1 }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('signingSecret'); + expect(received).toHaveLength(0); + }); + + it('REFUSES, and sends nothing, when a templated secret resolves to the empty string', async () => { + // Only an AUTHORED '' means "unsigned on purpose". A template that + // happens to render '' is a secret that did not resolve. + const result = await run( + engineWith(messaging()), + { ...base(), method: 'POST', signingSecret: '{signing_key}', body: { a: 1 } }, + { signing_key: '' }, + ); + + expect(result.success).toBe(false); + expect(result.error).toContain('signingSecret'); + expect(received).toHaveLength(0); + }); +}); + +/** + * The durable arm with the outbox wired — the arm that already signed. It is + * driven end to end (real `MessagingService` + `MemoryHttpOutbox` + + * `HttpDispatcher` on the global `fetch`) so the same receiver check covers + * both arms: one secret, one header, one verification, whichever arm ran. + */ +describe('http node signing — durable arm with the outbox wired', () => { + function outboxStack() { + const outbox = new MemoryHttpOutbox(); + const messaging = new MessagingService({ logger: silentLogger() }); + messaging.setHttpOutbox(outbox); + const dispatcher = new HttpDispatcher({ + nodeId: 'node-signing-test', + outbox, + partitionCount: 1, + intervalMs: 10_000, // ticked by hand + logger: silentLogger(), + }); + return { outbox, dispatcher, engine: engineWith(messaging) }; + } + + it('delivers a signature the receiver verifies over the bytes it received', async () => { + const { dispatcher, engine } = outboxStack(); + const result = await run(engine, { url: `${baseUrl}/hook`, durable: true, signingSecret: SECRET, body: { a: 1 } }); + expect(result.success).toBe(true); + + await dispatcher.tick(); + expectVerifies(only(), SECRET); + }); + + it("delivers NO signature for signingSecret: ''", async () => { + const { dispatcher, engine } = outboxStack(); + const result = await run(engine, { url: `${baseUrl}/hook`, durable: true, signingSecret: '', body: { a: 1 } }); + expect(result.success).toBe(true); + + await dispatcher.tick(); + expect(only().headers[SIGNATURE_HEADER_LC]).toBeUndefined(); + }); + + it('REFUSES, and enqueues nothing, when a templated secret resolves to nothing', async () => { + const { outbox, dispatcher, engine } = outboxStack(); + const result = await run(engine, { + url: `${baseUrl}/hook`, + durable: true, + signingSecret: '{signing_key}', + body: { a: 1 }, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('signingSecret'); + expect(await outbox.list()).toHaveLength(0); + await dispatcher.tick(); + expect(received).toHaveLength(0); + }); +}); + +/** + * One scheme, not two copies: the signer the node uses (`@objectstack/core`'s) + * and the one `@objectstack/service-messaging` publishes to receivers are the + * SAME binding, not two implementations that happen to agree today. + */ +describe('http signature scheme — one binding', () => { + it("service-messaging's published signer and header are @objectstack/core's own", () => { + expect(signHttpBody).toBe(coreSignHttpBody); + expect(HTTP_SIGNATURE_HEADER).toBe(CORE_HTTP_SIGNATURE_HEADER); + expect(HTTP_SIGNATURE_HEADER.toLowerCase()).toBe(SIGNATURE_HEADER_LC); + }); +}); diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index 01f7801bcee..2137f1ab205 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { HTTP_SIGNATURE_HEADER, signHttpBody } from '@objectstack/core'; import type { PluginContext } from '@objectstack/core'; import { defineActionDescriptor, HttpConfigSchema } from '@objectstack/spec/automation'; import type { HttpConfigParsed } from '@objectstack/spec/automation'; @@ -32,6 +33,14 @@ import { parseNodeConfig } from './parse-config.js'; * path; that descriptor key was retired in #6748 — a suspending HTTP node * would declare `supportsPause: true` plus a `resumeAuthority` and return * `suspend: true`, which is the mechanism the engine actually enforces.) + * + * `signingSecret` means `X-Objectstack-Signature` on EVERY arm, with ONE scheme + * (`@objectstack/core`'s `signHttpBody`): the outbox signs the body it will + * POST, and the inline call — including the durable arm's no-outbox fallback — + * signs the exact bytes it hands `fetch`, the empty string when there is no + * body. An authored `''` sends unsigned on purpose, on every arm; a non-empty + * value that renders to nothing at run time refuses the node rather than let + * the call leave unsigned. */ /** Structural view of `service-messaging`'s HTTP outbox surface (ADR-0018 M3). */ @@ -122,6 +131,21 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): const timeoutMs = cfg.timeoutMs; const signingSecret = cfg.signingSecret; + // A secret the author SET that rendered to nothing — a `{token}` + // with no value in this run — cannot sign, and sending anyway is + // the one outcome the key exists to prevent: the receiver gets a + // request it cannot authenticate while the run reports success. + // Only an authored `''` means "unsigned on purpose" (the outbox + // reads it the same way). A non-string result never reaches here: + // the contract parse above already refused it, naming the key. + if (typeof raw.signingSecret === 'string' && raw.signingSecret !== '' && !signingSecret) { + return refuseNode( + `http '${node.id}': config.signingSecret is set but resolved to no value in this run, so the ` + + `request cannot carry ${HTTP_SIGNATURE_HEADER} and was not sent. Give the value its template ` + + `reads to the run, or author signingSecret: '' to send unsigned on purpose.`, + ); + } + // ── Durable mode: enqueue onto the messaging HTTP outbox ────────── if (durable) { const messaging = getMessaging(); @@ -208,7 +232,8 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): return { success: false, error: `http (durable) failed to enqueue: ${(err as Error).message}` }; } } - // No outbox available — degrade to a best-effort inline call. + // No outbox available — degrade to a best-effort inline call, + // which signs exactly as the outbox would have. ctx.logger.warn( `[http] node '${node.id}' requested durable delivery but no messaging HTTP outbox is wired; falling back to inline fetch`, ); @@ -222,11 +247,19 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): const reads = /^(GET|HEAD|OPTIONS)$/i.test(method); const controller = new AbortController(); const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + // The exact bytes this arm sends, and therefore the bytes it signs. + // They are this arm's own serialization, not the outbox's + // `deliveryBody`; a receiver verifies over what it received, so each + // arm signs what it sends. No body is signed as the empty string. + const requestBody = body !== undefined && body !== null ? JSON.stringify(body) : undefined; + const requestHeaders = signingSecret + ? { ...headers, [HTTP_SIGNATURE_HEADER]: signHttpBody(requestBody ?? '', signingSecret) } + : headers; try { const response = await fetch(url, { method, - headers, - body: body !== undefined && body !== null ? JSON.stringify(body) : undefined, + headers: requestHeaders, + body: requestBody, signal: controller.signal, }); const data = await readBody(response); diff --git a/packages/services/service-automation/src/guard-refusal-inventory.test.ts b/packages/services/service-automation/src/guard-refusal-inventory.test.ts index 70a1421ab85..197889b9b35 100644 --- a/packages/services/service-automation/src/guard-refusal-inventory.test.ts +++ b/packages/services/service-automation/src/guard-refusal-inventory.test.ts @@ -153,6 +153,13 @@ const GUARDS: Array<{ name: string; why: string; node: Record; strip: 'url', expect: 'does not satisfy the http contract', }, + { + name: 'http whose signingSecret did not resolve', + why: 'the call would leave unsigned — a fault edge must not turn a missing credential into a sent request', + // The record carries no `signing_key`, so the template renders nothing. + node: { type: 'http', config: { url: 'http://127.0.0.1:9/never', method: 'POST', signingSecret: '{record.signing_key}' } }, + expect: 'signingSecret', + }, { name: 'subflow without flowName', why: 'a required config key', diff --git a/packages/services/service-messaging/src/http-sender.ts b/packages/services/service-messaging/src/http-sender.ts index 16ad8365502..616e30b2c30 100644 --- a/packages/services/service-messaging/src/http-sender.ts +++ b/packages/services/service-messaging/src/http-sender.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { createHmac, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; +import { HTTP_SIGNATURE_HEADER, signHttpBody } from '@objectstack/core'; import type { HttpAckResult, HttpDelivery } from './http-outbox.js'; /** @@ -10,19 +11,26 @@ import type { HttpAckResult, HttpDelivery } from './http-outbox.js'; * stateless attempt (`sendOnce`) plus the retry-schedule classifier * (`classifyAttempt`). The dispatcher owns claim/ack; this module owns the wire. * - * It also owns both halves of the HMAC contract — {@link deliveryBody} (the exact - * bytes that are signed AND sent) and {@link signBody} (how they are signed) — so - * the enqueue-time signer and the send-time transport cannot drift into signing - * one string and posting another. See {@link HttpDelivery.signature} for why the - * signature is computed once, at enqueue, instead of re-derived at send time from - * a secret carried on the row. + * It also owns the outbox's half of the HMAC contract — {@link deliveryBody}, the + * exact bytes that are signed AND sent — so the enqueue-time signer and the + * send-time transport cannot drift into signing one string and posting another. + * HOW those bytes are signed is not this module's: the scheme is + * `@objectstack/core`'s `signHttpBody` / `HTTP_SIGNATURE_HEADER`, shared with the + * flow `http` node's inline arm, and re-exported below under this module's + * historical names. See {@link HttpDelivery.signature} for why the signature is + * computed once, at enqueue, instead of re-derived at send time from a secret + * carried on the row. */ /** Default per-request timeout. */ export const DEFAULT_HTTP_TIMEOUT_MS = 15_000; -/** Header carrying the HMAC-SHA256 signature of the request body. */ -export const SIGNATURE_HEADER = 'X-Objectstack-Signature'; +/** + * The signature header and the signer — the SAME bindings `@objectstack/core` + * exports, never a second implementation: the outbox and the flow `http` node's + * inline arm must mean one thing by one `signingSecret`. + */ +export { HTTP_SIGNATURE_HEADER as SIGNATURE_HEADER, signHttpBody as signBody }; /** * The exact request body for a delivery — the bytes that get POSTed, and @@ -39,17 +47,6 @@ export function deliveryBody(payload: unknown): string { return typeof payload === 'string' ? payload : JSON.stringify(payload ?? null); } -/** - * Compute the `X-Objectstack-Signature` value for a body: `sha256=` of - * `HMAC-SHA256(body, secret)`. - * - * The output is safe to persist (it is handed to the receiver on the wire - * anyway); the `secret` argument is NOT — see {@link HttpDelivery.signature}. - */ -export function signBody(body: string, secret: string): string { - return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; -} - /** Truncate response bodies to keep storage cost predictable. */ const RESPONSE_BODY_CAP = 16 * 1024; @@ -105,7 +102,7 @@ export async function sendOnce( // signing secret is not on the row for this attempt — or any other — to // carry. #7722. if (delivery.signature) { - headers[SIGNATURE_HEADER] = delivery.signature; + headers[HTTP_SIGNATURE_HEADER] = delivery.signature; } const timeoutMs = delivery.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS; diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index 5506104ec13..a6045a9d051 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -173,7 +173,10 @@ export { newDeliveryId as newHttpDeliveryId, DEFAULT_HTTP_TIMEOUT_MS, // The signing pair (#7722) — exported so a receiver-side verifier (or a - // test) recomputes the HMAC over exactly the bytes the sender signed. + // test) recomputes the HMAC over exactly the bytes the sender signed. The + // signer and the header are `@objectstack/core`'s own bindings + // (`signHttpBody` / `HTTP_SIGNATURE_HEADER`), re-exported under the same + // names: one scheme for the outbox and the flow `http` node's inline arm. deliveryBody as httpDeliveryBody, signBody as signHttpBody, SIGNATURE_HEADER as HTTP_SIGNATURE_HEADER,