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'));
}