Skip to content

Commit 89801cd

Browse files
fix(service-automation): sign the http node's inline request with the one scheme, and refuse a secret that did not resolve (#20640)
Fixes #20628 Clause-②: yes (widening). `@objectstack/core` gains the exported scheme ⇒ at least `minor` for `core`. ## What changed A flow `http` node's `signingSecret` is declared as "HMAC-SHA256 secret → X-Objectstack-Signature", and no arm is named. Only the durable outbox arm signed. The inline arm, and the durable arm's fallback when no messaging HTTP outbox is wired, sent no signature header, and the run still reported success. After this PR the key means one thing on every arm. - **One scheme, moved to `@objectstack/core`** (the seat's ruling on the claim). Two new exports on the `@objectstack/core` root come from `packages/core/src/security/http-signature.ts` through `packages/core/src/security/index.ts`: - `signHttpBody(body: string, secret: string): string` returns `sha256=` plus the lowercase hex HMAC-SHA256 of the exact body bytes. - `HTTP_SIGNATURE_HEADER` is `'X-Objectstack-Signature'`. - `@objectstack/runtime` re-exports the core root with `export *`, so both names appear there too. - **`@objectstack/service-messaging` keeps its published names**, `signHttpBody` and `HTTP_SIGNATURE_HEADER`. They are now re-exports of the core bindings. `http-sender.ts` re-exports them under the module-internal names the two outboxes import (`signBody` / `SIGNATURE_HEADER`). No second implementation remains, and nothing it publishes is removed or renamed. A test pins `messaging.signHttpBody === core.signHttpBody` against the built packages. - **`http-nodes.ts`, the inline arm** (also the no-outbox fallback) sends `X-Objectstack-Signature` whenever `signingSecret` is set. The value is `signHttpBody` over the exact string it passes as `fetch`'s `body`, or over the empty string when there is no body. - **The refusal:** a non-empty authored `signingSecret` that resolves to no value in the run fails the node before either arm, so nothing is sent or enqueued. The full condition is below. ## Which bytes each arm signs - **Outbox arm (unchanged).** `MemoryHttpOutbox.enqueue` and `SqlHttpOutbox.enqueue` sign `deliveryBody(payload)` at enqueue, and `sendOnce` posts `deliveryBody(payload)`. The node passes `payload: body ?? {}`: - an object body is sent as its `JSON.stringify`; - a string body is sent verbatim; - no body is sent as `{}`. - **Inline arm and the no-outbox fallback.** These use the node's own serialization: - a non-null body is sent as `JSON.stringify(body)`, so a string body goes out JSON-quoted; - otherwise no body is sent, and the signature is over the empty string. - **The two serializations differ** for a string body and for no body. Each arm signs what it sends. On every arm the pins check at a real local receiver that the received header equals `signHttpBody(receivedBytes, secret)`. - **The empty-body HMAC is pinned as a literal** in both the core test and the node test (`sha256=28c9179f…bd5187f7` under the test secret). So the fallback can't drift to signing `{}` or `null` while it sends nothing. ## The refusal condition, from the measurement What `signingSecret` becomes after `interpolate(...)` and the contract parse. The "after" column was measured in a scratch run at the fixed head. The two refused rows were also measured on `main`. | authored `signingSecret` | resolves to | on `main` | after | |---|---|---|---| | absent | absent | no header | no header | | `''` | `''` | no header | no header: the unsigned-on-purpose spelling (the cleared form PR #20615 defines), on every arm | | a literal | the literal | inline: no header; outbox: signed | signed on every arm | | a whole `{token}` with no value in the run | `undefined` (the parse accepts it) | sent with no header, `success: true` | **refused** | | a `{token}` whose value is `''`, or several tokens that all render empty | `''` | sent with no header, `success: true` | **refused** | | a `{token}` whose value is `null` or a number | a non-string | refused by the contract parse, naming `config.signingSecret` | unchanged | | `k_{token}` with no value | `'k_'` | inline: no header | signed with `k_`. The receiver's check then fails, so the error is loud there. The executor can't tell this apart from a real literal. | - **The rule:** refuse when the authored value is a non-empty string and the resolved value is `undefined` or `''`. - **The refusal is `refuseNode`**, a guard refusal. That is the same class as this file's `url` refusal and the #3810 collapsed-filter precedent, so a fault edge does not route it. A new row in `guard-refusal-inventory.test.ts` pins this. - **It runs before the durable branch.** So the outbox arm also refuses, where before it enqueued the delivery unsigned. - **The message names the key** and the unsigned-on-purpose spelling, and it carries no tracker number. ## Evidence (final head `62f989f1c`) - **Measured before the fix, RED.** Commit `b9a7d9115` adds only the pins, on the unfixed executor at `542670da6`. - `http-node-signing.test.ts`: 19 failed, 8 passed. Every signing pin failed on all three in-process arms: inline; durable with no messaging service; durable with a `MessagingService` and no outbox. - The failures read `AssertionError: no X-Objectstack-Signature arrived: expected undefined to be type of 'string'`. The GET pin read `expected undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`. - Both refusal pins read `expected true to be false` on each arm and on the outbox arm. The run succeeded and the request left unsigned. - The 8 greens were the controls: no key and `''` on the three arms, plus the outbox arm's signature and its `''`. - `guard-refusal-inventory.test.ts`: the new row failed (1 failed, 15 passed), because the fault edge routed the failure. - **After the change.** The same two files, plus `http-nodes.test.ts` and `http-delivery-outcome.integration.test.ts`: 4 files, 56 passed. - **Ablation.** The fix was committed first, and the restore had its own trap. - `scripts/ablation-replace.mjs` replaced the inline arm's signing expression with `? headers`: anchor 1 → 0, blob `2137f1ab2056` → `061481dd31ee`. - `http-node-signing.test.ts` then gave 12 failed, 16 passed: exactly the 4 signing pins × 3 in-process arms. The quoted failures were `no X-Objectstack-Signature arrived: expected undefined to be type of 'string'` and `expected undefined to be 'sha256=28c9179fd9763c0e7d41dc5241d9d7…'`. - Restore: blob `2137f1ab2056` equals HEAD, and `git diff HEAD` is empty. The trap proved it a second time. - The ablation ran at `14828798f`. `git diff --stat 1482879 62f989f` on `http-nodes.ts` and the test file prints nothing. - There is no build leg: the subject is service-automation's own `src/`, which vitest reads directly. - **Package suites at `62f989f1c`**, after merging `origin/main` at `3f45b6cc1`: - `@objectstack/service-automation`: 153 files, 1895 tests passed. - `@objectstack/service-messaging`: 46 files, 507 passed. - `@objectstack/core` `--project local`: 57 files, 1525 passed. `http-signature.test.ts` alone: 3 passed. - `typecheck` on the three packages: exit 0. Both test layers compile, with no new debt. - **Whole tree.** `pnpm build --concurrency=2` at `62f989f1c`: 72 of 72 tasks succeeded. That includes every package downstream of `@objectstack/core` (the `...@objectstack/core` consumer direction). So the two new root names collide with no `export *` consumer (`@objectstack/runtime`, `@objectstack/plugin-hono-server`). - **Gates at `62f989f1c`.** - `dispatch-gates --commands` derived 65 families. All 65 ran, and every exit code was captured before any pipe: 65 exit 0. `--ran` reconciles to "65 run, 0 NOT-MEASURED (a DERIVED zero)". - The ⛔ artifact rosters under paths in this diff: 4 run, 4 exit 0 (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). Also run: `check:published-readme-exports`, exit 0. - `pnpm lint` (repo-wide eslint, `--no-inline-config`): exit 0. - **Not measured locally; CI runs these:** - the 5 path-scheduled CI jobs (the Test Core shards, Temporal Conformance, the Dogfood shards); - the 11 wide-population families; - the 6 families whose argv carries a workflow-only value. ## Acceptance notes - **Durable `GET` never delivers.** This is outside this card, a different defect, so it is not fixed here. It is recorded for the seat. - A `durable: true` node with `method: 'GET'` enqueues `payload: {}`. The dispatcher's send then refuses a GET that has a body, with `Request with GET/HEAD method cannot have body.`. - So the row stays pending and retrying, and it never reaches the receiver. The run meanwhile reports success. - Measured at the executor seam with a real `MessagingService`, `MemoryHttpOutbox` and `HttpDispatcher` and a local receiver. After one tick the row showed `status: pending`, `attempts: 1`, that error, and the receiver had nothing. - No public door was measured and no real producer is named, so it is not filed. - A side effect: the outbox arm would sign a bodyless GET over `{}`, not the empty body. That is moot while such a request cannot be sent. - **`flow-credential-projection.ts` docblock.** It says the durable arm hands `signingSecret` to the outbox, "which signs every delivery". That is still true, but now incomplete, because the inline arm signs too. It is not edited here: the file is outside this card's file surface. - **File surface.** `guard-refusal-inventory.test.ts` sits outside `builtin/`. I read "`http-nodes.ts` and its tests" as including the inventory row that drives the http executor. The inventory's own header asks that each new guard be added there. - **Header case.** The inline arm mirrors the outbox: author headers first, then the signature under the exact name `X-Objectstack-Signature`. That overrides an author header with the same casing. A differently-cased author header would travel beside it, on both arms alike. Noted only. - **Behaviour change to call out:** a durable node whose authored secret does not resolve now refuses. Before, it enqueued an unsigned delivery. ## Cross-lane `packages/core` belongs to `domain:engine`. The new exports, exactly: `signHttpBody` and `HTTP_SIGNATURE_HEADER` on the `@objectstack/core` root, which `@objectstack/runtime` also carries through its `export *`. No existing core export changed. ## Changeset `.changeset/20628-http-node-signs-both-arms.md`: `@objectstack/core` minor, `@objectstack/service-automation` and `@objectstack/service-messaging` patch. It carries the `Clause-②: yes (widening)` line. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd901d7 commit 89801cd

9 files changed

Lines changed: 491 additions & 25 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/service-automation": patch
4+
"@objectstack/service-messaging": patch
5+
---
6+
7+
**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.**
8+
9+
`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.
10+
11+
- `@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.
12+
- `@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.
13+
- `@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.
14+
- 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.
15+
- An authored `signingSecret: ''` still sends unsigned on purpose, on every arm.
16+
17+
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.
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

0 commit comments

Comments
 (0)