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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 25 additions & 1 deletion browser/data-browser/src/helpers/managed/recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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',
});
});
});
25 changes: 25 additions & 0 deletions browser/data-browser/src/helpers/managed/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RecoverySecret, 'owner_email' | 'agent_subject'> | 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;
Expand Down
32 changes: 29 additions & 3 deletions browser/data-browser/src/locales/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions browser/data-browser/src/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions browser/data-browser/src/locales/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions browser/data-browser/src/locales/fr.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,26 @@ 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(),
restoreFromVault: state.restoreVault,
}));
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() }));
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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');
});
Loading
Loading