Repository navigation
feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated - #22803
Conversation
… a tolerance window, verified through core Adds the versioned form of the one HTTP signature scheme beside the body-only one, in packages/core/src/security/http-signature.ts: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, refused once t is more than HTTP_SIGNATURE_TOLERANCE_SECONDS (300) from the receiver's clock. verifyHttpSignature is the receiver's half of both forms; it re-signs through signHttpBody / signHttpBodyAt and compares in constant time. trigger-api's inbound hook verifies through it instead of computing its own copy of the HMAC input. Body-only signatures stay accepted, with a warn line once per armed hook naming the flow and the hook. Our senders are unchanged. Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…w, and the body-only control Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…lled downgrade in core Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
…rsioned-signature Claude-Session: https://claude.ai/code/session_01CBAfsWMSfM3EToQGVStEcp Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9bd8cc4db80ca8be49fbe54b3586fd6904a7a315 && git checkout 9bd8cc4db80ca8be49fbe54b3586fd6904a7a315
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 680a86b4c55dcb6255151d9a038a32208400f66c ffb30285d37c5d322d20e29cff5db8e3146cdf88 && git checkout -B drift-repro 680a86b4c55dcb6255151d9a038a32208400f66c && git merge --no-ff ffb30285d37c5d322d20e29cff5db8e3146cdf88
node scripts/docs-audit/affected-docs.mjs --json 680a86b4c55dcb6255151d9a038a32208400f66c
|
Contract reviewServed-tier: Inputs read: card #22769 (body and every comment except the dispatching seat's own conclusion, which this review did not open), PR #22803 (body, 8-file list, net diff against ① Derived judgments
② Semver level
③ Boundary flagsDev deviations, all seven answered:
Open questions, both escalated to the owning seat (neither is this diff's to answer):
Out-of-scope findings, carriers named by the dev and accepted: ADR-0138 D7 going stale (Tier H, its F1 follow-up), PR #22779's docblock naming only One flag the dev's report does not name, escalated to the owning seat: the CI on the head, read after convergence: every check-run on Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #22769
Clause-②: yes
The inbound flow hook (
POST /api/v1/automation/hooks/:flowName/:hookId) now accepts a second, versioned form ofx-objectstack-signature. Its timestamp is inside the signed material and is checked against a fixed five-minute tolerance window. The body-only form is still accepted and now logs a deprecation signal. Our senders are unchanged. This is the additive scheme from triage's answer (6105617130). Refusing body-only signatures (the cutover) is the maintainer's decision and is not in this PR.What changes
packages/core/src/security/http-signature.tsgains three things besidesignHttpBody:signHttpBodyAt(body, secret, timestampSeconds)returnst=SECONDS,v1=HEX. HEX is the lowercase hex HMAC-SHA256, under the secret, of the stringSECONDS, a full stop, and then the raw body bytes (the Stripe shape).HTTP_SIGNATURE_TOLERANCE_SECONDS = 300. It is a named constant, ⛔ not a configuration key.verifyHttpSignature(body, secret, header)returnsHttpSignatureVerdict:{ valid: true, scheme: 'v1' | 'body-only' }, or{ valid: false, reason: 'absent' | 'malformed' | 'mismatch' | 'outside-window' }. It is the receiver's half of both forms. It re-signs throughsignHttpBody/signHttpBodyAtand compares withtimingSafeEqual, so no receiver holds a second copy of the HMAC input. The window is symmetric: atmore than 300 s ahead of the clock is refused too.@objectstack/core(src/security/index.ts). This is an additive public surface.ApiTrigger.handleRequestand the exportedverifySignature(same signature, still returnsboolean) callverifyHttpSignature. The localcreateHmaccopy of the signed material is gone. Every refusal is the existing401envelope{ success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } }, the status PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779 declares for the inbound-hook member, and nothing is enqueued.warnline per armed hook (ArmedHook.bodyOnlyDeprecationLogged). It names the flow and the hook, spells out the timestamped form, and states the window. Re-arming the hook (stop/start, which the engine does on a flow update) logs it once more. A sender that keeps posting cannot flood the log.packages/triggers/trigger-api/vitest.config.tsaliases bare@objectstack/coreto source, with an anchoredfind, the same wayservice-queuedoes. Without the alias, the new value import would make these pins judge core'sdist/, andcheck:test-source-aliaswould refuse the unregistered import.trigger-api-route-ledger.ts) names both forms.service-messaging's outbox,service-automation'shttpnode,packages/spec,packages/lint, docs, andtrigger-api/src/plugin.ts.Why this shape
sha256=againstt=). The self-hosted mount already passes the one header through unchanged, soplugin.tsneeds no change. PR feat(spec): declare ITriggerApiService.handleInboundHook, the optional transport-neutral inbound-hook member #22779's transport-neutral member says "Anything a later signing scheme adds (a timestamp, for one) arrives in the same forwarded request, so this signature gains no field for it", and this shape keeps that true. A separate timestamp header would need a mount change and a newhandleRequestinput, and this card's scope excludes both. ADR-0041 names "GitHub/Stripe style", and this is the Stripe grammar.t=is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a stalet, it is refused. It never drops back to the body-only check. The grammar is: a canonical decimaltof at most 15 digits, one 64-digit lowercase hexv1, and exactly those two elements in that order. Verification re-signs the value and compares the whole thing, so any other spelling is refused.v1entries for key rotation, nov0, no configuration key, no new header.Tests
On HEAD
ffb30285d, after mergingorigin/mainc74d84399and rebuilding (pnpm --filter '@objectstack/trigger-api...' --filter '@objectstack/objectql...' build):pnpm --filter @objectstack/core exec vitest run --project local --maxWorkers=2: Test Files 93 passed, Tests 2362 passed.pnpm --filter @objectstack/trigger-api test: Test Files 2 passed, Tests 38 passed.pnpm --filter @objectstack/core --filter @objectstack/trigger-api typecheck: bothDone. Core'scheck:test-typecheckheld its ledger: 4 files, 4 errors, 4 pinned signatures, no growth. trigger-api compiles against core's rebuilt.d.ts, andverifyHttpSignatureexists only in the new build.The pins (every refusal asserts the whole
401 INVALID_SIGNATUREenvelope and that nothing was enqueued):api-trigger.test.ts, "the timestamped signature form": the sender's value is computed withcreateHmacstraight from the recipe, not through core.202, enqueued, the flow runs, no deprecation line).v1=and thesha256=spelling.http-signature.test.ts: literal-value pins of the wire form, the window edges, the replay, no fallback, the malformed set, and trimming. A constant-time pin wrapsnode:crypto'stimingSafeEqualand asserts that it makes the one decision for every well-formed value of either form.Ablations. Each mutation went through
node scripts/ablation-replace.mjsin WRAP mode. Every one landed (anchor 1 to 0, blob changed) and was restored: the blob equals the HEAD blob andgit diff HEADis empty. A scripttraprestored on EXIT, INT and TERM. Both suites resolve the subject from source: core's test imports it relatively, and trigger-api through the alias. So nodist/leg applies, and the trigger-api reds below show the alias reaching source. Runs on115cd41f4, except A3b on9cb44814e. Neither source file has changed since.verifySignaturestalev1signs the body alone, nott.bodyverifySignaturet=value falls back to asha256elementtimingSafeEqualreplaced by===The first A3 run left core green, which showed that core's suite did not cover the
sha256-spelled downgrade.9cb44814eadded that case, and A3b turned core red as well.Gates. The list was derived on this branch at
ffb30285dwithnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, which gave 65 commands. Each command's exit code was recorded to disk before any pipe.--ranreconciled them: "Run reconciliation — 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN." All 65 exited 0 onffb30285d, includingcheck:test-source-alias("73 packages with tests scanned; 60 registered …") andcheck:nul-bytes.dist/-reading gates (check:dts-closure,check:sourcemap-no-sources-content,check:published-files) swept core and trigger-api as rebuilt at this head. Other packages'dist/came from an earlier build of this branch.check:dual-build-cjs-loadswas re-run after the workspacedist/had been rebuilt at this head: "107 published require entry point(s) across 66 package(s) load".Lint (narrowed, not the repo scan). I ran eslint over this diff's files on
ffb30285d, with no inline config.ESLint#isPathIgnoredandcalculateConfigForFilesay whether it is linted. 7 of 8 are; the changeset.mdis not.eslint.config.mjsnever enables type-aware linting (its own header: "noparserOptions.project, no typed@typescript-eslintrules"), so no untouched file's verdict depends on these edits. The fullpnpm lintis CI's.Acceptance notes
None of these is filed. Each is noted for the seat.
docs/adr/0138-…md, "Partner webhooks are served today by the signed inbound-hook channel (verifySignature), which verifies an HMAC and has no timestamp or replay window") goes stale for the versioned form once this lands. This is a Tier H surface and is not edited here. Carrier: whoever next amends ADR-0138, alongside its F1 follow-up.X-Objectstack-Timestampheader. No sender in this tree sends it, and no code reads it. This is pre-existing and was not changed.domain:spec, open): thehandleInboundHookTSDoc describes only the body-only form. Its refusal status agrees with this PR (401 INVALID_SIGNATURE). Whichever lands second may widen that sentence. ⛔ Not edited here.skills/objectstack-automation/SKILL.md§Inbound webhook,packages/lintrule-explanations.ts(flow-api-trigger-secret-missing) and thevalidate-flow-trigger-readiness.tshint, theexamples/app-showcaseinbound-flow comment, anddocs/qa/platform-checklist/areas/automation.json. None of them becomes false, because body-only is still accepted. They do steer an author to the deprecated form, so that question goes to the seat in the report.plugin.tschange and is out of scope.redeliver-guard.tsheader;webhooks.mdx§6.1). A sender adopting the timestamped form must sign at send time, or every retry older than 300 s fails. This is recorded for the cutover decision.Generated by Claude Code