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
17 changes: 17 additions & 0 deletions .changeset/20628-http-node-signs-both-arms.md
Original file line number Diff line number Diff line change
@@ -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=<lowercase hex HMAC-SHA256 of the exact body bytes>`, 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.
32 changes: 32 additions & 0 deletions packages/core/src/security/http-signature.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
});
39 changes: 39 additions & 0 deletions packages/core/src/security/http-signature.ts
Original file line number Diff line number Diff line change
@@ -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=<hex>` 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')}`;
}
6 changes: 6 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading