mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
**Category:** new-feature **User Impact:** Users can create, download, and verify a password-protected backup of their private identity from desktop Settings. **Problem:** Buzz does not currently give signed-in users a Settings-based path to protect or validate their private identity independently of onboarding. **Solution:** Add a focused backup menu to the private-key row, keep encryption and verification local in Rust, and preserve completed encrypted backups briefly so native saves can be retried without repeating encryption. <details> <summary>File changes</summary> **desktop/src/features/settings/** Adds the background backup lifecycle, create and test dialogs, private-key menu integration, password handling, and focused unit coverage. **desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx** Extends the masked private-key display with reusable overflow-menu actions used by Settings. **desktop/src/app/App.tsx** Mounts the backup provider at app scope so encryption and save work survive closing Settings or the modal. **desktop/src/shared/api/tauriIdentity.ts** Adds typed desktop bindings for local backup creation, save, selection, and verification. **desktop/src-tauri/src/key_backup.rs and desktop/src-tauri/src/commands/identity.rs** Implements local NIP-49 encryption, password generation, file handling, and public-identity-only verification results. **desktop/src-tauri/src/egress_guard.rs and guarded call sites** Blocks encrypted secret material from relay, websocket, snapshot, sharing, and huddle egress paths. **desktop/src-tauri tests and fixtures** Covers encryption, verification, file behavior, and fail-closed no-egress protections. **desktop/src/testing/e2eBridge.ts, desktop/tests/, and desktop/playwright.config.ts** Expands the mock native bridge and browser coverage across create, retry, expiry, and current/different-identity verification states. **desktop/src-tauri/Cargo.toml, Cargo.lock, and assets** Adds the local cryptography/password-generation dependencies and embedded short-word list. </details> ## Reproduction steps 1. Run the desktop app and open **Settings → Profile → Identity**. 2. Open the private-key overflow menu and choose **Create backup**. 3. Enter or generate a valid password, submit, and confirm progress continues if the dialog or Settings is closed. 4. Save the resulting `.ncryptsec` file; cancel and retry to confirm the temporary download remains available. 5. Choose **Test backup**, select the file, enter a wrong password, then retry with the correct password. 6. Confirm success identifies whether the backup matches the current identity and displays only the public `npub`. ## Screenshots | Settings identity | Private-key menu | Create backup | |---|---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/981e391b-6829-4081-95ca-ca75a369de71" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/7972c68e-7635-47d8-b0ad-9639390d3e6c" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/4709c8f7-cf02-46f1-bec9-b3f98fe56fb2" /> | | Encrypting | Download available | Test success | |---|---|---| | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/1ac3e934-2b4b-4135-bae6-126c715c8c59" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/cb6f07ee-a16f-44a5-b9a0-6b9fe0e4d40d" /> | <img width="1280" height="720" alt="image" src="https://github.com/user-attachments/assets/ea58b1b1-966c-46aa-8d59-92c9f06a25bd" /> | Visual review and additional states: [Buzz thread](buzz://message?channel=50ca7ef1-201e-4159-9499-40de3964b7c3&id=87eceb5f0f82fd50c32e560de3d35be48e293760f6620718aafdcef289d475fe) --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
260 lines
8.9 KiB
TypeScript
260 lines
8.9 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { npubEncode } from "nostr-tools/nip19";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
import { openSettings } from "../helpers/settings";
|
|
|
|
const CURRENT_PUBKEY = "deadbeef".repeat(8);
|
|
const DIFFERENT_PUBKEY = "c0ffee00".repeat(8);
|
|
const BACKUP_FILE = {
|
|
name: "identity.ncryptsec",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("ncryptsec1mockbackupmaterial"),
|
|
};
|
|
|
|
async function openIdentity(page: Page) {
|
|
const identity = page.getByTestId("profile-identity-card");
|
|
if (
|
|
!(await identity.evaluate(
|
|
(element) => element instanceof HTMLDetailsElement && element.open,
|
|
))
|
|
) {
|
|
await page.getByTestId("profile-identity-toggle").click();
|
|
}
|
|
}
|
|
|
|
async function openBackupSettings(
|
|
page: Page,
|
|
mock?: Parameters<typeof installMockBridge>[1],
|
|
) {
|
|
await installMockBridge(page, mock);
|
|
await page.goto("/");
|
|
await openSettings(page, "profile");
|
|
await openIdentity(page);
|
|
}
|
|
|
|
async function openPrivateKeyMenu(page: Page) {
|
|
const reveal = page.getByTestId("profile-private-key-toggle");
|
|
if ((await reveal.textContent())?.trim() === "Reveal") {
|
|
await reveal.click();
|
|
}
|
|
await page.getByTestId("nsec-actions").click();
|
|
await expect(page.getByTestId("private-key-create-backup")).toBeVisible();
|
|
}
|
|
|
|
async function openCreateBackup(page: Page) {
|
|
await openPrivateKeyMenu(page);
|
|
await page.getByTestId("private-key-create-backup").click();
|
|
const dialog = page.getByTestId("encrypted-backup-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
return dialog;
|
|
}
|
|
|
|
async function openTestBackup(page: Page) {
|
|
await openPrivateKeyMenu(page);
|
|
await page.getByTestId("private-key-test-backup").click();
|
|
const dialog = page.getByTestId("backup-test-dialog");
|
|
await expect(dialog).toBeVisible();
|
|
return dialog;
|
|
}
|
|
|
|
async function selectBackupFile(page: Page) {
|
|
await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE);
|
|
await expect(page.getByTestId("backup-test-file-accepted")).toContainText(
|
|
BACKUP_FILE.name,
|
|
);
|
|
}
|
|
|
|
async function verifyBackup(page: Page, password: string) {
|
|
await page.getByTestId("backup-test-password").fill(password);
|
|
await page.getByTestId("backup-test-verify").click();
|
|
}
|
|
|
|
async function backupSaveCallCount(page: Page) {
|
|
return page.evaluate(
|
|
() =>
|
|
window.__BUZZ_E2E_COMMANDS__?.filter(
|
|
(command) => command === "save_ncryptsec_copy",
|
|
).length ?? 0,
|
|
);
|
|
}
|
|
|
|
test("private key menu replaces the backup settings rows", async ({ page }) => {
|
|
await openBackupSettings(page);
|
|
|
|
await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0);
|
|
await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0);
|
|
|
|
await openPrivateKeyMenu(page);
|
|
await expect(page.getByTestId("nsec-copy")).toContainText("Copy");
|
|
await expect(page.getByTestId("private-key-create-backup")).toHaveText(
|
|
"Create backup",
|
|
);
|
|
await expect(page.getByTestId("private-key-test-backup")).toHaveText(
|
|
"Test backup",
|
|
);
|
|
|
|
await page.getByTestId("nsec-copy").click();
|
|
await expect(page.getByText(/clipboard$/i)).toBeVisible();
|
|
await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0);
|
|
|
|
await openPrivateKeyMenu(page);
|
|
await page.getByTestId("private-key-test-backup").click();
|
|
const testDialog = page.getByTestId("backup-test-dialog");
|
|
await expect(testDialog).toContainText("Test a key backup");
|
|
await expect(testDialog.getByText("Select your backup file")).toBeVisible();
|
|
await expect(testDialog).toContainText("standard NIP-49 format");
|
|
});
|
|
|
|
test("creation requires a sufficiently long password and exposes a temporary header download", async ({
|
|
page,
|
|
}) => {
|
|
await openBackupSettings(page, {
|
|
backupSavePaths: [
|
|
"/Users/test/Downloads/identity.ncryptsec",
|
|
"/Users/test/Desktop/identity-copy.ncryptsec",
|
|
],
|
|
});
|
|
const dialog = await openCreateBackup(page);
|
|
|
|
const password = dialog.getByTestId("backup-passphrase-input");
|
|
const submit = dialog.getByTestId("encrypted-backup-create");
|
|
await expect(password).toHaveAttribute(
|
|
"placeholder",
|
|
"Password (min 12 characters)",
|
|
);
|
|
await expect(submit).toBeDisabled();
|
|
await password.fill("short");
|
|
await expect(submit).toBeDisabled();
|
|
|
|
await password.fill("custom password");
|
|
await expect(submit).toBeEnabled();
|
|
await submit.click();
|
|
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
|
|
await expect(dialog).toBeHidden();
|
|
|
|
const keyRow = page.getByTestId("profile-private-key-row");
|
|
const download = keyRow.getByTestId("encrypted-backup-download");
|
|
await expect(download).toBeVisible();
|
|
await expect(download).toHaveText("Download backup");
|
|
await expect(download).toHaveClass(/bg-primary/);
|
|
await expect(
|
|
download.getByTestId("encrypted-backup-availability-fill"),
|
|
).toBeVisible();
|
|
await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible();
|
|
|
|
await download.click();
|
|
await expect.poll(() => backupSaveCallCount(page)).toBe(2);
|
|
});
|
|
|
|
test("encryption and native save continue after closing the dialog and settings", async ({
|
|
page,
|
|
}) => {
|
|
await openBackupSettings(page, {
|
|
backupEncryptionDelayMs: 750,
|
|
backupSavePaths: [null],
|
|
});
|
|
const dialog = await openCreateBackup(page);
|
|
await dialog
|
|
.getByTestId("backup-passphrase-input")
|
|
.fill("background password");
|
|
await dialog.getByTestId("encrypted-backup-create").click();
|
|
await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible();
|
|
|
|
await dialog.getByRole("button", { name: "Close" }).click();
|
|
await page.getByTestId("settings-back-to-app").click();
|
|
await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0);
|
|
await expect(
|
|
page.getByText("Preparing backup…", { exact: true }),
|
|
).toBeVisible();
|
|
|
|
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
|
|
const readyToast = page.getByText("Backup ready to download", {
|
|
exact: true,
|
|
});
|
|
await expect(readyToast).toBeVisible();
|
|
await expect(
|
|
page.getByText("Your backup will be available to download for 5 minutes.", {
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Open settings" }).click();
|
|
await expect(page.getByTestId("settings-back-to-app")).toBeVisible();
|
|
await openIdentity(page);
|
|
await expect(page.getByTestId("encrypted-backup-download")).toBeVisible();
|
|
});
|
|
|
|
test("the temporary download expires after five minutes", async ({ page }) => {
|
|
await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") });
|
|
await openBackupSettings(page);
|
|
const dialog = await openCreateBackup(page);
|
|
await dialog.getByTestId("backup-passphrase-input").fill("expiring password");
|
|
await dialog.getByTestId("encrypted-backup-create").click();
|
|
await page.clock.fastForward(1);
|
|
|
|
await expect.poll(() => backupSaveCallCount(page)).toBe(1);
|
|
const download = page.getByTestId("encrypted-backup-download");
|
|
await expect(download).toHaveText("Download backup");
|
|
await expect(
|
|
download.getByTestId("encrypted-backup-availability-fill"),
|
|
).toBeVisible();
|
|
await page.clock.fastForward(5 * 60 * 1000 + 1);
|
|
await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0);
|
|
});
|
|
|
|
test("wrong backup password permits a successful retry in the test modal", async ({
|
|
page,
|
|
}) => {
|
|
await openBackupSettings(page, {
|
|
backupVerificationErrors: ["Wrong password.", null],
|
|
});
|
|
const dialog = await openTestBackup(page);
|
|
await selectBackupFile(page);
|
|
|
|
await verifyBackup(page, "wrong password");
|
|
await expect(dialog.getByTestId("backup-test-error")).toHaveText(
|
|
"Wrong password.",
|
|
);
|
|
await expect(dialog.getByTestId("backup-test-password")).toHaveValue("");
|
|
await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled();
|
|
|
|
await verifyBackup(page, "correct password");
|
|
await expect(dialog.getByTestId("backup-test-success")).toContainText(
|
|
"It restores your current Buzz identity.",
|
|
);
|
|
});
|
|
|
|
for (const identity of [
|
|
{
|
|
label: "current",
|
|
pubkey: CURRENT_PUBKEY,
|
|
message: "It restores your current Buzz identity.",
|
|
},
|
|
{
|
|
label: "different",
|
|
pubkey: DIFFERENT_PUBKEY,
|
|
message: "It restores a different identity than the one signed in here.",
|
|
},
|
|
]) {
|
|
test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({
|
|
page,
|
|
}) => {
|
|
await openBackupSettings(page, {
|
|
backupVerificationPubkeys: [identity.pubkey],
|
|
});
|
|
const dialog = await openTestBackup(page);
|
|
await selectBackupFile(page);
|
|
await verifyBackup(page, "one-time password");
|
|
|
|
const success = dialog.getByTestId("backup-test-success");
|
|
await expect(success).toContainText(identity.message);
|
|
await expect(success.getByTestId("backup-test-npub")).toContainText(
|
|
npubEncode(identity.pubkey),
|
|
);
|
|
await expect(success).not.toContainText(identity.pubkey);
|
|
await expect(success).not.toContainText("one-time password");
|
|
await expect(success).not.toContainText(BACKUP_FILE.buffer.toString());
|
|
});
|
|
}
|