Skip to content

fix(rust/websocket): sign the upgrade with X-TinyPlace-Signature headers - #269

Merged
sanil-23 merged 2 commits into
mainfrom
ws-v2-custom-header
Jul 27, 2026
Merged

fix(rust/websocket): sign the upgrade with X-TinyPlace-Signature headers#269
sanil-23 merged 2 commits into
mainfrom
ws-v2-custom-header

Conversation

@sanil-23

@sanil-23 sanil-23 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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 with 401 Unauthorized and 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.Auth runs as ordinary Gin middleware on the upgrade GET, reading X-TinyPlace-Signature / X-TinyPlace-Public-Key, and aborts 401 before the handler ever reaches upgrader.Upgrade — so a bad handshake never becomes a 101.
  • Its only query fallbacks are ?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 the X-TinyPlace-Signature header. 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_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. It reuses the existing sign_fresh_canonical_payload, so it picks the same scheme the rest of the SDK would for a given signer. The X-Tinyplace-SDK client tag rides along, as it does on every HTTP request.

GET /inbox/stream
X-TinyPlace-Public-Key: <base64 ed25519 key>
X-TinyPlace-Crypto-Id:  <base58 address>
X-TinyPlace-Signature:  siws:<proof>   |  v1:<ts>:<nonce>:<sig>
X-Tinyplace-SDK:        rust/2.0.4

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:

case result
/inbox/stream, SIWS proof 101snapshot frame
/inbox/stream, v1: token 101snapshot frame
/inbox/stream, no credential 401 (negative control)
/a2a/<cryptoId>/stream (directory auth) connected
activity / ledger / explorer.live (public) snapshot / connected

Reverting 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 --check and cargo clippy --all-targets -- -D warnings clean for both the default and cli feature sets. New unit tests pin the header set, verify the v1: 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:

cd sdk/rust
TINYPLACE_E2E_URL=https://staging-api.tiny.place \
  cargo test --test e2e_docker -- --ignored --nocapture

Out of scope

sign_directory_write's non-SIWS branch still emits the legacy X-TinyPlace-Date/X-TinyPlace-Nonce + raw-base64 signature, which the backend no longer reads — so a .without_siws() signer gets 401 on 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

    • Added authenticated WebSocket upgrade support for the Rust SDK.
    • WebSocket handshakes now include signed authentication headers when configured, supporting both SIWS and freshness-bound signatures.
    • Added access to upgrade headers for custom WebSocket integrations.
    • Unsigned authenticated handshakes are rejected with a 401 response.
  • Documentation

    • Updated Rust SDK guidance for WebSocket authentication and streaming feature configuration.

sanil-23 and others added 2 commits July 27, 2026 23:47
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
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tiny-place-website Ready Ready Preview, Comment Jul 27, 2026 6:39pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f05928df-990d-411e-871e-8f9d2fd86f33

📥 Commits

Reviewing files that changed from the base of the PR and between 20e8c7e and fd98c9d.

📒 Files selected for processing (5)
  • sdk/rust/README.md
  • sdk/rust/src/auth.rs
  • sdk/rust/src/websocket.rs
  • sdk/rust/tests/e2e_docker.rs
  • sdk/rust/tests/websocket.rs

📝 Walkthrough

Walkthrough

The Rust SDK now authenticates WebSocket upgrades with signed X-TinyPlace-* headers, supports SIWS and freshness-bound v1 signatures, preserves legacy URL credentials, and adds unit, handshake, and Docker-backed coverage.

Changes

WebSocket authentication

Layer / File(s) Summary
Upgrade signing contract
sdk/rust/src/auth.rs
Adds WebSocket credential constants and signs a canonical empty payload with freshness binding.
Signed upgrade request wiring
sdk/rust/src/websocket.rs
Builds upgrade headers and injects them into WebSocket handshake requests while retaining signed URL credentials.
Authentication validation and documentation
sdk/rust/README.md, sdk/rust/tests/*
Documents header authentication and tests SIWS, v1, unsigned, local-handshake, and backend authentication behavior.

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
Loading

Possibly related PRs

Poem

A rabbit hops where sockets gleam,
Signed headers guard the streaming dream.
SIWS or v1, credentials take flight,
The server nods—or says 401 tonight.
“Upgrade!” squeaks Bun, “secure and bright!”

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: signing Rust WebSocket upgrades with X-TinyPlace-Signature headers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

Tools execution failed with the following error:

Failed to run tools: 14 UNAVAILABLE: Connection dropped


Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a real authentication failure where every auth-gated WebSocket stream returned 401 Unauthorized because the SDK was placing credentials in URL query params (?authorization=, ?X-TinyPlace-Signature=) that the backend's middleware.Auth never reads — it only reads X-TinyPlace-Signature / X-TinyPlace-Public-Key headers on the upgrade GET. The fix mirrors the REST signing pipeline: sign_websocket_upgrade produces a freshness-bound v1: token or a reusable SIWS proof over the empty canonical payload, delivered as headers on the handshake request.

  • auth.rs adds sign_websocket_upgrade, which reuses sign_fresh_canonical_payload so both SIWS and per-request schemes are handled correctly without new signing logic.
  • websocket.rs converts the URL string to a tungstenite Request object and inserts the signed headers before dialing; legacy URL-borne credentials are kept in signed_url() for backward compatibility with query-string backends.
  • Tests cover header presence, byte-for-byte signature verification of the v1: token, a real local-server handshake, and four e2e cases (SIWS, v1:, unsigned rejection, and directory-auth stream).

Confidence Score: 5/5

Safe to merge. The change is focused on the WebSocket auth path, does not touch REST signing, and was live-tested against staging with 9 passing cases including a negative control.

The signing logic correctly delegates to the existing sign_fresh_canonical_payload, so SIWS and v1: scheme selection is unchanged from the REST path. The tungstenite header insertion uses unique header names with no risk of overwriting transport-level headers. Public key availability is always guaranteed because HttpClient::new derives public_key_base64 directly from the signer, making it impossible for a signer-present handle to have None public key. The test suite adds a real handshake proof that the headers reach the server and verifies the v1: signature byte-for-byte against the signer's key.

Files Needing Attention: No files require special attention.

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "test(rust/websocket): pin the live upgra..." | Re-trigger Greptile

@sanil-23
sanil-23 merged commit 95e8874 into main Jul 27, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant