From 2bda8098ae6b4b7ec8c85a84e13ac857c0510b6e Mon Sep 17 00:00:00 2001 From: Joep Meindertsma Date: Sun, 27 Sep 2026 20:40:48 +0000 Subject: [PATCH] Ask before a pasted secret replaces the signed-in account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting an agent secret for a different agent than the signed-in account's used to end that account's session straight away and then show a toast, "Signed out of your account here — that secret belongs to a different one". The session is shared with the portal, so this signed people out of atomic.place too, and they only learned why afterwards. The sign-in now stops first on a screen that names the signed-in account, shows both agents, and offers "Stay signed in as " or "Use this secret and sign out". The session is only ended after the second choice. --- browser/CHANGELOG.md | 5 + .../src/helpers/managed/recovery.test.ts | 26 ++- .../src/helpers/managed/recovery.ts | 25 +++ browser/data-browser/src/locales/de.po | 32 +++- browser/data-browser/src/locales/en.po | 32 +++- browser/data-browser/src/locales/es.po | 32 +++- browser/data-browser/src/locales/fr.po | 32 +++- .../GettingStartedFlow.test.tsx | 75 +++++++++ .../getting-started/GettingStartedFlow.tsx | 154 +++++++++++++++--- 9 files changed, 376 insertions(+), 37 deletions(-) diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 5f0021e03f..0864ab6fa9 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -4,6 +4,11 @@ This changelog covers all five packages, as they are (for now) updated as a whol ## UNRELEASED +- Pasting an agent secret that opens a different agent than the signed-in + account no longer signs that account out on its own. The app now says which + account is signed in, shows both agents, and lets the user stay signed in or + use the secret and sign out. + - `@tomic/lib`: `store.createSubject()` and `store.isAliased()` are deprecated (they still work). The app no longer creates temporary `_new:` subjects that are renamed on first save: the new-resource form, new-resource dialogs diff --git a/browser/data-browser/src/helpers/managed/recovery.test.ts b/browser/data-browser/src/helpers/managed/recovery.test.ts index 3435bba64d..c146986722 100644 --- a/browser/data-browser/src/helpers/managed/recovery.test.ts +++ b/browser/data-browser/src/helpers/managed/recovery.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { passkeyRpId } from './recovery'; +import { passkeyRpId, secretAccountConflict } from './recovery'; /** * The RP ID a recovery passkey is created and asserted under. @@ -62,3 +62,27 @@ describe('passkeyRpId', () => { expect(passkeyRpId()).toBeUndefined(); }); }); + +describe('secretAccountConflict', () => { + const key = 'A7x4uVX5c0bGmCAX2Lr13sB8pH7gcDYHfJk9R0Wn3lE'; + const account = { + owner_email: 'joep@ontola.io', + agent_subject: `did:ad:agent:${key}`, + }; + + it('is no conflict when the secret opens the account agent', () => { + expect(secretAccountConflict(account, `atomic:agent:${key}`)).toBeNull(); + }); + + it('is no conflict when nobody is signed in', () => { + expect(secretAccountConflict(null, 'atomic:agent:other')).toBeNull(); + }); + + it('names both agents and the account when they differ', () => { + expect(secretAccountConflict(account, 'atomic:agent:other')).toEqual({ + email: 'joep@ontola.io', + accountAgent: `did:ad:agent:${key}`, + secretAgent: 'atomic:agent:other', + }); + }); +}); diff --git a/browser/data-browser/src/helpers/managed/recovery.ts b/browser/data-browser/src/helpers/managed/recovery.ts index 98751c8b0f..394c8ac272 100644 --- a/browser/data-browser/src/helpers/managed/recovery.ts +++ b/browser/data-browser/src/helpers/managed/recovery.ts @@ -67,6 +67,31 @@ export function sameAgent(a: string, b: string): boolean { return canonicalIdentifier(a) === canonicalIdentifier(b); } +/** A pasted secret that opens a different agent than the signed-in account's. */ +export type SecretAccountConflict = { + email: string; + accountAgent: string; + secretAgent: string; +}; + +/** + * Whether signing in with `secretAgent` would replace the account that is + * signed in here. Using it means ending that account's session, which also + * signs the user out of the portal, so the caller has to ask first. + */ +export function secretAccountConflict( + stored: Pick | null, + secretAgent: string, +): SecretAccountConflict | null { + if (!stored || sameAgent(stored.agent_subject, secretAgent)) return null; + + return { + email: stored.owner_email, + accountAgent: stored.agent_subject, + secretAgent, + }; +} + const RECOVERY_FORMAT_VERSION = 1; const ENVELOPE_V2_FORMAT_VERSION = 2; const KDF_ITERATIONS = 310_000; diff --git a/browser/data-browser/src/locales/de.po b/browser/data-browser/src/locales/de.po index a40a97c7d1..4d78d6e622 100644 --- a/browser/data-browser/src/locales/de.po +++ b/browser/data-browser/src/locales/de.po @@ -5272,9 +5272,8 @@ msgstr "" msgid "We've replaced your old recovery password with a stronger generated code. Keep it somewhere safe — you'll need it, plus your email, to get back in. We can't show it again, but you can generate a new one from Settings any time." msgstr "" -#: src/views/getting-started/GettingStartedFlow.tsx -msgid "Signed out of your account here — that secret belongs to a different one." -msgstr "" +#~ msgid "Signed out of your account here — that secret belongs to a different one." +#~ msgstr "" #: src/routes/SettingsAgent.tsx msgid "More profile fields" @@ -12398,3 +12397,30 @@ msgstr "" #: src/chunks/TablePage/ColumnFilterDropdown.tsx msgid "Search the drive for “{0}”" msgstr "" + +#. 0: conflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Signed out of {0}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret" +msgstr "" + +#. 0: secretConflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Stay signed in as {0}" +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret is for a different account" +msgstr "" + +#. 0: secretConflict.email; 1: secretConflict.email; 2: PRODUCT_NAME +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "You're signed in as {0}, but the secret you entered opens another agent. Using it signs you out of {1} on {2}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Use this secret and sign out" +msgstr "" diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po index 5fb05d6f24..ff4c64a8b1 100644 --- a/browser/data-browser/src/locales/en.po +++ b/browser/data-browser/src/locales/en.po @@ -5283,9 +5283,8 @@ msgstr "Unlock {0} with your fingerprint, face, or screen lock." msgid "We've replaced your old recovery password with a stronger generated code. Keep it somewhere safe — you'll need it, plus your email, to get back in. We can't show it again, but you can generate a new one from Settings any time." msgstr "We've replaced your old recovery password with a stronger generated code. Keep it somewhere safe — you'll need it, plus your email, to get back in. We can't show it again, but you can generate a new one from Settings any time." -#: src/views/getting-started/GettingStartedFlow.tsx -msgid "Signed out of your account here — that secret belongs to a different one." -msgstr "Signed out of your account here — that secret belongs to a different one." +#~ msgid "Signed out of your account here — that secret belongs to a different one." +#~ msgstr "Signed out of your account here — that secret belongs to a different one." #: src/routes/SettingsAgent.tsx msgid "More profile fields" @@ -12444,3 +12443,30 @@ msgstr "No column matches" #: src/chunks/TablePage/ColumnFilterDropdown.tsx msgid "Search the drive for “{0}”" msgstr "Search the drive for “{0}”" + +#. 0: conflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Signed out of {0}." +msgstr "Signed out of {0}." + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret" +msgstr "This secret" + +#. 0: secretConflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Stay signed in as {0}" +msgstr "Stay signed in as {0}" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret is for a different account" +msgstr "This secret is for a different account" + +#. 0: secretConflict.email; 1: secretConflict.email; 2: PRODUCT_NAME +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "You're signed in as {0}, but the secret you entered opens another agent. Using it signs you out of {1} on {2}." +msgstr "You're signed in as {0}, but the secret you entered opens another agent. Using it signs you out of {1} on {2}." + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Use this secret and sign out" +msgstr "Use this secret and sign out" diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po index d87acd56d5..a2974420a1 100644 --- a/browser/data-browser/src/locales/es.po +++ b/browser/data-browser/src/locales/es.po @@ -5272,9 +5272,8 @@ msgstr "" msgid "We've replaced your old recovery password with a stronger generated code. Keep it somewhere safe — you'll need it, plus your email, to get back in. We can't show it again, but you can generate a new one from Settings any time." msgstr "" -#: src/views/getting-started/GettingStartedFlow.tsx -msgid "Signed out of your account here — that secret belongs to a different one." -msgstr "" +#~ msgid "Signed out of your account here — that secret belongs to a different one." +#~ msgstr "" #: src/routes/SettingsAgent.tsx msgid "More profile fields" @@ -12398,3 +12397,30 @@ msgstr "" #: src/chunks/TablePage/ColumnFilterDropdown.tsx msgid "Search the drive for “{0}”" msgstr "" + +#. 0: conflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Signed out of {0}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret" +msgstr "" + +#. 0: secretConflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Stay signed in as {0}" +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret is for a different account" +msgstr "" + +#. 0: secretConflict.email; 1: secretConflict.email; 2: PRODUCT_NAME +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "You're signed in as {0}, but the secret you entered opens another agent. Using it signs you out of {1} on {2}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Use this secret and sign out" +msgstr "" diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po index 854db264ed..387abb76b4 100644 --- a/browser/data-browser/src/locales/fr.po +++ b/browser/data-browser/src/locales/fr.po @@ -5272,9 +5272,8 @@ msgstr "" msgid "We've replaced your old recovery password with a stronger generated code. Keep it somewhere safe — you'll need it, plus your email, to get back in. We can't show it again, but you can generate a new one from Settings any time." msgstr "" -#: src/views/getting-started/GettingStartedFlow.tsx -msgid "Signed out of your account here — that secret belongs to a different one." -msgstr "" +#~ msgid "Signed out of your account here — that secret belongs to a different one." +#~ msgstr "" #: src/routes/SettingsAgent.tsx msgid "More profile fields" @@ -12398,3 +12397,30 @@ msgstr "" #: src/chunks/TablePage/ColumnFilterDropdown.tsx msgid "Search the drive for “{0}”" msgstr "" + +#. 0: conflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Signed out of {0}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret" +msgstr "" + +#. 0: secretConflict.email +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Stay signed in as {0}" +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "This secret is for a different account" +msgstr "" + +#. 0: secretConflict.email; 1: secretConflict.email; 2: PRODUCT_NAME +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "You're signed in as {0}, but the secret you entered opens another agent. Using it signs you out of {1} on {2}." +msgstr "" + +#: src/views/getting-started/GettingStartedFlow.tsx +msgid "Use this secret and sign out" +msgstr "" diff --git a/browser/data-browser/src/views/getting-started/GettingStartedFlow.test.tsx b/browser/data-browser/src/views/getting-started/GettingStartedFlow.test.tsx index a1fffed7b4..2c57d7e72f 100644 --- a/browser/data-browser/src/views/getting-started/GettingStartedFlow.test.tsx +++ b/browser/data-browser/src/views/getting-started/GettingStartedFlow.test.tsx @@ -84,6 +84,18 @@ vi.mock('../../helpers/managed/recovery', () => ({ getUnlockableRecoverySecret: () => state.recovery(), getRecoverySecret: () => state.recovery(), readUnlockableCachedBackups: () => [], + envelopeWrapperKinds: () => ({ hasPasskey: true, hasCode: false }), + secretAccountConflict: ( + stored: { owner_email: string; agent_subject: string } | null, + secretAgent: string, + ) => + stored && stored.agent_subject !== secretAgent + ? { + email: stored.owner_email, + accountAgent: stored.agent_subject, + secretAgent, + } + : null, })); vi.mock('../../helpers/managed/vaultAutoBackup', () => ({ ensureVaultBackup: vi.fn(), @@ -91,6 +103,7 @@ vi.mock('../../helpers/managed/vaultAutoBackup', () => ({ })); vi.mock('../../helpers/managed/reconcile', () => ({ connectHostedDrive: async () => true, + shortDid: (subject: string) => subject, })); vi.mock('../../helpers/agentStorage', () => ({ saveAgentToIDB: vi.fn() })); vi.mock('../../helpers/deviceLock', () => ({ beat: vi.fn() })); @@ -143,6 +156,7 @@ vi.mock('./chrome', () => ({ BackLabel: 'span', })); import { GettingStartedFlow } from './GettingStartedFlow'; +import { logoutManagedSession } from '../../helpers/managed'; const show = async (query = '') => { window.history.replaceState(null, '', `/app/welcome${query}`); @@ -299,3 +313,64 @@ it('opens its own home without requiring another device', async () => { expect(state.navigate).toHaveBeenCalledWith('/app/show?subject=did:ad:home'); expect(screen.queryByText('Connect device')).toBeNull(); }); + +const pasteOtherAgentsSecret = async () => { + state.account = { email: 'joep@ontola.io' }; + state.recovery.mockResolvedValue({ + owner_email: 'joep@ontola.io', + agent_subject: 'did:ad:agent:account', + }); + await show('?return_to=agent'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Agent secret'), { + target: { value: 'test-secret' }, + }); + }); +}; + +it('asks before a secret for another agent replaces the account', async () => { + await pasteOtherAgentsSecret(); + expect( + screen.getByRole('heading', { + name: 'This secret is for a different account', + }), + ).toBeTruthy(); + expect(screen.getByText('did:ad:agent:account')).toBeTruthy(); + expect(screen.getByText('did:ad:agent:test')).toBeTruthy(); + expect(logoutManagedSession).not.toHaveBeenCalled(); + expect(state.setAgent).not.toHaveBeenCalled(); + + fireEvent.click( + screen.getByRole('button', { name: 'Stay signed in as joep@ontola.io' }), + ); + expect(screen.getByLabelText('Agent secret')).toBeTruthy(); + expect(logoutManagedSession).not.toHaveBeenCalled(); + expect(state.setAgent).not.toHaveBeenCalled(); +}); + +it('signs out of the account only once the user picks the secret', async () => { + await pasteOtherAgentsSecret(); + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'Use this secret and sign out' }), + ); + }); + expect(logoutManagedSession).toHaveBeenCalledTimes(1); + expect(state.setAgent).toHaveBeenCalled(); + expect(state.navigate).toHaveBeenCalledWith('/app/agent'); +}); + +it('signs in without asking when the secret is the account agent', async () => { + state.recovery.mockResolvedValue({ + owner_email: 'joep@ontola.io', + agent_subject: 'did:ad:agent:test', + }); + await show('?return_to=agent'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Agent secret'), { + target: { value: 'test-secret' }, + }); + }); + expect(logoutManagedSession).not.toHaveBeenCalled(); + expect(state.navigate).toHaveBeenCalledWith('/app/agent'); +}); diff --git a/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx b/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx index a469c0dd78..80d5dc4a0d 100644 --- a/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx +++ b/browser/data-browser/src/views/getting-started/GettingStartedFlow.tsx @@ -17,7 +17,7 @@ import { useSettings } from '../../helpers/AppSettings'; import { saveAgentToIDB } from '../../helpers/agentStorage'; import { beat } from '../../helpers/deviceLock'; import { fetchPrivateDriveSubject } from '../../helpers/privateDrive'; -import { connectHostedDrive } from '../../helpers/managed/reconcile'; +import { connectHostedDrive, shortDid } from '../../helpers/managed/reconcile'; import { deviceHasDriveData } from '../../helpers/driveData'; import { openPrivateHome } from '../../helpers/openPrivateHome'; import { privateHomeNudge } from '../../helpers/privateHomeNudge'; @@ -53,9 +53,10 @@ import { decryptEnvelopeV2, decryptEnvelopeWithPasskey, envelopeWrapperKinds, - sameAgent, + secretAccountConflict, upgradeToEnvelopeV2, type RecoverySecret, + type SecretAccountConflict, } from '../../helpers/managed/recovery'; import { CodeBlock } from '../../components/CodeBlock'; import { InputStyled, InputWrapper } from '../../components/forms/InputStyles'; @@ -85,6 +86,7 @@ type Step = | 'create' | 'restore' | 'restore-upgraded' + | 'secret-conflict' | 'connect-device' | 'opening-workspace'; @@ -235,6 +237,10 @@ export function GettingStartedFlow({ >(); const stepDotsSlotRef = useRef(null); const [secretValue, setSecretValue] = useState(''); + /** A pasted secret for another agent, held until the user says which wins. */ + const [secretConflict, setSecretConflict] = useState< + (SecretAccountConflict & { secret: string }) | null + >(null); /** Shown only after blur/Enter — every prefix of a valid secret is invalid, * so erroring while typing would be constant noise. */ const [secretError, setSecretError] = useState(); @@ -578,38 +584,68 @@ export function GettingStartedFlow({ } /** - * Pasting a secret is an explicit "I am this agent". If the control-plane - * session belongs to an account whose backup names a *different* agent, the - * reconcile gate would bounce the user straight back here - * (IDENTITY_RECONCILE_SCENARIOS.md scenario 4) — silently undoing what they - * just did, and looking exactly like "I can't sign in". - * - * That gate exists to converge a *stray* local agent at boot, not to - * override a deliberate action. So the stale thing here is the portal - * session: end it, and let the secret win. + * The account signed in here, when its agent is not the one `agentSubject` + * names. A pasted secret may simply be another identity the person owns (an + * older agent, or a local node's), and using it means ending this account's + * session, which signs them out of the portal too. That is never done + * without asking (IDENTITY_RECONCILE_SCENARIOS.md scenario 4). */ - async function releaseConflictingPortalSession(agentSubject: string) { + async function findSecretConflict( + agentSubject: string, + ): Promise { try { - const stored = await getRecoverySecret(); + return secretAccountConflict(await getRecoverySecret(), agentSubject); + } catch { + // No session, or the control plane is unreachable: nothing to replace. + return null; + } + } - if (stored && !sameAgent(stored.agent_subject, agentSubject)) { - clearManagedAccountBinding(); - await logoutManagedSession(); - toast( - 'Signed out of your account here — that secret belongs to a different one.', - ); - } + /** + * The user chose the pasted secret over the signed-in account. The reconcile + * gate would otherwise bounce them straight back here, so the account + * session is the stale thing now: end it, and say which account that was. + */ + async function releaseConflictingPortalSession( + conflict: SecretAccountConflict, + ) { + try { + clearManagedAccountBinding(); + await logoutManagedSession(); + toast(`Signed out of ${conflict.email}.`); } catch { - // No session, or the control plane is unreachable: nothing to release. + // Already gone, or the control plane is unreachable: nothing to release. } } - async function handleSignInWithSecret(secret: string) { + async function handleSignInWithSecret( + secret: string, + confirmed?: SecretAccountConflict, + ) { setLoading(true); setError(undefined); try { const newAgent = await Agent.fromSecret(secret); + + const conflict = + confirmed ?? + (newAgent.subject + ? await withDeadline( + findSecretConflict(newAgent.subject), + SIGN_IN_LOOKUP_TIMEOUT_MS, + null, + ) + : null); + + if (conflict && !confirmed) { + setSecretConflict({ ...conflict, secret }); + setStep('secret-conflict'); + + return; + } + + setSecretConflict(null); setWorkspaceStage('identity'); setStep('opening-workspace'); setAgent(newAgent); @@ -618,9 +654,9 @@ export function GettingStartedFlow({ // again, so start the clock fresh (see deviceLock.ts). beat(); - if (newAgent.subject) { + if (conflict) { await withDeadline( - releaseConflictingPortalSession(newAgent.subject), + releaseConflictingPortalSession(conflict), SIGN_IN_LOOKUP_TIMEOUT_MS, undefined, ); @@ -1387,6 +1423,57 @@ export function GettingStartedFlow({ + ) : step === 'secret-conflict' && secretConflict ? ( + + + + + + This secret is for a different account + +

+ You're signed in as {secretConflict.email}, but the + secret you entered opens another agent. Using it signs you out + of {secretConflict.email} on {PRODUCT_NAME}. +

+ +
{secretConflict.email}
+
{shortDid(secretConflict.accountAgent)}
+
This secret
+
{shortDid(secretConflict.secretAgent)}
+
+ + +
+
+
+
) : step === 'restore-upgraded' ? ( @@ -1599,6 +1686,25 @@ const PlainExternalLink = styled.a` white-space: nowrap; `; +/** The two agents a secret conflict is about, so the choice is concrete. */ +const AgentList = styled.dl` + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0; + font-size: 0.85rem; + + & dt { + color: ${p => p.theme.colors.textLight}; + } + + & dd { + margin: 0; + font-family: monospace; + overflow-wrap: anywhere; + } +`; + const OtherWaysLabel = styled.span` display: flex; align-items: center;