diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index 6a2ed99b3..46e04642e 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. + - Turning workspace sync off says what is actually in the way. All three of its preconditions used to answer with "Open this drive with local storage available before disconnecting", so someone signed out, or on a server this diff --git a/browser/data-browser/src/helpers/managed/recovery.test.ts b/browser/data-browser/src/helpers/managed/recovery.test.ts index 3435bba64..c14698672 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 98751c8b0..394c8ac27 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 e1b17a481..03b95ff94 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" @@ -12399,6 +12398,33 @@ msgstr "" 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 "" + #: src/components/Notifications/NotificationList.tsx #: src/components/Notifications/NotificationList.tsx #: src/components/SideBar/NotificationsMenuItem.tsx diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po index 08b68892d..66776c5f0 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" @@ -12445,6 +12444,33 @@ msgstr "No column matches" 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" + #: src/components/Notifications/NotificationList.tsx #: src/components/Notifications/NotificationList.tsx #: src/components/SideBar/NotificationsMenuItem.tsx diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po index 8d47ad7a5..d34b3b733 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" @@ -12399,6 +12398,33 @@ msgstr "" 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 "" + #: src/components/Notifications/NotificationList.tsx #: src/components/Notifications/NotificationList.tsx #: src/components/SideBar/NotificationsMenuItem.tsx diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po index 7a8edc893..bb7ee5ee8 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" @@ -12399,6 +12398,33 @@ msgstr "" 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 "" + #: src/components/Notifications/NotificationList.tsx #: src/components/Notifications/NotificationList.tsx #: src/components/SideBar/NotificationsMenuItem.tsx 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 a1fffed7b..2c57d7e72 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 a469c0dd7..80d5dc4a0 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;