fix(rust/websocket): sign the upgrade with X-TinyPlace-Signature headers - #269
Conversation
The WebSocket handle authenticated only through the URL, on the assumption
that "a WebSocket upgrade can't carry the usual custom headers". That is a
browser limitation, not a protocol one, and the backend does not read those
query params: `middleware.Auth` runs on the upgrade request and reads
`X-TinyPlace-Signature` / `X-TinyPlace-Public-Key` (falling back only to
`?signature` / `?signerPublicKey`), aborting with 401 before the handler ever
reaches `upgrader.Upgrade`. Every auth-gated stream — /a2a/:id/stream,
/inbox/stream, /broadcasts/:id/stream and friends — therefore failed the
handshake instead of upgrading.
Send the credential as real headers on the handshake, mirroring the REST
pipeline: `sign_websocket_upgrade` emits the signer's public key, its crypto
id, and a signature that is either a reusable `siws:` proof or a
freshness-bound `v1:<b64url(ts)>:<b64url(nonce)>:<b64(sig)>` token over the
canonical payload `{"action":"","fields":{}}` (a stream GET declares no action
and no fields). The `X-Tinyplace-SDK` client tag rides along, as it does on
every HTTP request. The existing URL-borne credentials are unchanged, so a
query-string backend still authenticates.
Tests pin the header set, verify the v1 token's signature against the signer's
key byte-for-byte, and assert through a real handshake that the server sees
the headers; the docker e2e suite gains an authenticated /inbox/stream case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1praWiMWorz1ZREBRfbwg
The suite proved the signed handshake with one endpoint on one signing scheme. Widen it so the credential contract is pinned where it can actually regress, and point the module docs at staging as a run target: - the `v1:<ts>:<nonce>:<sig>` scheme, not just the SIWS proof a default LocalSigner mints — both must authenticate the upgrade; - an unsigned upgrade, which must be refused with 401. Without this control the signed cases would pass just as well against a public endpoint; - the directory-auth path (/a2a/:id/stream), asserting the upgrade is never answered with 401 even when ownership resolution declines it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U1praWiMWorz1ZREBRfbwg
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThe Rust SDK now authenticates WebSocket upgrades with signed ChangesWebSocket authentication
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant TinyPlaceWebSocket
participant Signer
participant WebSocketServer
Client->>TinyPlaceWebSocket: connect()
TinyPlaceWebSocket->>Signer: sign canonical empty upgrade payload
Signer-->>TinyPlaceWebSocket: signature headers
TinyPlaceWebSocket->>WebSocketServer: upgrade request with X-TinyPlace headers
WebSocketServer-->>TinyPlaceWebSocket: 101 success or 401 rejection
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Warning Tools execution failed with the following error: Failed to run tools: 14 UNAVAILABLE: Connection dropped Comment |
|
| Filename | Overview |
|---|---|
| sdk/rust/src/auth.rs | Adds sign_websocket_upgrade function and WS_*_HEADER constants; signs the empty canonical payload (action="", fields={}) using the existing sign_fresh_canonical_payload, correctly reusing the SIWS/v1 scheme selection logic. |
| sdk/rust/src/websocket.rs | Wires sign_websocket_upgrade into connect() via a new public upgrade_headers() method; converts the URL string to a tungstenite Request and inserts the signed headers before dialing. Legacy URL params remain in signed_url() for backward compatibility. |
| sdk/rust/tests/websocket.rs | Adds four new unit/integration tests: header set validation, byte-for-byte v1 token signature verification against the signer's key, no-signer SDK-tag-only assertion, and a real local-server handshake that proves the headers actually reach the server. |
| sdk/rust/tests/e2e_docker.rs | Adds four ignored e2e tests covering SIWS auth, v1-token auth, unsigned-rejection (negative control), and directory-auth stream connect; also adds a signed_client helper for generating fresh key pairs. |
| sdk/rust/README.md | Updates the WebSocket auth description to reflect that credentials travel in headers (not URL), naming the specific header and the two signature formats. |
Sequence Diagram
sequenceDiagram
participant SDK as Rust SDK
participant TW as TinyPlaceWebSocket
participant BE as Backend (Gin)
note over SDK,BE: Before this PR (broken)
SDK->>TW: connect()
TW->>TW: "signed_url adds ?authorization= (ignored by backend)"
TW->>BE: "GET /inbox/stream?authorization=..."
BE->>BE: reads X-TinyPlace-Signature header - missing
BE-->>TW: 401 Unauthorized
note over SDK,BE: After this PR (fixed)
SDK->>TW: connect()
TW->>TW: signed_url adds legacy URL params for backward compat
TW->>TW: upgrade_headers calls sign_websocket_upgrade
TW->>TW: signs canonical payload with SIWS proof or v1 token
TW->>BE: GET /inbox/stream with X-TinyPlace-Signature header
BE->>BE: reads X-TinyPlace-Signature and X-TinyPlace-Public-Key
BE-->>TW: 101 Switching Protocols
TW-->>SDK: WebSocketConnection
Reviews (1): Last reviewed commit: "test(rust/websocket): pin the live upgra..." | Re-trigger Greptile
Problem
The Rust SDK could not open any authenticated WebSocket stream against the backend. Every auth-gated stream —
/a2a/:id/stream,/inbox/stream,/broadcasts/:id/stream,/conversations/:id/stream,/escrow/:id/stream— was answered with401 Unauthorizedand never upgraded.The handle authenticated purely through the URL, on the premise recorded in its own module docs that "a WebSocket upgrade can't carry the usual custom headers". That is a browser limitation, not a protocol one, and the backend does not read those params:
middleware.Authruns as ordinary Gin middleware on the upgradeGET, readingX-TinyPlace-Signature/X-TinyPlace-Public-Key, and aborts 401 before the handler ever reachesupgrader.Upgrade— so a bad handshake never becomes a101.?signature/?signerPublicKey. The SDK was sending?authorization=and?X-TinyPlace-Signature=, neither of which the router looks at.REST was unaffected and has been working all along: those calls go through
sign_directory_write, which already puts the credential in theX-TinyPlace-Signatureheader. Only the WebSocket path carried it in the URL, which is why this stayed invisible until an auth-gated stream was used.Fix
Send the credential as real headers on the handshake, mirroring the REST pipeline.
sign_websocket_upgradeemits the signer's public key, its crypto id, and a signature that is either a reusablesiws:proof or a freshness-boundv1:<b64url(ts)>:<b64url(nonce)>:<b64(sig)>token over the canonical payload{"action":"","fields":{}}— a streamGETdeclares no action and no fields. It reuses the existingsign_fresh_canonical_payload, so it picks the same scheme the rest of the SDK would for a given signer. TheX-Tinyplace-SDKclient tag rides along, as it does on every HTTP request.The existing URL-borne credentials are left in place, so a query-string backend still authenticates and nothing regresses for older deployments.
Verification
Live against
staging-api.tiny.place(version 0.1.4), 9/9:/inbox/stream, SIWS proof101→snapshotframe/inbox/stream,v1:token101→snapshotframe/inbox/stream, no credential401(negative control)/a2a/<cryptoId>/stream(directory auth)activity/ledger/explorer.live(public)snapshot/ connectedReverting just the two source files to the pre-fix state and re-running the authenticated case against the same server reproduces
401 Unauthorized— the header is what makes it connect.Offline suite: 33 test binaries, 0 failures.
cargo fmt --checkandcargo clippy --all-targets -- -D warningsclean for both the default andclifeature sets. New unit tests pin the header set, verify thev1:token's signature byte-for-byte against the signer's key, and assert through a real handshake that the server actually receives the headers.Run the live suite with:
Out of scope
sign_directory_write's non-SIWS branch still emits the legacyX-TinyPlace-Date/X-TinyPlace-Nonce+ raw-base64 signature, which the backend no longer reads — so a.without_siws()signer gets401on REST (measured, not inferred). Nothing constructs one by default, and it is pre-existing rather than touched here, so it is left for a follow-up. Note the WebSocket path above now works with both schemes.🤖 Generated with Claude Code
https://claude.ai/code/session_01U1praWiMWorz1ZREBRfbwg
Summary by CodeRabbit
New Features
Documentation