Files
buzz/desktop/tests/e2e/identity-lost.spec.ts
6eb65919f1 feat(identity): recover desktop identity from a signed-in phone (#4845)
**Category:** new-feature
**User Impact:** People who lose a desktop identity can securely restore
it from a signed-in Buzz phone without creating a replacement identity.

**Problem:** A fresh or identity-lost desktop could not recover its
existing full Buzz identity from an already-authorized phone.

**Solution:** Add a SAS-confirmed reverse NIP-AB transfer, durable
desktop import, a dedicated mobile recovery entry point, and clearer
desktop recovery dialogs with tested loading, drag-and-drop, and failure
states.


https://github.com/user-attachments/assets/e9215c9c-80d0-462f-9161-0fa184ca2f74

<details>
<summary>File changes</summary>

**crates/buzz-core/src/pairing/session.rs**
Adds the reverse encrypted payload and source-completion state
transitions used for phone-to-desktop recovery.

**desktop/src-tauri/src/commands/identity.rs**
Exposes the existing guarded identity commit path for recovery imports.

**desktop/src-tauri/src/commands/pairing.rs**
Adds recovery-mode pairing, durable nsec import, start serialization,
stale-task protection, and explicit rejection of unsupported recovery
payloads.

**desktop/src-tauri/src/lib.rs**
Registers the recovery pairing command.

**desktop/src/app/App.tsx**
Refreshes the recovered identity before continuing onboarding.

**desktop/src/features/onboarding/machineOnboarding.ts**
Adds recovery transitions to the onboarding state machine.

**desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx**
Adds the visual backup-to-password-to-unlock progression.

**desktop/src/features/onboarding/ui/IdentityRecoveryPairing.tsx**
Implements QR generation, copy fallback, SAS confirmation, cancellation,
expiry, and completion UI.

**desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx**
Connects private-key, phone, and backup recovery paths to the onboarding
flow.

**desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx**
Polishes recovery dialogs, backup drag-and-drop, loading stability, and
security copy.

**desktop/src/shared/api/tauri.ts**
Keeps the existing pairing API surface focused on standard
desktop-to-mobile pairing.

**desktop/src/shared/api/tauriPairing.ts**
Adds the recovery pairing invoke without growing the ratcheted shared
API file.

**desktop/src/testing/e2eBridge.ts**
Mocks recovery pairing commands and lifecycle events for browser tests.

**desktop/tests/e2e/identity-lost.spec.ts**
Covers lost-identity entry, QR/copy recovery, SAS, cancellation, expiry,
success, errors, backup import, drag-and-drop, and screenshots.

**desktop/tests/e2e/onboarding.spec.ts**
Verifies recovered identities continue through harness setup without
replacement-key side effects.

**mobile/lib/features/pairing/pairing_page.dart**
Adds recovery-only scanning and explicit identity-handoff warnings.

**mobile/lib/features/pairing/pairing_provider.dart**
Recognizes recovery codes, returns the signed-in nsec after mutual SAS
approval, and waits for desktop completion.

**mobile/lib/features/settings/settings_page.dart**
Accepts the recovery route builder at the app composition boundary to
preserve feature isolation.

**mobile/lib/features/settings/settings_page/connection_section.dart**
Adds the signed-in “Send identity to desktop” settings action.

**mobile/test/features/pairing/pairing_page_test.dart**
Covers recovery-only validation and handoff messaging.

**mobile/test/features/pairing/pairing_provider_test.dart**
Covers reverse payload encryption, confirmation ordering, success,
failure, timeout, and cleanup.

</details>

## Reproduction steps

1. Launch Buzz Desktop with identity-lost state and choose **Recover
from your phone**.
2. Confirm the QR and persistent **Copy pairing code** fallback appear
without layout shift.
3. On a signed-in phone, open **Settings → Send identity to desktop**,
scan or paste the recovery code, and compare the six-digit SAS on both
devices.
4. Confirm on both sides and verify Desktop restores the identity and
continues to harness setup.
5. Repeat from identity-lost state with **Recover from a backup file**;
verify picker and drag-and-drop both advance to password entry and
restore the encrypted backup.
6. Exercise cancellation, mismatched/unsupported codes, expired
sessions, and an invalid backup; verify each returns actionable,
non-stuck UI.

## Screenshots

### Desktop phone recovery — complete flow

| Recovery entry | Pairing QR | Code match | Receiving identity |
|---|---|---|---|
| ![Desktop recovery
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-01-recovery-entry.png)
| ![Desktop phone recovery
QR](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-02-qr.png)
| ![Desktop security-code
match](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-03-sas.png)
| ![Desktop receiving
identity](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-phone-04-receiving.png)
|

### iOS Simulator — complete handoff flow

| Settings entry | Recovery scanner | Manual recovery code | Code
confirmation |
|---|---|---|---|
| ![iOS Settings entry for Send identity to
desktop](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-01-settings-entry.png)
| ![iOS recovery scanner
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-02-recovery-entry.png)
| ![iOS manual recovery code
entry](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-03-manual-code.png)
| ![iOS security-code
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/ios-04-sas-verification.png)
|

### Encrypted backup recovery — adjusted file flow

| File picker | Drag-and-drop target | Password step |
|---|---|---|
| ![Desktop encrypted-backup file
picker](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-01-file-picker-settled.png)
| ![Desktop encrypted-backup drag-and-drop
target](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-02-drag-drop.png)
| ![Desktop backup password
step](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4845/desktop-backup-03-enter-password.png)
|

## Verification

- `cargo test -p buzz-core pairing` — 71 passed
- `just mobile-test` — 1,169 passed
- `pnpm build:e2e && pnpm exec playwright test identity-lost.spec.ts
--project=smoke` — 15 passed
- Full pre-push gates — desktop checks, desktop unit tests, Rust tests,
Tauri checks, and mobile tests passed

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-06 11:47:18 -07:00

494 lines
15 KiB
TypeScript

import { hexToBytes } from "@noble/hashes/utils.js";
import { expect, test } from "@playwright/test";
import { nsecEncode } from "nostr-tools/nip19";
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
test("normal first launch uses the already-persisted identity", async ({
page,
}) => {
await page.emulateMedia({ colorScheme: "dark" });
await installMockBridge(page, undefined, {
skipCommunitySeed: true,
skipOnboardingSeed: true,
});
await page.goto("/");
const gate = page.getByTestId("machine-onboarding-gate");
await expect(gate).toBeVisible();
await expect(gate).toHaveCSS("background-color", "rgb(215, 215, 46)");
// Landing carries a subtle dot-grid pattern over the chartreuse fill.
await expect(gate).toHaveCSS("background-image", /radial-gradient/);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
await expect(
page.getByRole("button", { name: "Create a new identity key" }),
).toHaveCSS("background-color", "rgb(23, 23, 23)");
await page.getByRole("button", { name: "Create a new identity key" }).click();
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toBeVisible();
// Non-landing pages layer the dot grid over the chartreuse→light-blue gradient.
await expect(gate).toHaveCSS(
"background-image",
/radial-gradient\(.*\), linear-gradient\(.*rgb\(215, 215, 46\).*rgb\(215, 231, 246\)\)/s,
);
await expect(gate).toHaveCSS("color", "rgb(23, 23, 23)");
const commands = await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
);
expect(commands.some((entry) => entry.command === "get_identity")).toBe(true);
expect(
commands.some((entry) => entry.command === "persist_current_identity"),
).toBe(false);
});
test("lost boot opens onboarding gate directly on the key-import page", async ({
page,
}, testInfo) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await page.waitForTimeout(1_000);
await page.screenshot({
path: testInfo.outputPath("desktop-private-key-recovery.png"),
});
});
test("lost boot keeps the pairing-code action stable while generating", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true, pairingStartDelayMs: 2_500 },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
const copyButton = page.getByTestId("copy-identity-recovery-code");
await expect(copyButton).toBeVisible();
await expect(copyButton).toBeDisabled();
await expect(copyButton).toHaveText("Generating pairing code...");
const loadingButton = await copyButton.elementHandle();
await expect(copyButton).toBeEnabled();
await expect(copyButton).toHaveText("Copy pairing code");
expect(
await copyButton.evaluate(
(button, loading) => button === loading,
loadingButton,
),
).toBe(true);
});
test("lost boot offers phone recovery with a single-use QR", async ({
page,
}, testInfo) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-pairing")).toBeVisible();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await expect(
page.getByText("Scan this code with a signed-in Buzz phone."),
).toBeVisible();
await expect(
page.getByText("On your phone, open Settings → Send identity to desktop."),
).toBeVisible();
await page.waitForTimeout(1_000); // Let the onboarding entrance motion settle.
await page.screenshot({
path: testInfo.outputPath("desktop-phone-recovery-qr.png"),
fullPage: true,
});
const copyButton = page.getByTestId("copy-identity-recovery-code");
await expect(copyButton).toHaveText("Copy pairing code");
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await copyButton.click();
await expect(copyButton).toHaveText("Copied");
const copiedPayload = await page.evaluate(() => {
const log = (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: Record<string, unknown> | null;
}>;
}
).__BUZZ_E2E_COMMAND_LOG__;
return log?.findLast(({ command }) => command === "copy_text_to_clipboard")
?.payload;
});
expect(copiedPayload?.text).toMatch(/^nostrpair:\/\/.+&mode=recover$/);
const commands = await page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
);
expect(
commands.some(
(entry) => entry.command === "start_identity_recovery_pairing",
),
).toBe(true);
});
test("phone recovery uses the desktop pairing card semantics", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
const card = page.getByTestId("identity-recovery-pairing");
const qrContainer = card.getByTestId("identity-recovery-qr-container");
const qrCode = card.getByTestId("identity-recovery-qr");
const copyButton = card.getByTestId("copy-identity-recovery-code");
await expect(qrCode).toBeVisible();
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
"animation-name",
"buzz-qr-cell-reveal",
);
const qrBox = await qrContainer.boundingBox();
const copyBox = await copyButton.boundingBox();
expect(qrBox).not.toBeNull();
expect(copyBox).not.toBeNull();
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(1);
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(1);
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-sas-received",
payload: { sas: "123456" },
});
});
await expect(
card.getByText("Does this code match your phone?"),
).toBeVisible();
await expect(
page.getByText("Confirm the code before sharing your identity."),
).toBeVisible();
await expect(
card.getByText(
"This gives this desktop permanent access to your Buzz identity. Only continue if you trust it.",
),
).toBeVisible();
await expect(
card.getByText(/On your phone, open Settings/),
).not.toBeVisible();
await expect(card.getByTestId("identity-recovery-sas")).toHaveText("123 456");
await expect(card.getByTestId("confirm-identity-recovery-sas")).toHaveText(
"Codes match",
);
await expect(card.getByTestId("deny-identity-recovery-sas")).toHaveText(
"Cancel",
);
const cancelBox = await card
.getByTestId("deny-identity-recovery-sas")
.boundingBox();
const confirmBox = await card
.getByTestId("confirm-identity-recovery-sas")
.boundingBox();
expect(cancelBox).not.toBeNull();
expect(confirmBox).not.toBeNull();
expect((cancelBox?.y ?? 0) - (confirmBox?.y ?? 0)).toBeGreaterThan(
confirmBox?.height ?? 0,
);
});
test("canceling recovery uses the standard pairing cancellation state", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-sas-received",
payload: { sas: "123456" },
});
});
await page.getByTestId("deny-identity-recovery-sas").click();
await expect(
page.getByText("The codes didn't match. Pairing was canceled."),
).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
({ command }) => command === "cancel_pairing",
).length,
),
)
.toBeGreaterThan(0);
});
test("phone recovery continues to harness setup without creating or restarting", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.(
"complete_identity_recovery_pairing",
);
});
await expect(
page.getByRole("heading", { name: "Set up your agent harnesses" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
await expect(
page.getByRole("heading", {
name: "Your unique identity key has been created",
}),
).toHaveCount(0);
});
test("recovery turns relay failures into actionable copy", async ({ page }) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
await page.evaluate(async () => {
await window.__TAURI_INTERNALS__?.invoke?.("plugin:event|emit", {
event: "pairing-error",
payload: { message: "failed to send sas-confirm" },
});
});
await expect(
page.getByText(
"This pairing code expired or lost its connection. Create a new code and try again.",
),
).toBeVisible();
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});
test("desktop refreshes recovery codes before the relay expires them", async ({
page,
}) => {
await page.clock.install();
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await page.getByTestId("nostr-import-phone-link").click();
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
const recoveryStarts = () =>
page.evaluate(
() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
({ command }) => command === "start_identity_recovery_pairing",
).length,
);
await expect.poll(recoveryStarts).toBe(1);
await page.clock.fastForward(90_000);
await expect.poll(recoveryStarts).toBe(2);
await expect(page.getByTestId("identity-recovery-qr")).toBeVisible();
});
test("importing a key from lost mode shows the relaunch-required screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
});
test("start-new-identity from lost mode persists the ephemeral key after confirmation", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page.getByRole("button", { name: "Start new identity" }).click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "persist_current_identity",
) ?? false,
),
)
.toBe(true);
});
test("cancelling start-new-identity in lost mode stays on the import screen", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLost: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
page.on("dialog", (dialog) => dialog.dismiss());
await page.getByRole("button", { name: "Start new identity" }).click();
// Still on the import screen — no navigation, no persist
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toBeVisible();
await expect(page.getByTestId("relaunch-required")).toHaveCount(0);
});
test("locked boot shows the keyring-locked screen without the onboarding gate or key-import UI", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Enter your private key" }),
).toHaveCount(0);
});
test("locked boot can re-import a key and requires relaunch", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
page.on("dialog", (dialog) => dialog.accept());
await page
.getByRole("button", { name: "Re-import your key instead" })
.click();
const importedNsec = nsecEncode(hexToBytes(TEST_IDENTITIES.alice.privateKey));
await page.getByTestId("nostr-import-nsec-input").fill(importedNsec);
await expect(page.getByTestId("nostr-import-npub-preview")).toBeVisible();
await page.getByTestId("nostr-import-submit").click();
await expect(page.getByTestId("relaunch-required")).toBeVisible();
await expect(page.getByTestId("keyring-locked")).toHaveCount(0);
});
test("locked screen relaunch button records the process-restart invoke", async ({
page,
}) => {
await installMockBridge(
page,
{ identityLocked: true },
{ skipOnboardingSeed: true },
);
await page.goto("/");
await expect(page.getByTestId("keyring-locked")).toBeVisible();
await page.getByTestId("relaunch-app").click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ command: string }>;
}
).__BUZZ_E2E_COMMAND_PAYLOADS__?.some(
(e) => e.command === "plugin:process|restart",
) ?? false,
),
)
.toBe(true);
});