mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(identity): add optional Keycase backup flow
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
855d25ef3a
commit
8840d6c63d
@@ -278,7 +278,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,
|
||||
"Encrypted key backup",
|
||||
"Keycase",
|
||||
&["ncryptsec"],
|
||||
)
|
||||
.await?
|
||||
|
||||
@@ -100,7 +100,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 passphrase or corrupted backup".to_string())?;
|
||||
.map_err(|_| "wrong Keycase password or damaged Keycase".to_string())?;
|
||||
Ok(Keys::new(secret_key))
|
||||
}
|
||||
|
||||
@@ -119,8 +119,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(|| "encrypted backup requires a passphrase".to_string())?;
|
||||
let password = password.ok_or_else(|| "Keycase 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 passphrase or corrupted backup");
|
||||
assert_eq!(err, "wrong Keycase password or damaged Keycase");
|
||||
}
|
||||
|
||||
#[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, "encrypted backup requires a passphrase");
|
||||
assert_eq!(err, "Keycase 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 passphrase or corrupted backup");
|
||||
assert_eq!(err, "wrong Keycase password or damaged Keycase");
|
||||
}
|
||||
|
||||
/// 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, "encrypted backup requires a passphrase");
|
||||
assert_eq!(err, "Keycase 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);
|
||||
|
||||
@@ -16,29 +16,9 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
|
||||
|
||||
export type BackupStepMode = "encrypted" | "raw";
|
||||
|
||||
/**
|
||||
* Pure helper so the disabled logic can be unit-tested without a DOM.
|
||||
*
|
||||
* Encrypted mode (default): Next unlocks once the backup blob exists — the
|
||||
* user must either create a backup or explicitly switch to the raw key.
|
||||
* Raw mode: disabled while loading or after a failed load (only the explicit
|
||||
* "Skip for now" ghost advances past an error), matching the previous flow.
|
||||
*/
|
||||
export function backupNextDisabled({
|
||||
mode,
|
||||
hasBackup,
|
||||
isLoading,
|
||||
loadError,
|
||||
}: {
|
||||
mode: BackupStepMode;
|
||||
hasBackup: boolean;
|
||||
isLoading: boolean;
|
||||
loadError: string | null;
|
||||
}): boolean {
|
||||
if (mode === "encrypted") {
|
||||
return !hasBackup;
|
||||
}
|
||||
return isLoading || loadError !== null;
|
||||
/** Saving a Keycase is recommended, never required to continue onboarding. */
|
||||
export function backupNextDisabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
type BackupStepProps = {
|
||||
@@ -48,14 +28,11 @@ type BackupStepProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding backup step — encrypted by default. The user protects their
|
||||
* freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…`
|
||||
* backup; the raw key is only fetched (and shown) after an explicit
|
||||
* "Show raw key instead" click. The default path never invokes `get_nsec`.
|
||||
* Onboarding backup step — recommends a portable Keycase without blocking
|
||||
* setup. The raw key is fetched only after the user chooses the advanced path.
|
||||
*/
|
||||
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
const [mode, setMode] = React.useState<BackupStepMode>("encrypted");
|
||||
const [hasBackup, setHasBackup] = React.useState(false);
|
||||
const [nsec, setNsec] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
@@ -103,11 +80,11 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
>
|
||||
<div className="flex w-full max-w-[500px] shrink-0 flex-col text-center">
|
||||
<h1 className="text-title font-normal text-foreground">
|
||||
Your unique identity key has been created
|
||||
Account created!
|
||||
</h1>
|
||||
<p className="mt-5 text-sm leading-6 text-foreground/80">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -116,10 +93,14 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
{mode === "encrypted" ? (
|
||||
<Card className="w-full px-8 py-6" variant="textured">
|
||||
<div className="mx-auto w-full max-w-[832px]">
|
||||
<EncryptedBackupCreator
|
||||
onCreated={() => setHasBackup(true)}
|
||||
variant="spotlight"
|
||||
/>
|
||||
<div className="mb-5 space-y-2 text-center">
|
||||
<h2 className="text-lg font-medium">Save a Keycase</h2>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
A Keycase is still private. Never publish or share it. You
|
||||
need both the file and password to restore your identity.
|
||||
</p>
|
||||
</div>
|
||||
<EncryptedBackupCreator variant="spotlight" />
|
||||
</div>
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
@@ -164,7 +145,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mode === "encrypted" && !hasBackup ? (
|
||||
{mode === "encrypted" ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
|
||||
@@ -174,7 +155,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Show raw key instead
|
||||
Advanced: show my private key instead
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -194,12 +175,7 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
<Button
|
||||
className={ONBOARDING_PRIMARY_CTA_CLASS}
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled({
|
||||
mode,
|
||||
hasBackup,
|
||||
isLoading,
|
||||
loadError,
|
||||
})}
|
||||
disabled={backupNextDisabled()}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -23,22 +23,18 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
|
||||
type EncryptedBackupCreatorProps = {
|
||||
/** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
|
||||
variant?: "spotlight" | "boxed";
|
||||
/** Fired once the backup blob exists (hosts gate Next / show toasts). */
|
||||
onCreated?: (ncryptsec: string) => void;
|
||||
/** Fired only after the portable Keycase has been saved successfully. */
|
||||
onSaved?: (path: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Passphrase-first NIP-49 backup creation flow, shared by the onboarding
|
||||
* BackupStep and the settings Password Backup row.
|
||||
*
|
||||
* The raw private key never enters this component: it collects a passphrase,
|
||||
* asks Rust to create + persist the encrypted backup, and displays the
|
||||
* returned `ncryptsec1…` blob. A generated 6-word passphrase is the default;
|
||||
* "choose my own" requires ≥12 characters plus confirmation.
|
||||
* Password-first Keycase creation flow shared by onboarding and Settings.
|
||||
* The raw private key never enters this component. Rust creates the NIP-49
|
||||
* payload locally, then the native save dialog produces the user-owned file.
|
||||
*/
|
||||
export function EncryptedBackupCreator({
|
||||
variant = "spotlight",
|
||||
onCreated,
|
||||
onSaved,
|
||||
}: EncryptedBackupCreatorProps) {
|
||||
const [state, dispatch] = React.useReducer(
|
||||
encryptedBackupReducer,
|
||||
@@ -61,7 +57,7 @@ export function EncryptedBackupCreator({
|
||||
message:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to generate a passphrase.",
|
||||
: "Failed to generate a Keycase password.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -82,16 +78,31 @@ export function EncryptedBackupCreator({
|
||||
const ncryptsec = await createNcryptsecBackup(passphrase);
|
||||
if (!mountedRef.current) return;
|
||||
dispatch({ type: "create-succeeded", ncryptsec });
|
||||
onCreated?.(ncryptsec);
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const path = await saveNcryptsecCopy(ncryptsec);
|
||||
if (mountedRef.current && path) {
|
||||
setSavedPath(path);
|
||||
onSaved?.(path);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setSaveError(
|
||||
err instanceof Error ? err.message : "Failed to save Keycase.",
|
||||
);
|
||||
} finally {
|
||||
if (mountedRef.current) setIsSaving(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
dispatch({
|
||||
type: "create-failed",
|
||||
message:
|
||||
err instanceof Error ? err.message : "Failed to create backup.",
|
||||
err instanceof Error ? err.message : "Failed to create Keycase.",
|
||||
});
|
||||
}
|
||||
}, [onCreated, state]);
|
||||
}, [onSaved, state]);
|
||||
|
||||
const handleSaveCopy = React.useCallback(async () => {
|
||||
if (!state.ncryptsec || isSaving) return;
|
||||
@@ -99,16 +110,19 @@ export function EncryptedBackupCreator({
|
||||
setSaveError(null);
|
||||
try {
|
||||
const path = await saveNcryptsecCopy(state.ncryptsec);
|
||||
if (mountedRef.current && path) setSavedPath(path);
|
||||
if (mountedRef.current && path) {
|
||||
setSavedPath(path);
|
||||
onSaved?.(path);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mountedRef.current)
|
||||
setSaveError(
|
||||
err instanceof Error ? err.message : "Failed to save a copy.",
|
||||
err instanceof Error ? err.message : "Failed to save Keycase.",
|
||||
);
|
||||
} finally {
|
||||
if (mountedRef.current) setIsSaving(false);
|
||||
}
|
||||
}, [isSaving, state.ncryptsec]);
|
||||
}, [isSaving, onSaved, state.ncryptsec]);
|
||||
|
||||
const isSpotlight = variant === "spotlight";
|
||||
const customIssue = customPassphraseIssue(
|
||||
@@ -135,14 +149,14 @@ export function EncryptedBackupCreator({
|
||||
variant="outline"
|
||||
>
|
||||
{isSaving ? <Spinner className="h-3.5 w-3.5 border-2" /> : null}
|
||||
Save a copy…
|
||||
Save Keycase…
|
||||
</Button>
|
||||
{savedPath ? (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="encrypted-backup-saved-path"
|
||||
>
|
||||
Saved to {savedPath}
|
||||
Keycase saved to {savedPath}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -150,8 +164,8 @@ export function EncryptedBackupCreator({
|
||||
<p className="text-center text-sm text-destructive">{saveError}</p>
|
||||
) : null}
|
||||
<p className="text-center text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -180,13 +194,13 @@ export function EncryptedBackupCreator({
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Could not generate a passphrase: {state.generateError}
|
||||
Could not generate a Keycase password: {state.generateError}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-foreground/70">
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Generating a passphrase…
|
||||
Generating a password…
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
@@ -199,7 +213,7 @@ export function EncryptedBackupCreator({
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
New passphrase
|
||||
New generated password
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 text-sm text-muted-foreground hover:text-accent-foreground"
|
||||
@@ -213,15 +227,15 @@ export function EncryptedBackupCreator({
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
aria-label="Backup passphrase"
|
||||
aria-label="Keycase password"
|
||||
autoComplete="new-password"
|
||||
className="h-10 bg-background"
|
||||
data-testid="backup-passphrase-custom"
|
||||
@@ -231,12 +245,12 @@ export function EncryptedBackupCreator({
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={`Passphrase (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`}
|
||||
placeholder={`Password (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`}
|
||||
type="password"
|
||||
value={state.customPassphrase}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Confirm backup passphrase"
|
||||
aria-label="Confirm Keycase password"
|
||||
autoComplete="new-password"
|
||||
className="h-10 bg-background"
|
||||
data-testid="backup-passphrase-confirm"
|
||||
@@ -246,7 +260,7 @@ export function EncryptedBackupCreator({
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Confirm passphrase"
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
value={state.customConfirm}
|
||||
/>
|
||||
@@ -268,11 +282,11 @@ export function EncryptedBackupCreator({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Use a generated passphrase
|
||||
Use a generated password
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-center text-xs leading-5 text-muted-foreground">
|
||||
Your passphrase protects the backup and cannot be recovered if lost.
|
||||
Your password protects the Keycase. Buzz cannot reset it if lost.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -297,10 +311,10 @@ export function EncryptedBackupCreator({
|
||||
{state.isCreating ? (
|
||||
<>
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Encrypting… this takes a couple of seconds
|
||||
Creating Keycase… this takes a couple of seconds
|
||||
</>
|
||||
) : (
|
||||
"Create encrypted backup"
|
||||
"Create Keycase"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export function NostrKeyImportForm({
|
||||
|
||||
if (file.size > NOSTR_KEY_FILE_MAX_BYTES) {
|
||||
setImportError(
|
||||
"That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.",
|
||||
"That file is too large to be a Keycase or private key. Choose another file.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +126,7 @@ export function NostrKeyImportForm({
|
||||
if (!isValid) {
|
||||
setImportError(
|
||||
isEncryptedInput
|
||||
? "Enter the passphrase for this encrypted backup."
|
||||
? "Enter the password for this Keycase."
|
||||
: "That doesn't look like a valid nsec. Paste an nsec1 key.",
|
||||
);
|
||||
return;
|
||||
@@ -244,7 +244,7 @@ export function NostrKeyImportForm({
|
||||
</div>
|
||||
|
||||
{/* Hidden file input shared by both variants: the default drop zone and
|
||||
the spotlight "Import from a file" button both open it. Accepts the
|
||||
the spotlight "Use a Keycase" button both open it. Accepts the
|
||||
.ncryptsec archives our own save flow emits alongside raw .key files. */}
|
||||
<input
|
||||
accept=".key,.ncryptsec,text/plain"
|
||||
@@ -273,7 +273,7 @@ export function NostrKeyImportForm({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Import from a file
|
||||
Use a Keycase
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -358,7 +358,7 @@ export function NostrKeyImportForm({
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="nostr-import-passphrase"
|
||||
>
|
||||
Backup passphrase
|
||||
Keycase password
|
||||
</label>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -370,11 +370,14 @@ export function NostrKeyImportForm({
|
||||
setPassphrase(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Passphrase"
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value={passphrase}
|
||||
/>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
Your Keycase and password stay on this device.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -393,7 +396,7 @@ export function NostrKeyImportForm({
|
||||
data-testid="nostr-import-encrypted-badge"
|
||||
>
|
||||
<KeyRound aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
Encrypted key backup — enter its passphrase to import
|
||||
Keycase · Private — enter its password to restore
|
||||
</p>
|
||||
) : previewNpub ? (
|
||||
variant === "spotlight" ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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() {
|
||||
<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">Password Backup</p>
|
||||
<p className="text-sm font-medium">Keycase</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Protect your key with a password and save a recoverable backup.
|
||||
Save a portable, password-protected copy of your identity. Keep it
|
||||
private.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -201,12 +202,16 @@ function EncryptedBackupRow() {
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Close" : "Create"}
|
||||
{isOpen ? "Close" : "Save a new Keycase"}
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -9608,7 +9608,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 encrypted key backup.");
|
||||
throw new Error("Not a valid Keycase.");
|
||||
}
|
||||
// Production opens a native save dialog; the harness pretends the
|
||||
// user picked a path.
|
||||
@@ -9644,7 +9644,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
input.trim() !== MOCK_NCRYPTSEC ||
|
||||
request?.password !== MOCK_BACKUP_PASSPHRASE
|
||||
) {
|
||||
throw new Error("Wrong passphrase or corrupted backup.");
|
||||
throw new Error("Wrong Keycase password or damaged Keycase.");
|
||||
}
|
||||
mockIdentityLostCleared = true;
|
||||
mockIdentityLockedCleared = true;
|
||||
|
||||
@@ -21,31 +21,31 @@ test("backup step appears on fresh-key path after profile submit", async ({
|
||||
await expect(page.getByTestId("onboarding-page-backup")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Your unique identity key has been created",
|
||||
name: "Account created!",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encrypted-by-default path (plan D3): passphrase → create → ncryptsec shown.
|
||||
// Keycase path: password → create locally → native save → saved confirmation.
|
||||
// The raw key must never be fetched on this path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("encrypted backup happy path: generated passphrase, create, save copy, Next", async ({
|
||||
test("Keycase happy path: generated password, create, native save, Next", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enterMachineBackup(page);
|
||||
|
||||
// Default mode: generated passphrase shown, Next locked until backup exists.
|
||||
// Default mode: generated password shown, but backup remains optional.
|
||||
await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` });
|
||||
|
||||
await page.getByTestId("encrypted-backup-create").click();
|
||||
|
||||
// The persisted blob is displayed masked; copy + save-a-copy available.
|
||||
// The locally created blob stays masked; the portable save action is explicit.
|
||||
const blob = page.getByTestId("ncryptsec-value");
|
||||
await expect(blob).toBeVisible();
|
||||
await expect(blob).toHaveCSS("filter", /blur/);
|
||||
@@ -55,7 +55,6 @@ test("encrypted backup happy path: generated passphrase, create, save copy, Next
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` });
|
||||
|
||||
await page.getByTestId("encrypted-backup-save-copy").click();
|
||||
await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText(
|
||||
"identity.ncryptsec",
|
||||
);
|
||||
@@ -98,7 +97,7 @@ test("custom passphrase requires 12 characters and confirmation", async ({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw-key path: preserved behind an explicit "Show raw key instead" click.
|
||||
// Raw-key path: preserved behind one explicit advanced action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("raw key path is one explicit click away and shows the masked nsec", async ({
|
||||
@@ -124,7 +123,7 @@ test("raw key path is one explicit click away and shows the masked nsec", async
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` });
|
||||
|
||||
// Raw mode keeps the previous gating: key shown → Next enabled.
|
||||
// Next remains enabled on the advanced raw-key path.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await page.getByTestId("onboarding-next").click();
|
||||
await expect(page.getByTestId("onboarding-page-2")).toBeVisible();
|
||||
@@ -168,8 +167,8 @@ test("raw path shows error banner and retry button when get_nsec fails", async (
|
||||
|
||||
await expect(page.getByTestId("backup-load-error")).toBeVisible();
|
||||
await expect(page.getByTestId("backup-retry")).toBeVisible();
|
||||
// Next is blocked on error; Skip for now ghost is shown instead.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeDisabled();
|
||||
// Keychain failure does not trap the user; both Next and explicit skip work.
|
||||
await expect(page.getByTestId("onboarding-next")).toBeEnabled();
|
||||
await expect(page.getByTestId("backup-skip")).toBeVisible();
|
||||
|
||||
// Skip for now still advances to machine setup.
|
||||
|
||||
@@ -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 passphrase/i,
|
||||
/wrong Keycase password/i,
|
||||
);
|
||||
|
||||
await page
|
||||
|
||||
Reference in New Issue
Block a user