fix(desktop): align password-protected backup semantics

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-29 01:01:50 -07:00
co-authored by Taylor Ho
parent 418c5f511c
commit cffa508047
14 changed files with 92 additions and 41 deletions
+1 -1
View File
@@ -285,7 +285,7 @@ pub async fn save_ncryptsec_copy(
let dest = match crate::commands::export_util::pick_save_path(
&app_handle,
crate::key_backup::BACKUP_FILE_NAME,
"Keycase",
"Password-protected key backup",
&["ncryptsec"],
)
.await?
+2 -2
View File
@@ -106,7 +106,7 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
let encrypted = parse_ncryptsec(input)?;
let secret_key = encrypted
.decrypt(password)
.map_err(|_| "wrong Keycase password or damaged Keycase".to_string())?;
.map_err(|_| "wrong backup password or damaged key backup".to_string())?;
Ok(Keys::new(secret_key))
}
@@ -125,7 +125,7 @@ pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Ke
.get(..NCRYPTSEC_HRP.len())
.is_some_and(|head| head.eq_ignore_ascii_case(NCRYPTSEC_HRP));
if hrp_match {
let password = password.ok_or_else(|| "Keycase requires a password".to_string())?;
let password = password.ok_or_else(|| "key backup requires a password".to_string())?;
decrypt_ncryptsec(trimmed, password)
} else {
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
+4 -4
View File
@@ -41,7 +41,7 @@ fn wrong_password_is_a_friendly_error() {
let keys = Keys::generate();
let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap();
let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err();
assert_eq!(err, "wrong Keycase password or damaged Keycase");
assert_eq!(err, "wrong backup password or damaged key backup");
}
#[test]
@@ -90,13 +90,13 @@ fn recover_keys_ncryptsec_happy_path() {
#[test]
fn recover_keys_ncryptsec_requires_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
assert_eq!(err, "Keycase requires a password");
assert_eq!(err, "key backup requires a password");
}
#[test]
fn recover_keys_ncryptsec_wrong_password() {
let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
assert_eq!(err, "wrong Keycase password or damaged Keycase");
assert_eq!(err, "wrong backup password or damaged key backup");
}
/// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as
@@ -108,7 +108,7 @@ fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
// Routing proof: encrypted path demands a passphrase.
let err = recover_keys_from_input(&upper, None).unwrap_err();
assert_eq!(err, "Keycase requires a password");
assert_eq!(err, "key backup requires a password");
// With the passphrase, the bech32 decoder accepts the uppercase form.
let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
@@ -221,11 +221,11 @@ export function BackupTestFlow({
}));
} else if (trimmed.toLowerCase().startsWith("ncryptsec1")) {
setFileError(
"That's a Keycase file, but not the one you just downloaded.",
"That's a key backup, but not the one you just downloaded.",
);
} else {
setFileError(
"That doesn't look like your Keycase file. Drop the file you just downloaded.",
"That doesn't look like your key backup. Choose the file you just downloaded.",
);
}
},
@@ -19,7 +19,7 @@ type DownloadKeyStepProps = {
direction: OnboardingTransitionDirection;
/**
* Backup state owned by the parent flow so Back navigation (which unmounts
* this step) doesn't discard the created Keycase, the entered password, or
* this step) doesn't discard the created backup, the entered password, or
* the backup-test progress.
*/
session: EncryptedBackupSession;
@@ -29,7 +29,7 @@ type DownloadKeyStepProps = {
/**
* Onboarding download step — the password-first encrypted key download
* (Keycase) flow, promoted to its own page in the machine onboarding flow.
* flow, promoted to its own page in the machine onboarding flow.
* The raw key never enters this component: Rust builds the NIP-49 payload
* locally and the native save dialog produces the user-owned file.
*/
@@ -72,7 +72,7 @@ export function MachineOnboardingFlow({
);
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
// Owned here (not by DownloadKeyStep) so Back navigation — which unmounts
// the step — keeps the created Keycase, entered password, and test progress.
// the step — keeps the created backup, entered password, and test progress.
const backupSession = useEncryptedBackupSession();
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
@@ -96,7 +96,7 @@ export function NostrKeyImportForm({
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
setImportError(
"That file is too large to be a Keycase or private key. Choose another file.",
"That file is too large to be a key backup or private key. Choose another file.",
);
return;
}
@@ -126,7 +126,7 @@ export function NostrKeyImportForm({
if (!isValid) {
setImportError(
isEncryptedInput
? "Enter the password for this Keycase."
? "Enter the password for this key backup."
: "That doesn't look like a valid nsec. Paste an nsec1 key.",
);
return;
@@ -244,8 +244,8 @@ export function NostrKeyImportForm({
</div>
{/* Hidden file input shared by both variants: the default drop zone and
the spotlight "Use a Keycase" button both open it. Accepts the
.ncryptsec archives our own save flow emits alongside raw .key files. */}
the spotlight "Choose a backup file" button both open it. Accepts the
.ncryptsec backups our own save flow emits alongside raw .key files. */}
<input
accept=".key,.ncryptsec,text/plain"
className="sr-only"
@@ -273,7 +273,7 @@ export function NostrKeyImportForm({
type="button"
variant="ghost"
>
Use a Keycase
Choose a backup file
</Button>
</div>
) : (
@@ -358,7 +358,7 @@ export function NostrKeyImportForm({
className="text-sm font-medium text-foreground"
htmlFor="nostr-import-passphrase"
>
Keycase password
Backup password
</label>
<Input
autoComplete="off"
@@ -376,7 +376,7 @@ export function NostrKeyImportForm({
value={passphrase}
/>
<p className="text-xs leading-5 text-muted-foreground">
Your Keycase and password stay on this device.
Your backup file and password stay on this device.
</p>
</div>
) : null}
@@ -396,7 +396,8 @@ export function NostrKeyImportForm({
data-testid="nostr-import-encrypted-badge"
>
<KeyRound aria-hidden="true" className="h-4 w-4 shrink-0" />
Keycase · Private — enter its password to restore
Password-protected key backup · Private — enter its password to
restore
</p>
) : previewNpub ? (
variant === "spotlight" ? (
@@ -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", () => {
@@ -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() {
<div className="px-4 py-3" data-testid="profile-encrypted-backup-row">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">Keycase</p>
<p className="text-sm font-medium">Password-protected key backup</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<button
aria-expanded={isOpen}
aria-label={
isOpen
? "Close password-protected key backup"
: "Create password-protected key backup"
}
className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid="profile-encrypted-backup-toggle"
onClick={() => setIsOpen((open) => !open)}
type="button"
>
{isOpen ? "Close" : "Save a new Keycase"}
{isOpen ? "Close" : "Create backup"}
</button>
</div>
{isOpen ? (
<div className="mt-3">
<EncryptedBackupCreator variant="boxed" />
<p className="mt-3 text-xs leading-5 text-muted-foreground">
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.
</p>
</div>
) : null}
@@ -31,9 +31,9 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
* Signing out wipes the identity key and all local data, so the confirm
* dialog gates the delete button behind two explicit steps:
*
* 1. Back up the key — the nsec is shown inline (masked, with reveal/copy);
* the "I have saved my private key" checkbox unlocks only after the user
* actually reveals or copies the key.
* 1. Confirm recovery — Settings offers a tested password-protected backup;
* the dialog also shows the raw nsec as a last-chance fallback. The
* checkbox unlocks only after the user reveals or copies that key.
* 2. Typed confirmation — the user must type the exact phrase
* "wipe all my data".
*
@@ -137,7 +137,8 @@ export function SignOutSection() {
<h2 className="text-lg font-semibold tracking-tight">Sign out</h2>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
<Button
@@ -175,7 +176,7 @@ export function SignOutSection() {
<div className="space-y-3">
<p className="text-sm font-medium">
1. Back up your private key (nsec)
1. Confirm you can restore your identity
</p>
{isNsecLoading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
@@ -208,10 +209,11 @@ export function SignOutSection() {
}
/>
<span>
I have saved my private key somewhere safe.
I have tested a key backup or saved this private key somewhere
safe.
{!canConfirmBackup ? (
<span className="block text-xs text-muted-foreground">
Reveal or copy the key above first.
Reveal or copy this last-chance private key first.
</span>
) : null}
</span>
+2 -2
View File
@@ -9879,7 +9879,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.
@@ -9915,7 +9915,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;
+1 -1
View File
@@ -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();
+10 -1
View File
@@ -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";
@@ -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!",
);
});