From 853f6e6d67823b5a58c4d799c8c185a192e0426f Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
<52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Date: Tue, 28 Jul 2026 12:46:30 -0700
Subject: [PATCH 01/21] feat(identity): add optional Keycase backup flow
Co-authored-by: Taylor Ho
{mode === "encrypted"
- ? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account."
+ ? "Buzz keeps your identity in the system keychain. Save a portable, password-protected Keycase in case you need to restore it elsewhere."
: "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."}
+ A Keycase is still private. Never publish or share it. You
+ need both the file and password to restore your identity.
+
- Your unique identity key has been created
+ Account created!
Save a Keycase
+
- Saved to {savedPath} + Keycase saved to {savedPath}
) : null} @@ -150,8 +164,8 @@ export function EncryptedBackupCreator({{saveError}
) : null}- This backup can only be unlocked with your passphrase. Without the - passphrase it cannot be recovered — not even by Buzz. + Keep this Keycase private. You need both the file and its password to + restore your identity. Buzz cannot reset the password.
); @@ -180,13 +194,13 @@ export function EncryptedBackupCreator({ >- Write this passphrase down. It protects your backup and cannot be - recovered if lost. + Save this generated passphrase as your Keycase password. Store it + separately from the private Keycase file.
) : (- Your passphrase protects the backup and cannot be recovered if lost. + Your password protects the Keycase. Buzz cannot reset it if lost.
+ Your Keycase and password stay on this device. +
) : null} @@ -393,7 +396,7 @@ export function NostrKeyImportForm({ data-testid="nostr-import-encrypted-badge" > - Encrypted key backup — enter its passphrase to import + Keycase · Private — enter its password to restore ) : previewNpub ? ( variant === "spotlight" ? ( diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index df4536ef1d..1dda9b5e22 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -9,8 +9,8 @@ type NsecMaskedDisplayProps = { variant?: "boxed" | "bare"; /** * What kind of secret is displayed. Drives labels, aria and testids: - * a raw private key ("nsec", default) can impersonate its holder; an - * encrypted backup ("ncryptsec") is only as sensitive as its passphrase. + * a raw private key ("nsec", default) can impersonate its holder; a + * Keycase ("ncryptsec") is only as sensitive as its passphrase. */ kind?: "nsec" | "ncryptsec"; /** @@ -26,7 +26,7 @@ const KIND_LABELS = { testIdPrefix: "nsec", }, ncryptsec: { - noun: "encrypted backup", + noun: "Keycase", testIdPrefix: "ncryptsec", }, } as const; diff --git a/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs b/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs index 720741eff1..c28be13864 100644 --- a/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs @@ -53,74 +53,11 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => { }); // --------------------------------------------------------------------------- -// BackupStep gating: backupNextDisabled() pure helper +// BackupStep gating: saving a Keycase is recommended, not required // --------------------------------------------------------------------------- -test("backup_next_disabled_in_encrypted_mode_until_backup_exists", () => { - // Encrypted (default) mode: the user must create a backup — or explicitly - // switch to the raw key — before Next unlocks. Loading/error state belongs - // to the raw path and must not leak into the encrypted gate. - assert.equal( - backupNextDisabled({ - mode: "encrypted", - hasBackup: false, - isLoading: false, - loadError: null, - }), - true, - ); -}); - -test("backup_next_enabled_in_encrypted_mode_once_backup_created", () => { - assert.equal( - backupNextDisabled({ - mode: "encrypted", - hasBackup: true, - isLoading: false, - loadError: null, - }), - false, - ); -}); - -test("backup_next_disabled_while_loading_raw_key", () => { - // During a slow keychain read, Next must be blocked — user cannot race past - // the key display before it is shown. - assert.equal( - backupNextDisabled({ - mode: "raw", - hasBackup: false, - isLoading: true, - loadError: null, - }), - true, - ); -}); - -test("backup_next_disabled_on_raw_load_error", () => { - // Error state: only the explicit "Skip for now" ghost advances; Next blocked. - assert.equal( - backupNextDisabled({ - mode: "raw", - hasBackup: false, - isLoading: false, - loadError: "IPC error", - }), - true, - ); -}); - -test("backup_next_enabled_after_clean_raw_load", () => { - // Key shown (or backend cleanly returned none) — user may proceed. - assert.equal( - backupNextDisabled({ - mode: "raw", - hasBackup: false, - isLoading: false, - loadError: null, - }), - false, - ); +test("backup_next_is_always_enabled", () => { + assert.equal(backupNextDisabled(), false); }); // --------------------------------------------------------------------------- diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 5326243ff4..bec4d2e4ae 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -178,7 +178,7 @@ function NsecRevealRow() { } /** - * Collapsible row for creating an encrypted NIP-49 backup on demand. The raw + * Collapsible row for saving a portable Keycase on demand. The raw * private key never reaches this flow — the passphrase goes to Rust, which * returns the persisted `ncryptsec1…` blob. */ @@ -189,9 +189,10 @@ function EncryptedBackupRow() {Password Backup
+Keycase
- Protect your key with a password and save a recoverable backup. + Save a portable, password-protected copy of your identity. Keep it + private.
+ Buzz cannot reset the password. Saving a new Keycase does not + invalidate copies you saved before. +
- {mode === "encrypted" - ? "Buzz keeps your identity in the system keychain. Save a portable, password-protected Keycase in case you need to restore it elsewhere." - : "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."} -
+ {created ? ( ++ Your identity key will be saved to your keychain. Back it up + somewhere safe so you can restore your account. Never share your + key. +
+ ) : null}- A Keycase is still private. Never publish or share it. You - need both the file and password to restore your identity. -
+ {!created ? ( ++ A Keycase is still private. Never publish or share it. You + need both the file and password to restore your identity. +
++ No key available to back up. +
+ )} + + {mode === "encrypted" ? ( +- No key available to back up. -
- )} + ) : null} + + {mode === "raw" && nsec ? ( +
+
-
- A Keycase is still private. Never publish or share it. You - need both the file and password to restore your identity. -
+ {view === "choose" ? ( ++ {isRevealed && nsec ? nsec : maskedKey} +
++ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +
+ ) : null} + + +
+
- No key available to back up. -
- )} - - {mode === "encrypted" ? ( -+ Your key is encrypted with your password before it + downloads. Keep the file private — you need both it and + the password to restore your identity. +
+
-
- Keycase saved to {savedPath} + Saved to {savedPath}
) : null}{saveError}
) : null}- Keep this Keycase private. You need both the file and its password to + Keep this file private. You need both the file and its password to restore your identity. Buzz cannot reset the password.
- Save this generated passphrase as your Keycase password. Store it - separately from the private Keycase file. + Save this generated passphrase as your encryption password. Store it + separately from the downloaded key file.
- Your password protects the Keycase. Buzz cannot reset it if lost. + Your password protects your downloaded key. Buzz cannot reset it if + lost.
+ {isRevealed && nsec ? nsec : maskedKey} +
+- {isRevealed && nsec ? nsec : maskedKey} + Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity.
-- Could not retrieve your private key: {copyError}. You can - continue and find it later in Settings > Profile > - Identity. -
- ) : null} -
- Your key is encrypted with your password before it - downloads. Keep the file private — you need both it and - the password to restore your identity. -
++ Your key is encrypted with your password before it + downloads. Keep the file private — you need both it and + the password to restore your identity. +
+- {isRevealed && nsec ? nsec : maskedKey} -
-+ {isRevealed && nsec ? nsec : maskedKey} +
+- Could not retrieve your private key: {copyError}. You can - continue and find it later in Settings > Profile > - Identity. -
- ) : null} -
-
- Your key is encrypted with your password before it - downloads. Keep the file private — you need both it and - the password to restore your identity. -
-+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +
+ ) : null} + + +
+
+ You can back up your key anytime in Settings → Profile → + Identity. +
+ Your key is encrypted with your password before it downloads. Keep the + file private — you need both it and the password to restore your + identity. +
++ You can back up your key anytime in Settings → Profile → + Identity. +
+
+
- {state.generatedPassphrase} -
-- {customIssue} -
- ) : null} -
- Your password protects your downloaded key. Buzz cannot reset it if
- lost.
+
+
+ {issue}
{
+ // Absolute spinner: signals the background encryption without
+ // shifting the centered button while it appears and disappears.
const createButton = (
-
+ Your backup works! +
++ File and password verified. Keep them both somewhere safe — that's + all you need to restore your identity. +
++ {fileError} +
+ ) : null} ++ Saved to {savedPath} +
+ ) : null} +{saveError}
+ ) : null} + > + ) : ( + <> ++ That's the one. Now type your password to prove you can unlock it. +
++ Not quite — check the password you saved. +
+ ) : null} +- Keep the downloaded file private — you need both it and your password - to restore your identity. Save the password somewhere safe; Buzz - cannot reset it if lost. + {hasCreated + ? "Make sure your backup works: drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
- You can back up your key anytime in Settings → Profile → - Identity. -
); diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 2726599321..bcca3fb090 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -21,7 +21,7 @@ import { initialEncryptedBackupState, MIN_PASSPHRASE_LEN, } from "../lib/encryptedBackup"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { BackupTestFlow } from "./BackupTestFlow"; /** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ const MIN_GENERATED_WORDS = 3; @@ -440,46 +440,20 @@ export function EncryptedBackupCreator({ } }, [isSaving, onSaved, state.ncryptsec]); - const isSpotlight = variant === "spotlight"; const issue = passphraseIssue(state.passphrase); if (state.ncryptsec) { return ( -- Saved to {savedPath} -
- ) : null} -{saveError}
- ) : null} -- Keep this file private. You need both the file and its password to - restore your identity. Buzz cannot reset the password. -
{hasCreated
@@ -69,6 +74,7 @@ export function DownloadKeyStep({
createButtonClassName={ONBOARDING_PRIMARY_CTA_CLASS}
createButtonPortal={createButtonSlot}
onCreated={() => setHasCreated(true)}
+ onVerified={() => setHasVerified(true)}
variant="spotlight"
/>
- {hasCreated ? "Test your backup" : "Backup your key with a password"}
+ {hasCreated
+ ? "Now, test your backup"
+ : "Backup your key with a password"}
- You can back up your key anytime in Settings → Profile → - Identity. -
+ You can back up your key anytime in Settings → Profile → + Identity. +
+ )}+ {saveError} +
+ ) : null} + {(() => { // Absolute spinner: signals the background encryption without // shifting the centered button while it appears and disappears. const createButton = (- Your Keycase and password stay on this device. + Your backup file and password stay on this device.
) : null} @@ -396,7 +396,8 @@ export function NostrKeyImportForm({ data-testid="nostr-import-encrypted-badge" > - Keycase · Private — enter its password to restore + Password-protected key backup · Private — enter its password to + restore ) : previewNpub ? ( variant === "spotlight" ? ( diff --git a/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs b/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs index c28be13864..622db610aa 100644 --- a/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs @@ -53,7 +53,7 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => { }); // --------------------------------------------------------------------------- -// BackupStep gating: saving a Keycase is recommended, not required +// BackupStep gating: saving a password-protected backup is recommended, not required // --------------------------------------------------------------------------- test("backup_next_is_always_enabled", () => { diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index bec4d2e4ae..8066ba6dbe 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -178,9 +178,9 @@ function NsecRevealRow() { } /** - * Collapsible row for saving a portable Keycase on demand. The raw - * private key never reaches this flow — the passphrase goes to Rust, which - * returns the persisted `ncryptsec1…` blob. + * Collapsible row for creating and testing a password-protected key backup. + * The raw private key never reaches this flow — the password goes to Rust, + * which returns the persisted `ncryptsec1…` blob. */ function EncryptedBackupRow() { const [isOpen, setIsOpen] = React.useState(false); @@ -189,28 +189,34 @@ function EncryptedBackupRow() {Keycase
+Password-protected key backup
- Save a portable, password-protected copy of your identity. Keep it - private. + Download an encrypted copy of your identity key, then test the file + and password before relying on it.
- Buzz cannot reset the password. Saving a new Keycase does not - invalidate copies you saved before. + Keep the downloaded file private and save its password somewhere + safe. Buzz cannot reset the password. Creating another backup does + not invalidate copies you saved before.
Removes your identity key and all local app data from this device. - Back up your private key (nsec) first — this cannot be undone. + Before signing out, create and test a password-protected key backup + above — this cannot be undone.
- 1. Back up your private key (nsec) + 1. Confirm you can restore your identity
{isNsecLoading ? (Loading…
@@ -208,10 +209,11 @@ export function SignOutSection() { } /> - I have saved my private key somewhere safe. + I have tested a key backup or saved this private key somewhere + safe. {!canConfirmBackup ? ( - Reveal or copy the key above first. + Reveal or copy this last-chance private key first. ) : null} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 87a2a3c0b4..99e6fb2d83 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9631,7 +9631,7 @@ export function maybeInstallE2eTauriMocks() { case "save_ncryptsec_copy": { const blob = (payload as { ncryptsec?: string } | null)?.ncryptsec; if (!blob?.startsWith("ncryptsec1")) { - throw new Error("Not a valid Keycase."); + throw new Error("Not a valid key backup."); } // Production opens a native save dialog; the harness pretends the // user picked a path. @@ -9667,7 +9667,7 @@ export function maybeInstallE2eTauriMocks() { input.trim() !== MOCK_NCRYPTSEC || request?.password !== MOCK_BACKUP_PASSPHRASE ) { - throw new Error("Wrong Keycase password or damaged Keycase."); + throw new Error("Wrong backup password or damaged key backup."); } mockIdentityLostCleared = true; mockIdentityLockedCleared = true; diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 3994c555ad..6e7c90c67f 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -172,7 +172,7 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await page.getByTestId("backup-test-file-input").setInputFiles({ name: "notes.txt", mimeType: "text/plain", - buffer: Buffer.from("not a keycase"), + buffer: Buffer.from("not a key backup"), }); await expect(page.getByTestId("backup-test-file-error")).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0cf08f9be6..de6aa0dda6 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -645,7 +645,7 @@ test("first-launch encrypted backup import asks for a passphrase and continues", await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase"); await page.getByTestId("nostr-import-submit").click(); await expect(page.getByTestId("nostr-import-feedback")).toContainText( - /wrong Keycase password/i, + /wrong backup password/i, ); await page @@ -679,6 +679,15 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ ".key,.ncryptsec,text/plain", ); + await fileInput.setInputFiles({ + buffer: Buffer.alloc(1_025, "x"), + mimeType: "text/plain", + name: "not-a-backup.txt", + }); + await expect(page.getByTestId("nostr-import-feedback")).toContainText( + /too large to be a key backup/i, + ); + // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; diff --git a/desktop/tests/e2e/profile-nsec-reveal.spec.ts b/desktop/tests/e2e/profile-nsec-reveal.spec.ts index 47e4c8a2de..057c815ceb 100644 --- a/desktop/tests/e2e/profile-nsec-reveal.spec.ts +++ b/desktop/tests/e2e/profile-nsec-reveal.spec.ts @@ -63,3 +63,36 @@ test("reveal shows error when get_nsec fails", async ({ page }) => { "Keychain locked", ); }); + +test("settings creates and tests a password-protected key backup", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await openSettings(page, "profile"); + await expandIdentity(page); + + const row = page.getByTestId("profile-encrypted-backup-row"); + await expect(row).toContainText("Password-protected key backup"); + await page.getByTestId("profile-encrypted-backup-toggle").click(); + + await page + .getByTestId("backup-passphrase-input") + .fill("mock horse battery staple"); + await page.getByTestId("encrypted-backup-create").click(); + await expect(page.getByTestId("backup-test-dropzone")).toBeVisible(); + + await page.getByTestId("backup-test-file-input").setInputFiles({ + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from( + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p", + ), + }); + await page + .getByTestId("backup-test-password") + .fill("mock horse battery staple"); + await expect(page.getByTestId("backup-test-success")).toContainText( + "Your backup works!", + ); +}); From 581f1eed3a804bfbebdeb446f96a86f5141c1c24 Mon Sep 17 00:00:00 2001 From: Taylor Ho+
Password-protected key backup
++ Download an encrypted copy of your identity key, then test the file + and password before relying on it. +
++ Keep the downloaded file private and save its password somewhere + safe. Buzz cannot reset the password. Creating another backup does + not invalidate copies you saved before. +
+Password-protected key backup
-- Download an encrypted copy of your identity key, then test the file - and password before relying on it. -
-- Keep the downloaded file private and save its password somewhere - safe. Buzz cannot reset the password. Creating another backup does - not invalidate copies you saved before. -
-- Your backup works! +
Valid backup
++ {result.matchesCurrentIdentity + ? "Matches your current Buzz identity." + : "Belongs to a different identity."}
-- File and password verified. Keep them both somewhere safe — that's - all you need to restore your identity. -
- +
+ {result.npub}
+
+ - {fileError} -
+ ) : null} -
+
{saveError}
+ {isSaving ?+ Saved to {savedPath} +
+ ) : null} +- That's the one. Now type your password to prove you can unlock it. +
+ Enter its password, then verify it with real NIP-49 decryption.
- Not quite — check the password you saved. -
- ) : null}+ {error ?? saveError} +
+ ) : null}Backup downloaded
++ Buzz cleared the password from this session. You can download another + copy without entering it again. +
+{saveError}
+ ) : null} +Password-protected key backup
-- Download an encrypted copy of your identity key, then test the file - and password before relying on it. -
+{title}
+{description}
- Keep the downloaded file private and save its password somewhere - safe. Buzz cannot reset the password. Creating another backup does - not invalidate copies you saved before. -
-+ Keep the file private and save its password somewhere safe. Buzz + cannot reset it. +
+Valid backup
-
- {result.matchesCurrentIdentity
- ? "Matches your current Buzz identity."
- : "Belongs to a different identity."}
+ {reduceMotion ? null :
+ {isCeremony ? "Your backup works!" : "This backup works"}
- {result.npub}
-
- + {isCeremony + ? "File and password verified. Keep them both somewhere safe — that's all you need to restore your identity." + : result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +
+ {isCeremony ? null : ( ++ {error} +
) : null} {onSaveCopy ? ( -Saved to {savedPath} @@ -262,74 +443,108 @@ export function BackupTestFlow({ ) : null}
{saveError}
+ ) : null} > ) : ( <>- Enter its password, then verify it with real NIP-49 decryption. +
+ That's the one. Now enter your password to prove you can unlock it.
+ {error} +
+ ) : null}- {error ?? saveError} -
- ) : null}Backup downloaded
-- Buzz cleared the password from this session. You can download another - copy without entering it again. -
-{saveError}
- ) : null} -+ Backup saved to {savedPath} +
++ Your password isn't kept — download another copy anytime, or start + over to choose a new password. +
+
- Keep the file private and save its password somewhere safe. Buzz
- cannot reset it.
+ Keep the file private and save its password somewhere safe — Buzz
+ cannot reset it. Creating another backup does not invalidate copies
+ you saved before.
+ Backups use the standard NIP-49 format, so this works for backups from
+ compatible Nostr apps too.
+ (null);
const [nsecError, setNsecError] = React.useState