mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Taylor Ho
parent
418c5f511c
commit
cffa508047
@@ -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?
|
||||
|
||||
@@ -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}"))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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!",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user