From f6d14a39e857d695bb0db6714058664ad607dd35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 16:55:31 +0000 Subject: [PATCH 1/5] feat: extract the login orchestration into a host-agnostic package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sequencing — provider credential → API exchange → Core Kit login → secret export → start(secret) — moves to packages/login, which both hosts import (ADR 0008 D3). v1 drew this boundary at the bearer token and the two hosts drifted; the boundary is now credential collection, one step earlier. Credential collection is injected: CredentialCollector carries one optional member per method, so a host offers a subset by omitting the rest, and the flow refuses a method it cannot collect rather than pretending it exists. Desktop will omit wallet on those terms. The material each collector is handed is host-shaped — web's UI already holds it when it calls, a host that drives its own flow does the work inside the collector. The facade is a parameter too, since the transport differs per host: a WASM worker facade on web, Tauri IPC on desktop. The package imports no browser API and no React. tsconfig drops the DOM lib, so a browser API cannot typecheck; hostAgnostic.test.ts runs a whole login with the browser globals booby-trapped and asserts React cannot resolve. apps/web keeps what is web-only: the Web3Auth session construction over localStorage and IndexedDB, the LoginSecretSource a leader promotion re-exports through, the auth chrome store, and useAuth, now a React binding over the shared flow. Its auth suite is unchanged and still passes. --- .github/workflows/ci.yml | 1 + apps/web/package.json | 1 + apps/web/src/auth/CoreKitProvider.tsx | 2 +- apps/web/src/auth/IdentityProvider.tsx | 19 +- apps/web/src/auth/coreKit.test.ts | 2 +- apps/web/src/auth/coreKit.ts | 30 +-- apps/web/src/auth/useAuth.ts | 160 ++++----------- apps/web/src/auth/webCollector.test.ts | 27 +++ apps/web/src/auth/webCollector.ts | 32 +++ apps/web/src/engine/introspection.ts | 4 +- apps/web/src/engine/loginHandoff.test.ts | 190 +++--------------- apps/web/src/engine/loginHandoff.ts | 65 +----- apps/web/src/main.tsx | 2 +- apps/web/src/test/authFakes.tsx | 5 +- apps/web/tsconfig.json | 5 +- packages/login/package.json | 24 +++ packages/login/src/collector.ts | 52 +++++ packages/login/src/flow.test.ts | 165 +++++++++++++++ packages/login/src/flow.ts | 181 +++++++++++++++++ packages/login/src/hostAgnostic.test.ts | 93 +++++++++ .../login/src/identity.test.ts | 2 +- .../login/src/identity.ts | 0 packages/login/src/index.ts | 28 +++ packages/login/src/secret.test.ts | 169 ++++++++++++++++ packages/login/src/secret.ts | 94 +++++++++ packages/login/src/session.ts | 45 +++++ packages/login/src/testFakes.ts | 146 ++++++++++++++ packages/login/tsconfig.build.json | 9 + packages/login/tsconfig.json | 12 ++ pnpm-lock.yaml | 19 +- 30 files changed, 1206 insertions(+), 378 deletions(-) create mode 100644 apps/web/src/auth/webCollector.test.ts create mode 100644 apps/web/src/auth/webCollector.ts create mode 100644 packages/login/package.json create mode 100644 packages/login/src/collector.ts create mode 100644 packages/login/src/flow.test.ts create mode 100644 packages/login/src/flow.ts create mode 100644 packages/login/src/hostAgnostic.test.ts rename apps/web/src/auth/identityExchange.test.ts => packages/login/src/identity.test.ts (99%) rename apps/web/src/auth/identityExchange.ts => packages/login/src/identity.ts (100%) create mode 100644 packages/login/src/index.ts create mode 100644 packages/login/src/secret.test.ts create mode 100644 packages/login/src/secret.ts create mode 100644 packages/login/src/session.ts create mode 100644 packages/login/src/testFakes.ts create mode 100644 packages/login/tsconfig.build.json create mode 100644 packages/login/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e47ceca5..3608daf1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,7 @@ jobs: web: - 'apps/web/**' - 'packages/client/**' + - 'packages/login/**' - 'crates/**' - 'Cargo.toml' - 'Cargo.lock' diff --git a/apps/web/package.json b/apps/web/package.json index 98ac38313..eda08dfdf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@cipherbox/client": "workspace:*", + "@cipherbox/login": "workspace:*", "@tanstack/react-query": "^5.90.2", "@toruslabs/tss-dkls-lib": "^4.1.0", "@web3auth/mpc-core-kit": "^3.5.0", diff --git a/apps/web/src/auth/CoreKitProvider.tsx b/apps/web/src/auth/CoreKitProvider.tsx index 3ae07a8bd..2f3aaaa4a 100644 --- a/apps/web/src/auth/CoreKitProvider.tsx +++ b/apps/web/src/auth/CoreKitProvider.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import { errorMessage } from '../lib/errorMessage'; -import type { CoreKitSession } from './coreKit'; +import type { CoreKitSession } from '@cipherbox/login'; /** Whether this tab knows if it has a session. */ export type CoreKitStatus = 'checking' | 'ready' | 'unavailable'; diff --git a/apps/web/src/auth/IdentityProvider.tsx b/apps/web/src/auth/IdentityProvider.tsx index eb3c6c580..c78c085fe 100644 --- a/apps/web/src/auth/IdentityProvider.tsx +++ b/apps/web/src/auth/IdentityProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; -import type { IdentityExchange } from './identityExchange'; +import type { CredentialCollector, IdentityExchange } from '@cipherbox/login'; +import { webCollector, type WebCollected } from './webCollector'; export interface IdentityContextValue { exchange: IdentityExchange; @@ -8,20 +9,28 @@ export interface IdentityContextValue { * when the build carries none, which leaves that method unavailable. */ googleClientId: string | undefined; + /** What this host collects; a method absent here is one web does not offer. */ + collector: CredentialCollector; } const IdentityContext = createContext(undefined); -export interface IdentityProviderProps extends IdentityContextValue { +export interface IdentityProviderProps { + exchange: IdentityExchange; + googleClientId: string | undefined; children: ReactNode; } /** - * Holds the identity exchange so the login flow reaches the API without - * importing a transport — which is what keeps `useAuth` host-agnostic. + * Holds the identity exchange and this host's collector so the login flow + * reaches the API without importing a transport — which is what keeps the + * sequencing host-agnostic. */ export function IdentityProvider({ exchange, googleClientId, children }: IdentityProviderProps) { - const value = useMemo(() => ({ exchange, googleClientId }), [exchange, googleClientId]); + const value = useMemo( + () => ({ exchange, googleClientId, collector: webCollector(googleClientId) }), + [exchange, googleClientId] + ); return {children}; } diff --git a/apps/web/src/auth/coreKit.test.ts b/apps/web/src/auth/coreKit.test.ts index 27f28a2f6..d1f7c9c91 100644 --- a/apps/web/src/auth/coreKit.test.ts +++ b/apps/web/src/auth/coreKit.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MemoryKeys, sealedTestStore } from '../test/storeFakes'; import type { SealedStore } from './sealedStore'; import { createCoreKitSession } from './coreKit'; -import type { IdentityCredential } from './identityExchange'; +import type { IdentityCredential } from '@cipherbox/login'; const STORE_KEY = 'corekit_store'; diff --git a/apps/web/src/auth/coreKit.ts b/apps/web/src/auth/coreKit.ts index e3512c408..e1d18f8ae 100644 --- a/apps/web/src/auth/coreKit.ts +++ b/apps/web/src/auth/coreKit.ts @@ -2,35 +2,21 @@ * Web3Auth Core Kit on the UI thread: it owns its own popup and redirect flows, * so it cannot live in the engine worker (blueprint/web-client.md "Login and * identity"). The one thing it produces that the vault cares about is the login - * secret, which `engine/loginHandoff` transfers to the engine. + * secret, which the login flow transfers to the engine. */ import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'; import { tssLib } from '@toruslabs/tss-dkls-lib'; +import { + isIdentityMethod, + type CoreKitSession, + type IdentityCredential, + type IdentityMethod, +} from '@cipherbox/login'; import { environment, loginEnv } from '../engine/config'; -import type { LoginSecretExporter } from '../engine/loginHandoff'; -import { type IdentityCredential, type IdentityMethod, isIdentityMethod } from './identityExchange'; import { indexedDbWrappingKeys, SealedStore } from './sealedStore'; -/** - * The Core Kit surface the login flow drives. Narrow by construction: the hook - * never sees a Web3Auth parameter shape, and a test substitutes a plain object. - */ -export interface CoreKitSession extends LoginSecretExporter { - /** Restores a prior session, if the SDK has one on this device. */ - restore(): Promise; - /** True once a login (or a restore) has completed on this device. */ - isLoggedIn(): boolean; - /** Redeems a CipherBox identity token for this device's share of the key. */ - login(credential: IdentityCredential): Promise; - /** How the live session was established; unknown after a bare restore. */ - method(): IdentityMethod | null; - /** The signed-in user's email, when the method carries one. */ - email(): string | null; - logout(): Promise; -} - -/** Adapts the Web3Auth SDK to the narrow session seam above. */ +/** Adapts the Web3Auth SDK to the narrow session seam the login flow drives. */ class Web3AuthSession implements CoreKitSession { /** The address the exchange reported; the token deliberately carries no PII. */ private signedInEmail: string | null = null; diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index 1681c13e3..dba089bf5 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -1,22 +1,18 @@ /** - * The login flow, rewired onto the facade (blueprint/web-client.md "Login and - * identity"). CipherBox verifies the provider credential and mints the token - * the Core Kit redeems (ADR 0008 D1); the only thing that crosses into the - * vault is the login secret, transferred once by `handOffLoginSecret`. - * - * Credential collection is per-host and stays outside this hook — it takes - * already-collected credentials and drives the sequencing. + * React's binding to the shared login flow (ADR 0008 D3). The sequencing lives + * in `@cipherbox/login`; this hook supplies the web host's parts — the facade + * the client wraps, the Core Kit session, the collector, the auth chrome — and + * renders the transitions as component state. */ -import { useCallback, useEffect, useState } from 'react'; -import { handOffLoginSecret } from '../engine/loginHandoff'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { createLoginFlow, type LoginProgress } from '@cipherbox/login'; import { errorMessage } from '../lib/errorMessage'; import { authStore, useAuthState } from '../stores/auth.store'; import { useEngine, useLoginSecretSource, useRebuildEngine } from '../providers/EngineProvider'; import { useCoreKit } from './CoreKitProvider'; import { useIdentity } from './IdentityProvider'; -import type { CoreKitSession } from './coreKit'; -import type { IdentityCredential } from './identityExchange'; +import type { WebCollected } from './webCollector'; export interface Auth { isAuthenticated: boolean; @@ -43,20 +39,12 @@ export interface Auth { logout(): Promise; } -/** - * There is one engine per origin and one cold start per tab, so these guards - * are module-scoped: every `useAuth()` consumer drives the same transitions, - * and a second one must not start a second login. - */ -let inFlight = false; -let restoredFor: CoreKitSession | null = null; - export function useAuth(): Auth { const client = useEngine(); const secrets = useLoginSecretSource(); const rebuildEngine = useRebuildEngine(); const { session, status, error: coreKitError } = useCoreKit(); - const { exchange } = useIdentity(); + const { exchange, collector } = useIdentity(); const { isAuthenticated } = useAuthState(); const [isBusy, setIsBusy] = useState(false); @@ -65,112 +53,50 @@ export function useAuth(): Auth { const isReady = client !== null && session !== null && status === 'ready'; const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable'); - /** Serializes the auth transitions; a collision rejects rather than no-ops. */ - const exclusively = useCallback(async (step: () => Promise): Promise => { - if (inFlight) throw new Error('another sign-in is already in progress'); - inFlight = true; - setIsBusy(true); - setError(null); - try { - await step(); - } catch (failure) { - setError(errorMessage(failure)); - throw failure; - } finally { - inFlight = false; - setIsBusy(false); - } - }, []); - - /** - * The Core Kit → engine handoff. The secret source is armed first so a - * leadership failover mid-start can re-export it; every step after that stays - * inside the failure envelope, so nothing can leave it armed over a UI that - * renders signed out. - */ - const handOff = useCallback(async (): Promise => { - if (!client || !session) throw new Error('the engine is not ready to accept a login'); - const method = session.method(); - const email = session.email(); - - secrets?.use(session); - try { - await handOffLoginSecret(client, session); - authStore.signedIn(method, email); - } catch (failure) { - secrets?.use(null); - // A Core Kit session the engine refused is a live credential on this - // device that nothing in the UI can reach; end it here. - await session.logout().catch(() => undefined); - throw failure; - } - }, [client, secrets, session]); - - /** - * One sequencing for every method: exchange the collected credential, redeem - * it with the Core Kit, then hand the engine its secret. - */ - const login = useCallback( - (collect: () => Promise) => - exclusively(async () => { - if (!session) throw new Error('the login provider is not ready'); - await session.login(await collect()); - await handOff(); - }), - [exclusively, handOff, session] - ); - - const loginWithGoogle = useCallback( - (idToken: string) => login(() => exchange.fromGoogleToken(idToken)), - [exchange, login] + const progress = useMemo( + () => ({ + begin: () => { + setIsBusy(true); + setError(null); + }, + failed: (failure) => setError(errorMessage(failure)), + end: () => setIsBusy(false), + }), + [] ); - const sendEmailCode = useCallback( - (email: string) => exclusively(() => exchange.sendEmailCode(email)), - [exchange, exclusively] + const flow = useMemo( + () => + createLoginFlow({ + exchange, + collector, + session, + facade: client?.facade ?? null, + secrets: secrets ?? null, + account: authStore, + progress, + // `facade.logout` closes the client for good, so the tab needs a new one. + afterLogout: rebuildEngine, + }), + [client, collector, exchange, progress, rebuildEngine, secrets, session] ); const loginWithEmailCode = useCallback( - (email: string, code: string) => login(() => exchange.fromEmailCode(email, code)), - [exchange, login] + (email: string, code: string) => flow.loginWithEmailCode({ email, code }), + [flow] ); - const walletNonce = useCallback(() => exchange.walletNonce(), [exchange]); - const loginWithWallet = useCallback( - (message: string, signature: string) => - login(() => exchange.fromWalletSignature(message, signature)), - [exchange, login] - ); - - const logout = useCallback( - () => - exclusively(async () => { - // Every leg runs: a refused engine zeroize must not strand the Core Kit - // session, and a failed Core Kit logout must not leave the UI signed in. - const outcomes = await Promise.allSettled([ - client?.facade.logout() ?? Promise.resolve(), - session?.logout() ?? Promise.resolve(), - ]); - secrets?.use(null); - restoredFor = null; - authStore.signedOut(); - rebuildEngine(); - const failed = outcomes.find((outcome) => outcome.status === 'rejected'); - if (failed) throw failed.reason as Error; - }), - [client, exclusively, rebuildEngine, secrets, session] + (message: string, signature: string) => flow.loginWithWallet({ message, signature }), + [flow] ); // A Core Kit session that survived the reload still has to hand the engine its // secret; without this the tab renders logged-out over a live login. useEffect(() => { - if (!isReady || isAuthenticated || restoredFor === session || !session?.isLoggedIn()) return; - restoredFor = session; - exclusively(handOff).catch(() => { - restoredFor = null; - }); - }, [exclusively, handOff, isAuthenticated, isReady, session]); + if (!isReady || isAuthenticated) return; + void flow.resume(); + }, [flow, isAuthenticated, isReady]); return { isAuthenticated, @@ -178,11 +104,11 @@ export function useAuth(): Auth { isSignedOut, isBusy, error: error ?? coreKitError, - loginWithGoogle, - sendEmailCode, + loginWithGoogle: flow.loginWithGoogle, + sendEmailCode: flow.sendEmailCode, loginWithEmailCode, - walletNonce, + walletNonce: flow.walletNonce, loginWithWallet, - logout, + logout: flow.logout, }; } diff --git a/apps/web/src/auth/webCollector.test.ts b/apps/web/src/auth/webCollector.test.ts new file mode 100644 index 000000000..0f117cb48 --- /dev/null +++ b/apps/web/src/auth/webCollector.test.ts @@ -0,0 +1,27 @@ +import { collectedMethods } from '@cipherbox/login'; +import { describe, expect, it } from 'vitest'; +import { webCollector } from './webCollector'; + +describe('web credential collection', () => { + it('offers every method a browser can collect', () => { + expect(collectedMethods(webCollector('google-client-id'))).toEqual([ + 'google', + 'email', + 'wallet', + ]); + }); + + // The missing variable disables one method, never the session: a build + // without it still logs in by email and wallet (`engine/config`). + it('drops only google when the build carries no client ID', () => { + expect(collectedMethods(webCollector(undefined))).toEqual(['email', 'wallet']); + }); + + it('passes the material the page already collected straight through', async () => { + const collector = webCollector('google-client-id'); + const proof = { message: 'siwe-message', signature: '0xabc' }; + + await expect(collector.google?.('google.id.token')).resolves.toBe('google.id.token'); + await expect(collector.wallet?.(proof)).resolves.toBe(proof); + }); +}); diff --git a/apps/web/src/auth/webCollector.ts b/apps/web/src/auth/webCollector.ts new file mode 100644 index 000000000..6111afc85 --- /dev/null +++ b/apps/web/src/auth/webCollector.ts @@ -0,0 +1,32 @@ +/** + * Web's credential collection (ADR 0008 D3). Collection happens in the DOM + * before the flow is called — Google Identity Services renders its own button + * and hands the page a token, and wagmi signs in the wallet — so what the flow + * asks for is already in hand. A host that drives its own flow, as desktop's + * loopback OAuth listener does, does that work inside these collectors instead. + */ + +import type { CollectedMaterial, CredentialCollector, WalletProof } from '@cipherbox/login'; + +/** What each of web's collectors is handed. */ +export interface WebCollected extends CollectedMaterial { + /** The ID token Google Identity Services delivered. */ + google: string; + email: { email: string; code: string }; + wallet: WalletProof; +} + +/** + * A build carrying no Google client ID renders no Google button, so the method + * is absent here to match — that alone disables it, and no other method is + * affected (`engine/config`). + */ +export function webCollector( + googleClientId: string | undefined +): CredentialCollector { + return { + google: googleClientId ? (idToken) => Promise.resolve(idToken) : undefined, + email: (answer) => Promise.resolve(answer), + wallet: (proof) => Promise.resolve(proof), + }; +} diff --git a/apps/web/src/engine/introspection.ts b/apps/web/src/engine/introspection.ts index 9b9e8188e..81b18e29b 100644 --- a/apps/web/src/engine/introspection.ts +++ b/apps/web/src/engine/introspection.ts @@ -9,9 +9,9 @@ */ import { toHex } from '@cipherbox/client'; +import { handOffLoginSecret } from '@cipherbox/login'; import type { EngineClient, EventDescriptor, SnapshotDescriptor } from '@cipherbox/client'; import { authStore } from '../stores/auth.store'; -import { handOffLoginSecret } from './loginHandoff'; /** * A structured-clone-safe projection of an engine descriptor: `Uint8Array` @@ -63,7 +63,7 @@ export function installIntrospection(client: EngineClient): EngineClient { window.__CIPHERBOX_ENGINE__ = { async signIn(loginSecretHex) { - await handOffLoginSecret(client, { + await handOffLoginSecret(client.facade, { _UNSAFE_exportTssKey: () => Promise.resolve(loginSecretHex), }); authStore.signedIn(null); diff --git a/apps/web/src/engine/loginHandoff.test.ts b/apps/web/src/engine/loginHandoff.test.ts index 65b8bd545..be9d1503a 100644 --- a/apps/web/src/engine/loginHandoff.test.ts +++ b/apps/web/src/engine/loginHandoff.test.ts @@ -1,198 +1,58 @@ -import type { EngineClient } from '@cipherbox/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - LoginSecretSource, - exportLoginSecret, - handOffLoginSecret, - type LoginSecretExporter, -} from './loginHandoff'; +import { handOffLoginSecret, type LoginFacade, type LoginSecretExporter } from '@cipherbox/login'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { LoginSecretSource } from './loginHandoff'; const SECRET_HEX = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; const SECRET_BYTES = Uint8Array.from({ length: 32 }, (_, i) => Number.parseInt(SECRET_HEX.slice(i * 2, i * 2 + 2), 16) ); -function exporter(key: string | Error): LoginSecretExporter { - return { - _UNSAFE_exportTssKey: () => (key instanceof Error ? Promise.reject(key) : Promise.resolve(key)), - }; -} - -/** A client whose `start` behaviour the test scripts; records what it was handed. */ -function fakeClient(start: (secret: ArrayBuffer) => Promise) { - const received: ArrayBuffer[] = []; - const client = { - facade: { - start(secret: ArrayBuffer) { - received.push(secret); - return start(secret); - }, - }, - } as unknown as EngineClient; - return { client, received }; +function exporter(key: string): LoginSecretExporter { + return { _UNSAFE_exportTssKey: () => Promise.resolve(key) }; } /** `postMessage(msg, [secret])` detaches the sender's buffer; so does this. */ -const transferring = (secret: ArrayBuffer): Promise => { - structuredClone(secret, { transfer: [secret] }); - return Promise.resolve(); +const facade: LoginFacade = { + start(secret) { + structuredClone(secret, { transfer: [secret] }); + return Promise.resolve(); + }, + logout: () => Promise.resolve(), }; -describe('exportLoginSecret', () => { - it('decodes the Core Kit hex export, with or without the 0x prefix', async () => { - expect(new Uint8Array(await exportLoginSecret(exporter(SECRET_HEX)))).toEqual(SECRET_BYTES); - expect(new Uint8Array(await exportLoginSecret(exporter(`0x${SECRET_HEX}`)))).toEqual( - SECRET_BYTES - ); - }); - - it('rejects a malformed export without echoing it', async () => { - await expect(exportLoginSecret(exporter('nothex'))).rejects.toThrow( - /^login secret export is not hex$/ - ); - await expect(exportLoginSecret(exporter(''))).rejects.toThrow(/32-byte scalar/); - // Short of a full secp256k1 scalar: rejected here, not after a transfer. - await expect(exportLoginSecret(exporter(SECRET_HEX.slice(2)))).rejects.toThrow( - /32-byte scalar/ - ); - }); -}); - -describe('handOffLoginSecret', () => { - it('hands the engine the exported secret', async () => { - const seen: number[] = []; - const { client } = fakeClient((secret) => { - seen.push(...new Uint8Array(secret)); - return transferring(secret); - }); - - await handOffLoginSecret(client, exporter(SECRET_HEX)); - - expect(Uint8Array.from(seen)).toEqual(SECRET_BYTES); - }); - - it('tolerates the buffer the transport already detached', async () => { - const { client, received } = fakeClient(transferring); - - await handOffLoginSecret(client, exporter(SECRET_HEX)); - - // Post-transfer the buffer is neutered, so the `finally` must not re-view it. - expect(received[0].byteLength).toBe(0); - }); - - it('zeroes the buffer when the engine never took it', async () => { - const { client, received } = fakeClient(() => - Promise.reject(new Error('engine client closed')) - ); - - await expect(handOffLoginSecret(client, exporter(SECRET_HEX))).rejects.toThrow( - 'engine client closed' - ); - - expect(received[0].byteLength).toBe(32); - expect(new Uint8Array(received[0])).toEqual(new Uint8Array(32)); - }); - - it('zeroes the buffer when start throws synchronously', async () => { - const { client, received } = fakeClient(() => { - throw new Error('transport gone'); - }); - - await expect(handOffLoginSecret(client, exporter(SECRET_HEX))).rejects.toThrow( - 'transport gone' - ); +describe('LoginSecretSource', () => { + it('re-exports the secret for a failover promotion', async () => { + const source = new LoginSecretSource(); + source.use(exporter(SECRET_HEX)); - expect(new Uint8Array(received[0])).toEqual(new Uint8Array(32)); + expect(new Uint8Array(await source.provideSecret())).toEqual(SECRET_BYTES); }); - it('never starts the engine when the export fails', async () => { - const { client, received } = fakeClient(transferring); - - await expect( - handOffLoginSecret(client, exporter(new Error('core kit locked'))) - ).rejects.toThrow('core kit locked'); + it('refuses to provide a secret with no live session', async () => { + const source = new LoginSecretSource(); + await expect(source.provideSecret()).rejects.toThrow(/no login session/); - expect(received).toEqual([]); + source.use(exporter(SECRET_HEX)); + source.use(null); + await expect(source.provideSecret()).rejects.toThrow(/no login session/); }); }); -describe('secret containment', () => { - const consoleMethods = ['log', 'info', 'warn', 'error', 'debug'] as const; - let logged: string[]; - +describe('secret containment in the browser', () => { beforeEach(() => { - logged = []; - for (const method of consoleMethods) { - vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { - logged.push(args.map((arg) => String(arg)).join(' ')); - }); - } localStorage.clear(); sessionStorage.clear(); }); - afterEach(() => vi.restoreAllMocks()); - - it('writes no secret to storage and logs nothing at all', async () => { - const { client } = fakeClient(transferring); + it('writes no secret to origin storage', async () => { const source = new LoginSecretSource(); source.use(exporter(SECRET_HEX)); - await handOffLoginSecret(client, exporter(SECRET_HEX)); + await handOffLoginSecret(facade, exporter(SECRET_HEX)); const reExported = await source.provideSecret(); expect(new Uint8Array(reExported)).toEqual(SECRET_BYTES); expect(localStorage.length).toBe(0); expect(sessionStorage.length).toBe(0); - // The whole path is silent, so no encoding of the secret can reach a log. - expect(logged).toEqual([]); - }); - - it('parks no copy of the secret in the realm-global RegExp statics', async () => { - const { client } = fakeClient(transferring); - /(sentinel)/.exec('sentinel'); - - await handOffLoginSecret(client, exporter(SECRET_HEX)); - - expect(RegExp.input).toBe('sentinel'); - expect(RegExp.lastMatch).toBe('sentinel'); - }); - - it('keeps the secret out of a failure message, in any encoding', async () => { - const { client } = fakeClient(() => Promise.reject(new Error('boom'))); - - const failure = await handOffLoginSecret(client, exporter(SECRET_HEX)).catch( - (error: unknown) => error - ); - const malformed = await exportLoginSecret(exporter(`0x${SECRET_HEX}zz`)).catch( - (error: unknown) => error - ); - - const decimal = [...SECRET_BYTES].join(','); - for (const error of [failure, malformed]) { - expect(error).toBeInstanceOf(Error); - const text = `${String(error)}\n${(error as Error).stack ?? ''}`; - expect(text).not.toContain(SECRET_HEX); - expect(text).not.toContain(SECRET_HEX.toUpperCase()); - expect(text).not.toContain(decimal); - } - }); -}); - -describe('LoginSecretSource', () => { - it('re-exports the secret for a failover promotion', async () => { - const source = new LoginSecretSource(); - source.use(exporter(SECRET_HEX)); - - expect(new Uint8Array(await source.provideSecret())).toEqual(SECRET_BYTES); - }); - - it('refuses to provide a secret with no live session', async () => { - const source = new LoginSecretSource(); - await expect(source.provideSecret()).rejects.toThrow(/no login session/); - - source.use(exporter(SECRET_HEX)); - source.use(null); - await expect(source.provideSecret()).rejects.toThrow(/no login session/); }); }); diff --git a/apps/web/src/engine/loginHandoff.ts b/apps/web/src/engine/loginHandoff.ts index feb0fa4eb..3572492fd 100644 --- a/apps/web/src/engine/loginHandoff.ts +++ b/apps/web/src/engine/loginHandoff.ts @@ -1,64 +1,13 @@ /** - * The Web3Auth Core Kit → engine secret handoff (blueprint/web-client.md "Login - * and identity"). Core Kit runs on the UI thread and exports the login secret; - * this module hands it to the engine once, transferred, and holds nothing. + * Web's re-export capability over the shared secret handoff. The export and the + * transfer to `start(secret)` are host-agnostic and live in `@cipherbox/login`; + * what is web-only is this: a tab promoted to leader re-exports the secret from + * its own Core Kit session (blueprint/web-client.md "Engine hosting and tab + * leadership"). */ -import { fromHex, type EngineClient, type SecretSource } from '@cipherbox/client'; - -/** The Core Kit surface this handoff drives, as a seam. */ -export interface LoginSecretExporter { - _UNSAFE_exportTssKey(): Promise; -} - -/** The secp256k1 scalar length `crates/engine/src/session.rs` requires. */ -const LOGIN_SECRET_LEN = 32; - -/** - * Exports the login secret as a buffer the caller owns and must transfer or - * zero. Core Kit yields hex in an immutable JS string that cannot be scrubbed; - * the decoded buffer is the only copy whose lifetime we control. - */ -export async function exportLoginSecret(exporter: LoginSecretExporter): Promise { - const exported = await exporter._UNSAFE_exportTssKey(); - const hex = exported.startsWith('0x') ? exported.slice(2) : exported; - - let decoded: Uint8Array; - try { - decoded = fromHex(hex); - } catch { - // Never re-raise the decoder's message: its input is the secret. - throw new Error('login secret export is not hex'); - } - if (decoded.length !== LOGIN_SECRET_LEN) { - decoded.fill(0); - throw new Error('login secret export is not a 32-byte scalar'); - } - - // Copy rather than hand over `decoded.buffer`: the transferred buffer must - // hold the secret and nothing else, whatever the decoder allocated. - const secret = new ArrayBuffer(decoded.length); - new Uint8Array(secret).set(decoded); - decoded.fill(0); - return secret; -} - -/** - * Cold-starts the engine with the login secret. `EngineClient.start` can reject - * before it delegates, so this frame stays the buffer's terminal owner until a - * transfer detaches it (security rule 7). - */ -export async function handOffLoginSecret( - client: EngineClient, - exporter: LoginSecretExporter -): Promise { - const secret = await exportLoginSecret(exporter); - try { - await client.facade.start(secret); - } finally { - if (secret.byteLength > 0) new Uint8Array(secret).fill(0); - } -} +import { exportLoginSecret, type LoginSecretExporter } from '@cipherbox/login'; +import type { SecretSource } from '@cipherbox/client'; /** The `SecretSource` a failover promotion re-exports through. */ export class LoginSecretSource implements SecretSource { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5c993f232..4cc3f7858 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -21,7 +21,7 @@ import { App } from './App'; import { createCoreKitSession, sealedCoreKitStore } from './auth/coreKit'; import { CoreKitProvider } from './auth/CoreKitProvider'; import { IdentityProvider } from './auth/IdentityProvider'; -import { createIdentityExchange } from './auth/identityExchange'; +import { createIdentityExchange } from '@cipherbox/login'; import { apiBaseUrl, googleClientId } from './engine/config'; import { createEngineClient } from './engine/createEngineClient'; import { installIntrospection } from './engine/introspection'; diff --git a/apps/web/src/test/authFakes.tsx b/apps/web/src/test/authFakes.tsx index fc10e719d..5b8afff85 100644 --- a/apps/web/src/test/authFakes.tsx +++ b/apps/web/src/test/authFakes.tsx @@ -8,13 +8,14 @@ import type { EngineClient } from '@cipherbox/client'; import type { ReactNode } from 'react'; import { WagmiProvider } from 'wagmi'; import { CoreKitProvider } from '../auth/CoreKitProvider'; -import type { CoreKitSession } from '../auth/coreKit'; + import { IdentityProvider } from '../auth/IdentityProvider'; import type { + CoreKitSession, IdentityCredential, IdentityExchange, IdentityMethod, -} from '../auth/identityExchange'; +} from '@cipherbox/login'; import { wagmiConfig } from '../lib/wagmi'; import { EngineProvider } from '../providers/EngineProvider'; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d7d86a248..b2d4c2d2b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,6 +8,9 @@ "declaration": false, "declarationMap": false }, - "references": [{ "path": "../../packages/client/tsconfig.build.json" }], + "references": [ + { "path": "../../packages/client/tsconfig.build.json" }, + { "path": "../../packages/login/tsconfig.build.json" } + ], "include": ["src", "vite.config.ts"] } diff --git a/packages/login/package.json b/packages/login/package.json new file mode 100644 index 000000000..dfebb5b34 --- /dev/null +++ b/packages/login/package.json @@ -0,0 +1,24 @@ +{ + "name": "@cipherbox/login", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.19.7", + "typescript": "^5.9.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/login/src/collector.ts b/packages/login/src/collector.ts new file mode 100644 index 000000000..7d830387b --- /dev/null +++ b/packages/login/src/collector.ts @@ -0,0 +1,52 @@ +import type { IdentityMethod } from './identity'; + +/** The address a code was sent to, and the code the member read off it. */ +export interface EmailAnswer { + email: string; + code: string; +} + +/** A signed EIP-4361 statement, as the API verifies it. */ +export interface WalletProof { + message: string; + /** The `0x`-prefixed EIP-191 hex the wallet returned, sent verbatim. */ + signature: string; +} + +/** + * What each of this host's collectors is handed. A host that already holds the + * provider's answer when it calls names it here; one that drives its own flow — + * a native loopback OAuth listener — takes `void` and does the work inside the + * collector. A method the host cannot collect at all is `never`. + */ +export interface CollectedMaterial { + google: unknown; + email: unknown; + wallet: unknown; +} + +/** + * Credential collection, injected per host (ADR 0008 D3): each collector ends + * where the provider's own proof does, and the shared sequencing takes over + * from there. + * + * A method absent here is a method this host does not have — desktop reaches no + * wallet, and a build carrying no Google client ID renders no Google button — + * so per-method availability is read off this object rather than branched on + * inside the sequencing. + */ +export interface CredentialCollector { + /** Yields a Google ID token for the API to verify. */ + google?(collected: C['google']): Promise; + email?(collected: C['email']): Promise; + wallet?(collected: C['wallet']): Promise; +} + +/** The methods this collector offers, in the order a front door should show them. */ +export function collectedMethods(collector: CredentialCollector): readonly IdentityMethod[] { + const offered: IdentityMethod[] = []; + if (collector.google) offered.push('google'); + if (collector.email) offered.push('email'); + if (collector.wallet) offered.push('wallet'); + return offered; +} diff --git a/packages/login/src/flow.test.ts b/packages/login/src/flow.test.ts new file mode 100644 index 000000000..367e1c701 --- /dev/null +++ b/packages/login/src/flow.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createLoginFlow, type LoginFlow } from './flow'; +import type { LoginSecretExporter } from './secret'; +import { + fakeAccount, + fakeExchange, + fakeFacade, + fakeProgress, + fakeSession, + FAKE_IDENTITY_TOKEN, + FAKE_NONCE, + passThroughCollector, + type WebCollected, +} from './testFakes'; + +const SECRET_BYTES = Uint8Array.from({ length: 32 }, () => 0x0f); + +type Parts = ReturnType; + +function build( + options: { + offered?: { google?: boolean; email?: boolean; wallet?: boolean }; + facade?: ReturnType; + session?: ReturnType; + } = {} +) { + const exchange = fakeExchange(); + const session = options.session ?? fakeSession(); + const facade = options.facade ?? fakeFacade(); + const account = fakeAccount(); + const progress = fakeProgress(); + const armed: (LoginSecretExporter | null)[] = []; + let rebuilds = 0; + const flow: LoginFlow = createLoginFlow({ + exchange: exchange.exchange, + collector: passThroughCollector(options.offered), + session: session.session, + facade: facade.facade, + secrets: { use: (exporter) => armed.push(exporter) }, + account: account.account, + progress: progress.progress, + afterLogout: () => { + rebuilds += 1; + }, + }); + return { flow, exchange, session, facade, account, progress, armed, rebuilds: () => rebuilds }; +} + +const loggedIn = (parts: Parts) => parts.account.calls.signedIn; + +describe('the login flow', () => { + it('exchanges the collected google token, then hands the engine the login secret', async () => { + const parts = build(); + + await parts.flow.loginWithGoogle('google.id.token'); + + expect(parts.exchange.calls.google).toEqual(['google.id.token']); + expect(parts.session.calls.logins).toEqual([ + { + method: 'google', + token: FAKE_IDENTITY_TOKEN, + verifierId: 'subject-for-google', + email: 'user@example.test', + }, + ]); + expect(parts.facade.calls.secrets).toEqual([SECRET_BYTES]); + expect(loggedIn(parts)).toEqual([{ method: 'google', email: 'user@example.test' }]); + }); + + it('asks CipherBox for the code, then redeems what the host collected', async () => { + const parts = build(); + + await parts.flow.sendEmailCode('user@example.test'); + await parts.flow.loginWithEmailCode({ email: 'user@example.test', code: '123456' }); + + expect(parts.exchange.calls.sentCodes).toEqual(['user@example.test']); + expect(parts.exchange.calls.verified).toEqual([{ email: 'user@example.test', code: '123456' }]); + expect(parts.facade.calls.secrets).toEqual([SECRET_BYTES]); + }); + + it('carries the wallet proof to the API verbatim', async () => { + const parts = build(); + const signature = `0x${'07'.repeat(65)}`; + + await expect(parts.flow.walletNonce()).resolves.toBe(FAKE_NONCE); + await parts.flow.loginWithWallet({ message: 'siwe-message', signature }); + + expect(parts.exchange.calls.wallet).toEqual([{ message: 'siwe-message', signature }]); + expect(loggedIn(parts)).toEqual([{ method: 'wallet', email: null }]); + }); + + // Desktop reaches no wallet, and a build with no Google client ID renders no + // Google button: the method is absent rather than present and unable to finish. + it('offers only what the host collects, and refuses the rest', async () => { + const parts = build({ offered: { google: false, email: true, wallet: false } }); + + expect(parts.flow.methods).toEqual(['email']); + expect(parts.flow.offers('wallet')).toBe(false); + await expect(parts.flow.loginWithWallet({ message: 'm', signature: '0x00' })).rejects.toThrow( + /wallet sign-in is not available on this device/ + ); + await expect(parts.flow.walletNonce()).rejects.toThrow(/not available/); + await expect(parts.flow.loginWithGoogle('google.id.token')).rejects.toThrow(/not available/); + + // Refused before anything was dispatched, so no half-login is left behind. + expect(parts.exchange.calls.nonces).toBe(0); + expect(parts.session.calls.logins).toEqual([]); + expect(parts.progress.seen).toEqual([]); + }); + + it('refuses a second sign-in while the first is still in flight', async () => { + let release!: () => void; + const facade = fakeFacade({ start: () => new Promise((r) => (release = r)) }); + const parts = build({ facade }); + + const first = parts.flow.loginWithGoogle('google.id.token'); + await vi.waitUntil(() => parts.facade.calls.secrets.length === 1); + + await expect(parts.flow.loginWithGoogle('google.id.token')).rejects.toThrow( + /another sign-in is already in progress/ + ); + expect(parts.session.calls.logins).toHaveLength(1); + + release(); + await first; + }); + + it('hands the secret over for a Core Kit session that outlived the page, once', async () => { + const parts = build({ session: fakeSession({ loggedIn: true }) }); + + await parts.flow.resume(); + await parts.flow.resume(); + + expect(parts.session.calls.logins).toEqual([]); + expect(parts.facade.calls.secrets).toEqual([SECRET_BYTES]); + // The identity token carries no email claim, so a restored session has no + // address to show until the member signs in again. + expect(loggedIn(parts)).toEqual([{ method: null, email: null }]); + }); + + it('disarms the re-export and ends the session when the engine refuses the secret', async () => { + const facade = fakeFacade({ start: () => Promise.reject(new Error('trust violation')) }); + const parts = build({ facade }); + + await expect(parts.flow.loginWithGoogle('google.id.token')).rejects.toThrow('trust violation'); + + expect(loggedIn(parts)).toEqual([]); + expect(parts.armed.at(-1)).toBeNull(); + expect(parts.session.calls.logouts).toBe(1); + expect(parts.progress.failures.map(String)).toEqual(['Error: trust violation']); + }); + + it('tears both sides down on logout and still reports the failed leg', async () => { + const facade = fakeFacade({ logout: () => Promise.reject(new Error('engine gone')) }); + const parts = build({ facade }); + await parts.flow.loginWithGoogle('google.id.token'); + + await expect(parts.flow.logout()).rejects.toThrow('engine gone'); + + expect(parts.session.calls.logouts).toBe(1); + expect(parts.armed.at(-1)).toBeNull(); + expect(parts.account.signOuts()).toBe(1); + expect(parts.rebuilds()).toBe(1); + }); +}); diff --git a/packages/login/src/flow.ts b/packages/login/src/flow.ts new file mode 100644 index 000000000..149a15983 --- /dev/null +++ b/packages/login/src/flow.ts @@ -0,0 +1,181 @@ +/** + * The login sequencing, host-agnostic (ADR 0008 D3): provider credential → API + * exchange → Core Kit login → secret export → `start(secret)`. Credential + * collection is injected, and so is the facade this starts, because both differ + * per host; everything between them is this file and is shared. + */ + +import { collectedMethods, type CollectedMaterial, type CredentialCollector } from './collector'; +import type { IdentityCredential, IdentityExchange, IdentityMethod } from './identity'; +import { handOffLoginSecret, type LoginFacade } from './secret'; +import type { AccountRecord, CoreKitSession, LoginProgress, SecretRearm } from './session'; + +/** What the sequencing needs from the host it runs on. */ +export interface LoginHost { + /** The API's identity surface — one network map for every host. */ + exchange: IdentityExchange; + collector: CredentialCollector; + /** This host's Core Kit session; `null` until it is built and its restore settles. */ + session: CoreKitSession | null; + /** The facade to start; `null` until this host has one. */ + facade: LoginFacade | null; + secrets: SecretRearm | null; + account: AccountRecord; + progress: LoginProgress; + /** Runs once a logout has torn the facade down, so the host can replace it. */ + afterLogout?: () => void; +} + +export interface LoginFlow { + /** The methods this host collects for, in the order a front door should show them. */ + readonly methods: readonly IdentityMethod[]; + offers(method: IdentityMethod): boolean; + loginWithGoogle(collected: C['google']): Promise; + /** Asks CipherBox to deliver a verification code. */ + sendEmailCode(email: string): Promise; + loginWithEmailCode(collected: C['email']): Promise; + /** Issues the single-use nonce the wallet's EIP-4361 message embeds. */ + walletNonce(): Promise; + loginWithWallet(collected: C['wallet']): Promise; + logout(): Promise; + /** + * Hands the engine its secret for a Core Kit session that outlived the page. + * A no-op unless one is live, and it never rejects: nothing asked for it. + */ + resume(): Promise; +} + +/** + * There is one engine per host and one cold start per page, so these guards are + * module-scoped: every consumer drives the same transitions, and a second one + * must not start a second login. + */ +let inFlight = false; +let restoredFor: CoreKitSession | null = null; + +export function createLoginFlow( + host: LoginHost +): LoginFlow { + const { exchange, collector, session, facade, secrets, account, progress, afterLogout } = host; + + /** Serializes the auth transitions; a collision rejects rather than no-ops. */ + const exclusively = async (step: () => Promise): Promise => { + if (inFlight) throw new Error('another sign-in is already in progress'); + inFlight = true; + progress.begin(); + try { + await step(); + } catch (failure) { + progress.failed(failure); + throw failure; + } finally { + inFlight = false; + progress.end(); + } + }; + + /** + * The Core Kit → engine handoff. The secret source is armed first so a + * leadership failover mid-start can re-export it; every step after that stays + * inside the failure envelope, so nothing can leave it armed over a host that + * renders signed out. + */ + const handOff = async (): Promise => { + if (!facade || !session) throw new Error('the engine is not ready to accept a login'); + const method = session.method(); + const email = session.email(); + + secrets?.use(session); + try { + await handOffLoginSecret(facade, session); + account.signedIn(method, email); + } catch (failure) { + secrets?.use(null); + // A Core Kit session the engine refused is a live credential on this + // device that nothing in the UI can reach; end it here. + await session.logout().catch(() => undefined); + throw failure; + } + }; + + /** + * One sequencing for every method: exchange the collected credential, redeem + * it with the Core Kit, then hand the engine its secret. + */ + const login = (collect: () => Promise) => + exclusively(async () => { + if (!session) throw new Error('the login provider is not ready'); + await session.login(await collect()); + await handOff(); + }); + + const unavailable = (method: IdentityMethod): Promise => + Promise.reject(new Error(`${method} sign-in is not available on this device`)); + + const methods = collectedMethods(collector); + + return { + methods, + + offers: (method) => methods.includes(method), + + loginWithGoogle(collected) { + const collect = collector.google; + if (!collect) return unavailable('google'); + return login(async () => exchange.fromGoogleToken(await collect(collected))); + }, + + sendEmailCode(email) { + if (!collector.email) return unavailable('email'); + return exclusively(() => exchange.sendEmailCode(email)); + }, + + loginWithEmailCode(collected) { + const collect = collector.email; + if (!collect) return unavailable('email'); + return login(async () => { + const answer = await collect(collected); + return exchange.fromEmailCode(answer.email, answer.code); + }); + }, + + walletNonce() { + if (!collector.wallet) return unavailable('wallet'); + return exchange.walletNonce(); + }, + + loginWithWallet(collected) { + const collect = collector.wallet; + if (!collect) return unavailable('wallet'); + return login(async () => { + const proof = await collect(collected); + return exchange.fromWalletSignature(proof.message, proof.signature); + }); + }, + + logout() { + return exclusively(async () => { + // Every leg runs: a refused engine zeroize must not strand the Core Kit + // session, and a failed Core Kit logout must not leave the host signed in. + const outcomes = await Promise.allSettled([ + facade?.logout() ?? Promise.resolve(), + session?.logout() ?? Promise.resolve(), + ]); + secrets?.use(null); + restoredFor = null; + account.signedOut(); + afterLogout?.(); + const failed = outcomes.find((outcome) => outcome.status === 'rejected'); + if (failed) throw failed.reason as Error; + }); + }, + + resume() { + if (!session || restoredFor === session || !session.isLoggedIn()) return Promise.resolve(); + restoredFor = session; + return exclusively(handOff).catch(() => { + restoredFor = null; + }); + }, + }; +} diff --git a/packages/login/src/hostAgnostic.test.ts b/packages/login/src/hostAgnostic.test.ts new file mode 100644 index 000000000..a633e527d --- /dev/null +++ b/packages/login/src/hostAgnostic.test.ts @@ -0,0 +1,93 @@ +/** + * The package is host-agnostic by law (ADR 0008 D3), which is what lets desktop + * import it: no browser API, no React. `tsconfig.json` drops the `DOM` lib so a + * browser API cannot typecheck here; this runs the whole sequencing with the + * browser globals booby-trapped, so one reached at runtime fails the suite too. + */ + +import { describe, expect, it } from 'vitest'; +import { createLoginFlow } from './flow'; +import { createIdentityExchange } from './identity'; +import { + fakeAccount, + fakeFacade, + fakeProgress, + fakeSession, + passThroughCollector, + type WebCollected, +} from './testFakes'; + +/** What a browser host would have and this package must never reach for. */ +const BROWSER_GLOBALS = [ + 'window', + 'document', + 'navigator', + 'location', + 'localStorage', + 'sessionStorage', + 'indexedDB', + 'caches', + 'BroadcastChannel', + 'Worker', + 'XMLHttpRequest', +] as const; + +/** Runs `body` in a realm where every browser global throws on first touch. */ +async function withNoBrowserApi(body: () => Promise): Promise { + const original = BROWSER_GLOBALS.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const + ); + for (const [name] of original) { + Object.defineProperty(globalThis, name, { + configurable: true, + get() { + throw new Error(`the login package reached for the browser API \`${name}\``); + }, + }); + } + try { + await body(); + } finally { + for (const [name, descriptor] of original) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } + } +} + +describe('a host-agnostic login', () => { + it('sequences a whole login with no browser API in reach', async () => { + const session = fakeSession(); + const facade = fakeFacade(); + const account = fakeAccount(); + const grant = { token: 'header.payload.signature', verifierId: 'subject-42', email: null }; + const flow = createLoginFlow({ + exchange: createIdentityExchange('https://api.example.test'), + collector: passThroughCollector(), + session: session.session, + facade: facade.facade, + secrets: null, + account: account.account, + progress: fakeProgress().progress, + }); + + await withNoBrowserApi(async () => { + globalThis.fetch = () => + Promise.resolve( + new Response(JSON.stringify(grant), { headers: { 'content-type': 'application/json' } }) + ); + await flow.loginWithGoogle('google.id.token'); + }); + + expect(session.calls.logins).toHaveLength(1); + expect(facade.calls.secrets).toHaveLength(1); + expect(account.calls.signedIn).toEqual([{ method: 'google', email: null }]); + }); + + it('cannot reach React at all', async () => { + // React is not a declared dependency, so this package's module graph cannot + // resolve it: an import added here would fail to load rather than pass. + const react = 'react'; + await expect(import(/* @vite-ignore */ react)).rejects.toThrow(); + }); +}); diff --git a/apps/web/src/auth/identityExchange.test.ts b/packages/login/src/identity.test.ts similarity index 99% rename from apps/web/src/auth/identityExchange.test.ts rename to packages/login/src/identity.test.ts index 4362f1fd4..6941bae4c 100644 --- a/apps/web/src/auth/identityExchange.test.ts +++ b/packages/login/src/identity.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createIdentityExchange, isIdentityMethod } from './identityExchange'; +import { createIdentityExchange, isIdentityMethod } from './identity'; const BASE = 'https://api.example.test'; diff --git a/apps/web/src/auth/identityExchange.ts b/packages/login/src/identity.ts similarity index 100% rename from apps/web/src/auth/identityExchange.ts rename to packages/login/src/identity.ts diff --git a/packages/login/src/index.ts b/packages/login/src/index.ts new file mode 100644 index 000000000..c9d357ab3 --- /dev/null +++ b/packages/login/src/index.ts @@ -0,0 +1,28 @@ +/** + * The host-agnostic login package (ADR 0008 D3). It owns the sequencing and the + * API's identity surface; the host owns credential collection, the Core Kit + * instance, and the facade this starts. + */ + +export { + collectedMethods, + type CollectedMaterial, + type CredentialCollector, + type EmailAnswer, + type WalletProof, +} from './collector'; +export { createLoginFlow, type LoginFlow, type LoginHost } from './flow'; +export { + createIdentityExchange, + isIdentityMethod, + type IdentityCredential, + type IdentityExchange, + type IdentityMethod, +} from './identity'; +export { + exportLoginSecret, + handOffLoginSecret, + type LoginFacade, + type LoginSecretExporter, +} from './secret'; +export type { AccountRecord, CoreKitSession, LoginProgress, SecretRearm } from './session'; diff --git a/packages/login/src/secret.test.ts b/packages/login/src/secret.test.ts new file mode 100644 index 000000000..f6e0c42f5 --- /dev/null +++ b/packages/login/src/secret.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + exportLoginSecret, + handOffLoginSecret, + type LoginFacade, + type LoginSecretExporter, +} from './secret'; + +const SECRET_HEX = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; +const SECRET_BYTES = Uint8Array.from({ length: 32 }, (_, i) => + Number.parseInt(SECRET_HEX.slice(i * 2, i * 2 + 2), 16) +); + +function exporter(key: string | Error): LoginSecretExporter { + return { + _UNSAFE_exportTssKey: () => (key instanceof Error ? Promise.reject(key) : Promise.resolve(key)), + }; +} + +/** A facade whose `start` behaviour the test scripts; records what it was handed. */ +function fakeFacade(start: (secret: ArrayBuffer) => Promise) { + const received: ArrayBuffer[] = []; + const facade: LoginFacade = { + start(secret) { + received.push(secret); + return start(secret); + }, + logout: () => Promise.resolve(), + }; + return { facade, received }; +} + +/** `postMessage(msg, [secret])` detaches the sender's buffer; so does this. */ +const transferring = (secret: ArrayBuffer): Promise => { + structuredClone(secret, { transfer: [secret] }); + return Promise.resolve(); +}; + +describe('exportLoginSecret', () => { + it('decodes the Core Kit hex export, with or without the 0x prefix', async () => { + expect(new Uint8Array(await exportLoginSecret(exporter(SECRET_HEX)))).toEqual(SECRET_BYTES); + expect(new Uint8Array(await exportLoginSecret(exporter(`0x${SECRET_HEX}`)))).toEqual( + SECRET_BYTES + ); + }); + + it('rejects a malformed export without echoing it', async () => { + await expect(exportLoginSecret(exporter('nothex'))).rejects.toThrow( + /^login secret export is not hex$/ + ); + await expect(exportLoginSecret(exporter(''))).rejects.toThrow(/32-byte scalar/); + // Short of a full secp256k1 scalar: rejected here, not after a transfer. + await expect(exportLoginSecret(exporter(SECRET_HEX.slice(2)))).rejects.toThrow( + /32-byte scalar/ + ); + }); +}); + +describe('handOffLoginSecret', () => { + it('hands the engine the exported secret', async () => { + const seen: number[] = []; + const { facade } = fakeFacade((secret) => { + seen.push(...new Uint8Array(secret)); + return transferring(secret); + }); + + await handOffLoginSecret(facade, exporter(SECRET_HEX)); + + expect(Uint8Array.from(seen)).toEqual(SECRET_BYTES); + }); + + it('tolerates the buffer the transport already detached', async () => { + const { facade, received } = fakeFacade(transferring); + + await handOffLoginSecret(facade, exporter(SECRET_HEX)); + + // Post-transfer the buffer is neutered, so the `finally` must not re-view it. + expect(received[0].byteLength).toBe(0); + }); + + it('zeroes the buffer when the engine never took it', async () => { + const { facade, received } = fakeFacade(() => + Promise.reject(new Error('engine client closed')) + ); + + await expect(handOffLoginSecret(facade, exporter(SECRET_HEX))).rejects.toThrow( + 'engine client closed' + ); + + expect(received[0].byteLength).toBe(32); + expect(new Uint8Array(received[0])).toEqual(new Uint8Array(32)); + }); + + it('zeroes the buffer when start throws synchronously', async () => { + const { facade, received } = fakeFacade(() => { + throw new Error('transport gone'); + }); + + await expect(handOffLoginSecret(facade, exporter(SECRET_HEX))).rejects.toThrow( + 'transport gone' + ); + + expect(new Uint8Array(received[0])).toEqual(new Uint8Array(32)); + }); + + it('never starts the engine when the export fails', async () => { + const { facade, received } = fakeFacade(transferring); + + await expect( + handOffLoginSecret(facade, exporter(new Error('core kit locked'))) + ).rejects.toThrow('core kit locked'); + + expect(received).toEqual([]); + }); +}); + +describe('secret containment', () => { + const consoleMethods = ['log', 'info', 'warn', 'error', 'debug'] as const; + let logged: string[]; + + beforeEach(() => { + logged = []; + for (const method of consoleMethods) { + vi.spyOn(console, method).mockImplementation((...args: unknown[]) => { + logged.push(args.map((arg) => String(arg)).join(' ')); + }); + } + }); + + afterEach(() => vi.restoreAllMocks()); + + it('logs nothing at all, so no encoding of the secret can reach a log', async () => { + const { facade } = fakeFacade(transferring); + + await handOffLoginSecret(facade, exporter(SECRET_HEX)); + + expect(logged).toEqual([]); + }); + + it('parks no copy of the secret in the realm-global RegExp statics', async () => { + const { facade } = fakeFacade(transferring); + /(sentinel)/.exec('sentinel'); + + await handOffLoginSecret(facade, exporter(SECRET_HEX)); + + expect(RegExp.input).toBe('sentinel'); + expect(RegExp.lastMatch).toBe('sentinel'); + }); + + it('keeps the secret out of a failure message, in any encoding', async () => { + const { facade } = fakeFacade(() => Promise.reject(new Error('boom'))); + + const failure = await handOffLoginSecret(facade, exporter(SECRET_HEX)).catch( + (error: unknown) => error + ); + const malformed = await exportLoginSecret(exporter(`0x${SECRET_HEX}zz`)).catch( + (error: unknown) => error + ); + + const decimal = [...SECRET_BYTES].join(','); + for (const error of [failure, malformed]) { + expect(error).toBeInstanceOf(Error); + const text = `${String(error)}\n${(error as Error).stack ?? ''}`; + expect(text).not.toContain(SECRET_HEX); + expect(text).not.toContain(SECRET_HEX.toUpperCase()); + expect(text).not.toContain(decimal); + } + }); +}); diff --git a/packages/login/src/secret.ts b/packages/login/src/secret.ts new file mode 100644 index 000000000..5ce8660d7 --- /dev/null +++ b/packages/login/src/secret.ts @@ -0,0 +1,94 @@ +/** + * The Core Kit → engine secret handoff. Core Kit runs on the host's UI thread + * and exports the login secret; this module hands it to the facade once, + * transferred, and holds nothing. + */ + +/** The Core Kit surface this handoff drives, as a seam. */ +export interface LoginSecretExporter { + _UNSAFE_exportTssKey(): Promise; +} + +/** + * The facade the login sequence starts, parameterised because the transport is + * per host: a WASM worker on web, Tauri IPC on desktop. + */ +export interface LoginFacade { + start(secret: ArrayBuffer): Promise; + logout(): Promise; +} + +/** The secp256k1 scalar length `crates/engine/src/session.rs` requires. */ +const LOGIN_SECRET_LEN = 32; + +/** + * Exports the login secret as a buffer the caller owns and must transfer or + * zero. Core Kit yields hex in an immutable JS string that cannot be scrubbed; + * the decoded buffer is the only copy whose lifetime we control. + */ +export async function exportLoginSecret(exporter: LoginSecretExporter): Promise { + const exported = await exporter._UNSAFE_exportTssKey(); + const hex = exported.startsWith('0x') ? exported.slice(2) : exported; + + let decoded: Uint8Array; + try { + decoded = fromHex(hex); + } catch { + // Never re-raise the decoder's message: its input is the secret. + throw new Error('login secret export is not hex'); + } + if (decoded.length !== LOGIN_SECRET_LEN) { + decoded.fill(0); + throw new Error('login secret export is not a 32-byte scalar'); + } + + // Copy rather than hand over `decoded.buffer`: the transferred buffer must + // hold the secret and nothing else, whatever the decoder allocated. + const secret = new ArrayBuffer(decoded.length); + new Uint8Array(secret).set(decoded); + decoded.fill(0); + return secret; +} + +/** + * Cold-starts the engine with the login secret. `start` can reject before it + * delegates, so this frame stays the buffer's terminal owner until a transfer + * detaches it (security rule 7). + */ +export async function handOffLoginSecret( + facade: LoginFacade, + exporter: LoginSecretExporter +): Promise { + const secret = await exportLoginSecret(exporter); + try { + await facade.start(secret); + } finally { + if (secret.byteLength > 0) new Uint8Array(secret).fill(0); + } +} + +/** + * Decodes secret-bearing hex, so the bytes already decoded must not survive the + * throw (blueprint/core.md: scrub on error paths too). + */ +function fromHex(hex: string): Uint8Array { + if (hex.length % 2 !== 0) throw new TypeError('odd-length hex'); + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i += 1) { + const high = nibble(hex.charCodeAt(i * 2)); + const low = nibble(hex.charCodeAt(i * 2 + 1)); + if (high < 0 || low < 0) { + out.fill(0, 0, i); + throw new TypeError('non-hex character'); + } + out[i] = (high << 4) | low; + } + return out; +} + +function nibble(code: number): number { + if (code >= 0x30 && code <= 0x39) return code - 0x30; + if (code >= 0x61 && code <= 0x66) return code - 0x61 + 10; + if (code >= 0x41 && code <= 0x46) return code - 0x41 + 10; + return -1; +} diff --git a/packages/login/src/session.ts b/packages/login/src/session.ts new file mode 100644 index 000000000..bfc0769e1 --- /dev/null +++ b/packages/login/src/session.ts @@ -0,0 +1,45 @@ +import type { IdentityCredential, IdentityMethod } from './identity'; +import type { LoginSecretExporter } from './secret'; + +/** + * The Core Kit surface the login flow drives. Narrow by construction: the flow + * never sees a Web3Auth parameter shape, the host builds the instance, and a + * test substitutes a plain object. + */ +export interface CoreKitSession extends LoginSecretExporter { + /** Restores a prior session, if the SDK has one on this device. */ + restore(): Promise; + /** True once a login (or a restore) has completed on this device. */ + isLoggedIn(): boolean; + /** Redeems a CipherBox identity token for this device's share of the key. */ + login(credential: IdentityCredential): Promise; + /** How the live session was established; unknown after a bare restore. */ + method(): IdentityMethod | null; + /** The signed-in user's email, when the method carries one. */ + email(): string | null; + logout(): Promise; +} + +/** Where the host records who is signed in — its own UI chrome, never key material. */ +export interface AccountRecord { + signedIn(method: IdentityMethod | null, email: string | null): void; + signedOut(): void; +} + +/** + * The host's re-export capability, armed with the live session for as long as + * one exists. Web re-exports through it when a tab is promoted to leader. + */ +export interface SecretRearm { + use(exporter: LoginSecretExporter | null): void; +} + +/** How the host renders a transition's progress. */ +export interface LoginProgress { + /** A transition took the flow; nothing has failed yet. */ + begin(): void; + /** The transition threw `failure`, which the host renders. */ + failed(failure: unknown): void; + /** The transition settled, whether or not it failed. */ + end(): void; +} diff --git a/packages/login/src/testFakes.ts b/packages/login/src/testFakes.ts new file mode 100644 index 000000000..e48b3c43a --- /dev/null +++ b/packages/login/src/testFakes.ts @@ -0,0 +1,146 @@ +/** The seams the flow drives, recorded — so a test asserts what it dispatched. */ + +import type { CredentialCollector } from './collector'; +import type { IdentityCredential, IdentityExchange, IdentityMethod } from './identity'; +import type { LoginFacade } from './secret'; +import type { AccountRecord, CoreKitSession, LoginProgress } from './session'; + +/** A 32-byte scalar in the hex shape Core Kit exports. */ +export const SECRET_HEX = '0f'.repeat(32); +export const FAKE_NONCE = 'nonce123456789ab'; +export const FAKE_IDENTITY_TOKEN = 'header.payload.signature'; + +/** What web's collectors do: the UI already holds the provider's answer. */ +export interface WebCollected { + google: string; + email: { email: string; code: string }; + wallet: { message: string; signature: string }; +} + +export function fakeExchange() { + const calls = { + google: [] as string[], + sentCodes: [] as string[], + verified: [] as { email: string; code: string }[], + nonces: 0, + wallet: [] as { message: string; signature: string }[], + }; + const grant = (method: IdentityMethod, email: string | null): IdentityCredential => ({ + method, + token: FAKE_IDENTITY_TOKEN, + verifierId: `subject-for-${method}`, + email, + }); + const exchange: IdentityExchange = { + fromGoogleToken(idToken) { + calls.google.push(idToken); + return Promise.resolve(grant('google', 'user@example.test')); + }, + sendEmailCode(email) { + calls.sentCodes.push(email); + return Promise.resolve(); + }, + fromEmailCode(email, code) { + calls.verified.push({ email, code }); + return Promise.resolve(grant('email', email)); + }, + walletNonce() { + calls.nonces += 1; + return Promise.resolve(FAKE_NONCE); + }, + fromWalletSignature(message, signature) { + calls.wallet.push({ message, signature }); + return Promise.resolve(grant('wallet', null)); + }, + }; + return { exchange, calls }; +} + +export function fakeSession(options: { loggedIn?: boolean } = {}) { + const calls = { logins: [] as IdentityCredential[], exports: 0, logouts: 0 }; + let loggedIn = options.loggedIn ?? false; + let method: IdentityMethod | null = null; + let email: string | null = null; + const session: CoreKitSession = { + restore: () => Promise.resolve(), + isLoggedIn: () => loggedIn, + login(credential) { + calls.logins.push(credential); + method = credential.method; + email = credential.email; + loggedIn = true; + return Promise.resolve(); + }, + method: () => method, + email: () => email, + logout() { + calls.logouts += 1; + loggedIn = false; + return Promise.resolve(); + }, + _UNSAFE_exportTssKey() { + calls.exports += 1; + return Promise.resolve(SECRET_HEX); + }, + }; + return { session, calls }; +} + +export function fakeFacade(overrides: Partial = {}) { + const calls = { secrets: [] as Uint8Array[], logouts: 0 }; + const facade: LoginFacade = { + start(secret) { + calls.secrets.push(new Uint8Array(secret).slice()); + return overrides.start?.(secret) ?? Promise.resolve(); + }, + logout() { + calls.logouts += 1; + return overrides.logout?.() ?? Promise.resolve(); + }, + }; + return { facade, calls }; +} + +/** A host whose UI collected the material before it called, as web's does. */ +export function passThroughCollector( + offered: { google?: boolean; email?: boolean; wallet?: boolean } = { + google: true, + email: true, + wallet: true, + } +): CredentialCollector { + return { + google: offered.google ? (idToken) => Promise.resolve(idToken) : undefined, + email: offered.email ? (answer) => Promise.resolve(answer) : undefined, + wallet: offered.wallet ? (proof) => Promise.resolve(proof) : undefined, + }; +} + +export function fakeAccount() { + const calls = { signedIn: [] as { method: IdentityMethod | null; email: string | null }[] }; + let signedOut = 0; + const account: AccountRecord = { + signedIn(method, email) { + calls.signedIn.push({ method, email }); + }, + signedOut() { + signedOut += 1; + }, + }; + return { account, calls, signOuts: () => signedOut }; +} + +/** Records the transitions a host would render. */ +export function fakeProgress() { + const seen: string[] = []; + const failures: unknown[] = []; + const progress: LoginProgress = { + begin: () => seen.push('begin'), + failed: (failure) => { + failures.push(failure); + seen.push('failed'); + }, + end: () => seen.push('end'), + }; + return { progress, seen, failures }; +} diff --git a/packages/login/tsconfig.build.json b/packages/login/tsconfig.build.json new file mode 100644 index 000000000..fb34c2ee8 --- /dev/null +++ b/packages/login/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // Referenced by apps/web, which builds this project to get its declarations. + "composite": true, + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "exclude": ["src/**/*.test.ts", "src/testFakes.ts"] +} diff --git a/packages/login/tsconfig.json b/packages/login/tsconfig.json new file mode 100644 index 000000000..fd3b35862 --- /dev/null +++ b/packages/login/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + // No `DOM`: this package is host-agnostic (ADR 0008 D3), so a browser API + // must not typecheck here. `node` carries the fetch the exchange speaks. + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d911502c..738e297cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: '@cipherbox/client': specifier: workspace:* version: link:../../packages/client + '@cipherbox/login': + specifier: workspace:* + version: link:../../packages/login '@tanstack/react-query': specifier: ^5.90.2 version: 5.101.4(react@19.2.7) @@ -235,6 +238,18 @@ importers: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.8.2)) + packages/login: + devDependencies: + '@types/node': + specifier: ^22.19.7 + version: 22.19.7 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(vite@8.1.5(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.8.2)) + tests/web-e2e: devDependencies: '@cipherbox/client': @@ -12108,11 +12123,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.5 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.1.5(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.8.2) why-is-node-running: 2.3.0 From 864e665791ad3b01a71f73a7ac4cec05d816280d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 16:55:39 +0000 Subject: [PATCH 2/5] docs: name the shared login package in the host login sections ADR 0008 D3 consequence 2: both host blueprints describe the shared orchestration, and now that it exists they can name it. --- blueprint/desktop.md | 2 +- blueprint/web-client.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/blueprint/desktop.md b/blueprint/desktop.md index 9c4b6c025..a74e7716e 100644 --- a/blueprint/desktop.md +++ b/blueprint/desktop.md @@ -251,7 +251,7 @@ navigation (FSM1/cipher-box-next#33 D2): survives: a debug flag feeds the staging test-login path through the same facade, keychain bypassed — the headless agent/e2e seam. - **The orchestration is the web client's** ([ADR 0008](https://github.com/FSM1/cipher-box-next/blob/main/decisions/0008-cipherbox-issues-the-identity-token.md) D3): the shell imports the - same host-agnostic login package and supplies its own credential collector. It + same host-agnostic `packages/login` and supplies its own credential collector. It does **not** take `packages/client` — the worker, leadership and Service Worker machinery has no place here. - **Google collection is native, not in-webview.** Google Identity Services does diff --git a/blueprint/web-client.md b/blueprint/web-client.md index 05f559824..76addfc2e 100644 --- a/blueprint/web-client.md +++ b/blueprint/web-client.md @@ -200,9 +200,10 @@ all living in `packages/client` and running inside the engine worker realm: the Http seam. This is a distinct authentication from unlocking the Core Kit, and only the latter involves an identity provider ([ADR 0008](https://github.com/FSM1/cipher-box-next/blob/main/decisions/0008-cipherbox-issues-the-identity-token.md)). - **The login orchestration is shared, the credential collection is not** - (ADR 0008 D3). One host-agnostic package sequences provider credential → API - exchange → Core Kit login → secret export → `start(secret)`; both hosts import - it and inject their own collector. On web that collector is the Google popup + (ADR 0008 D3). `packages/login` (`@cipherbox/login`) is host-agnostic — no + browser API, no React — and sequences provider credential → API exchange → + Core Kit login → secret export → `start(secret)`; both hosts import it, + parameterise it over their own facade, and inject their own collector. On web that collector is the Google popup and wagmi; desktop's is its own (desktop.md). The boundary is credential collection, not the bearer token — v1 drew it at the token and the two hosts drifted. From db020f95dce350a53a8965e642d0facde9836111 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:04:16 +0000 Subject: [PATCH 3/5] test: cover web email collection and the login hex decoder edges --- apps/web/src/auth/webCollector.test.ts | 2 ++ packages/login/src/secret.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/apps/web/src/auth/webCollector.test.ts b/apps/web/src/auth/webCollector.test.ts index 0f117cb48..f27021dcc 100644 --- a/apps/web/src/auth/webCollector.test.ts +++ b/apps/web/src/auth/webCollector.test.ts @@ -20,8 +20,10 @@ describe('web credential collection', () => { it('passes the material the page already collected straight through', async () => { const collector = webCollector('google-client-id'); const proof = { message: 'siwe-message', signature: '0xabc' }; + const answer = { email: 'user@example.com', code: '123456' }; await expect(collector.google?.('google.id.token')).resolves.toBe('google.id.token'); + await expect(collector.email?.(answer)).resolves.toBe(answer); await expect(collector.wallet?.(proof)).resolves.toBe(proof); }); }); diff --git a/packages/login/src/secret.test.ts b/packages/login/src/secret.test.ts index f6e0c42f5..0bd207c94 100644 --- a/packages/login/src/secret.test.ts +++ b/packages/login/src/secret.test.ts @@ -44,10 +44,20 @@ describe('exportLoginSecret', () => { ); }); + it('decodes uppercase hex to the same bytes', async () => { + expect(new Uint8Array(await exportLoginSecret(exporter(SECRET_HEX.toUpperCase())))).toEqual( + SECRET_BYTES + ); + }); + it('rejects a malformed export without echoing it', async () => { await expect(exportLoginSecret(exporter('nothex'))).rejects.toThrow( /^login secret export is not hex$/ ); + // An odd length is a hex failure, not a short scalar. + await expect(exportLoginSecret(exporter(SECRET_HEX.slice(1)))).rejects.toThrow( + /^login secret export is not hex$/ + ); await expect(exportLoginSecret(exporter(''))).rejects.toThrow(/32-byte scalar/); // Short of a full secp256k1 scalar: rejected here, not after a transfer. await expect(exportLoginSecret(exporter(SECRET_HEX.slice(2)))).rejects.toThrow( From 8b602d19d9f21ee9868d7b237387c1cc0dd53365 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:04:19 +0000 Subject: [PATCH 4/5] docs: correct the hex codec claim and the shared login orchestration --- blueprint/desktop.md | 9 +++++---- blueprint/web-client.md | 8 ++++---- packages/client/src/index.ts | 5 +++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/blueprint/desktop.md b/blueprint/desktop.md index a74e7716e..c61f0d6bc 100644 --- a/blueprint/desktop.md +++ b/blueprint/desktop.md @@ -250,10 +250,11 @@ navigation (FSM1/cipher-box-next#33 D2): the shell's only credential duty is hosting the keychain seam. Dev-key mode survives: a debug flag feeds the staging test-login path through the same facade, keychain bypassed — the headless agent/e2e seam. -- **The orchestration is the web client's** ([ADR 0008](https://github.com/FSM1/cipher-box-next/blob/main/decisions/0008-cipherbox-issues-the-identity-token.md) D3): the shell imports the - same host-agnostic `packages/login` and supplies its own credential collector. It - does **not** take `packages/client` — the worker, leadership and Service Worker - machinery has no place here. +- **The orchestration is shared through `packages/login`** ([ADR 0008](https://github.com/FSM1/cipher-box-next/blob/main/decisions/0008-cipherbox-issues-the-identity-token.md) D3): the shell + imports the same host-agnostic package and supplies both its own credential + collector and its own start facade — `LoginFacade` is `{ start, logout }`, over + Tauri IPC here. It does **not** take `packages/client` — the worker, leadership + and Service Worker machinery has no place here. - **Google collection is native, not in-webview.** Google Identity Services does not run in this webview, and the manual OAuth2 flow it falls back to needs a `redirect_uri` a packaged Tauri origin cannot satisfy — `tauri://localhost` is diff --git a/blueprint/web-client.md b/blueprint/web-client.md index 76addfc2e..8e3cde026 100644 --- a/blueprint/web-client.md +++ b/blueprint/web-client.md @@ -203,10 +203,10 @@ all living in `packages/client` and running inside the engine worker realm: (ADR 0008 D3). `packages/login` (`@cipherbox/login`) is host-agnostic — no browser API, no React — and sequences provider credential → API exchange → Core Kit login → secret export → `start(secret)`; both hosts import it, - parameterise it over their own facade, and inject their own collector. On web that collector is the Google popup - and wagmi; desktop's is its own (desktop.md). The boundary is credential - collection, not the bearer token — v1 drew it at the token and the two hosts - drifted. + parameterise it over their own facade, and inject their own collector. On web + that collector is the Google popup, the emailed code and wagmi; desktop's is + its own (desktop.md). The boundary is credential collection, not the bearer + token — v1 drew it at the token and the two hosts drifted. - **Wallet is a first login here** (ADR 0008 D2): wagmi collects the signature on the UI thread, the API verifies it and mints the identity token, and the Core Kit login proceeds as for any other method. Web only. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 7e8a87f82..313b63f45 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -39,8 +39,9 @@ export { MediaService } from './media/service.js'; export type { MediaStreamFailure } from './media/service.js'; export type { MediaReader } from './media/broker.js'; -// The one hex codec in TypeScript, for hosts that receive hex-encoded bytes -// from a third-party SDK or address opaque engine byte strings by string key. +// The browser hex codec, for hosts that receive hex-encoded bytes from a +// third-party SDK or address opaque engine byte strings by string key. +// Host-agnostic `packages/login` carries its own; it cannot depend on this package. export { fromHex, toHex } from './seams/bytes.js'; // The wire descriptors the UI exchanges with the engine over the transport. From 3d1c41986cc3412be9b7a3868c3c040613640698 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:00:54 +0000 Subject: [PATCH 5/5] fix(login): key the restore latch on session and facade The reload-restore latch was keyed on the Core Kit session alone, but a host replaces the facade independently of it: any rebuild other than the logout leg left the latch set, so resume() returned early and the new facade never received the login secret while the host rendered signed in. Key the latch on the pair, still module-scoped so a useMemo rebuild does not re-run the handoff for an unchanged pair. --- packages/login/src/flow.test.ts | 15 +++++++++++++++ packages/login/src/flow.ts | 12 ++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/login/src/flow.test.ts b/packages/login/src/flow.test.ts index 367e1c701..d3b28f199 100644 --- a/packages/login/src/flow.test.ts +++ b/packages/login/src/flow.test.ts @@ -138,6 +138,21 @@ describe('the login flow', () => { expect(loggedIn(parts)).toEqual([{ method: null, email: null }]); }); + // The host rebuilds the flow whenever it replaces the engine facade, and a + // replacement facade holds no secret however old the Core Kit session is. + it('hands the secret to a replacement facade for an unchanged session', async () => { + const session = fakeSession({ loggedIn: true }); + const before = build({ session }); + await before.flow.resume(); + + const after = build({ session, facade: fakeFacade() }); + await after.flow.resume(); + + expect(before.facade.calls.secrets).toEqual([SECRET_BYTES]); + expect(after.facade.calls.secrets).toEqual([SECRET_BYTES]); + expect(after.progress.failures).toEqual([]); + }); + it('disarms the re-export and ends the session when the engine refuses the secret', async () => { const facade = fakeFacade({ start: () => Promise.reject(new Error('trust violation')) }); const parts = build({ facade }); diff --git a/packages/login/src/flow.ts b/packages/login/src/flow.ts index 149a15983..6d52255a4 100644 --- a/packages/login/src/flow.ts +++ b/packages/login/src/flow.ts @@ -48,10 +48,12 @@ export interface LoginFlow { /** * There is one engine per host and one cold start per page, so these guards are * module-scoped: every consumer drives the same transitions, and a second one - * must not start a second login. + * must not start a second login. The restore latch keys on the session *and* the + * facade, because a host replaces either independently: a facade that never + * received the secret must still get it, however old the session is. */ let inFlight = false; -let restoredFor: CoreKitSession | null = null; +let restoredFor: { session: CoreKitSession; facade: LoginFacade | null } | null = null; export function createLoginFlow( host: LoginHost @@ -171,8 +173,10 @@ export function createLoginFlow }, resume() { - if (!session || restoredFor === session || !session.isLoggedIn()) return Promise.resolve(); - restoredFor = session; + const restored = + restoredFor !== null && restoredFor.session === session && restoredFor.facade === facade; + if (!session || restored || !session.isLoggedIn()) return Promise.resolve(); + restoredFor = { session, facade }; return exclusively(handOff).catch(() => { restoredFor = null; });