feat: move the SIWE challenge below the facade - #943
Conversation
The nonce an EIP-4361 message embeds now comes from the engine, over the API client's existing siwe_challenge, instead of a plain fetch in apps/web against the public challenge endpoint. That was the only direct API call in apps/web and it contradicted blueprint/web-client.md: the app's only vault-facing dependency is packages/client. The engine validates the nonce against EIP-4361's alphanumeric class at the trust boundary, so a hostile challenge response cannot inject extra fields into the text a wallet signs. That fail-closed check moves with the code from the deleted apps/web/src/auth/siweNonce.ts. siweChallenge is gated on start exactly like the siweLogin that spends it: SIWE is a secondary method, so a nonce the engine could not spend is refused before the host prompts a wallet for it rather than after. Closes #910
Review-gate follow-ups on the same change. The engine hard-rejects a nonce outside EIP-4361's alphanumeric class, so the API that produces it must be pinned to the same invariant: challenge.service asserts the class it issues, and the contract suite's vacuous non-empty check becomes the class assertion it was standing in for. Without that, changing the API's alphabet breaks every wallet login with an opaque decode error that only the live-stack suite can see. Also: unicode-confusable and boundary cases on the client-side check, an assertion that a refusal never echoes the nonce it rejected, the wasm host module header refreshed to list the new read, and the nonce fetched before the signing phase latches so the label stops promising a wallet prompt that has not happened yet. Drops the redundant second NotStarted guard, the duplicated gate rationale, and a hand-copied nonce literal.
WalkthroughThe SIWE nonce request moved from the web API layer into the engine facade. The request now traverses WASM, worker, local, correlated, and broadcast transports before reaching wallet login. Nonce validation now enforces the EIP-4361 format. ChangesSIWE challenge flow
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant WalletLoginButton
participant AuthHook
participant EngineFacade
participant ClientTransport
participant WasmHost
participant ApiClient
WalletLoginButton->>AuthHook: requestNonce()
AuthHook->>EngineFacade: siweChallenge()
EngineFacade->>ClientTransport: siweChallenge()
ClientTransport->>WasmHost: siweChallenge()
WasmHost->>ApiClient: POST /auth/siwe/challenge
ApiClient-->>WasmHost: validated nonce
WasmHost-->>ClientTransport: nonce string
ClientTransport-->>EngineFacade: nonce string
EngineFacade-->>AuthHook: nonce string
AuthHook-->>WalletLoginButton: nonce string
WalletLoginButton->>WalletLoginButton: create SIWE message
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/client/src/worker/engineHost.ts`:
- Around line 110-112: Update EngineHost construction in onBootstrap() to pass
config.apiBaseUrl alongside config.profile, then update the EngineHost
constructor and its EngineHandle creation to accept and forward apiBaseUrl as
api_base_url. Preserve siweChallenge() behavior while ensuring requests target
the configured origin.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 2e78cd65-0aa6-49b0-b881-854e53e85b39
📒 Files selected for processing (30)
apps/api/src/auth/services/challenge.service.test.tsapps/web/src/auth/siweNonce.test.tsapps/web/src/auth/siweNonce.tsapps/web/src/auth/useAuth.test.tsxapps/web/src/auth/useAuth.tsapps/web/src/components/auth/WalletLoginButton.tsxapps/web/src/routes/LoginPage.tsxapps/web/src/test/authFakes.tsxcrates/contract/tests/contract.rscrates/engine/src/api/client.rscrates/engine/src/facade.rscrates/engine/tests/facade.rscrates/wasm/src/host.rspackages/client/src/broadcast.tspackages/client/src/broadcastTransport.test.tspackages/client/src/broadcastTransport.tspackages/client/src/correlatedTransport.tspackages/client/src/engineClient.tspackages/client/src/facade.test.tspackages/client/src/facade.tspackages/client/src/leaderRelay.tspackages/client/src/testkit.tspackages/client/src/transport.tspackages/client/src/worker/engineHost.tspackages/client/src/worker/engineWasm.tspackages/client/src/worker/protocol.tspackages/client/src/worker/serve.test.tspackages/client/src/worker/serve.tspackages/client/test/browser/fakeEngine.worker.tspackages/client/test/browser/journalEngine.worker.ts
💤 Files with no reviewable changes (2)
- apps/web/src/auth/siweNonce.test.ts
- apps/web/src/auth/siweNonce.ts
Problem
facade.siweLogin(message, signature)existed, but nothing exposed the challenge an EIP-4361message must embed. The engine's API client already had
siwe_challenge()againstPOST /auth/siwe/challenge; it was unreachable from the facade.So #804 left
apps/web/src/auth/siweNonce.tsdoing a plainfetchagainst the API's publicchallenge endpoint. That was the only direct API call in
apps/web, and it contradictedblueprint/web-client.md— the app's only vault-facing dependency ispackages/client.Change
Engine::siwe_challengeover the existingApiClient::siwe_challenge, returning thenonce.
ApiClient::siwe_challengenow validates it against EIP-4361's alphanumeric class beforeconstructing a
SiweNonce: the nonce lands verbatim in the text a wallet signs, so anythingoutside that class would let a hostile challenge response splice extra fields into the signed
message. That fail-closed check is what moved with the code out of the deleted
siweNonce.ts.crates/wasmbinding, thenpackages/client:worker/protocol.tsrequest +resultunion,worker/engineWasm.ts,worker/engineHost.ts,worker/serve.ts,transport.ts,correlatedTransport.ts,broadcast.tsWireRead,broadcastTransport.ts,leaderRelay.ts,engineClient.ts,facade.ts. The nonce crosses as a plainstring, so no new descriptor type isminted.
apps/web/src/auth/siweNonce.tsand its test are deleted.WalletLoginButtontakes arequestNonceprop fed byuseAuth().siweChallenge()and no longer takesapiBaseUrl.leaderRelay's read dispatch becomes a switch behind an annotated return type, so a fourthWireReadkind fails to compile instead of leaving a follower's request unanswered.!nonce.is_empty()becomes theclass assertion it was standing in for. A decode-side hard reject needs its producer pinned at the
producer, or a change of alphabet breaks every wallet login with an opaque decode error visible
only to the live-stack suite.
Settling the issue's Note
The issue asked whether the wallet method belongs on the cold login page at all. It does not.
Command::SiweLoginis refused withNotStartedbeforefacade.startand cannot be otherwise —SIWE authenticates the account against the API and cannot produce the Core Kit login secret a vault
cold start needs;
blueprint/engine.mdandblueprint/web-client.mdboth call it a secondarymethod. So
siweChallengeis gated identically, and the refusal now lands before the walletsignature prompt instead of after it.
The placement half could not land here:
App.tsxhas only/and/files/:nodeId?, so there is nostarted-engine surface to move the button to. Filed as #938.
Deferred, each with a depends-on edge back to #910
LoginPage, andcorrect the
blueprint/web-client.mdroute table that still lists/as hosting SIWE. Carries thereview's finding that
useAuth's exclusion lock covers only the second half of the wallet flow.Httpseam a request deadline. The deletedsiweNonce.tscarried its ownAbortSignal.timeout(10_000);FetchHttp.sendpasses no signal, so every engine API call isunbounded, and the wasm host holds the engine read lock across the await. Not new —
downloadhasthe same shape — but this change inherits it where an explicit bound used to exist.
packages/clientread rail.siweChallengeis the third read and paid an11-file mechanical tax that a command does not; sequenced behind web: derive auth state from the engine instead of a tab-local store #914 deliberately.
One informational delta, no action: the challenge POST now rides
FetchHttp, which setscredentials: 'include', so it carries the HTTP-only refresh cookie where the page-levelfetchsent none. No CSRF surface — the nonce is session-unbound and side-effect-free — and the read is
gated on
started, so the session is already correlated.#914 rebase note
This shares a facade-threading surface with #914, which is not being worked yet. The threading is
deliberately minimal and mechanical: one new member per file, placed next to the existing
snapshot/downloadreads, no shared machinery reshaped.correlatedTransport'srequest/dispatch/settlecore is untouched — it is already result-type-agnostic. The onenon-additive edit is
leaderRelay's read dispatch, which #914 does not touch since it adds an event,not a read. #942 is filed to sequence the real generalization after #914.
Tests
crates/engine/src/api/client.rs—siwe_challenge_refuses_a_nonce_outside_the_eip4361_classcovers short, hyphenated, newline-bearing, spaced, empty, over-long, and unicode-confusable nonces
(Arabic-Indic digits and fullwidth Latin, which
char::is_alphanumericwould have accepted), andasserts the refusal never echoes the value it rejected.
siwe_challenge_accepts_the_class_boundariespins 8 and 128. Reverting
is_eip4361_noncefails both, plus the pre-existingsiwe_challenge_returns_a_nonce_with_no_request_body, whose fixture had to become a real nonce.apps/api—issues a nonce inside the EIP-4361 class the engine enforces. Change the alphabet inchallenge.service.tsand it fails.crates/engine/tests/facade.rs—a_siwe_challenge_before_start_is_rejected_not_started(drop thegate and it fails) and
a_started_engine_serves_the_nonce_from_its_api_client, which asserts therequest reaches
/auth/siwe/challenge.packages/client— facade delegation, theserveEngineworker round-trip, and a followerchallenge served off the leader engine. Remove the
serve.tscase and the round-trip hangs totimeout; remove the
leaderRelayarm and it fails to compile.apps/web—useAuthreads the nonce from the facade and never from the API. The deletedsiweNonce.test.ts's validation assertions live on in the Rust client test above.Verification
All exit 0 on this branch:
cargo fmt --allcargo clippy --workspace --all-targets -- -D warnings— cleancargo check --workspace --all-targetscargo check -p cipherbox-wasm --target wasm32-unknown-unknown --all-targetscargo test -p cipherbox-engine -p cipherbox-wasm— 554 lib + 11 facade + the rest, 0 failedpnpm -r --if-present run typecheck— api, client, web all cleanpnpm -r --if-present run test— client 114, api 178, web 64, all passedpnpm --filter @cipherbox/client test:browser— 19 passedpnpm exec eslint .— cleanReview gates run on
git diff main...HEAD:/security-review(no findings),/simplify(fourangles),
/crypto-privacy-review(no must-fix). Findings folded into the second commit; the restfiled as #938 / #939 / #942.
Closes #910
Summary by CodeRabbit