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 c14cd65cfd..0000000000 --- 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 4d43da10cb..0000000000 --- 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 3bda120881..711f9bc757 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 bfec8d660d..e8317daea3 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 75e678571e..aff98b506c 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 71bb507e56..1e4023161e 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 05db042141..cbc36db8fa 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 a271b061bf..666b386c9a 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 89658dca41..28b4873595 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 4a2ad0c31d..d287811181 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 2f4d3f5401..c5bdb44004 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 f98eea743a..a9d9be4635 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 7ad26d8f5d..6b9f2c2116 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 5d6c5d58ca..68bda29164 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 3cf7867d10..ea421670b5 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 4b17e57601..7b8ac3a180 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 02f84aa1eb..e916865b72 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 e566e158cd..e691cc428b 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 ddfc738071..eac5443104 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 884b6cb408..1d227388f1 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 8c9c5373fa..54b83aa555 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 b9899beff5..21d70cbc54 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 71382666d2..69849a4959 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 6e419b5d8f..daf0652070 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 b1f741ac55..fea2913753 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 194de4d32f..b04a70002b 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 7da47c11b5..f20f2dcf52 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 5f38ba51b3..1630e5d4a0 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 f00fd66acc..003c10dd5d 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 e8317daea3..b85e3cac7a 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 aff98b506c..e79e7bd945 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 09eb8d9144..fcdeee8409 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 666b386c9a..e549823507 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 28b4873595..d79f12a694 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 d287811181..67011aa27c 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 c5bdb44004..1b0f8839b1 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 e916865b72..2983abe04b 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 e691cc428b..14f4ae6c15 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 eac5443104..b33fecfffe 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 1d227388f1..5b384922c7 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 21d70cbc54..6e7365d626 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 fea2913753..892767d78c 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); });