Skip to content

Require v2 signatures on state-changing endpoints, each proof once (#1700, piece 7) - #1832

Closed
michielbdejong wants to merge 1 commit into
claude/proxy-hardening-wasip2from
claude/proxy-hardening-v2
Closed

michielbdejong wants to merge 1 commit into
claude/proxy-hardening-wasip2from
claude/proxy-hardening-v2

Conversation

@michielbdejong

Copy link
Copy Markdown
Contributor

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:

  1. Refuse wasip2 class extenders at the integration proxy, and say why (#1700, piece 5) #1831: piece 5;
  2. this one: stacked on Refuse wasip2 class extenders at the integration proxy, and say why (#1700, piece 5) #1831;
  3. piece 8: stacked on this one.

Endpoints that now require v2

This applies to POST and DELETE on the routes below. v1 signatures, bearer tokens and session cookies get a 401 that 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}
  • all 13 /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/activate

GET on these paths still accepts v1.

I checked this list against the current tree. Not changed:

How it works

  • require_v2 middleware (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 their web::Json extractors, 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 with Content-Encoding is refused. The verified agent goes into the request extensions, and helpers::get_client_agent_of and get_client_agent_for_request return it from there. Refusals come back as responses, not errors, so CORS headers still apply.

  • Replay cache design (server/src/replay_cache.rs):

    • One cache per node, in memory, in AppState.
    • Keyed on the SHA-256 of the decoded signature bytes. v2 uses strict Ed25519, so a signature can't be re-encoded into a second valid one.
    • Each entry is kept until timestamp + AUTH_MAX_AGE_MS, the last moment the proof counts as fresh. Entries are pruned in expiry order from a BTreeSet.
    • Bounded at 100,000 entries. When it's full of live entries it refuses new proofs with 429 rather than evicting one, since an evicted proof could be replayed.
    • A replayed proof gets 401 "already used".
    • A restart forgets the cache, which reopens at most one 5-minute window.
    • Ed25519 is deterministic, so two identical requests signed in the same millisecond get the same signature, and the second one is refused. The docs say to sign every request anew.
  • Browser: @tomic/lib gets signedRequestInit(url, agent, { method, body, headers }). Every caller of these routes in @tomic/lib and the data browser now signs its writes with it:

    • plugin-connection, integration-actions, plugin-server, plugin-install (release package);
    • app agent, app write, view token;
    • plugin schedule, secrets, trigger, event automation;
    • website hosting and assets;
    • forget-peer and iroh-sync.

    Reads still sign with v1.

  • Docs: docs/src/authentication.md lists the endpoints and explains the replay rule.

Tests

  • replay_cache unit 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 the DELETEs) 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.
  • Tests moved to v2: existing ones in app_endpoints_test, website, and server/tests/it/iroh_pairing.rs, which runs two real servers.
  • vitest: 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)
  • clippy -D warnings, and the pre-commit workspace clippy
  • cargo test -p atomic-server --test it iroh_pairing
  • the full browser/lib vitest suite
  • data-browser vitest for the touched files
  • pnpm run lint
  • typecheck for lib and data-browser

This base has no plugin-routes feature and no plugin_proxy test in it.

Not in this PR: the atomic-plugins repo, if anything there calls these routes directly (for example publishing to /plugin-release-package from a CLI). It needs the same switch to v2.

🤖 Generated with Claude Code

…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>
@michielbdejong

Copy link
Copy Markdown
Contributor Author

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 develop as one chain before merging, so line references are against the current stacked base.

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 (server/tests/it/iroh_pairing.rs, app_endpoints_test.rs); restore the originals so they prove v1 still passes. (2) The clients (managedServer.ts, pairing.ts, plugin-server.ts, hostingClient.ts → signedRequestInit) now always sign v2 on these endpoints. v0.40.4 has no v2 verification, so a new web app or @tomic/lib against an older server (or atomic-saas, if it verifies these routes itself, e.g. /forget-peer) gets a 401. Keep sending v1 unless the server advertises v2, or ship client and server strictly together. (3) Note: require_v2 buffers every POST body up to PAYLOAD_MAX before auth, which matters for plugin-release and website asset uploads.

🤖 Generated with Claude Code

@michielbdejong

Copy link
Copy Markdown
Contributor Author

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 claude/plugin-stack-chain, built on develop 2e509a925. It is one linear chain in which every stack PR's own commits are cherry-picked in order. No PR branch has been force-pushed yet, and the old heads are still in claude/atomic-plugins-pin.

  • The plan, checklist and triage findings are in planning/plugin-stack-rebase.md on that branch.
  • The per-PR commit positions are in planning/plugin-stack-rebase/positions.txt.
  • The last commit on the branch is only these notes. Drop it before pushing PR branches.

Done in the chain, from #1786 through #1832:

Next:

  1. Pick Fail closed on installation identities too; merges #1644 (#1700, piece 8) #1833, only 4df695145.
  2. Add App frames: openExternal(url) and openResource(subject) #1744's 1545cab1a (openResource asks before leaving the app). It currently lives only on origin/claude/frame-open-external.
  3. Apply the triage fixes listed in the planning doc, each folded into its own PR's commit. The triage is posted as a comment on each stack PR.
  4. Finish Require v2 signatures on state-changing endpoints, each proof once (#1700, piece 7) #1832 on the client side: calls that may reach a node of another version keep sending v1. That covers pairing, managedServer, hosting, forget-peer, iroh-sync and bind-drive. Also restore tests/it/iroh_pairing.rs to v1.
  5. Run pnpm clean-translations, lint, tsc, vitest and e2e light on the chain. The JS side has not been checked yet, and the .po merges only appended entries.
  6. Handle Make fresh worktrees build with two commands #1830, Demo-session tooling for live user-testing sessions #1834, Integrations soft launch: no error without a server, a new-feature note, experimental toggle behind a facilitator unlock #1835 and Find already-fetched external classes in New Table's class picker #1842 on their own, rebased directly onto develop.
  7. Force-push each PR branch to its chain position and retarget it to the previous PR's branch. TrekMeester picks them up after that.

Watch out: the disk on the old machine filled up during the Rust test link step. Build with CARGO_INCREMENTAL=0.

🤖 Generated with Claude Code

@michielbdejong

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

browser plugin Should probably be an Atomic Plugin security server atomic-server @tomic/lib @tomic/lib

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant