From e9dd0ed59632d6753d83991eeb98bcefbb91f2ab Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 1 Aug 2026 11:29:03 +0200 Subject: [PATCH 1/2] feat(web): move the SIWE challenge below the facade 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 --- apps/web/src/auth/siweNonce.test.ts | 61 ------------------- apps/web/src/auth/siweNonce.ts | 28 --------- apps/web/src/auth/useAuth.test.tsx | 18 +++++- apps/web/src/auth/useAuth.ts | 10 +++ .../src/components/auth/WalletLoginButton.tsx | 8 +-- apps/web/src/routes/LoginPage.tsx | 4 +- apps/web/src/test/authFakes.tsx | 16 ++++- crates/engine/src/api/client.rs | 40 +++++++++++- crates/engine/src/facade.rs | 17 ++++++ crates/engine/tests/facade.rs | 37 ++++++++++- crates/wasm/src/host.rs | 16 +++++ packages/client/src/broadcast.ts | 6 +- .../client/src/broadcastTransport.test.ts | 8 +++ packages/client/src/broadcastTransport.ts | 4 ++ packages/client/src/correlatedTransport.ts | 1 + packages/client/src/engineClient.ts | 4 ++ packages/client/src/facade.test.ts | 14 +++++ packages/client/src/facade.ts | 10 +++ packages/client/src/leaderRelay.ts | 37 +++++++---- packages/client/src/testkit.ts | 7 +++ packages/client/src/transport.ts | 8 +++ packages/client/src/worker/engineHost.ts | 6 ++ packages/client/src/worker/engineWasm.ts | 1 + packages/client/src/worker/protocol.ts | 13 +++- packages/client/src/worker/serve.test.ts | 18 ++++++ packages/client/src/worker/serve.ts | 5 ++ .../client/test/browser/fakeEngine.worker.ts | 4 ++ .../test/browser/journalEngine.worker.ts | 4 ++ 28 files changed, 289 insertions(+), 116 deletions(-) delete mode 100644 apps/web/src/auth/siweNonce.test.ts delete mode 100644 apps/web/src/auth/siweNonce.ts diff --git a/apps/web/src/auth/siweNonce.test.ts b/apps/web/src/auth/siweNonce.test.ts deleted file mode 100644 index c14cd65cf..000000000 --- a/apps/web/src/auth/siweNonce.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { requestSiweNonce } from './siweNonce'; - -// A fresh Response per call: a body can only be read once. -function respond(body: unknown, status = 200) { - return vi.spyOn(globalThis, 'fetch').mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) - ) - ); -} - -describe('requestSiweNonce', () => { - afterEach(() => vi.restoreAllMocks()); - - it('posts to the API challenge endpoint under a timeout and returns the nonce', async () => { - const fetchSpy = respond({ nonce: 'abc12345', expiresAt: '2026-01-01T00:00:00.000Z' }); - - await expect(requestSiweNonce('https://api.test')).resolves.toBe('abc12345'); - - const [url, init] = fetchSpy.mock.calls[0]; - expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); - expect(init?.signal).toBeInstanceOf(AbortSignal); - }); - - it('resolves the endpoint against a base with a path or a trailing slash', async () => { - const fetchSpy = respond({ nonce: 'abc12345' }); - - await requestSiweNonce('https://api.test/'); - await requestSiweNonce('https://api.test/ignored'); - - for (const [url] of fetchSpy.mock.calls) { - expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); - } - }); - - it('refuses a nonce too weak, too long, or too malformed to sign over', async () => { - respond({ nonce: 'short' }); - await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); - - respond({ nonce: 'not a nonce!' }); - await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); - - // A hostile API must not push an unbounded string into the signing prompt. - respond({ nonce: 'a'.repeat(129) }); - await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); - - respond({}); - await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); - }); - - it('surfaces a refused challenge by status, without echoing its body', async () => { - respond({ message: '' }, 429); - await expect(requestSiweNonce('https://api.test')).rejects.toThrow( - /^siwe challenge refused with 429$/ - ); - }); -}); diff --git a/apps/web/src/auth/siweNonce.ts b/apps/web/src/auth/siweNonce.ts deleted file mode 100644 index 4d43da10c..000000000 --- a/apps/web/src/auth/siweNonce.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * The single-use nonce an EIP-4361 message must carry. The engine owns the SIWE - * exchange itself (`facade.siweLogin`) but exposes no challenge command, so the - * page fetches the nonce from the API's public challenge endpoint — see #910 to - * move this below the facade. - */ - -/** EIP-4361 requires 8+ alphanumerics; the API issues 32 hex characters. */ -const NONCE = /^[A-Za-z0-9]{8,128}$/; - -const TIMEOUT_MS = 10_000; - -export async function requestSiweNonce(apiBaseUrl: string): Promise { - const response = await fetch(new URL('/auth/siwe/challenge', apiBaseUrl), { - method: 'POST', - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`siwe challenge refused with ${response.status}`); - - const { nonce } = (await response.json()) as { nonce?: unknown }; - // Fail closed: an unusable nonce must not reach the wallet as a signing - // prompt, and its character class is what keeps a hostile response from - // injecting extra EIP-4361 fields into the signed text. - if (typeof nonce !== 'string' || !NONCE.test(nonce)) { - throw new Error('siwe challenge returned an unusable nonce'); - } - return nonce; -} diff --git a/apps/web/src/auth/useAuth.test.tsx b/apps/web/src/auth/useAuth.test.tsx index 3bda12088..711f9bc75 100644 --- a/apps/web/src/auth/useAuth.test.tsx +++ b/apps/web/src/auth/useAuth.test.tsx @@ -1,7 +1,13 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import { authStore } from '../stores/auth.store'; -import { authWrapper, fakeCoreKitSession, fakeEngineClient, SECRET_HEX } from '../test/authFakes'; +import { + authWrapper, + FAKE_NONCE, + fakeCoreKitSession, + fakeEngineClient, + SECRET_HEX, +} from '../test/authFakes'; import { useLoginSecretSource } from '../providers/EngineProvider'; import { useAuth } from './useAuth'; @@ -64,6 +70,16 @@ describe('useAuth', () => { expect(authStore.getState()).toMatchObject({ isAuthenticated: true, method: 'wallet' }); }); + it('reads the SIWE nonce from the facade, never from the API', async () => { + const engine = fakeEngineClient(); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await expect(result.current.auth.siweChallenge()).resolves.toBe(FAKE_NONCE); + expect(engine.calls.siweChallenges).toBe(1); + }); + it('tears down the engine and the Core Kit session on logout', async () => { const engine = fakeEngineClient(); const coreKit = fakeCoreKitSession(); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index bfec8d660..e8317daea 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -23,6 +23,8 @@ export interface Auth { error: string | null; loginWithGoogle(): Promise; loginWithEmail(email: string): Promise; + /** Issues the single-use nonce the wallet's EIP-4361 message embeds. */ + siweChallenge(): Promise; /** Exchanges a wallet-signed SIWE message; secondary to the Core Kit methods. */ loginWithWallet(message: string, signature: Uint8Array): Promise; logout(): Promise; @@ -102,6 +104,13 @@ export function useAuth(): Auth { const loginWithGoogle = useCallback(() => login('google'), [login]); const loginWithEmail = useCallback((email: string) => login('email', email), [login]); + // Outside `exclusively`: the nonce is one step inside the wallet flow, whose + // handoff takes the lock at `loginWithWallet`. + const siweChallenge = useCallback(async (): Promise => { + if (!client) throw new Error('the engine is not ready to accept a login'); + return client.facade.siweChallenge(); + }, [client]); + const loginWithWallet = useCallback( (message: string, signature: Uint8Array) => exclusively(async () => { @@ -150,6 +159,7 @@ export function useAuth(): Auth { error: error ?? coreKitError, loginWithGoogle, loginWithEmail, + siweChallenge, loginWithWallet, logout, }; diff --git a/apps/web/src/components/auth/WalletLoginButton.tsx b/apps/web/src/components/auth/WalletLoginButton.tsx index 75e678571..aff98b506 100644 --- a/apps/web/src/components/auth/WalletLoginButton.tsx +++ b/apps/web/src/components/auth/WalletLoginButton.tsx @@ -3,14 +3,14 @@ import { useConnect, useDisconnect, useSignMessage } from 'wagmi'; import { mainnet } from 'wagmi/chains'; import { hexToBytes } from 'viem'; import { createSiweMessage } from 'viem/siwe'; -import { requestSiweNonce } from '../../auth/siweNonce'; import { errorMessage } from '../../lib/errorMessage'; import { LoginError } from './LoginError'; interface WalletLoginButtonProps { + /** Reads the single-use nonce the EIP-4361 message embeds, from the facade. */ + requestNonce: () => Promise; /** Hands the signed EIP-4361 message to the facade. */ onLogin: (message: string, signature: Uint8Array) => Promise; - apiBaseUrl: string; disabled?: boolean; } @@ -28,7 +28,7 @@ const PHASE_LABEL: Record = { * identity"): wagmi collects the wallet signature here and the facade forwards * it. */ -export function WalletLoginButton({ onLogin, apiBaseUrl, disabled }: WalletLoginButtonProps) { +export function WalletLoginButton({ requestNonce, onLogin, disabled }: WalletLoginButtonProps) { const { connectors, connectAsync } = useConnect(); const { signMessageAsync } = useSignMessage(); const { disconnect } = useDisconnect(); @@ -67,7 +67,7 @@ export function WalletLoginButton({ onLogin, apiBaseUrl, disabled }: WalletLogin address: account, chainId: mainnet.id, domain: window.location.host, - nonce: await requestSiweNonce(apiBaseUrl), + nonce: await requestNonce(), uri: window.location.origin, version: '1', statement: 'Sign in to CipherBox encrypted storage', diff --git a/apps/web/src/routes/LoginPage.tsx b/apps/web/src/routes/LoginPage.tsx index 71bb507e5..1e4023161 100644 --- a/apps/web/src/routes/LoginPage.tsx +++ b/apps/web/src/routes/LoginPage.tsx @@ -7,7 +7,6 @@ import { LoginError } from '../components/auth/LoginError'; import { WalletLoginButton } from '../components/auth/WalletLoginButton'; import { MatrixBackground } from '../components/MatrixBackground'; import { StagingBanner } from '../components/StagingBanner'; -import { apiBaseUrl } from '../engine/config'; /** * The vault's front door: the Core Kit methods plus SIWE @@ -21,6 +20,7 @@ export function LoginPage() { error, loginWithGoogle, loginWithEmail, + siweChallenge, loginWithWallet, } = useAuth(); const navigate = useNavigate(); @@ -69,8 +69,8 @@ export function LoginPage() { diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx index 05db04214..cbc36db8f 100644 --- a/apps/web/src/test/authFakes.tsx +++ b/apps/web/src/test/authFakes.tsx @@ -15,19 +15,29 @@ import { EngineProvider } from '../providers/EngineProvider'; /** A 32-byte scalar in the hex shape Core Kit exports. */ export const SECRET_HEX = '0f'.repeat(32); +/** The nonce the fake facade issues for a SIWE challenge. */ +export const FAKE_NONCE = 'nonce123456789ab'; + export interface EngineCalls { /** The buffers `start` was handed, still live so a test can check zeroization. */ started: ArrayBuffer[]; /** What each buffer held on arrival, before the handoff scrubbed it. */ secrets: Uint8Array[]; siwe: { message: string; signature: Uint8Array }[]; + siweChallenges: number; logouts: number; } export function fakeEngineClient( overrides: Partial Promise>> = {} ) { - const calls: EngineCalls = { started: [], secrets: [], logouts: 0, siwe: [] }; + const calls: EngineCalls = { + started: [], + secrets: [], + logouts: 0, + siwe: [], + siweChallenges: 0, + }; const client = { facade: { start(secret: ArrayBuffer) { @@ -35,6 +45,10 @@ export function fakeEngineClient( calls.secrets.push(new Uint8Array(secret).slice()); return overrides.start?.() ?? Promise.resolve(); }, + siweChallenge() { + calls.siweChallenges += 1; + return Promise.resolve(FAKE_NONCE); + }, siweLogin(message: string, signature: Uint8Array) { calls.siwe.push({ message, signature }); return Promise.resolve(); diff --git a/crates/engine/src/api/client.rs b/crates/engine/src/api/client.rs index a271b061b..666b386c9 100644 --- a/crates/engine/src/api/client.rs +++ b/crates/engine/src/api/client.rs @@ -141,6 +141,9 @@ impl ApiClient { }; let response = ok_or_err(self.http.send(request).await?)?; let body: SiweChallengeResponse = decode(&response)?; + if !is_eip4361_nonce(&body.nonce) { + return Err(ApiError::Decode("unusable siwe nonce".into())); + } Ok(SiweNonce { nonce: body.nonce, expires_at: body.expires_at, @@ -594,6 +597,14 @@ fn is_success(status: u16) -> bool { (200..300).contains(&status) } +/// EIP-4361 fixes the nonce at 8+ alphanumerics. The check is fail-closed +/// rather than cosmetic: the nonce is interpolated verbatim into the text a +/// wallet signs, so anything outside that class lets a hostile challenge +/// response inject extra fields into the signed message. +fn is_eip4361_nonce(nonce: &str) -> bool { + (8..=128).contains(&nonce.len()) && nonce.chars().all(|c| c.is_ascii_alphanumeric()) +} + /// Serialize a request body. The client's own request types are always /// serializable, so a failure is a programmer error, not a runtime condition. fn to_json(body: &B) -> Vec { @@ -888,15 +899,40 @@ mod tests { let (http, _creds, client) = fakes(); http.enqueue_response(json_response( 200, - json!({ "nonce": "nonce-123", "expiresAt": "2026-01-01T00:00:00Z" }), + json!({ "nonce": "a1b2c3d4e5f60718", "expiresAt": "2026-01-01T00:00:00Z" }), )); let nonce = block_on(client.siwe_challenge()).expect("nonce"); - assert_eq!(nonce.nonce, "nonce-123"); + assert_eq!(nonce.nonce, "a1b2c3d4e5f60718"); let request = &http.requests()[0]; assert_eq!(request.url, "http://api.test/auth/siwe/challenge"); assert!(request.body.is_none()); } + /// The nonce lands verbatim in the text a wallet signs, so anything outside + /// EIP-4361's alphanumeric class is refused rather than forwarded. + #[test] + fn siwe_challenge_refuses_a_nonce_outside_the_eip4361_class() { + for unusable in [ + "short7", + "has-a-hyphen-in-it", + "line\nbreak12345", + "spaced out nonce", + "", + &"a".repeat(129), + ] { + let (http, _creds, client) = fakes(); + http.enqueue_response(json_response( + 200, + json!({ "nonce": unusable, "expiresAt": "2026-01-01T00:00:00Z" }), + )); + assert_eq!( + block_on(client.siwe_challenge()).unwrap_err(), + ApiError::Decode("unusable siwe nonce".into()), + "accepted {unusable:?}" + ); + } + } + #[test] fn siwe_login_unlinked_wallet_is_unauthorized() { let (http, _creds, client) = fakes(); diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index 89658dca4..28b487359 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -2006,6 +2006,23 @@ impl Engine { .map_err(EngineError::from_seam) } + /// Issues the single-use nonce an EIP-4361 message must embed, so the host + /// collects a wallet signature without reaching the API itself + /// (blueprint/web-client.md: `apps/web` holds no seam of its own). + /// + /// Gated like every other read: SIWE is a secondary method + /// (blueprint/engine.md "API client"), so a nonce the engine could not + /// spend through [`Command::SiweLogin`] is refused before the host prompts + /// a wallet for it rather than after. + pub async fn siwe_challenge(&self) -> Result { + if !self.started { + return Err(EngineError::NotStarted); + } + let api = self.api.as_ref().ok_or(EngineError::NotStarted)?; + let nonce = api.siwe_challenge().await.map_err(EngineError::from_api)?; + Ok(nonce.nonce) + } + /// A rendered read of the current state — the gate-passing base snapshot ⊕ /// the pending-op overlay — for FUSE-shaped reads (children/lookup/attrs/ /// statfs). Fails `NotStarted` before [`start`](Self::start). diff --git a/crates/engine/tests/facade.rs b/crates/engine/tests/facade.rs index 4a2ad0c31..d28781118 100644 --- a/crates/engine/tests/facade.rs +++ b/crates/engine/tests/facade.rs @@ -2,7 +2,7 @@ //! and event-stream plumbing over a fully faked seam set. use cipherbox_engine::net::RE_PUT_INTERVAL; -use cipherbox_engine::seams::{Scheduler, UnixMillis}; +use cipherbox_engine::seams::{HttpResponse, Scheduler, UnixMillis}; use cipherbox_engine::testkit::{FakeDevice, FakeSeamTypes, FakeWorld, SeededEntropy, block_on}; use cipherbox_engine::{ Command, ContentProfile, Engine, EngineError, EventStream, GatewayConfig, LoginSecret, NodeId, @@ -89,6 +89,41 @@ fn commands_before_start_are_rejected_not_started() { assert_eq!(result, Err(EngineError::NotStarted)); } +/// SIWE is a secondary method: the nonce is refused before the host prompts a +/// wallet for it, exactly as the `siweLogin` that would spend it is. +#[test] +fn a_siwe_challenge_before_start_is_rejected_not_started() { + let world = FakeWorld::new(); + let device = world.device(b"alice-pk"); + let (engine, _events) = new_engine(&device); + + assert_eq!( + block_on(engine.siwe_challenge()), + Err(EngineError::NotStarted) + ); +} + +#[test] +fn a_started_engine_serves_the_nonce_from_its_api_client() { + let world = FakeWorld::new(); + let device = world.device(b"alice-pk"); + let (mut engine, _events) = new_engine(&device); + block_on(engine.start(secret())).expect("start"); + + device.http.enqueue_response(HttpResponse { + status: 200, + headers: vec![("Content-Type".to_owned(), "application/json".to_owned())], + body: br#"{"nonce":"a1b2c3d4e5f60718","expiresAt":"2026-01-01T00:00:00Z"}"#.to_vec(), + }); + + assert_eq!( + block_on(engine.siwe_challenge()), + Ok("a1b2c3d4e5f60718".to_owned()) + ); + let request = device.http.requests().pop().expect("one request"); + assert!(request.url.ends_with("/auth/siwe/challenge"), "{request:?}"); +} + #[test] fn start_succeeds_once_and_only_once() { let world = FakeWorld::new(); diff --git a/crates/wasm/src/host.rs b/crates/wasm/src/host.rs index 2f4d3f540..c5bdb4400 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -344,6 +344,22 @@ impl EngineHandle { }) } + /// Issues the single-use nonce an EIP-4361 message must embed. Resolves + /// with the nonce as a string; rejects with the engine error. + #[wasm_bindgen(js_name = siweChallenge)] + pub fn siwe_challenge(&self) -> Promise { + let engine = self.engine.clone(); + future_to_promise(async move { + let nonce = engine + .read() + .await + .siwe_challenge() + .await + .map_err(engine_error)?; + Ok(JsValue::from_str(&nonce)) + }) + } + /// Downloads and decrypts one file node's content through the verified /// read pipeline. Resolves with the plaintext bytes as a `Uint8Array`; /// rejects with the engine error. diff --git a/packages/client/src/broadcast.ts b/packages/client/src/broadcast.ts index f98eea743..a9d9be463 100644 --- a/packages/client/src/broadcast.ts +++ b/packages/client/src/broadcast.ts @@ -35,6 +35,7 @@ export interface BroadcastChannelLike { /** A follower read intent: served by the leader's engine, answered by value. */ export type WireRead = | { kind: 'snapshot'; folder: Uint8Array | null } + | { kind: 'siweChallenge' } | { kind: 'download'; node: Uint8Array }; /** A follower streaming-write step, driven against the leader's engine. */ @@ -75,7 +76,8 @@ export type LeaderMessage = /** * The correlated result of a follower's command, read, or write step. A * snapshot read's ok carries the descriptor in `result`; a download's carries - * a `Blob`; `beginWrite`/`commitWrite` carry the handle / durable op id. + * a `Blob`; a SIWE challenge's carries the nonce string; + * `beginWrite`/`commitWrite` carry the handle / durable op id. */ | { type: 'cb:response'; @@ -83,7 +85,7 @@ export type LeaderMessage = clientId: string; requestId: number; ok: true; - result?: SnapshotDescriptor | Blob | bigint; + result?: SnapshotDescriptor | Blob | bigint | string; } /** * A failed command/read. `error` is the human-readable diagnostic; `code` is diff --git a/packages/client/src/broadcastTransport.test.ts b/packages/client/src/broadcastTransport.test.ts index 7ad26d8f5..6b9f2c211 100644 --- a/packages/client/src/broadcastTransport.test.ts +++ b/packages/client/src/broadcastTransport.test.ts @@ -225,6 +225,14 @@ describe('broadcast transport ↔ leader relay', () => { expect(engine.downloads).toEqual([node]); }); + it('serves a follower SIWE challenge off the leader engine', async () => { + const { engine, follower } = wire(); + engine.respondSiweChallenge = () => Promise.resolve('leaderNonce12345'); + + await expect(follower.siweChallenge()).resolves.toBe('leaderNonce12345'); + expect(engine.siweChallenges).toBe(1); + }); + it('propagates a read rejection back to the follower with the stable code', async () => { const { engine, follower } = wire(); engine.respondSnapshot = () => diff --git a/packages/client/src/broadcastTransport.ts b/packages/client/src/broadcastTransport.ts index 5d6c5d58c..68bda2916 100644 --- a/packages/client/src/broadcastTransport.ts +++ b/packages/client/src/broadcastTransport.ts @@ -94,6 +94,10 @@ export class BroadcastTransport extends CorrelatedTransport { return this.read({ kind: 'snapshot', folder }); } + siweChallenge(): Promise { + return this.read({ kind: 'siweChallenge' }); + } + async download(node: Uint8Array): Promise { // The leader answers with a `Blob` handle (shared backing, no byte copy); // materialize the bytes only here, in the requesting follower. diff --git a/packages/client/src/correlatedTransport.ts b/packages/client/src/correlatedTransport.ts index 3cf7867d1..ea421670b 100644 --- a/packages/client/src/correlatedTransport.ts +++ b/packages/client/src/correlatedTransport.ts @@ -70,6 +70,7 @@ export abstract class CorrelatedTransport implements EngineTransport { abstract commitWrite(handle: WriteHandle): Promise; abstract abortWrite(handle: WriteHandle): Promise; abstract snapshot(folder: Uint8Array | null): Promise; + abstract siweChallenge(): Promise; abstract download(node: Uint8Array): Promise; abstract close(): void; diff --git a/packages/client/src/engineClient.ts b/packages/client/src/engineClient.ts index 4b17e5760..7b8ac3a18 100644 --- a/packages/client/src/engineClient.ts +++ b/packages/client/src/engineClient.ts @@ -152,6 +152,10 @@ export class EngineClient implements EngineTransport { return this.current.snapshot(folder); } + siweChallenge(): Promise { + return this.current.siweChallenge(); + } + download(node: Uint8Array): Promise { return this.current.download(node); } diff --git a/packages/client/src/facade.test.ts b/packages/client/src/facade.test.ts index 02f84aa1e..e916865b7 100644 --- a/packages/client/src/facade.test.ts +++ b/packages/client/src/facade.test.ts @@ -15,6 +15,7 @@ class FakeTransport implements EngineTransport { commands: Array<{ command: CommandDescriptor; transfer: Transferable[] }> = []; snapshots: Uint8Array[] = []; downloads: Uint8Array[] = []; + siweChallenges = 0; beginWrites: Array<{ target: WriteTarget; size: number }> = []; chunks: Array<{ handle: WriteHandle; chunk: ArrayBuffer }> = []; commits: WriteHandle[] = []; @@ -57,6 +58,11 @@ class FakeTransport implements EngineTransport { return Promise.resolve(emptySnapshot(folder)); } + siweChallenge(): Promise { + this.siweChallenges += 1; + return Promise.resolve('nonce123456789ab'); + } + download(node: Uint8Array): Promise { this.downloads.push(node); return Promise.resolve(new Uint8Array([1, 2, 3]).buffer); @@ -174,6 +180,14 @@ describe('EngineFacade', () => { expect(transport.downloads).toEqual([node]); }); + it('reads the SIWE nonce over the transport rather than the API', async () => { + const transport = new FakeTransport(); + const facade = new EngineFacade(transport); + + await expect(facade.siweChallenge()).resolves.toBe('nonce123456789ab'); + expect(transport.siweChallenges).toBe(1); + }); + it('delegates event subscription to the transport', () => { const transport = new FakeTransport(); const facade = new EngineFacade(transport); diff --git a/packages/client/src/facade.ts b/packages/client/src/facade.ts index e566e158c..e691cc428 100644 --- a/packages/client/src/facade.ts +++ b/packages/client/src/facade.ts @@ -148,6 +148,16 @@ export class EngineFacade { return this.command({ kind: 'rotateNow', node }); } + /** + * Issues the single-use nonce an EIP-4361 message must embed, so the host + * builds one without reaching the API itself. SIWE is a secondary method: the + * engine refuses this before [`start`](EngineFacade#start), exactly as it + * refuses the [`siweLogin`](EngineFacade#siweLogin) that would spend it. + */ + siweChallenge(): Promise { + return this.transport.siweChallenge(); + } + siweLogin(message: string, signature: Uint8Array): Promise { return this.command({ kind: 'siweLogin', message, signature }); } diff --git a/packages/client/src/leaderRelay.ts b/packages/client/src/leaderRelay.ts index ddfc73807..eac544310 100644 --- a/packages/client/src/leaderRelay.ts +++ b/packages/client/src/leaderRelay.ts @@ -13,10 +13,15 @@ * ride the outbound wire, exactly the facade's event surface. */ -import type { BroadcastChannelLike, FollowerMessage, LeaderMessage } from './broadcast.js'; +import type { + BroadcastChannelLike, + FollowerMessage, + LeaderMessage, + WireRead, +} from './broadcast.js'; import { EngineRequestError } from './correlatedTransport.js'; import type { EngineTransport } from './transport.js'; -import type { WriteHandle } from './worker/protocol.js'; +import type { SnapshotDescriptor, WriteHandle } from './worker/protocol.js'; import { WriteQueue } from './writeQueue.js'; /** The correlated ack envelope addressing one follower request. */ @@ -166,20 +171,30 @@ export class LeaderRelay { const { clientId, requestId, read } = message; const ack = { type: 'cb:response', token: this.token, clientId, requestId } as const; try { - if (read.kind === 'snapshot') { - const result = await this.transport.snapshot(read.folder); - this.post({ ...ack, ok: true, result }); - } else { - // Wrap the plaintext in a `Blob` so the per-receiver structured clone - // shares the immutable backing store instead of copying the bytes. - const bytes = await this.transport.download(read.node); - this.post({ ...ack, ok: true, result: new Blob([bytes]) }); - } + this.post({ ...ack, ok: true, result: await this.serve(read) }); } catch (error) { this.post({ ...ack, ok: false, ...wireError(error) }); } } + /** + * Serves one follower read off the leader's engine. The annotated return type + * makes the switch exhaustive over [`WireRead`], so a new kind fails to + * compile instead of leaving the follower's request unanswered. + */ + private async serve(read: WireRead): Promise { + switch (read.kind) { + case 'snapshot': + return this.transport.snapshot(read.folder); + case 'siweChallenge': + return this.transport.siweChallenge(); + case 'download': + // Wrap the plaintext in a `Blob` so the per-receiver structured clone + // shares the immutable backing store instead of copying the bytes. + return new Blob([await this.transport.download(read.node)]); + } + } + private serveWrite(message: Extract): void { const { clientId, requestId, write } = message; const ack: Ack = { type: 'cb:response', token: this.token, clientId, requestId }; diff --git a/packages/client/src/testkit.ts b/packages/client/src/testkit.ts index 884b6cb40..1d227388f 100644 --- a/packages/client/src/testkit.ts +++ b/packages/client/src/testkit.ts @@ -182,6 +182,7 @@ export class FakeEngineTransport implements EngineTransport { readonly commands: CommandDescriptor[] = []; readonly snapshots: Array = []; readonly downloads: Uint8Array[] = []; + siweChallenges = 0; readonly beginWrites: Array<{ target: WriteTarget; size: number }> = []; readonly chunks: Array<{ handle: WriteHandle; chunk: ArrayBuffer }> = []; readonly commits: WriteHandle[] = []; @@ -196,6 +197,7 @@ export class FakeEngineTransport implements EngineTransport { Promise.resolve(emptySnapshot(folder ?? undefined)); respondDownload: (node: Uint8Array) => Promise = () => Promise.resolve(new ArrayBuffer(0)); + respondSiweChallenge: () => Promise = () => Promise.resolve('nonce123456789ab'); private readonly listeners = new Set(); start(secret: ArrayBuffer): Promise { @@ -233,6 +235,11 @@ export class FakeEngineTransport implements EngineTransport { return this.respondSnapshot(folder); } + siweChallenge(): Promise { + this.siweChallenges += 1; + return this.respondSiweChallenge(); + } + download(node: Uint8Array): Promise { this.downloads.push(node); return this.respondDownload(node); diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 8c9c5373f..54b83aa55 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -38,6 +38,8 @@ export interface EngineTransport { abortWrite(handle: WriteHandle): Promise; /** Reads a key-free snapshot of `folder`, or of the vault root for `null`. */ snapshot(folder: Uint8Array | null): Promise; + /** Issues the single-use nonce an EIP-4361 message must embed. */ + siweChallenge(): Promise; /** Downloads one file node's plaintext through the verified read pipeline. */ download(node: Uint8Array): Promise; /** Subscribes to the one-way event stream; returns an unsubscribe. */ @@ -142,6 +144,12 @@ export class LocalTransport extends CorrelatedTransport { ); } + siweChallenge(): Promise { + return this.request(this.ready, (id) => + this.worker.postMessage({ type: 'siweChallenge', id }, []) + ); + } + download(node: Uint8Array): Promise { return this.request(this.ready, (id) => this.worker.postMessage({ type: 'download', id, node }, []) diff --git a/packages/client/src/worker/engineHost.ts b/packages/client/src/worker/engineHost.ts index b9899beff..21d70cbc5 100644 --- a/packages/client/src/worker/engineHost.ts +++ b/packages/client/src/worker/engineHost.ts @@ -29,6 +29,8 @@ export interface EngineHostLike { commitWrite(handle: WriteHandle): Promise; abortWrite(handle: WriteHandle): Promise; snapshot(folder: Uint8Array | null): Promise; + /** Issues the single-use nonce an EIP-4361 message must embed. */ + siweChallenge(): Promise; download(node: Uint8Array): Promise; nextEvent(): Promise; } @@ -106,6 +108,10 @@ export class EngineHost implements EngineHostLike { return readSnapshot(this.wasm, view); } + siweChallenge(): Promise { + return this.handle.siweChallenge(); + } + async download(node: Uint8Array): Promise { const bytes = await this.handle.download(this.wasm.NodeId.fromBytes(node)); // The handle returns a JS-owned copy (never a WASM-memory view); reuse its diff --git a/packages/client/src/worker/engineWasm.ts b/packages/client/src/worker/engineWasm.ts index 71382666d..69849a495 100644 --- a/packages/client/src/worker/engineWasm.ts +++ b/packages/client/src/worker/engineWasm.ts @@ -92,6 +92,7 @@ export interface WasmEngineHandle { commitWrite(handle: bigint): Promise; abortWrite(handle: bigint): Promise; snapshot(folder?: WasmNodeId): Promise; + siweChallenge(): Promise; download(node: WasmNodeId): Promise; nextEvent(): Promise; } diff --git a/packages/client/src/worker/protocol.ts b/packages/client/src/worker/protocol.ts index 6e419b5d8..daf065207 100644 --- a/packages/client/src/worker/protocol.ts +++ b/packages/client/src/worker/protocol.ts @@ -178,6 +178,7 @@ export type WorkerRequest = | { type: 'commitWrite'; id: number; handle: WriteHandle } | { type: 'abortWrite'; id: number; handle: WriteHandle } | { type: 'snapshot'; id: number; folder: Uint8Array | null } + | { type: 'siweChallenge'; id: number } | { type: 'download'; id: number; node: Uint8Array }; /** A worker → UI message. */ @@ -187,10 +188,16 @@ export type WorkerMessage = /** * The correlated result of a request. A value-bearing ok response carries it: * a `SnapshotDescriptor` for `snapshot`, the plaintext `ArrayBuffer` - * (transferred, not copied) for `download`, the write handle for `beginWrite`, - * the durable op id for `commitWrite`. + * (transferred, not copied) for `download`, the nonce string for + * `siweChallenge`, the write handle for `beginWrite`, the durable op id for + * `commitWrite`. */ - | { type: 'response'; id: number; ok: true; result?: SnapshotDescriptor | ArrayBuffer | bigint } + | { + type: 'response'; + id: number; + ok: true; + result?: SnapshotDescriptor | ArrayBuffer | bigint | string; + } /** * A failed request. `error` is the human-readable diagnostic; `code` is the * engine's stable machine-readable error code (the wasm host's camelCase diff --git a/packages/client/src/worker/serve.test.ts b/packages/client/src/worker/serve.test.ts index b1f741ac5..fea291375 100644 --- a/packages/client/src/worker/serve.test.ts +++ b/packages/client/src/worker/serve.test.ts @@ -68,6 +68,8 @@ class ReadHost implements EngineHostLike { respondSnapshot: () => Promise = () => Promise.resolve(SNAPSHOT); respondDownload: () => Promise = () => Promise.resolve(new Uint8Array([9, 8, 7]).buffer); + respondSiweChallenge: () => Promise = () => Promise.resolve('nonce123456789ab'); + siweChallenges = 0; start(): Promise { return Promise.resolve(); @@ -102,6 +104,11 @@ class ReadHost implements EngineHostLike { return this.respondSnapshot(); } + siweChallenge(): Promise { + this.siweChallenges += 1; + return this.respondSiweChallenge(); + } + download(node: Uint8Array): Promise { this.downloads.push(node); return this.respondDownload(); @@ -140,6 +147,16 @@ describe('serveEngine read requests', () => { expect(response!.transfer).toEqual([content]); }); + it('serves a SIWE challenge end to end over the transport', async () => { + const { scope, worker } = loopback(); + const host = new ReadHost(); + serveEngine(scope, host); + const transport = new LocalTransport(worker); + + await expect(transport.siweChallenge()).resolves.toBe('nonce123456789ab'); + expect(host.siweChallenges).toBe(1); + }); + it('maps a rejected read to a correlated error response with the stable code', async () => { const { scope, worker } = loopback(); const host = new ReadHost(); @@ -316,6 +333,7 @@ describe('serveEngine event pump over the real EngineHost', () => { commitWrite: () => Promise.resolve(1n), abortWrite: () => Promise.resolve(undefined), snapshot: () => Promise.reject(new Error('unused')), + siweChallenge: () => Promise.reject(new Error('unused')), download: () => Promise.reject(new Error('unused')), nextEvent: () => pumped.length > 0 diff --git a/packages/client/src/worker/serve.ts b/packages/client/src/worker/serve.ts index 194de4d32..b04a70002 100644 --- a/packages/client/src/worker/serve.ts +++ b/packages/client/src/worker/serve.ts @@ -73,6 +73,11 @@ export function serveEngine(scope: WorkerScopeLike, host: EngineHostLike): void post({ type: 'response', id: request.id, ok: true, result }); return; } + case 'siweChallenge': { + const result = await host.siweChallenge(); + post({ type: 'response', id: request.id, ok: true, result }); + return; + } case 'download': { const result = await host.download(request.node); // Transfer the plaintext buffer: no byte copy through the boundary. diff --git a/packages/client/test/browser/fakeEngine.worker.ts b/packages/client/test/browser/fakeEngine.worker.ts index 7da47c11b..f20f2dcf5 100644 --- a/packages/client/test/browser/fakeEngine.worker.ts +++ b/packages/client/test/browser/fakeEngine.worker.ts @@ -60,6 +60,10 @@ class FakeHost implements EngineHostLike { return Promise.reject(new Error('fake host serves no snapshots')); } + siweChallenge(): Promise { + return Promise.reject(new Error('fake host serves no siwe challenges')); + } + download(): Promise { return Promise.reject(new Error('fake host serves no downloads')); } diff --git a/packages/client/test/browser/journalEngine.worker.ts b/packages/client/test/browser/journalEngine.worker.ts index 5f38ba51b..1630e5d4a 100644 --- a/packages/client/test/browser/journalEngine.worker.ts +++ b/packages/client/test/browser/journalEngine.worker.ts @@ -97,6 +97,10 @@ class JournalHost implements EngineHostLike { return Promise.reject(new Error('journal host serves no snapshots')); } + siweChallenge(): Promise { + return Promise.reject(new Error('journal host serves no siwe challenges')); + } + download(): Promise { return Promise.reject(new Error('journal host serves no downloads')); } From 8cf97b6e6bc6a5a2071fc3977ca3e1d4397f79f8 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Sat, 1 Aug 2026 11:41:17 +0200 Subject: [PATCH 2/2] test(engine): pin the SIWE nonce class at both ends of the wire 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. --- .../auth/services/challenge.service.test.ts | 7 +++++ apps/web/src/auth/useAuth.ts | 2 -- .../src/components/auth/WalletLoginButton.tsx | 5 +++- crates/contract/tests/contract.rs | 7 ++++- crates/engine/src/api/client.rs | 29 +++++++++++++++++-- crates/engine/src/facade.rs | 13 +++------ crates/engine/tests/facade.rs | 2 -- crates/wasm/src/host.rs | 9 +++--- packages/client/src/facade.test.ts | 6 ++-- packages/client/src/facade.ts | 7 +---- packages/client/src/leaderRelay.ts | 10 ++----- packages/client/src/testkit.ts | 5 +++- packages/client/src/worker/engineHost.ts | 1 - packages/client/src/worker/serve.test.ts | 7 ++--- 14 files changed, 66 insertions(+), 44 deletions(-) diff --git a/apps/api/src/auth/services/challenge.service.test.ts b/apps/api/src/auth/services/challenge.service.test.ts index f00fd66ac..003c10dd5 100644 --- a/apps/api/src/auth/services/challenge.service.test.ts +++ b/apps/api/src/auth/services/challenge.service.test.ts @@ -45,6 +45,13 @@ describe('ChallengeService', () => { expect(() => service.consume(challenge, 'siwe')).toThrow(UnauthorizedException); }); + // The engine hard-rejects a nonce outside this class, because the nonce lands + // verbatim in the text a wallet signs. Pinned here, at the producer, so a + // change of alphabet fails a unit gate rather than every wallet login. + it('issues a nonce inside the EIP-4361 class the engine enforces', () => { + expect(service.issueSiweNonce().nonce).toMatch(/^[A-Za-z0-9]{8,128}$/); + }); + it('rejects unknown challenges', () => { expect(() => service.consume('never-issued', 'identity', PUBLIC_KEY)).toThrow( UnauthorizedException diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index e8317daea..b85e3cac7 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -104,8 +104,6 @@ export function useAuth(): Auth { const loginWithGoogle = useCallback(() => login('google'), [login]); const loginWithEmail = useCallback((email: string) => login('email', email), [login]); - // Outside `exclusively`: the nonce is one step inside the wallet flow, whose - // handoff takes the lock at `loginWithWallet`. const siweChallenge = useCallback(async (): Promise => { if (!client) throw new Error('the engine is not ready to accept a login'); return client.facade.siweChallenge(); diff --git a/apps/web/src/components/auth/WalletLoginButton.tsx b/apps/web/src/components/auth/WalletLoginButton.tsx index aff98b506..e79e7bd94 100644 --- a/apps/web/src/components/auth/WalletLoginButton.tsx +++ b/apps/web/src/components/auth/WalletLoginButton.tsx @@ -62,12 +62,15 @@ export function WalletLoginButton({ requestNonce, onLogin, disabled }: WalletLog const [account] = accounts; if (!account) throw new Error('the wallet returned no account'); + // The nonce first, then the phase: the label promises a wallet prompt + // that only appears once the message exists. + const nonce = await requestNonce(); setPhase('signing'); const message = createSiweMessage({ address: account, chainId: mainnet.id, domain: window.location.host, - nonce: await requestNonce(), + nonce, uri: window.location.origin, version: '1', statement: 'Sign in to CipherBox encrypted storage', diff --git a/crates/contract/tests/contract.rs b/crates/contract/tests/contract.rs index 09eb8d914..fcdeee840 100644 --- a/crates/contract/tests/contract.rs +++ b/crates/contract/tests/contract.rs @@ -301,7 +301,12 @@ async fn siwe_secondary_surface_is_reachable_and_gated() { // The nonce endpoint issues a fresh nonce. let nonce = expect_auth("siwe nonce", client.siwe_challenge().await); - assert!(!nonce.nonce.is_empty(), "a nonce is issued"); + assert!( + (8..=128).contains(&nonce.nonce.len()) + && nonce.nonce.chars().all(|c| c.is_ascii_alphanumeric()), + "the live API issues a nonce inside the EIP-4361 class the client enforces, got {:?}", + nonce.nonce + ); // A well-formed-but-unlinked SIWE login is refused (the wallet is not // linked to any account). The signature is shaped to pass DTO validation diff --git a/crates/engine/src/api/client.rs b/crates/engine/src/api/client.rs index 666b386c9..e54982350 100644 --- a/crates/engine/src/api/client.rs +++ b/crates/engine/src/api/client.rs @@ -908,8 +908,6 @@ mod tests { assert!(request.body.is_none()); } - /// The nonce lands verbatim in the text a wallet signs, so anything outside - /// EIP-4361's alphanumeric class is refused rather than forwarded. #[test] fn siwe_challenge_refuses_a_nonce_outside_the_eip4361_class() { for unusable in [ @@ -918,18 +916,43 @@ mod tests { "line\nbreak12345", "spaced out nonce", "", + "1234567", &"a".repeat(129), + // `char::is_alphanumeric` would accept both; the ASCII class must + // not — a confusable nonce is one a wallet renders unreadably. + "١٢٣٤٥٦٧٨", + "ABCDEFGH", ] { let (http, _creds, client) = fakes(); http.enqueue_response(json_response( 200, json!({ "nonce": unusable, "expiresAt": "2026-01-01T00:00:00Z" }), )); + let error = block_on(client.siwe_challenge()).unwrap_err(); assert_eq!( - block_on(client.siwe_challenge()).unwrap_err(), + error, ApiError::Decode("unusable siwe nonce".into()), "accepted {unusable:?}" ); + assert!( + unusable.is_empty() || !error.to_string().contains(unusable), + "the refusal echoed the offending nonce" + ); + } + } + + #[test] + fn siwe_challenge_accepts_the_class_boundaries() { + for usable in ["12345678", &"a".repeat(128)] { + let (http, _creds, client) = fakes(); + http.enqueue_response(json_response( + 200, + json!({ "nonce": usable, "expiresAt": "2026-01-01T00:00:00Z" }), + )); + assert_eq!( + block_on(client.siwe_challenge()).expect("nonce").nonce, + usable + ); } } diff --git a/crates/engine/src/facade.rs b/crates/engine/src/facade.rs index 28b487359..d79f12a69 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -2008,16 +2008,11 @@ impl Engine { /// Issues the single-use nonce an EIP-4361 message must embed, so the host /// collects a wallet signature without reaching the API itself - /// (blueprint/web-client.md: `apps/web` holds no seam of its own). - /// - /// Gated like every other read: SIWE is a secondary method - /// (blueprint/engine.md "API client"), so a nonce the engine could not - /// spend through [`Command::SiweLogin`] is refused before the host prompts - /// a wallet for it rather than after. + /// (blueprint/web-client.md: `apps/web` holds no seam of its own). Fails + /// `NotStarted` before [`start`](Self::start), like the + /// [`Command::SiweLogin`] that spends the nonce — SIWE is a secondary + /// method (blueprint/engine.md "API client"). pub async fn siwe_challenge(&self) -> Result { - if !self.started { - return Err(EngineError::NotStarted); - } let api = self.api.as_ref().ok_or(EngineError::NotStarted)?; let nonce = api.siwe_challenge().await.map_err(EngineError::from_api)?; Ok(nonce.nonce) diff --git a/crates/engine/tests/facade.rs b/crates/engine/tests/facade.rs index d28781118..67011aa27 100644 --- a/crates/engine/tests/facade.rs +++ b/crates/engine/tests/facade.rs @@ -89,8 +89,6 @@ fn commands_before_start_are_rejected_not_started() { assert_eq!(result, Err(EngineError::NotStarted)); } -/// SIWE is a secondary method: the nonce is refused before the host prompts a -/// wallet for it, exactly as the `siweLogin` that would spend it is. #[test] fn a_siwe_challenge_before_start_is_rejected_not_started() { let world = FakeWorld::new(); diff --git a/crates/wasm/src/host.rs b/crates/wasm/src/host.rs index c5bdb4400..1b0f8839b 100644 --- a/crates/wasm/src/host.rs +++ b/crates/wasm/src/host.rs @@ -1,11 +1,12 @@ //! The production engine host: constructs the one engine instance over the -//! browser seams and exposes `start` / `command` / `snapshot` / `download` / -//! `nextEvent` to the worker. +//! browser seams and exposes `start` / `command` / `snapshot` / `siweChallenge` +//! / `download` / `nextEvent` to the worker. //! //! Loaded inside `packages/client`'s dedicated engine worker (never the UI //! realm). The single engine sits behind an async RwLock: `start`/`command` -//! take the write lock and serialize, while the reads (`snapshot`, `download`) -//! share the read lock — a long download never blocks a snapshot. `nextEvent` +//! take the write lock and serialize, while the reads (`snapshot`, +//! `siweChallenge`, `download`) share the read lock — a long download never +//! blocks a snapshot. `nextEvent` //! reads the independent event stream and runs concurrently with a command. //! //! Key material lives only in this worker's WASM linear memory: the login diff --git a/packages/client/src/facade.test.ts b/packages/client/src/facade.test.ts index e916865b7..2983abe04 100644 --- a/packages/client/src/facade.test.ts +++ b/packages/client/src/facade.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { EngineFacade } from './facade.js'; -import { emptySnapshot } from './testkit.js'; +import { emptySnapshot, FAKE_SIWE_NONCE } from './testkit.js'; import type { EngineEventListener, EngineTransport } from './transport.js'; import type { CommandDescriptor, @@ -60,7 +60,7 @@ class FakeTransport implements EngineTransport { siweChallenge(): Promise { this.siweChallenges += 1; - return Promise.resolve('nonce123456789ab'); + return Promise.resolve(FAKE_SIWE_NONCE); } download(node: Uint8Array): Promise { @@ -184,7 +184,7 @@ describe('EngineFacade', () => { const transport = new FakeTransport(); const facade = new EngineFacade(transport); - await expect(facade.siweChallenge()).resolves.toBe('nonce123456789ab'); + await expect(facade.siweChallenge()).resolves.toBe(FAKE_SIWE_NONCE); expect(transport.siweChallenges).toBe(1); }); diff --git a/packages/client/src/facade.ts b/packages/client/src/facade.ts index e691cc428..14f4ae6c1 100644 --- a/packages/client/src/facade.ts +++ b/packages/client/src/facade.ts @@ -148,12 +148,7 @@ export class EngineFacade { return this.command({ kind: 'rotateNow', node }); } - /** - * Issues the single-use nonce an EIP-4361 message must embed, so the host - * builds one without reaching the API itself. SIWE is a secondary method: the - * engine refuses this before [`start`](EngineFacade#start), exactly as it - * refuses the [`siweLogin`](EngineFacade#siweLogin) that would spend it. - */ + /** Issues the single-use nonce an EIP-4361 message must embed. */ siweChallenge(): Promise { return this.transport.siweChallenge(); } diff --git a/packages/client/src/leaderRelay.ts b/packages/client/src/leaderRelay.ts index eac544310..b33fecfff 100644 --- a/packages/client/src/leaderRelay.ts +++ b/packages/client/src/leaderRelay.ts @@ -171,18 +171,14 @@ export class LeaderRelay { const { clientId, requestId, read } = message; const ack = { type: 'cb:response', token: this.token, clientId, requestId } as const; try { - this.post({ ...ack, ok: true, result: await this.serve(read) }); + this.post({ ...ack, ok: true, result: await this.readValue(read) }); } catch (error) { this.post({ ...ack, ok: false, ...wireError(error) }); } } - /** - * Serves one follower read off the leader's engine. The annotated return type - * makes the switch exhaustive over [`WireRead`], so a new kind fails to - * compile instead of leaving the follower's request unanswered. - */ - private async serve(read: WireRead): Promise { + /** The annotated return type keeps the switch exhaustive over `WireRead`. */ + private async readValue(read: WireRead): Promise { switch (read.kind) { case 'snapshot': return this.transport.snapshot(read.folder); diff --git a/packages/client/src/testkit.ts b/packages/client/src/testkit.ts index 1d227388f..5b384922c 100644 --- a/packages/client/src/testkit.ts +++ b/packages/client/src/testkit.ts @@ -49,6 +49,9 @@ export const fakeWasmEnums = { }, } as const; +/** A nonce inside the EIP-4361 class the engine enforces. */ +export const FAKE_SIWE_NONCE = 'nonce123456789ab'; + /** A minimal empty snapshot descriptor for transport-plumbing assertions. */ export function emptySnapshot(folder: Uint8Array = new Uint8Array(16)): SnapshotDescriptor { return { @@ -197,7 +200,7 @@ export class FakeEngineTransport implements EngineTransport { Promise.resolve(emptySnapshot(folder ?? undefined)); respondDownload: (node: Uint8Array) => Promise = () => Promise.resolve(new ArrayBuffer(0)); - respondSiweChallenge: () => Promise = () => Promise.resolve('nonce123456789ab'); + respondSiweChallenge: () => Promise = () => Promise.resolve(FAKE_SIWE_NONCE); private readonly listeners = new Set(); start(secret: ArrayBuffer): Promise { diff --git a/packages/client/src/worker/engineHost.ts b/packages/client/src/worker/engineHost.ts index 21d70cbc5..6e7365d62 100644 --- a/packages/client/src/worker/engineHost.ts +++ b/packages/client/src/worker/engineHost.ts @@ -29,7 +29,6 @@ export interface EngineHostLike { commitWrite(handle: WriteHandle): Promise; abortWrite(handle: WriteHandle): Promise; snapshot(folder: Uint8Array | null): Promise; - /** Issues the single-use nonce an EIP-4361 message must embed. */ siweChallenge(): Promise; download(node: Uint8Array): Promise; nextEvent(): Promise; diff --git a/packages/client/src/worker/serve.test.ts b/packages/client/src/worker/serve.test.ts index fea291375..892767d78 100644 --- a/packages/client/src/worker/serve.test.ts +++ b/packages/client/src/worker/serve.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { emptySnapshot, fakeWasmEnums } from '../testkit.js'; +import { emptySnapshot, FAKE_SIWE_NONCE, fakeWasmEnums } from '../testkit.js'; import { LocalTransport, type EngineWorkerLike } from '../transport.js'; import { EngineHost, type EngineHostLike } from './engineHost.js'; import type { EngineWasm, WasmEngineHandle, WasmEvent } from './engineWasm.js'; @@ -68,7 +68,6 @@ class ReadHost implements EngineHostLike { respondSnapshot: () => Promise = () => Promise.resolve(SNAPSHOT); respondDownload: () => Promise = () => Promise.resolve(new Uint8Array([9, 8, 7]).buffer); - respondSiweChallenge: () => Promise = () => Promise.resolve('nonce123456789ab'); siweChallenges = 0; start(): Promise { @@ -106,7 +105,7 @@ class ReadHost implements EngineHostLike { siweChallenge(): Promise { this.siweChallenges += 1; - return this.respondSiweChallenge(); + return Promise.resolve(FAKE_SIWE_NONCE); } download(node: Uint8Array): Promise { @@ -153,7 +152,7 @@ describe('serveEngine read requests', () => { serveEngine(scope, host); const transport = new LocalTransport(worker); - await expect(transport.siweChallenge()).resolves.toBe('nonce123456789ab'); + await expect(transport.siweChallenge()).resolves.toBe(FAKE_SIWE_NONCE); expect(host.siweChallenges).toBe(1); });