Repository navigation
Require v2 signatures on state-changing endpoints, each proof once (#1700, piece 7) - #1832
michielbdejong wants to merge 1 commit into
Conversation
…1700) Piece 6 and answer 7 on #1700. A v1 signature covers only "{url} {timestamp}", and a session cookie is a reusable v1 proof, so whoever captured either could send another body to the same endpoint for five minutes; a v2 proof could be replayed byte for byte in that window. Server: - `require_v2` middleware (server/src/require_v2.rs), wrapped on each route below. On POST/DELETE it refuses anything but `x-atomic-signature-version: 2` (v1, bearer tokens, cookies: 401 with REQUIRES_V2), reads the raw body, checks the signature over method, full URL and exactly those bytes, then hands the same bytes to the handler, so handlers keep their `web::Json` extractors and the hash always matches what is parsed. A body with Content-Encoding is refused. The verified agent goes into the request extensions; `helpers::get_client_agent_of` and `get_client_agent_for_request` return it. GET/HEAD/OPTIONS pass through. Refusals are rendered as responses, so CORS still applies. - Routes: /app-agent, /app-write, /plugin-view-token, /plugin-secret, /plugin-run, /plugin-schedule, /plugin-resume, /plugin-auto-apply, /plugin-trigger, /plugin-release, /plugin-release-package, /plugin-release-pin, /plugin-sync-{preview,apply,schedule,status}, /plugin-connection-{state,checkpoint}, /plugin-external-{read,status,confirm,apply}, the 13 /integration-action* routes, /bind-drive, /forget-peer, /iroh-sync, and the /website-hosting POSTs. Not /commit, PUT /blob, /upload, or POST on other resources. - `ReplayCache` (server/src/replay_cache.rs), one per node in AppState: keyed on the SHA-256 of the decoded signature bytes, each entry kept until timestamp + AUTH_MAX_AGE_MS (the last moment the proof is fresh), pruned in expiry order. Bounded at 100,000; when full of live entries it refuses (429) rather than evict one, which would make it replayable. Browser: `@tomic/lib` gets `signedRequestInit(url, agent, {method, body, headers})`. Every data-browser and @tomic/lib caller of these routes signs its writes with it: plugin-connection, integration-actions, plugin-server, plugin-install (release package), app agent/write/view token, plugin schedule/secret/trigger/automation, website hosting and assets, forget-peer, iroh-sync. Reads keep v1. Tests: replay_cache unit tests (replay refused, forgotten after the window, clock skew, full cache); app_endpoints_test sends every listed route v1, a cookie and v2 through the real router; a captured v2 request replayed is refused; GET keeps v1. Existing tests moved to v2 (app_endpoints_test, website, it/iroh_pairing). vitest for signedRequestInit and the touched callers. Docs in authentication.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Merge-risk triage (2026-09-28). This is a read-only review of this PR's own diff. The rule it applies: nothing that works today may break for existing users or clients, and new behaviour is added alongside the old. The stack is being rebased onto Verdict: SAFE-WITH-FIX (in addition to the planned v1 acceptance and replay-after-authz fixes) Requiring v2 on state-changing endpoints is fine once v1 stays accepted. Fix before merging: (1) This PR edits existing tests ( 🤖 Generated with Claude Code |
Hand-off: plugin stack rebase onto develop (in progress)Goal agreed by Michiel and Joep (2026-09-28): keep building plugin features, but nothing that exists today may break. New behaviour is added alongside the old; for example v2 signatures are accepted, and v1 is not deprecated. Where the work is: branch
Done in the chain, from #1786 through #1832:
Next:
Watch out: the disk on the old machine filled up during the Rust test link step. Build with 🤖 Generated with Claude Code |
|
Closing as part of the plugin-host stack cleanup (Q-101): not needed by atomic-plugins now. See the inventory at #1726 (comment). The branch is kept, and pin candidates are unaffected. Closed by Claude (VPS coordinator) on behalf of Michiel. |
Piece 7 of #1700: answer 7, plus piece 6's endpoint list. State-changing endpoints now require a v2 request signature, and each signature is accepted once.
Second of three stacked PRs:
Endpoints that now require v2
This applies to
POSTandDELETEon the routes below. v1 signatures, bearer tokens and session cookies get a401that explains why./app-agent,/app-write,/plugin-view-token,/plugin-secret/plugin-run,/plugin-schedule,/plugin-resume,/plugin-auto-apply,/plugin-trigger/plugin-release,/plugin-release-package,/plugin-release-pin/plugin-sync-{preview,apply,schedule,status},/plugin-connection-{state,checkpoint},/plugin-external-{read,status,confirm,apply}/integration-action*routes:/integration-actions, plus-approve,-call,-cancel,-consumer-abandon,-consumers,-grant,-grants,-history,-history-compact,-proposals,-recovery-confirm,-recovery-inspect/bind-drive,/forget-peer,/iroh-sync/website-hosting/assets/{hash},/website-hosting/deployments,/website-hosting/activateGETon these paths still accepts v1.I checked this list against the current tree. Not changed:
/commitandPUT /blob, which Plugins phase 2 (atomic-plugins#54): installation identities, host-signed proxy calls, connection requests #1700 puts out of scope;/uploadandPOSTon any other resource./uploadis multipart, so a browser can't hash the exact bytes it sends without building the multipart body by hand.POSTon a resource is the general endpoint API, used by invites, endpoints and third-party clients, so requiring v2 there breaks things outside this repo and should be its own change. Both still refuse v2, as before.How it works
require_v2middleware (server/src/require_v2.rs) is wrapped on each route above. It reads the raw body before the handler runs, checks the signature over the method, the full URL and exactly those bytes, then passes the same bytes on to the handler. So handlers keep theirweb::Jsonextractors, and the hash always matches what gets parsed. That covers Plugins phase 2 (atomic-plugins#54): installation identities, host-signed proxy calls, connection requests #1700's "read the raw body first" prerequisite in one place. A body withContent-Encodingis refused. The verified agent goes into the request extensions, andhelpers::get_client_agent_ofandget_client_agent_for_requestreturn it from there. Refusals come back as responses, not errors, so CORS headers still apply.Replay cache design (
server/src/replay_cache.rs):AppState.timestamp + AUTH_MAX_AGE_MS, the last moment the proof counts as fresh. Entries are pruned in expiry order from aBTreeSet.429rather than evicting one, since an evicted proof could be replayed.401"already used".Browser:
@tomic/libgetssignedRequestInit(url, agent, { method, body, headers }). Every caller of these routes in@tomic/liband the data browser now signs its writes with it:Reads still sign with v1.
Docs:
docs/src/authentication.mdlists the endpoints and explains the replay rule.Tests
replay_cacheunit tests: a replay is refused; a proof is refused through its last fresh millisecond and forgotten after; a proof signed ahead of the server clock; a full cache refuses rather than evicting.app_endpoints_test::state_changing_routes_refuse_v1_and_cookies_and_accept_v2: sends every route above (including theDELETEs) a v1 signature, a cookie and a v2 signature through the real router and middleware. v1 and the cookie get 401 with the reason. v2 gets past authentication.a_replayed_v2_request_is_refused: a captured request is replayed byte for byte.reads_on_v2_routes_still_accept_v1: GET still works with v1.app_endpoints_test,website, andserver/tests/it/iroh_pairing.rs, which runs two real servers.signedRequestInit(the signature verifies over exactly the body sent), plus every touched caller's test.Commands run:
cargo test -p atomic-server --lib(294 passing on this branch)-D warnings, and the pre-commit workspace clippycargo test -p atomic-server --test it iroh_pairingbrowser/libvitest suitepnpm run lintThis base has no
plugin-routesfeature and noplugin_proxytest init.Not in this PR: the atomic-plugins repo, if anything there calls these routes directly (for example publishing to
/plugin-release-packagefrom a CLI). It needs the same switch to v2.🤖 Generated with Claude Code