Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
853f6e6
feat(identity): add optional Keycase backup flow
Jul 28, 2026
e63562d
feat(onboarding): add perceived key-creation loading state to backup …
tellaho Jul 28, 2026
0cd947c
feat(onboarding): rework backup step into masked-key chooser with cop…
tellaho Jul 28, 2026
a9f9d4f
feat(onboarding): morph nsec card into bracketed shell box on download
tellaho Jul 29, 2026
8b3a71f
feat(onboarding): promote encrypted key download to a "Backup your ke…
tellaho Jul 29, 2026
b4782d1
feat(onboarding): single password field with 1Password-style generato…
tellaho Jul 29, 2026
f940bb7
feat(onboarding): ticker label and password lockdown for queued key d…
tellaho Jul 29, 2026
3b635e3
feat(onboarding): chooser CTA becomes Next; skip and Backup key label…
tellaho Jul 29, 2026
301e534
feat(onboarding): replace post-download blob view with Test your back…
tellaho Jul 29, 2026
1ec89cd
feat(onboarding): gate Next on backup test with select-button dropzone
tellaho Jul 29, 2026
2c7bbf2
feat(onboarding): durable backup session with save-gated test flow
tellaho Jul 29, 2026
bfbfdfb
fix(desktop): align password-protected backup semantics
Jul 29, 2026
581f1ee
fix(desktop): variant-aware backup test buttons for the settings context
tellaho Jul 29, 2026
b92af23
refactor(desktop): composer-style drop overlay for backup test flow
tellaho Jul 29, 2026
18a8c82
fix(desktop): harden NIP-49 backup verification
Jul 29, 2026
7700be3
fix(desktop): restore backup flow polish over hardened NIP-49 verific…
tellaho Jul 29, 2026
ac9f984
fix(desktop): lay-person copy for the new-backup-password dialog
tellaho Jul 29, 2026
bbebe38
fix(desktop): ungate sign-out backup checkbox and wrap revealed nsec
tellaho Jul 29, 2026
49f9c44
fix(desktop): remove duplicate backup row divider
tellaho Jul 29, 2026
2644b5e
chore(desktop): format identity backup tests
Jul 29, 2026
b8d5822
test(desktop): disambiguate onboarding back button
Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 55 additions & 211 deletions desktop/src-tauri/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,18 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
.map_err(|error| format!("encode nsec: {error}"))
}

/// Generate a 6-word passphrase for a new encrypted backup (EFF short
/// wordlist, OS entropy, ≈62 bits before the scrypt work factor).
/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS
/// entropy). `words` is clamped to the range allowed by `key_backup`;
/// `separator` joins the words (defaults to a space).
#[tauri::command]
pub fn generate_backup_passphrase() -> Result<String, String> {
crate::key_backup::generate_passphrase()
pub fn generate_backup_passphrase(
words: Option<u32>,
separator: Option<String>,
) -> Result<String, String> {
crate::key_backup::generate_passphrase(
words.map_or(crate::key_backup::DEFAULT_PASSPHRASE_WORDS, |w| w as usize),
separator.as_deref().unwrap_or(" "),
)
}

/// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a
Expand Down Expand Up @@ -259,6 +266,47 @@ pub async fn create_ncryptsec_backup(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackupVerification {
pub pubkey: String,
pub npub: String,
pub matches_current_identity: bool,
}

fn verify_ncryptsec_backup_inner(
state: &AppState,
ncryptsec: &str,
password: &str,
) -> Result<BackupVerification, String> {
let keys = crate::key_backup::decrypt_ncryptsec(ncryptsec, password)?;
let pubkey = keys.public_key();
let current = state.signing_keys()?.public_key();
Ok(BackupVerification {
pubkey: pubkey.to_hex(),
npub: pubkey
.to_bech32()
.map_err(|e| format!("encode backup identity: {e}"))?,
matches_current_identity: pubkey == current,
})
}

/// Decrypt and validate a NIP-49 backup without exposing its secret key.
#[tauri::command]
pub async fn verify_ncryptsec_backup(
ncryptsec: String,
password: String,
app_handle: tauri::AppHandle,
) -> Result<BackupVerification, String> {
tokio::task::spawn_blocking(move || {
let password = zeroize::Zeroizing::new(password);
let state = app_handle.state::<AppState>();
verify_ncryptsec_backup_inner(&state, &ncryptsec, &password)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

/// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path.
///
/// The input must parse as a structurally valid NIP-49 payload. The dialog is
Expand All @@ -278,7 +326,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",
"Password-protected key backup",
&["ncryptsec"],
)
.await?
Expand Down Expand Up @@ -744,209 +792,5 @@ mod nostr_identity_binding_tests {
}

#[cfg(test)]
mod key_backup_command_tests {
use super::create_and_persist_backup_with_log_n;
use crate::app_state::build_app_state;
use nostr::Keys;

/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered
/// once in key_backup_tests::round_trip_at_production_cost.
const FAST_LOG_N: u8 = 16;
const PASSWORD: &str = "correct horse battery";

#[test]
fn returned_bytes_equal_on_disk_bytes() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let returned =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();

let path = crate::key_backup::backup_file_path(dir.path());
let on_disk = std::fs::read_to_string(&path).unwrap();
assert_eq!(
returned, on_disk,
"webview must receive the exact persisted bytes"
);

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}

// And the persisted blob provably recovers the live identity.
let keys = state.keys.lock().unwrap().clone();
let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap();
assert_eq!(recovered.public_key(), keys.public_key());
}

#[test]
fn overwrite_replaces_atomically() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let first =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
let second = create_and_persist_backup_with_log_n(
&state,
dir.path(),
"another passphrase",
FAST_LOG_N,
)
.unwrap();
assert_ne!(first, second, "fresh salt/nonce per action");

let path = crate::key_backup::backup_file_path(dir.path());
assert_eq!(std::fs::read_to_string(&path).unwrap(), second);
}

#[test]
fn rejects_short_passphrase() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N)
.unwrap_err();
assert!(err.contains("at least"), "{err}");
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
}

#[test]
fn recovery_mode_blocks_backup_creation() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();

state
.identity_lost
.store(true, std::sync::atomic::Ordering::Release);
assert!(
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
"lost identity must not be backed up"
);
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);

state
.keyring_locked
.store(true, std::sync::atomic::Ordering::Release);
assert!(
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(),
"locked keyring must not be backed up"
);
assert!(!crate::key_backup::backup_file_path(dir.path()).exists());
}

/// Blocker-1 regression (Wren, implementation review): a failed
/// different-key import must leave BOTH the old in-memory identity and
/// the old canonical backup intact. Persistence runs before cleanup, so
/// an `Err` from persist means nothing was mutated or deleted.
#[test]
fn failed_import_persistence_preserves_old_identity_and_backup() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();
let old_pubkey = state.keys.lock().unwrap().public_key();

// A valid canonical backup for the live (old) identity.
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
let backup_path = crate::key_backup::backup_file_path(dir.path());
let backup_before = std::fs::read_to_string(&backup_path).unwrap();

// Different-key import whose durable persistence fails (both
// keyring and file fallback down).
let _guard = state.identity_mutation.lock().unwrap();
let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| {
Err("keyring and file both unavailable".to_string())
})
.unwrap_err();
assert!(err.contains("unavailable"), "{err}");

// Old identity still live; old backup untouched byte-for-byte.
assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey);
assert_eq!(
std::fs::read_to_string(&backup_path).unwrap(),
backup_before
);
assert!(
crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD)
.unwrap()
.public_key()
== old_pubkey,
"surviving backup must still recover the still-live identity"
);
}

/// Successful different-key import removes the previous identity's
/// backup — cleanup runs after the durable commit, not before.
#[test]
fn successful_import_removes_stale_backup_after_commit() {
let state = build_app_state();
let dir = tempfile::tempdir().unwrap();

create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
let backup_path = crate::key_backup::backup_file_path(dir.path());
assert!(backup_path.exists());

let new_keys = Keys::generate();
let backup_present_at_persist = std::cell::Cell::new(false);
let _guard = state.identity_mutation.lock().unwrap();
let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| {
// Ordering probe: the old backup must still exist while
// persistence is running (cleanup has not happened yet).
backup_present_at_persist.set(backup_path.exists());
Ok(())
})
.unwrap();

assert!(
backup_present_at_persist.get(),
"cleanup must not precede persist"
);
assert_eq!(pubkey, new_keys.public_key());
assert_eq!(
state.keys.lock().unwrap().public_key(),
new_keys.public_key()
);
assert!(
!backup_path.exists(),
"stale backup must be removed post-commit"
);
}

/// Concurrent identity swap vs backup creation: `identity_mutation`
/// serializes both, so every persisted blob decrypts to the identity that
/// was live for the whole of its create operation — never a torn state.
#[test]
fn concurrent_identity_swap_vs_backup_is_serialized() {
let state = std::sync::Arc::new(build_app_state());
let dir = tempfile::tempdir().unwrap();
let key_a = state.keys.lock().unwrap().clone();
let key_b = Keys::generate();

let swapper = {
let state = state.clone();
let key_b = key_b.clone();
std::thread::spawn(move || {
// Mirrors import_identity's locking: mutation guard held
// across the key swap.
let _guard = state.identity_mutation.lock().unwrap();
*state.keys.lock().unwrap() = key_b;
})
};

let backup =
create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap();
swapper.join().unwrap();

let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD)
.unwrap()
.public_key();
assert!(
recovered == key_a.public_key() || recovered == key_b.public_key(),
"backup must match one coherent identity"
);
// Whichever won, the persisted file equals the returned blob.
let on_disk =
std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap();
assert_eq!(on_disk, backup);
}
}
#[path = "identity_key_backup_tests.rs"]
mod key_backup_command_tests;
Loading
Loading