Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/src/auth/services/challenge.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 0 additions & 61 deletions apps/web/src/auth/siweNonce.test.ts

This file was deleted.

28 changes: 0 additions & 28 deletions apps/web/src/auth/siweNonce.ts

This file was deleted.

18 changes: 17 additions & 1 deletion apps/web/src/auth/useAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface Auth {
error: string | null;
loginWithGoogle(): Promise<void>;
loginWithEmail(email: string): Promise<void>;
/** Issues the single-use nonce the wallet's EIP-4361 message embeds. */
siweChallenge(): Promise<string>;
/** Exchanges a wallet-signed SIWE message; secondary to the Core Kit methods. */
loginWithWallet(message: string, signature: Uint8Array): Promise<void>;
logout(): Promise<void>;
Expand Down Expand Up @@ -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<string> => {
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 () => {
Expand Down Expand Up @@ -150,6 +157,7 @@ export function useAuth(): Auth {
error: error ?? coreKitError,
loginWithGoogle,
loginWithEmail,
siweChallenge,
loginWithWallet,
logout,
};
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/components/auth/WalletLoginButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
/** Hands the signed EIP-4361 message to the facade. */
onLogin: (message: string, signature: Uint8Array) => Promise<void>;
apiBaseUrl: string;
disabled?: boolean;
}

Expand All @@ -28,7 +28,7 @@ const PHASE_LABEL: Record<Phase, string> = {
* 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();
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/routes/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +20,7 @@ export function LoginPage() {
error,
loginWithGoogle,
loginWithEmail,
siweChallenge,
loginWithWallet,
} = useAuth();
const navigate = useNavigate();
Expand Down Expand Up @@ -69,8 +69,8 @@ export function LoginPage() {
</div>

<WalletLoginButton
requestNonce={siweChallenge}
onLogin={loginWithWallet}
apiBaseUrl={apiBaseUrl(import.meta.env)}
disabled={!isReady || isBusy}
/>
</div>
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/test/authFakes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,40 @@ 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<Record<'start' | 'logout', () => Promise<void>>> = {}
) {
const calls: EngineCalls = { started: [], secrets: [], logouts: 0, siwe: [] };
const calls: EngineCalls = {
started: [],
secrets: [],
logouts: 0,
siwe: [],
siweChallenges: 0,
};
const client = {
facade: {
start(secret: ArrayBuffer) {
calls.started.push(secret);
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();
Expand Down
7 changes: 6 additions & 1 deletion crates/contract/tests/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 61 additions & 2 deletions crates/engine/src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ impl<H: Http, C: CredentialStore> ApiClient<H, C> {
};
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,
Expand Down Expand Up @@ -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<B: Serialize + ?Sized>(body: &B) -> Vec<u8> {
Expand Down Expand Up @@ -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();
Expand Down
Loading