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/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..b85e3cac7 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,11 @@ export function useAuth(): Auth { const loginWithGoogle = useCallback(() => login('google'), [login]); const loginWithEmail = useCallback((email: string) => login('email', email), [login]); + 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 +157,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..e79e7bd94 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(); @@ -62,12 +62,15 @@ export function WalletLoginButton({ onLogin, apiBaseUrl, disabled }: WalletLogin 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 requestSiweNonce(apiBaseUrl), + nonce, 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/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 a271b061b..e54982350 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,63 @@ 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()); } + #[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", + "", + "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!( + 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 + ); + } + } + #[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..d79f12a69 100644 --- a/crates/engine/src/facade.rs +++ b/crates/engine/src/facade.rs @@ -2006,6 +2006,18 @@ 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). 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 { + 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..67011aa27 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,39 @@ fn commands_before_start_are_rejected_not_started() { assert_eq!(result, Err(EngineError::NotStarted)); } +#[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..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 @@ -344,6 +345,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..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, @@ -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(FAKE_SIWE_NONCE); + } + 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(FAKE_SIWE_NONCE); + 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..14f4ae6c1 100644 --- a/packages/client/src/facade.ts +++ b/packages/client/src/facade.ts @@ -148,6 +148,11 @@ export class EngineFacade { return this.command({ kind: 'rotateNow', node }); } + /** Issues the single-use nonce an EIP-4361 message must embed. */ + 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..b33fecfff 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,26 @@ 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.readValue(read) }); } catch (error) { this.post({ ...ack, ok: false, ...wireError(error) }); } } + /** 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); + 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..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 { @@ -182,6 +185,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 +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(FAKE_SIWE_NONCE); private readonly listeners = new Set(); start(secret: ArrayBuffer): Promise { @@ -233,6 +238,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..6e7365d62 100644 --- a/packages/client/src/worker/engineHost.ts +++ b/packages/client/src/worker/engineHost.ts @@ -29,6 +29,7 @@ export interface EngineHostLike { commitWrite(handle: WriteHandle): Promise; abortWrite(handle: WriteHandle): Promise; snapshot(folder: Uint8Array | null): Promise; + siweChallenge(): Promise; download(node: Uint8Array): Promise; nextEvent(): Promise; } @@ -106,6 +107,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..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,6 +68,7 @@ class ReadHost implements EngineHostLike { respondSnapshot: () => Promise = () => Promise.resolve(SNAPSHOT); respondDownload: () => Promise = () => Promise.resolve(new Uint8Array([9, 8, 7]).buffer); + siweChallenges = 0; start(): Promise { return Promise.resolve(); @@ -102,6 +103,11 @@ class ReadHost implements EngineHostLike { return this.respondSnapshot(); } + siweChallenge(): Promise { + this.siweChallenges += 1; + return Promise.resolve(FAKE_SIWE_NONCE); + } + download(node: Uint8Array): Promise { this.downloads.push(node); return this.respondDownload(); @@ -140,6 +146,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(FAKE_SIWE_NONCE); + 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 +332,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')); }