From 45c7039379d637e0186dca40f39a339a60044656 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 20 Aug 2026 01:24:47 +0200 Subject: [PATCH 1/3] fix: refuse a second account the origin's engine and reclaim the stores it leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One origin elects one engine leader, and a follower tab's reads, writes and commands are served by that leader's engine. Nothing checked that the two tabs were the same account: a second account signing in on a second tab was served the first account's vault, and a write from it landed there under the first account's keys. The port handshake now carries the account each side holds. `LeaderRelay.serves` records the account its engine cold-started for, a `cb:portHello` naming any other is refused with `cb:portRefused`, and the follower's `start` rejects with `EngineHeldElsewhereError` naming the account that does hold the engine. The web front door renders that as an explicit signed-in-elsewhere state with the way out, rather than a one-line failure. Namespacing the durable stores per account also left one set per account that ever signed in, with no path to reclaim them — an abandoned account's staged op bodies were charged against the live account's staging budget for good. `reclaimOtherAccountStores` sweeps them at cold start, keyed strictly by the names the live account opens. --- apps/web/src/auth/useAuth.test.tsx | 58 +++++++ apps/web/src/auth/useAuth.ts | 20 ++- .../src/components/auth/SignedInElsewhere.tsx | 21 +++ apps/web/src/lib/accountId.test.ts | 18 ++ apps/web/src/lib/accountId.ts | 13 ++ apps/web/src/routes/LoginPage.tsx | 3 + apps/web/src/styles/login.css | 8 + packages/client/src/broadcast.ts | 19 +- .../client/src/broadcastTransport.test.ts | 164 ++++++++++++++---- packages/client/src/broadcastTransport.ts | 75 +++++++- packages/client/src/engineClient.test.ts | 11 +- packages/client/src/engineClient.ts | 9 + packages/client/src/index.ts | 2 +- packages/client/src/leaderRelay.ts | 28 ++- .../client/src/worker/browserSeams.test.ts | 122 ++++++++++++- packages/client/src/worker/browserSeams.ts | 95 +++++++++- packages/client/src/worker/engineWorker.ts | 15 +- .../client/test/browser/conformance.spec.ts | 8 + .../client/test/browser/conformance.worker.ts | 83 ++++++++- packages/client/test/browser/hexUtil.ts | 3 + .../client/test/browser/leadership.spec.ts | 68 +++++++- packages/client/test/browser/leadership.ts | 6 +- 22 files changed, 792 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/auth/SignedInElsewhere.tsx create mode 100644 apps/web/src/lib/accountId.test.ts create mode 100644 apps/web/src/lib/accountId.ts diff --git a/apps/web/src/auth/useAuth.test.tsx b/apps/web/src/auth/useAuth.test.tsx index c9b9fb92b..3b1818922 100644 --- a/apps/web/src/auth/useAuth.test.tsx +++ b/apps/web/src/auth/useAuth.test.tsx @@ -1,3 +1,4 @@ +import { EngineHeldElsewhereError } from '@cipherbox/client'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; import { authStore } from '../stores/auth.store'; @@ -341,3 +342,60 @@ describe('useAuth', () => { expect(rendered).not.toContain([...SECRET_BYTES].join(',')); }); }); + +describe('useAuth against an engine another account holds', () => { + beforeEach(() => authStore.signedOut()); + + it('renders the refusal as a signed-in-elsewhere state naming the holder', async () => { + const engine = fakeEngineClient({ + start: () => Promise.reject(new EngineHeldElsewhereError('other-account')), + }); + const coreKit = fakeCoreKitSession(); + const { result } = mount(engine, coreKit); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + expect(result.current.auth.heldElsewhere).toEqual({ heldBy: 'other-account' }); + // A one-line banner cannot say what to do about it, so none is rendered. + expect(result.current.auth.error).toBeNull(); + expect(authStore.getState().isAuthenticated).toBe(false); + // The credential the engine refused does not outlive the refusal. + expect(coreKit.calls.logouts).toBe(1); + }); + + it('reports a tab hosting the engine that has started none as holding no account', async () => { + const engine = fakeEngineClient({ + start: () => Promise.reject(new EngineHeldElsewhereError(null)), + }); + const { result } = mount(engine, fakeCoreKitSession()); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + expect(result.current.auth.heldElsewhere).toEqual({ heldBy: null }); + }); + + it('clears the state when the next attempt begins', async () => { + let refuse = true; + const engine = fakeEngineClient({ + start: () => + refuse ? Promise.reject(new EngineHeldElsewhereError(null)) : Promise.resolve(), + }); + const { result } = mount(engine, fakeCoreKitSession()); + await waitFor(() => expect(result.current.auth.isReady).toBe(true)); + await act(async () => { + await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow(); + }); + + refuse = false; + await act(() => result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)); + + expect(result.current.auth.heldElsewhere).toBeNull(); + expect(authStore.getState()).toMatchObject({ isAuthenticated: true }); + }); +}); diff --git a/apps/web/src/auth/useAuth.ts b/apps/web/src/auth/useAuth.ts index 141c92037..48d52f30e 100644 --- a/apps/web/src/auth/useAuth.ts +++ b/apps/web/src/auth/useAuth.ts @@ -6,6 +6,7 @@ */ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { EngineHeldElsewhereError } from '@cipherbox/client'; import { createLoginFlow, RecoveryRequiredError, type LoginProgress } from '@cipherbox/login'; import { errorMessage } from '../lib/errorMessage'; import { authStore, useAuthState } from '../stores/auth.store'; @@ -28,6 +29,12 @@ export interface Auth { isBusy: boolean; /** The last failure, already stripped of anything secret-shaped. */ error: string | null; + /** + * The origin's one engine belongs to another account, so this tab was refused + * rather than served that account's vault. `heldBy` names it, or is `null` + * when the tab hosting the engine has started none. + */ + heldElsewhere: { heldBy: string | null } | null; /** Exchanges a Google ID token collected on this host. */ loginWithGoogle(idToken: string): Promise; /** Asks CipherBox to deliver a verification code. */ @@ -60,6 +67,7 @@ export function useAuth(): Auth { const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); + const [heldElsewhere, setHeldElsewhere] = useState<{ heldBy: string | null } | null>(null); const isReady = client !== null && session !== null && status === 'ready'; const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable'); @@ -69,8 +77,17 @@ export function useAuth(): Auth { begin: () => { setIsBusy(true); setError(null); + setHeldElsewhere(null); + }, + // A refusal by account is a state the front door renders in full, not a + // one-line failure: its message alone cannot say what to do about it. + failed: (failure) => { + if (failure instanceof EngineHeldElsewhereError) { + setHeldElsewhere({ heldBy: failure.heldBy }); + return; + } + setError(errorMessage(failure)); }, - failed: (failure) => setError(errorMessage(failure)), end: () => setIsBusy(false), }), [] @@ -166,6 +183,7 @@ export function useAuth(): Auth { isSignedOut, isBusy, error: error ?? coreKitError, + heldElsewhere, loginWithGoogle, sendEmailCode: flow.sendEmailCode, loginWithEmailCode, diff --git a/apps/web/src/components/auth/SignedInElsewhere.tsx b/apps/web/src/components/auth/SignedInElsewhere.tsx new file mode 100644 index 000000000..a57252987 --- /dev/null +++ b/apps/web/src/components/auth/SignedInElsewhere.tsx @@ -0,0 +1,21 @@ +import { shortAccountId } from '../../lib/accountId'; + +/** + * The origin hosts one engine, so it hosts one account: a sign-in this browser + * already has another tab's session for is refused rather than served that + * tab's vault (blueprint/web-client.md "Engine hosting and tab leadership"). + * Only the tab holding the engine can give it up, so the way out is stated + * there rather than offered as a button here. + */ +export function SignedInElsewhere({ heldBy }: { heldBy: string | null }) { + return ( +
+

+ {heldBy === null + ? 'another tab in this browser is running CipherBox and is not signed in.' + : `another account is already signed in to CipherBox in this browser: ${shortAccountId(heldBy)}.`} +

+

sign out in that tab, or close it, then sign in again here.

+
+ ); +} diff --git a/apps/web/src/lib/accountId.test.ts b/apps/web/src/lib/accountId.test.ts new file mode 100644 index 000000000..5bfce64e0 --- /dev/null +++ b/apps/web/src/lib/accountId.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { shortAccountId } from './accountId'; + +describe('shortAccountId', () => { + it('takes both ends of a real account id, so two are told apart', () => { + const shared = 'ab'.repeat(32); + const first = shortAccountId(`${shared}-${'cd'.repeat(32)}`); + const second = shortAccountId(`${shared}-${'ef'.repeat(32)}`); + + expect(first).toBe('ababab…cdcd'); + expect(first).not.toBe(second); + }); + + it('leaves an id no longer than the elision it would apply', () => { + expect(shortAccountId('acct01')).toBe('acct01'); + }); +}); diff --git a/apps/web/src/lib/accountId.ts b/apps/web/src/lib/accountId.ts new file mode 100644 index 000000000..8dc0b9e39 --- /dev/null +++ b/apps/web/src/lib/accountId.ts @@ -0,0 +1,13 @@ +/** + * An account id is a secp256k1 public point written as two hex coordinates + * (`packages/login` `accountIdFromTssPoint`) — 129 characters, unreadable in a + * banner. This is the form a user can compare against another tab's, taken from + * both ends so two accounts sharing a leading run still read apart. + */ +const HEAD = 6; +const TAIL = 4; + +export function shortAccountId(accountId: string): string { + if (accountId.length <= HEAD + TAIL + 1) return accountId; + return `${accountId.slice(0, HEAD)}…${accountId.slice(-TAIL)}`; +} diff --git a/apps/web/src/routes/LoginPage.tsx b/apps/web/src/routes/LoginPage.tsx index 011a2b7eb..8937526bf 100644 --- a/apps/web/src/routes/LoginPage.tsx +++ b/apps/web/src/routes/LoginPage.tsx @@ -6,6 +6,7 @@ import { EmailLoginForm } from '../components/auth/EmailLoginForm'; import { GoogleLoginButton } from '../components/auth/GoogleLoginButton'; import { LoginError } from '../components/auth/LoginError'; import { RecoveryPhraseLogin } from '../components/auth/RecoveryPhraseLogin'; +import { SignedInElsewhere } from '../components/auth/SignedInElsewhere'; import { WalletLoginButton } from '../components/auth/WalletLoginButton'; import { MatrixBackground } from '../components/MatrixBackground'; import { StagingBanner } from '../components/StagingBanner'; @@ -20,6 +21,7 @@ export function LoginPage() { isReady, isBusy, error, + heldElsewhere, loginWithGoogle, sendEmailCode, loginWithEmailCode, @@ -86,6 +88,7 @@ export function LoginPage() { )} + {heldElsewhere && } {error && !recoveryRequired && }