From 5df4287a9f42ca301565d9dc0c78f3df6b10f1bc Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 16:00:51 -0700 Subject: [PATCH 1/5] feat(settings): add password-protected key backups Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/playwright.config.ts | 1 + desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 5 +- .../src/assets/eff_short_wordlist_2_0.txt | 1296 +++++++++++++++++ desktop/src-tauri/src/commands/export_util.rs | 34 +- desktop/src-tauri/src/commands/identity.rs | 141 ++ .../src/commands/identity_key_backup_tests.rs | 103 ++ .../src/commands/personas/snapshot/import.rs | 33 +- .../src-tauri/src/commands/team_snapshot.rs | 4 +- .../src/commands/team_snapshot/tests.rs | 28 + desktop/src-tauri/src/egress_guard.rs | 58 + desktop/src-tauri/src/egress_guard_tests.rs | 446 ++++++ desktop/src-tauri/src/huddle/pipeline.rs | 24 +- desktop/src-tauri/src/key_backup.rs | 173 +++ desktop/src-tauri/src/key_backup_tests.rs | 155 ++ desktop/src-tauri/src/lib.rs | 6 + desktop/src-tauri/src/native_websocket.rs | 20 +- desktop/src-tauri/src/relay.rs | 2 + desktop/src-tauri/src/relay/submit.rs | 1 + .../settings/lib/encryptedBackup.test.mjs | 117 ++ .../features/settings/lib/encryptedBackup.ts | 128 ++ .../features/settings/ui/BackupTestFlow.tsx | 459 ++++++ .../settings/ui/EncryptedBackupCreator.tsx | 647 ++++++++ .../settings/ui/EncryptedBackupRow.tsx | 97 ++ .../settings/ui/ProfileSettingsCard.tsx | 2 + desktop/src/shared/api/tauriIdentity.ts | 49 + .../tests/e2e/profile-backup-settings.spec.ts | 43 + 27 files changed, 4055 insertions(+), 18 deletions(-) create mode 100644 desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt create mode 100644 desktop/src-tauri/src/commands/identity_key_backup_tests.rs create mode 100644 desktop/src-tauri/src/egress_guard.rs create mode 100644 desktop/src-tauri/src/egress_guard_tests.rs create mode 100644 desktop/src-tauri/src/key_backup.rs create mode 100644 desktop/src-tauri/src/key_backup_tests.rs create mode 100644 desktop/src/features/settings/lib/encryptedBackup.test.mjs create mode 100644 desktop/src/features/settings/lib/encryptedBackup.ts create mode 100644 desktop/src/features/settings/ui/BackupTestFlow.tsx create mode 100644 desktop/src/features/settings/ui/EncryptedBackupCreator.tsx create mode 100644 desktop/src/features/settings/ui/EncryptedBackupRow.tsx create mode 100644 desktop/tests/e2e/profile-backup-settings.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c79ef1bf9d..b86406d9b0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -119,6 +119,7 @@ export default defineConfig({ "**/nostr-bind.spec.ts", "**/mobile-pairing-qr.spec.ts", "**/profile-nsec-reveal.spec.ts", + "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index cd0fabb69f..325eb9aa67 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1045,6 +1045,7 @@ dependencies = [ "ed25519-dalek", "flate2", "futures-util", + "getrandom 0.2.17", "hex", "image", "infer", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 735a45c3b7..6f3c03c5a5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -86,7 +86,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" toml = "0.8" -nostr = { version = "0.44", features = ["nip44"] } +nostr = { version = "0.44", features = ["nip44", "nip49"] } +# OS-entropy source for backup passphrase generation (already in the tree as a +# transitive dependency; pinned here for direct use). +getrandom = "0.2" zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } diff --git a/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt new file mode 100644 index 0000000000..9ac732fe36 --- /dev/null +++ b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt @@ -0,0 +1,1296 @@ +aardvark +abandoned +abbreviate +abdomen +abhorrence +abiding +abnormal +abrasion +absorbing +abundant +abyss +academy +accountant +acetone +achiness +acid +acoustics +acquire +acrobat +actress +acuteness +aerosol +aesthetic +affidavit +afloat +afraid +aftershave +again +agency +aggressor +aghast +agitate +agnostic +agonizing +agreeing +aidless +aimlessly +ajar +alarmclock +albatross +alchemy +alfalfa +algae +aliens +alkaline +almanac +alongside +alphabet +already +also +altitude +aluminum +always +amazingly +ambulance +amendment +amiable +ammunition +amnesty +amoeba +amplifier +amuser +anagram +anchor +android +anesthesia +angelfish +animal +anklet +announcer +anonymous +answer +antelope +anxiety +anyplace +aorta +apartment +apnea +apostrophe +apple +apricot +aquamarine +arachnid +arbitrate +ardently +arena +argument +aristocrat +armchair +aromatic +arrowhead +arsonist +artichoke +asbestos +ascend +aseptic +ashamed +asinine +asleep +asocial +asparagus +astronaut +asymmetric +atlas +atmosphere +atom +atrocious +attic +atypical +auctioneer +auditorium +augmented +auspicious +automobile +auxiliary +avalanche +avenue +aviator +avocado +awareness +awhile +awkward +awning +awoke +axially +azalea +babbling +backpack +badass +bagpipe +bakery +balancing +bamboo +banana +barracuda +basket +bathrobe +bazooka +blade +blender +blimp +blouse +blurred +boatyard +bobcat +body +bogusness +bohemian +boiler +bonnet +boots +borough +bossiness +bottle +bouquet +boxlike +breath +briefcase +broom +brushes +bubblegum +buckle +buddhist +buffalo +bullfrog +bunny +busboy +buzzard +cabin +cactus +cadillac +cafeteria +cage +cahoots +cajoling +cakewalk +calculator +camera +canister +capsule +carrot +cashew +cathedral +caucasian +caviar +ceasefire +cedar +celery +cement +census +ceramics +cesspool +chalkboard +cheesecake +chimney +chlorine +chopsticks +chrome +chute +cilantro +cinnamon +circle +cityscape +civilian +clay +clergyman +clipboard +clock +clubhouse +coathanger +cobweb +coconut +codeword +coexistent +coffeecake +cognitive +cohabitate +collarbone +computer +confetti +copier +cornea +cosmetics +cotton +couch +coverless +coyote +coziness +crawfish +crewmember +crib +croissant +crumble +crystal +cubical +cucumber +cuddly +cufflink +cuisine +culprit +cup +curry +cushion +cuticle +cybernetic +cyclist +cylinder +cymbal +cynicism +cypress +cytoplasm +dachshund +daffodil +dagger +dairy +dalmatian +dandelion +dartboard +dastardly +datebook +daughter +dawn +daytime +dazzler +dealer +debris +decal +dedicate +deepness +defrost +degree +dehydrator +deliverer +democrat +dentist +deodorant +depot +deranged +desktop +detergent +device +dexterity +diamond +dibs +dictionary +diffuser +digit +dilated +dimple +dinnerware +dioxide +diploma +directory +dishcloth +ditto +dividers +dizziness +doctor +dodge +doll +dominoes +donut +doorstep +dorsal +double +downstairs +dozed +drainpipe +dresser +driftwood +droppings +drum +dryer +dubiously +duckling +duffel +dugout +dumpster +duplex +durable +dustpan +dutiful +duvet +dwarfism +dwelling +dwindling +dynamite +dyslexia +eagerness +earlobe +easel +eavesdrop +ebook +eccentric +echoless +eclipse +ecosystem +ecstasy +edged +editor +educator +eelworm +eerie +effects +eggnog +egomaniac +ejection +elastic +elbow +elderly +elephant +elfishly +eliminator +elk +elliptical +elongated +elsewhere +elusive +elves +emancipate +embroidery +emcee +emerald +emission +emoticon +emperor +emulate +enactment +enchilada +endorphin +energy +enforcer +engine +enhance +enigmatic +enjoyably +enlarged +enormous +enquirer +enrollment +ensemble +entryway +enunciate +envoy +enzyme +epidemic +equipment +erasable +ergonomic +erratic +eruption +escalator +eskimo +esophagus +espresso +essay +estrogen +etching +eternal +ethics +etiquette +eucalyptus +eulogy +euphemism +euthanize +evacuation +evergreen +evidence +evolution +exam +excerpt +exerciser +exfoliate +exhale +exist +exorcist +explode +exquisite +exterior +exuberant +fabric +factory +faded +failsafe +falcon +family +fanfare +fasten +faucet +favorite +feasibly +february +federal +feedback +feigned +feline +femur +fence +ferret +festival +fettuccine +feudalist +feverish +fiberglass +fictitious +fiddle +figurine +fillet +finalist +fiscally +fixture +flashlight +fleshiness +flight +florist +flypaper +foamless +focus +foggy +folksong +fondue +footpath +fossil +fountain +fox +fragment +freeway +fridge +frosting +fruit +fryingpan +gadget +gainfully +gallstone +gamekeeper +gangway +garlic +gaslight +gathering +gauntlet +gearbox +gecko +gem +generator +geographer +gerbil +gesture +getaway +geyser +ghoulishly +gibberish +giddiness +giftshop +gigabyte +gimmick +giraffe +giveaway +gizmo +glasses +gleeful +glisten +glove +glucose +glycerin +gnarly +gnomish +goatskin +goggles +goldfish +gong +gooey +gorgeous +gosling +gothic +gourmet +governor +grape +greyhound +grill +groundhog +grumbling +guacamole +guerrilla +guitar +gullible +gumdrop +gurgling +gusto +gutless +gymnast +gynecology +gyration +habitat +hacking +haggard +haiku +halogen +hamburger +handgun +happiness +hardhat +hastily +hatchling +haughty +hazelnut +headband +hedgehog +hefty +heinously +helmet +hemoglobin +henceforth +herbs +hesitation +hexagon +hubcap +huddling +huff +hugeness +hullabaloo +human +hunter +hurricane +hushing +hyacinth +hybrid +hydrant +hygienist +hypnotist +ibuprofen +icepack +icing +iconic +identical +idiocy +idly +igloo +ignition +iguana +illuminate +imaging +imbecile +imitator +immigrant +imprint +iodine +ionosphere +ipad +iphone +iridescent +irksome +iron +irrigation +island +isotope +issueless +italicize +itemizer +itinerary +itunes +ivory +jabbering +jackrabbit +jaguar +jailhouse +jalapeno +jamboree +janitor +jarring +jasmine +jaundice +jawbreaker +jaywalker +jazz +jealous +jeep +jelly +jeopardize +jersey +jetski +jezebel +jiffy +jigsaw +jingling +jobholder +jockstrap +jogging +john +joinable +jokingly +journal +jovial +joystick +jubilant +judiciary +juggle +juice +jujitsu +jukebox +jumpiness +junkyard +juror +justifying +juvenile +kabob +kamikaze +kangaroo +karate +kayak +keepsake +kennel +kerosene +ketchup +khaki +kickstand +kilogram +kimono +kingdom +kiosk +kissing +kite +kleenex +knapsack +kneecap +knickers +koala +krypton +laboratory +ladder +lakefront +lantern +laptop +laryngitis +lasagna +latch +laundry +lavender +laxative +lazybones +lecturer +leftover +leggings +leisure +lemon +length +leopard +leprechaun +lettuce +leukemia +levers +lewdness +liability +library +licorice +lifeboat +lightbulb +likewise +lilac +limousine +lint +lioness +lipstick +liquid +listless +litter +liverwurst +lizard +llama +luau +lubricant +lucidity +ludicrous +luggage +lukewarm +lullaby +lumberjack +lunchbox +luridness +luscious +luxurious +lyrics +macaroni +maestro +magazine +mahogany +maimed +majority +makeover +malformed +mammal +mango +mapmaker +marbles +massager +matchstick +maverick +maximum +mayonnaise +moaning +mobilize +moccasin +modify +moisture +molecule +momentum +monastery +moonshine +mortuary +mosquito +motorcycle +mousetrap +movie +mower +mozzarella +muckiness +mudflow +mugshot +mule +mummy +mundane +muppet +mural +mustard +mutation +myriad +myspace +myth +nail +namesake +nanosecond +napkin +narrator +nastiness +natives +nautically +navigate +nearest +nebula +nectar +nefarious +negotiator +neither +nemesis +neoliberal +nephew +nervously +nest +netting +neuron +nevermore +nextdoor +nicotine +niece +nimbleness +nintendo +nirvana +nuclear +nugget +nuisance +nullify +numbing +nuptials +nursery +nutcracker +nylon +oasis +oat +obediently +obituary +object +obliterate +obnoxious +observer +obtain +obvious +occupation +oceanic +octopus +ocular +office +oftentimes +oiliness +ointment +older +olympics +omissible +omnivorous +oncoming +onion +onlooker +onstage +onward +onyx +oomph +opaquely +opera +opium +opossum +opponent +optical +opulently +oscillator +osmosis +ostrich +otherwise +ought +outhouse +ovation +oven +owlish +oxford +oxidize +oxygen +oyster +ozone +pacemaker +padlock +pageant +pajamas +palm +pamphlet +pantyhose +paprika +parakeet +passport +patio +pauper +pavement +payphone +pebble +peculiarly +pedometer +pegboard +pelican +penguin +peony +pepperoni +peroxide +pesticide +petroleum +pewter +pharmacy +pheasant +phonebook +phrasing +physician +plank +pledge +plotted +plug +plywood +pneumonia +podiatrist +poetic +pogo +poison +poking +policeman +poncho +popcorn +porcupine +postcard +poultry +powerboat +prairie +pretzel +princess +propeller +prune +pry +pseudo +psychopath +publisher +pucker +pueblo +pulley +pumpkin +punchbowl +puppy +purse +pushup +putt +puzzle +pyramid +python +quarters +quesadilla +quilt +quote +racoon +radish +ragweed +railroad +rampantly +rancidity +rarity +raspberry +ravishing +rearrange +rebuilt +receipt +reentry +refinery +register +rehydrate +reimburse +rejoicing +rekindle +relic +remote +renovator +reopen +reporter +request +rerun +reservoir +retriever +reunion +revolver +rewrite +rhapsody +rhetoric +rhino +rhubarb +rhyme +ribbon +riches +ridden +rigidness +rimmed +riptide +riskily +ritzy +riverboat +roamer +robe +rocket +romancer +ropelike +rotisserie +roundtable +royal +rubber +rudderless +rugby +ruined +rulebook +rummage +running +rupture +rustproof +sabotage +sacrifice +saddlebag +saffron +sainthood +saltshaker +samurai +sandworm +sapphire +sardine +sassy +satchel +sauna +savage +saxophone +scarf +scenario +schoolbook +scientist +scooter +scrapbook +sculpture +scythe +secretary +sedative +segregator +seismology +selected +semicolon +senator +septum +sequence +serpent +sesame +settler +severely +shack +shelf +shirt +shovel +shrimp +shuttle +shyness +siamese +sibling +siesta +silicon +simmering +singles +sisterhood +sitcom +sixfold +sizable +skateboard +skeleton +skies +skulk +skylight +slapping +sled +slingshot +sloth +slumbering +smartphone +smelliness +smitten +smokestack +smudge +snapshot +sneezing +sniff +snowsuit +snugness +speakers +sphinx +spider +splashing +sponge +sprout +spur +spyglass +squirrel +statue +steamboat +stingray +stopwatch +strawberry +student +stylus +suave +subway +suction +suds +suffocate +sugar +suitcase +sulphur +superstore +surfer +sushi +swan +sweatshirt +swimwear +sword +sycamore +syllable +symphony +synagogue +syringes +systemize +tablespoon +taco +tadpole +taekwondo +tagalong +takeout +tallness +tamale +tanned +tapestry +tarantula +tastebud +tattoo +tavern +thaw +theater +thimble +thorn +throat +thumb +thwarting +tiara +tidbit +tiebreaker +tiger +timid +tinsel +tiptoeing +tirade +tissue +tractor +tree +tripod +trousers +trucks +tryout +tubeless +tuesday +tugboat +tulip +tumbleweed +tupperware +turtle +tusk +tutorial +tuxedo +tweezers +twins +tyrannical +ultrasound +umbrella +umpire +unarmored +unbuttoned +uncle +underwear +unevenness +unflavored +ungloved +unhinge +unicycle +unjustly +unknown +unlocking +unmarked +unnoticed +unopened +unpaved +unquenched +unroll +unscrewing +untied +unusual +unveiled +unwrinkled +unyielding +unzip +upbeat +upcountry +update +upfront +upgrade +upholstery +upkeep +upload +uppercut +upright +upstairs +uptown +upwind +uranium +urban +urchin +urethane +urgent +urologist +username +usher +utensil +utility +utmost +utopia +utterance +vacuum +vagrancy +valuables +vanquished +vaporizer +varied +vaseline +vegetable +vehicle +velcro +vendor +vertebrae +vestibule +veteran +vexingly +vicinity +videogame +viewfinder +vigilante +village +vinegar +violin +viperfish +virus +visor +vitamins +vivacious +vixen +vocalist +vogue +voicemail +volleyball +voucher +voyage +vulnerable +waffle +wagon +wakeup +walrus +wanderer +wasp +water +waving +wheat +whisper +wholesaler +wick +widow +wielder +wifeless +wikipedia +wildcat +windmill +wipeout +wired +wishbone +wizardry +wobbliness +wolverine +womb +woolworker +workbasket +wound +wrangle +wreckage +wristwatch +wrongdoing +xerox +xylophone +yacht +yahoo +yard +yearbook +yesterday +yiddish +yield +yo-yo +yodel +yogurt +yuppie +zealot +zebra +zeppelin +zestfully +zigzagged +zillion +zipping +zirconium +zodiac +zombie +zookeeper +zucchini diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index 806f58d739..ded14679c1 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -1,16 +1,14 @@ use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; -/// Show a save-file dialog with a custom filter and write `data` to the chosen -/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the -/// user cancelled the dialog. -pub async fn save_bytes_with_dialog( +/// Show a save-file dialog with a custom filter and return the chosen path, +/// or `None` when the user cancelled. Selection only — no write. +pub async fn pick_save_path( app: &AppHandle, suggested_filename: &str, filter_name: &str, extensions: &[&str], - data: &[u8], -) -> Result { +) -> Result, String> { let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() @@ -23,12 +21,34 @@ pub async fn save_bytes_with_dialog( let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; let file_path = match selected { Some(p) => p, - None => return Ok(false), + None => return Ok(None), }; let dest = file_path .as_path() .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; + Ok(Some(dest.to_path_buf())) +} + +/// Show a save-file dialog with a custom filter and write `data` to the chosen +/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the +/// user cancelled the dialog. +/// +/// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no +/// 0o600). Secret exports go through `pick_save_path` + +/// `key_backup::write_backup_file`. +pub async fn save_bytes_with_dialog( + app: &AppHandle, + suggested_filename: &str, + filter_name: &str, + extensions: &[&str], + data: &[u8], +) -> Result { + let dest = match pick_save_path(app, suggested_filename, filter_name, extensions).await? { + Some(p) => p, + None => return Ok(false), + }; + std::fs::write(dest, data).map_err(|e| format!("Failed to write file: {e}"))?; Ok(true) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2840c0ade6..142e3bac88 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -194,6 +194,143 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } +/// 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( + words: Option, + separator: Option, +) -> Result { + 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 +/// bare `AppState` + temp dir (and a fast scrypt tier) without an `AppHandle`. +pub(crate) fn create_backup_with_log_n( + state: &AppState, + password: &str, + log_n: u8, +) -> Result { + if password.chars().count() < crate::key_backup::MIN_PASSPHRASE_LEN { + return Err(format!( + "passphrase must be at least {} characters", + crate::key_backup::MIN_PASSPHRASE_LEN + )); + } + + // Serialize against import_identity/persist_current_identity: the blob + // must be derived from — and persisted for — one stable identity. Also + // caps KDF concurrency at one. + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + + // Recovery mode (lost/locked) → Err, same gate as signing. + let keys = state.signing_keys()?; + + crate::key_backup::create_backup_blob(&keys, password, log_n) +} + +/// Create a NIP-49 backup of the live identity in memory. +/// +/// Encrypts under `password`, decrypt-verifies the fresh blob against the live +/// pubkey, and returns the `ncryptsec1…` string for the native save flow. The +/// body runs under `identity_mutation`, so identity changes cannot race the KDF. +#[tauri::command] +pub async fn create_ncryptsec_backup( + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + create_backup_with_log_n(&state, &password, crate::key_backup::BACKUP_LOG_N) + }) + .await + .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 { + 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 { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + 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 +/// selection-only; the write uses secret-file semantics (atomic + 0o600). +/// Never mutates canonical app state. Returns the chosen path, or `None` when +/// the user cancelled. +#[tauri::command] +pub async fn save_ncryptsec_copy( + ncryptsec: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + // Reject anything that is not a valid encrypted-key blob — this command + // must not become a generic file writer. + crate::key_backup::parse_ncryptsec(&ncryptsec)?; + let normalized = ncryptsec.trim().to_string(); + + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::key_backup::BACKUP_FILE_NAME, + "Password-protected key backup", + &["ncryptsec"], + ) + .await? + { + Some(p) => p, + None => return Ok(None), + }; + + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + crate::key_backup::write_backup_file(&dest_for_write, &normalized) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, @@ -589,3 +726,7 @@ mod nostr_identity_binding_tests { assert_eq!(error, "expires_at is expired"); } } + +#[cfg(test)] +#[path = "identity_key_backup_tests.rs"] +mod identity_key_backup_tests; diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs new file mode 100644 index 0000000000..333d8c365c --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -0,0 +1,103 @@ +use super::{create_backup_with_log_n, verify_ncryptsec_backup_inner}; +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 verification_returns_only_public_identity_and_match_status() { + let state = build_app_state(); + let backup = create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!( + result.pubkey, + state.keys.lock().unwrap().public_key().to_hex() + ); + assert!(result.npub.starts_with("npub1")); + assert!(result.matches_current_identity); +} + +#[test] +fn verification_reports_valid_backup_for_a_different_identity() { + let state = build_app_state(); + let other = Keys::generate(); + let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!(result.pubkey, other.public_key().to_hex()); + assert!(!result.matches_current_identity); +} + +#[test] +fn verification_rejects_wrong_password() { + let state = build_app_state(); + let backup = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + assert_eq!( + verify_ncryptsec_backup_inner(&state, &backup, "wrong password").unwrap_err(), + "wrong backup password or damaged key backup" + ); +} + +#[test] +fn rejects_short_passphrase() { + let state = build_app_state(); + let err = create_backup_with_log_n(&state, "short", FAST_LOG_N).unwrap_err(); + assert!(err.contains("at least"), "{err}"); +} + +#[test] +fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_backup_with_log_n(&state, 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_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); +} + +/// Concurrent identity changes serialize with backup creation. +#[test] +fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + 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_backup_with_log_n(&state, 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" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d23efe7730..eccf8ee601 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -685,7 +685,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -695,6 +695,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "persona snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. @@ -739,6 +741,35 @@ async fn submit_engram_event( Ok(()) } +// ── NIP-49 egress guard: boundary 7 (persona snapshot engram submit) ───────── + +#[cfg(test)] +mod egress_guard_tests { + use super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} + #[cfg(test)] mod import_avatar_tests { use super::materialize_import_avatar; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 91a0126f58..97cd11933d 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -895,7 +895,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. Mirrors the same helper in `snapshot::import`. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -905,6 +905,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 0616411307..c9a6d8812a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -733,3 +733,31 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +// ── NIP-49 egress guard: boundary 6 (team snapshot engram submit) ──────────── + +mod egress_guard_boundary { + use super::super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs new file mode 100644 index 0000000000..db58ddafa0 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard.rs @@ -0,0 +1,58 @@ +//! Relay egress guard for NIP-49 key-backup material. +//! +//! The local `ncryptsec` backup (see [`crate::key_backup`]) must NEVER be +//! transmitted to a relay. This module enforces that contract at runtime, +//! fail-closed, at every relay-bound egress boundary: +//! +//! | # | Boundary | Site | +//! |---|----------|------| +//! | 1 | `submit_signed_event_at_with_keys` (funnel for `submit_event*`) | `relay/submit.rs` | +//! | 2 | `sync_managed_agent_profile` | `relay.rs` | +//! | 3 | pre-signed path into the boundary-1 funnel | `relay/submit.rs` | +//! | 4 | `submit_signed_event_with_keys` | `relay.rs` | +//! | 5 | huddle STT publisher | `huddle/pipeline.rs` | +//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | +//! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | +//! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! +//! The inventory-completeness test in `egress_guard_tests.rs` asserts that +//! every `/events` URL-construction site in the tree calls this guard, so a +//! new submission path fails the build until it is wired. +//! +//! Scope: `ncryptsec1` only. The raw `nsec` intentionally transits the +//! NIP-44-encrypted pairing session (NIP-AB payload_type "nsec"); guarding it +//! here would break pairing. Raw-key DLP is separate policy work. + +/// Bech32 HRP of NIP-49 encrypted secret keys. +const NCRYPTSEC_PREFIX: &str = "ncryptsec1"; +/// Bech32 also permits an ALL-UPPERCASE encoding of the same payload +/// (BIP-173); an uppercased valid backup decodes identically, so the guard +/// must reject it too. Mixed case is invalid bech32 and cannot decode — a +/// substring matching either all-lower or all-upper prefix covers every +/// decodable form. +const NCRYPTSEC_PREFIX_UPPER: &str = "NCRYPTSEC1"; + +/// Reject `text` if it contains NIP-49 key-backup material. +/// +/// Returns `Err` when an `ncryptsec1…` (or uppercase `NCRYPTSEC1…`) +/// substring is present. Callers MUST abort the network operation on `Err` — +/// this is a fail-closed guard, not a warning. +pub fn assert_no_key_backup(text: &str, context: &'static str) -> Result<(), String> { + if text.contains(NCRYPTSEC_PREFIX) || text.contains(NCRYPTSEC_PREFIX_UPPER) { + return Err(format!( + "blocked {context}: payload contains NIP-49 key-backup material \ + (ncryptsec); the local key backup must never be transmitted to a relay" + )); + } + Ok(()) +} + +/// Byte-slice variant for callers that hold serialized bodies. +pub fn assert_no_key_backup_bytes(body: &[u8], context: &'static str) -> Result<(), String> { + // ncryptsec is ASCII bech32; a UTF-8-lossy view preserves any occurrence. + assert_no_key_backup(&String::from_utf8_lossy(body), context) +} + +#[cfg(test)] +#[path = "egress_guard_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs new file mode 100644 index 0000000000..f487c8ce16 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -0,0 +1,446 @@ +use super::*; + +/// NIP-49 spec vector — a real ncryptsec blob for injection payloads. +const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +fn assert_guard_error(err: &str) { + assert!( + err.contains("key-backup material"), + "expected the egress-guard error, got: {err}" + ); +} + +// ── Guard unit behavior ─────────────────────────────────────────────────────── + +#[test] +fn rejects_ncryptsec_anywhere_in_text() { + assert_guard_error(&assert_no_key_backup(NCRYPTSEC, "test").unwrap_err()); + assert_guard_error( + &assert_no_key_backup( + &format!("{{\"content\":\"my backup: {NCRYPTSEC}\"}}"), + "test", + ) + .unwrap_err(), + ); +} + +/// Bech32 permits an all-uppercase encoding of the same payload — an +/// uppercased valid backup must not bypass the guard (text and bytes). +/// Mixed case is invalid bech32 (cannot decode) and is deliberately not +/// blocked. +#[test] +fn rejects_uppercase_ncryptsec() { + let upper = NCRYPTSEC.to_ascii_uppercase(); + assert_guard_error(&assert_no_key_backup(&upper, "test").unwrap_err()); + assert_guard_error(&assert_no_key_backup_bytes(upper.as_bytes(), "test").unwrap_err()); + // Mixed case cannot decode; not blocked. + assert!(assert_no_key_backup("nCrYpTsEc1qgg9947r", "test").is_ok()); +} + +#[test] +fn passes_clean_payloads_including_raw_nsec() { + assert!(assert_no_key_backup("hello world", "test").is_ok()); + assert!(assert_no_key_backup("", "test").is_ok()); + // Scope is ncryptsec1 ONLY: raw nsec intentionally transits the encrypted + // pairing session and must NOT be blocked (plan D4 / pairing.rs). + let nsec = nostr::ToBech32::to_bech32(nostr::Keys::generate().secret_key()).unwrap(); + assert!(assert_no_key_backup(&nsec, "test").is_ok()); + // Near-miss prefixes are not blocked. + assert!(assert_no_key_backup("ncryptsec", "test").is_ok()); +} + +#[test] +fn byte_variant_matches_text_variant() { + assert_guard_error(&assert_no_key_backup_bytes(NCRYPTSEC.as_bytes(), "test").unwrap_err()); + assert!(assert_no_key_backup_bytes(b"clean body", "test").is_ok()); + // Invalid UTF-8 around an intact ncryptsec substring must still trip the + // guard (from_utf8_lossy preserves the ASCII run). + let mut body = vec![0xff, 0xfe]; + body.extend_from_slice(NCRYPTSEC.as_bytes()); + body.push(0xff); + assert_guard_error(&assert_no_key_backup_bytes(&body, "test").unwrap_err()); +} + +#[test] +fn error_names_the_boundary_context() { + let err = assert_no_key_backup(NCRYPTSEC, "huddle STT publish").unwrap_err(); + assert!(err.contains("huddle STT publish"), "{err}"); +} + +// ── Runtime injection per boundary ──────────────────────────────────────────── +// +// Each test drives the real production function with an ncryptsec-bearing +// payload and asserts the guard aborts the operation before any network I/O +// (no listener exists at the target address; a distinctive guard error — not +// a connection error — proves the abort happened first). +// +// Boundaries 6 and 7 (`submit_engram_event` twins) are module-private inside +// `commands`; their injection tests live next to them: +// - commands/team_snapshot/tests.rs::egress_guard_boundary +// - commands/personas/snapshot/import.rs::egress_guard_tests + +/// Boundary 1: `relay/submit.rs` `submit_event_at_with_keys` (the funnel for +/// all `submit_event*` variants). +#[tokio::test] +async fn boundary_submit_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let builder = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC); + let err = crate::relay::submit_event_at_with_keys( + builder, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 2: `relay.rs` `sync_managed_agent_profile` (agent kind:0 profile). +#[tokio::test] +async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let err = crate::relay::sync_managed_agent_profile( + &state, + "ws://127.0.0.1:9", + &keys, + &format!("agent {NCRYPTSEC}"), + None, + None, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 3: `relay/submit.rs` `submit_signed_event_at_with_keys` — the +/// pre-signed entry into the boundary-1 funnel (main's submit refactor +/// replaced `relay.rs` `submit_signed_event` with this scoped form). +#[tokio::test] +async fn boundary_submit_signed_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_at_with_keys( + &event, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 4: `relay.rs` `submit_signed_event_with_keys`. +#[tokio::test] +async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string()); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_with_keys(&event, &state, &keys, None) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`). +#[test] +fn boundary_huddle_stt_blocks_ncryptsec() { + let keys = nostr::Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let builder = + crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap(); + let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); + assert_guard_error(&err); + + // Clean transcripts pass through the same seam. + let builder = + crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap(); + assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); +} + +/// Boundary 8: native websocket send loop — the single choke point for all +/// webview-originated relay websocket frames. +#[tokio::test] +async fn boundary_native_websocket_blocks_ncryptsec() { + let manager = crate::native_websocket::WebSocketManager::default(); + // Text frame: guard fires before the connection lookup, so no connection + // is needed — and the error must be the guard's, not "not found". + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text(format!( + "[\"EVENT\",{{\"content\":\"{NCRYPTSEC}\"}}]" + )), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Binary frame variant. + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Binary(NCRYPTSEC.as_bytes().to_vec()), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Clean frames fall through to normal handling ("connection not found" + // here — the guard did not reject them). + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text("[\"REQ\",\"sub\",{}]".to_string()), + ) + .await + .unwrap_err(); + assert!(err.contains("not found"), "{err}"); +} + +// ── Structural tripwires ────────────────────────────────────────────────────── + +fn src_rust_files() -> Vec { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(path); + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + walk(&root, &mut out); + out +} + +/// Site-granular `/events` inventory: `(file suffix, expected non-comment +/// `/events` occurrences, expected guard call sites — full-path calls into +/// the egress-guard module)`. +/// +/// Every entry pairs the URL-construction count with the guard-call count for +/// that file, so BOTH of these fail the scan (not just a brand-new file): +/// - adding an unguarded ninth `/events` site inside an already-listed file +/// (count goes up without a matching table update), and +/// - removing/refactoring away a guard call while its egress site remains. +/// +/// Updating a row here is the deliberate act that must accompany wiring the +/// guard + adding an injection test for the new site. +const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ + // Production egress boundaries (see egress_guard.rs table): + ("src/relay.rs", 2, 2), // boundaries 2, 4 + ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) + ("src/huddle/pipeline.rs", 1, 1), // boundary 5 + ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 + ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL + ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + // Test-only fixtures — no production egress, no guard: + ("src/relay_admission.rs", 1, 0), + ("src/archive/mod_tests.rs", 1, 0), + ("src/managed_agents/persona_events/tests.rs", 1, 0), + ("src/commands/team_snapshot/tests.rs", 1, 0), + // Mock-relay route in its in-file tests; production publish goes through + // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). + ("src/commands/personas/sharing.rs", 1, 0), +]; + +// Needles are assembled at runtime so this scan file itself contains no +// contiguous match and needs no self-referential inventory row. +fn events_needle() -> String { + ["/ev", "ents"].concat() +} +fn guard_needle() -> String { + ["egress_guard::", "assert_no_key_backup"].concat() +} + +/// Pure scan core over `(relative path, content)` pairs. Returns violations; +/// empty means every file matches its inventory row exactly (files absent +/// from the table are expected to have zero `/events` sites and zero guard +/// calls). +fn events_inventory_violations(files: &[(String, String)]) -> Vec { + let events = events_needle(); + let guard = guard_needle(); + let mut violations = Vec::new(); + + for (rel, content) in files { + let expected = EVENTS_INVENTORY + .iter() + .find(|(suffix, _, _)| rel.ends_with(suffix)) + .map(|&(_, e, g)| (e, g)) + .unwrap_or((0, 0)); + + let mut event_sites = Vec::new(); + for (i, line) in content.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; // doc/comment mentions + } + if line.contains(&events) { + event_sites.push(format!(" {rel}:{}: {}", i + 1, line.trim())); + } + } + let guard_count = content.matches(&guard).count(); + + if (event_sites.len(), guard_count) != expected { + violations.push(format!( + "{rel}: found {} events-URL site(s) + {} guard call(s), inventory \ + expects {} + {}. Sites found:\n{}", + event_sites.len(), + guard_count, + expected.0, + expected.1, + if event_sites.is_empty() { + " (none)".to_string() + } else { + event_sites.join("\n") + }, + )); + } + } + violations +} + +fn read_src_files() -> Vec<(String, String)> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + src_rust_files() + .into_iter() + .map(|path| { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let content = std::fs::read_to_string(&path).unwrap(); + (rel, content) + }) + .collect() +} + +/// Inventory completeness: every `/events` URL-construction site in +/// `desktop/src-tauri/src` must match the site-granular inventory above. A +/// future ninth submission path — in a NEW file or an ALREADY-LISTED one — +/// fails this test until its guard is wired, its injection test exists, and +/// its inventory row is updated. +#[test] +fn events_url_inventory_is_fully_guarded() { + let violations = events_inventory_violations(&read_src_files()); + assert!( + violations.is_empty(), + "events-URL egress inventory drift — wire crate::egress_guard, add an \ + injection test, then update EVENTS_INVENTORY:\n{}", + violations.join("\n") + ); +} + +/// Mutation-style proof of the tripwire's guarantee: an unguarded ninth +/// `/events` site added to an already-inventoried file (relay.rs) is caught. +#[test] +fn inventory_scan_catches_new_site_in_allowlisted_file() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1.push_str(&format!( + "\nfn sneaky_ninth_site(base: &str) -> String {{ format!(\"{{base}}{}\") }}\n", + events_needle() + )); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "an unguarded ninth events-URL site in relay.rs must trip the scan: {violations:?}" + ); +} + +/// The pairing also fires in reverse: a guard call deleted while its egress +/// site remains is caught. +#[test] +fn inventory_scan_catches_removed_guard_call() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1 = relay.1.replacen(&guard_needle(), "removed_guard", 1); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "a removed guard call in relay.rs must trip the scan: {violations:?}" + ); +} + +/// A brand-new file with an `/events` site (no inventory row) is caught. +#[test] +fn inventory_scan_catches_new_unlisted_file() { + let mut files = read_src_files(); + files.push(( + "src/brand_new_egress.rs".to_string(), + format!("let url = format!(\"{{}}{}\", base);", events_needle()), + )); + let violations = events_inventory_violations(&files); + assert!( + violations + .iter() + .any(|v| v.contains("src/brand_new_egress.rs")), + "{violations:?}" + ); +} + +/// Source allowlist: NIP-49 material handling is confined to the identity / +/// backup / import / guard files. Anything else touching ncryptsec or the +/// nip49 codec is structural drift. +#[test] +fn ncryptsec_handling_is_confined_to_allowlisted_files() { + let allowlist: &[&str] = &[ + "src/key_backup.rs", + "src/key_backup_tests.rs", + "src/egress_guard.rs", + "src/egress_guard_tests.rs", + "src/commands/identity.rs", + "src/commands/identity_key_backup_tests.rs", + "src/lib.rs", // module registration + invoke handler + // boundary wiring (guard call sites name the module, not the codec): + "src/relay.rs", + "src/relay/submit.rs", + "src/huddle/pipeline.rs", + "src/commands/team_snapshot.rs", + "src/commands/team_snapshot/tests.rs", + "src/commands/personas/snapshot/import.rs", + "src/native_websocket.rs", + ]; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut violations = Vec::new(); + for path in src_rust_files() { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if allowlist.iter().any(|a| rel.ends_with(a)) { + continue; + } + let content = std::fs::read_to_string(&path).unwrap(); + for needle in ["ncryptsec", "EncryptedSecretKey", "nip49"] { + if content.contains(needle) { + violations.push(format!("{rel}: contains {needle:?}")); + } + } + } + assert!( + violations.is_empty(), + "NIP-49 material outside allowlisted files:\n{}", + violations.join("\n") + ); +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6..6a4cf26201 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -251,6 +251,23 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("sign event: {e}"))?; + let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "huddle STT publish")?; + Ok(body_bytes) +} + /// Spawn a tokio task that reads text_rx and posts kind:9 events. /// /// Fix 1: `agent_pubkeys_arc` is an `Arc>>` cloned from @@ -310,14 +327,13 @@ pub(crate) fn spawn_transcription_task( // the kind event and build NIP-98 auth after the wait so both // timestamps are fresh — single clean order: wait → sign → auth → send. crate::relay_admission::wait_for_rate_limit().await; - let event = match builder.sign_with_keys(&keys) { - Ok(e) => e, + let body_bytes = match sign_and_guard_stt_body(builder, &keys) { + Ok(b) => b, Err(e) => { - eprintln!("buzz-desktop: STT sign event: {e}"); + eprintln!("buzz-desktop: STT publish: {e}"); continue; } }; - let body_bytes = event.as_json().into_bytes(); let url = format!("{relay_base_url}/events"); let auth_header = match crate::relay::build_nip98_auth_header_for_keys( &keys, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs new file mode 100644 index 0000000000..c705b825a2 --- /dev/null +++ b/desktop/src-tauri/src/key_backup.rs @@ -0,0 +1,173 @@ +//! NIP-49 encrypted local key backup. +//! +//! Creates a password-encrypted `ncryptsec` backup of the user's identity key +//! for a user-selected local file. The blob is **local-only by contract**: it must +//! never be transmitted to a relay on any path. That contract is enforced at +//! runtime by [`crate::egress_guard`] (wired into every relay event-body +//! constructor and the native websocket send loop) and structurally by the +//! source-allowlist scan in this module's tests. +//! +//! Creation decrypt-verifies the fresh blob against the live identity before +//! returning it. Portable copies use atomic, owner-only file writes. + +use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; +use nostr::{FromBech32, Keys, ToBech32}; + +/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). +/// The blob self-describes its cost, so this can be raised later without +/// breaking existing backups. +pub const BACKUP_LOG_N: u8 = 18; + +/// Filename of the app-managed canonical backup inside the app data dir. +pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec"; + +/// Default number of words in a generated backup passphrase. Three words +/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor. +pub const DEFAULT_PASSPHRASE_WORDS: usize = 3; + +/// Bounds for the generator's word-count control. At the lower bound a draw +/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so +/// [`generate_passphrase`] re-draws until the phrase meets the minimum. +pub const MIN_PASSPHRASE_WORDS: usize = 3; +pub const MAX_PASSPHRASE_WORDS: usize = 10; + +/// EFF short wordlist 2.0 (1296 words, one per line). +const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt"); + +/// Minimum length for a user-chosen passphrase. +pub const MIN_PASSPHRASE_LEN: usize = 12; + +/// Encrypt the identity secret key under `password` and verify the result. +/// +/// Returns the bech32 `ncryptsec1…` string. The fresh blob is decrypted and +/// its derived pubkey compared to the live identity **before** returning, so +/// a returned blob is always provably recoverable with the same password. +pub fn create_backup_blob(keys: &Keys, password: &str, log_n: u8) -> Result { + let secret_key = keys.secret_key(); + + let encrypted = EncryptedSecretKey::new(secret_key, password, log_n, KeySecurity::Unknown) + .map_err(|e| format!("encrypt key backup: {e}"))?; + + let ncryptsec = encrypted + .to_bech32() + .map_err(|e| format!("encode ncryptsec: {e}"))?; + + // Integrity check: decrypt the fresh blob and confirm it recovers the + // exact live identity. A corrupted or mis-encrypted blob must never be + // shown to the user as a "backup". This is the second, deliberate KDF + // invocation of the one-artifact-per-action contract. + verify_backup_blob(&ncryptsec, password, &keys.public_key())?; + + Ok(ncryptsec) +} + +/// Decrypt `ncryptsec` with `password` and assert it recovers a key whose +/// public key equals `expected_pubkey`. +pub fn verify_backup_blob( + ncryptsec: &str, + password: &str, + expected_pubkey: &nostr::PublicKey, +) -> Result<(), String> { + let encrypted = parse_ncryptsec(ncryptsec)?; + let recovered = encrypted + .decrypt(password) + .map_err(|e| format!("verify key backup (decrypt): {e}"))?; + let recovered_keys = Keys::new(recovered); + if recovered_keys.public_key() != *expected_pubkey { + return Err("verify key backup: decrypted key does not match identity".to_string()); + } + Ok(()) +} + +/// Parse a bech32 `ncryptsec1…` string, rejecting anything that is not a +/// structurally valid NIP-49 payload. +pub fn parse_ncryptsec(input: &str) -> Result { + EncryptedSecretKey::from_bech32(input.trim()).map_err(|e| format!("invalid ncryptsec: {e}")) +} + +/// Decrypt an `ncryptsec1…` string with `password` into identity keys. +pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { + let encrypted = parse_ncryptsec(input)?; + let secret_key = encrypted + .decrypt(password) + .map_err(|_| "wrong backup password or damaged key backup".to_string())?; + Ok(Keys::new(secret_key)) +} + +/// Atomically write `ncryptsec` to `path` with owner-only permissions, then +/// reread and byte-compare. Same crash-safety pattern as +/// `app_state::save_key_file`. +pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use atomic_write_file::AtomicWriteFile; + use std::io::Write; + + let mut file = AtomicWriteFile::open(path) + .map_err(|e| format!("open backup file for atomic write: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("set backup file permissions: {e}"))?; + } + + file.write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write backup file: {e}"))?; + file.commit() + .map_err(|e| format!("commit backup file: {e}"))?; + + // Reread and byte-compare: only report success for bytes that are + // actually on disk. + let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?; + if on_disk != ncryptsec { + return Err("backup file verification failed: on-disk bytes differ".to_string()); + } + + Ok(()) +} + +/// Generate a passphrase of `word_count` EFF short-wordlist words joined by +/// `separator`, using OS entropy. +/// +/// `word_count` is clamped to `MIN_PASSPHRASE_WORDS..=MAX_PASSPHRASE_WORDS`. +/// Because a low-word-count draw can land under [`MIN_PASSPHRASE_LEN`] +/// (e.g. three 3-char words), whole phrases below the minimum are rejected +/// and re-drawn — the result always passes the same length gate applied to +/// user-chosen passphrases. Uses rejection sampling for a uniform +/// distribution over the 1296 words. +pub fn generate_passphrase(word_count: usize, separator: &str) -> Result { + let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS); + let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + if words.len() != 1296 { + return Err(format!( + "wordlist corrupted: expected 1296 words, found {}", + words.len() + )); + } + + // At 3 words the under-length probability per draw is small, so a few + // attempts always suffice; the cap only guards against a logic bug + // becoming an infinite loop. + for _ in 0..128 { + let mut chosen: Vec<&str> = Vec::with_capacity(word_count); + while chosen.len() < word_count { + let mut buf = [0u8; 2]; + getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?; + let value = u16::from_le_bytes(buf); + // Rejection sampling: accept only values below the largest + // multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800). + if value < 64800 { + chosen.push(words[(value as usize) % 1296]); + } + } + let phrase = chosen.join(separator); + if phrase.chars().count() >= MIN_PASSPHRASE_LEN { + return Ok(phrase); + } + } + Err("could not generate a passphrase meeting the minimum length".to_string()) +} + +#[cfg(test)] +#[path = "key_backup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs new file mode 100644 index 0000000000..e5892ad99e --- /dev/null +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -0,0 +1,155 @@ +use super::*; + +/// NIP-49 spec vector (same as rust-nostr's upstream test): decrypts with +/// password "nostr" at our call sites. +const SPEC_NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; +const SPEC_SECRET_HEX: &str = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683"; + +/// Fast scrypt tier for tests. log_n 18 is exercised once in +/// `round_trip_at_production_cost`. +const FAST_LOG_N: u8 = 16; + +// ── Codec ───────────────────────────────────────────────────────────────────── + +#[test] +fn spec_vector_decrypts_at_our_call_site() { + let keys = decrypt_ncryptsec(SPEC_NCRYPTSEC, "nostr").unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn round_trip_fast_tier() { + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "correct horse battery", FAST_LOG_N).unwrap(); + assert!(blob.starts_with("ncryptsec1")); + let recovered = decrypt_ncryptsec(&blob, "correct horse battery").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn round_trip_at_production_cost() { + // One log_n 18 round trip: proves the production constant works end to + // end (slow — several seconds — but deliberate; see plan D5). + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "production cost tier check", BACKUP_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, "production cost tier check").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +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 backup password or damaged key backup"); +} + +#[test] +fn nfkc_cross_form_passphrase_round_trips() { + // "é" composed (U+00E9) vs decomposed (e + U+0301): NIP-49 mandates NFKC + // normalization, so a passphrase entered in either form must decrypt. + let keys = Keys::generate(); + let composed = "caf\u{00e9} passphrase"; + let decomposed = "cafe\u{0301} passphrase"; + assert_ne!(composed, decomposed); + let blob = create_backup_blob(&keys, composed, FAST_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, decomposed).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn parse_rejects_garbage_and_wrong_hrp() { + assert!(parse_ncryptsec("garbage").is_err()); + assert!(parse_ncryptsec("").is_err()); + // Valid bech32, wrong HRP (an nsec is not an encrypted backup). + let nsec = Keys::generate().secret_key().to_bech32().unwrap(); + assert!(parse_ncryptsec(&nsec).is_err()); + // Truncated blob. + assert!(parse_ncryptsec(&SPEC_NCRYPTSEC[..SPEC_NCRYPTSEC.len() - 10]).is_err()); +} + +#[test] +fn verify_backup_blob_catches_pubkey_mismatch() { + // Corrupted-blob simulation: the blob decrypts fine but recovers a key + // that is not the live identity — verification must fail. + let other = Keys::generate(); + let blob = create_backup_blob(&other, "some password", FAST_LOG_N).unwrap(); + let live = Keys::generate(); + let err = verify_backup_blob(&blob, "some password", &live.public_key()).unwrap_err(); + assert!(err.contains("does not match identity"), "{err}"); +} + +// ── File lifecycle ──────────────────────────────────────────────────────────── + +#[test] +fn write_backup_file_persists_0600_and_verifies() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!(on_disk, SPEC_NCRYPTSEC); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "backup file must be owner-only"); + } +} + +#[test] +fn write_backup_file_overwrites_atomically() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, "ncryptsec1old").unwrap(); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + // No leftover temp files from the atomic write. + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); +} + +#[test] +fn generated_passphrase_respects_word_count_and_separator() { + let words: std::collections::HashSet<&str> = + WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words"); + + for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] { + let phrase = generate_passphrase(count, separator).unwrap(); + if separator.is_empty() { + // No separator to split on; length gate below still applies. + } else { + let parts: Vec<&str> = phrase.split(separator).collect(); + assert_eq!(parts.len(), count); + for w in &parts { + assert!(words.contains(w), "unknown word {w:?}"); + } + } + assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN); + } +} + +#[test] +fn generated_passphrase_clamps_word_count() { + // Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter. + let phrase = generate_passphrase(1, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS); + // Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS. + let phrase = generate_passphrase(50, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS); +} + +#[test] +fn generated_passphrases_are_not_repeated() { + // 3 words × ~10.3 bits each — a collision across 8 draws would indicate a + // broken entropy source, not bad luck. + let mut seen = std::collections::HashSet::new(); + for _ in 0..8 { + assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap())); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c005d511e6..7dcc5994ae 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,9 +4,11 @@ mod archive; mod builderlab; mod commands; mod deep_link; +mod egress_guard; mod event_sync; mod events; mod huddle; +mod key_backup; mod linux_media; mod managed_agents; mod media_proxy; @@ -669,6 +671,10 @@ pub fn run() { title_bar_double_click, get_identity, get_nsec, + generate_backup_passphrase, + create_ncryptsec_backup, + verify_ncryptsec_backup, + save_ncryptsec_copy, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index c0cf2e76f1..128f2df79d 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -24,7 +24,7 @@ type Id = u32; #[derive(Debug, Deserialize)] #[serde(tag = "type", content = "data")] -enum WebSocketMessage { +pub(crate) enum WebSocketMessage { Text(String), Binary(Vec), Ping(Vec), @@ -33,7 +33,7 @@ enum WebSocketMessage { } #[derive(Debug, Deserialize)] -struct CloseFramePayload { +pub(crate) struct CloseFramePayload { code: u16, reason: String, } @@ -82,7 +82,7 @@ struct ConnectionHandle { } #[derive(Clone)] -struct WebSocketManager { +pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, } @@ -182,11 +182,23 @@ async fn connect( open_connection(manager.inner(), &url, on_message).await } -async fn send_message( +pub(crate) async fn send_message( manager: &WebSocketManager, id: Id, message: WebSocketMessage, ) -> Result<(), String> { + // Egress guard: the NIP-49 local key backup must never reach a relay. + // This is the single choke point for all webview-originated websocket + // frames (see `crate::egress_guard`). + match &message { + WebSocketMessage::Text(text) => { + crate::egress_guard::assert_no_key_backup(text, "websocket text frame")? + } + WebSocketMessage::Binary(bytes) => { + crate::egress_guard::assert_no_key_backup_bytes(bytes, "websocket binary frame")? + } + _ => {} + } let handle = manager .connections .lock() diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f896695624..71aa21c413 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -450,6 +450,7 @@ pub async fn sync_managed_agent_profile( let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; @@ -566,6 +567,7 @@ pub async fn submit_signed_event_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let mut request = state diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 2a42d86c2b..eaad29d3b1 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -25,6 +25,7 @@ pub async fn submit_signed_event_at_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let response = state diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..1d98a98541 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("success clears password and retains only encrypted blob", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.savedPassword, true); + assert.equal(state.requestId, null); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("Back preserves blob for immediate re-download without password", () => { + const made = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + { type: "back-to-password" }, + ]); + assert.equal(made.ncryptsec, "ncryptsec1abc"); + assert.equal(made.passphrase, ""); + assert.equal(downloadDisabled(made), false); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts new file mode 100644 index 0000000000..d2aad94422 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -0,0 +1,128 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "back-to-password" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: state.downloadPending ? event.ncryptsec : state.ncryptsec, + downloadPending: false, + savedPassword: true, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, downloadPending: true }; + case "back-to-password": + return { ...state, createError: null }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if (state.savedPassword || state.encrypted || state.requestId !== null) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..65ef40a98a --- /dev/null +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -0,0 +1,459 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Progress through the Settings backup-test flow. The password attempt is + * deliberately NOT part of this state — it lives only in short-lived + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + progress, + onProgressChange, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError("That doesn't look like a key backup file."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange]); + + if (stage === "success" && result) { + return ( +
+ {reduceMotion ? null : } + + + +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+
+ +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..751a841990 --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,647 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; + +import { + createNcryptsecBackup, + generateBackupPassphrase, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, + MIN_PASSPHRASE_LEN, +} from "../lib/encryptedBackup"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow for 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. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. + */ +export function EncryptedBackupCreator() { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [isRevealed, setIsRevealed] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); + const mountedRef = React.useRef(true); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + // Correlate KDF completion by an opaque request id. The password exists only + // in this short-lived effect closure and is cleared from reducer state once + // Rust returns; stale completions cannot commit. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelled) return; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }), + ) + .catch((err: unknown) => + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }), + ); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce, state.nextRequestId]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); + } + }) + .catch((err: unknown) => { + rollBack(); + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + }) + .finally(() => { + if (mountedRef.current) setIsSaving(false); + }); + }, [state.ncryptsec]); + + const handleSaveCopy = React.useCallback(async () => { + if (!state.ncryptsec || isSaving) return; + setIsSaving(true); + setSaveError(null); + try { + const path = await saveNcryptsecCopy(state.ncryptsec); + if (mountedRef.current && path) { + setSavedPath(path); + } + } catch (err) { + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, [isSaving, state.ncryptsec]); + + const issue = passphraseIssue(state.passphrase); + + // Without the guided test (settings), a completed save keeps the form + // visible in its saved-password state: masked input, instant re-download, + // and the change-password confirmation guarding any edit. + + return ( +
+
+ { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onPaste={(event) => { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onChange={(event) => + dispatch({ type: "set-passphrase", value: event.target.value }) + } + placeholder={ + state.savedPassword + ? "" + : `Password (min ${MIN_PASSPHRASE_LEN} characters)` + } + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + {state.savedPassword ? ( +
+ •••••••••••••••••••••••••••••••• +
+ ) : null} + {state.savedPassword ? ( + + Backup password saved; hidden for security. + + ) : null} + + setConfirmNewPassword(true) : undefined + } + onGenerated={(value) => { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> + {issue ? ( +

+ {issue} +

+ ) : null} +
+ + {state.savedPassword && state.ncryptsec && savedPath ? ( +
+

+ Backup saved to {savedPath} +

+

+ Your password isn't kept — download another copy anytime, or start + over to choose a new password. +

+
+ ) : null} + + {state.createError ? ( +

+ {state.createError} +

+ ) : null} + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + {(() => { + // Absolute spinner: signals the background encryption without + // shifting the centered button while it appears and disappears. + const createButton = ( +
+ {isEncrypting(state) || state.downloadPending || isSaving ? ( + + ) : null} + +
+ ); + return
{createButton}
; + })()} + + + + Create a new backup password? + + Starting over lets you pick a new password and download a fresh + backup file. Backups you saved earlier will still work — just use + the password you created them with. + + + + Keep current backup + { + dispatch({ type: "start-new-backup" }); + setSavedPath(null); + savedForRef.current = null; + setIsRevealed(false); + }} + > + Start with a new password + + + + +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupRow.tsx b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx new file mode 100644 index 0000000000..30d50e2a9b --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; + +import { + BackupTestFlow, + initialBackupTestProgress, +} from "@/features/settings/ui/BackupTestFlow"; +import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; + +/** + * Collapsible settings row shared by the two backup tools. `relative` + * anchors the backup-test drop overlay (BackupTestFlow) so a file drag takes + * over the whole row, mirroring the composer treatment. + */ +function ToolRow({ + title, + description, + action, + open, + onToggle, + children, + testId, +}: { + title: string; + description: string; + action: string; + open: boolean; + onToggle: () => void; + children: React.ReactNode; + testId: string; +}) { + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ {open ?
{children}
: null} +
+ ); +} + +/** + * Sibling settings tools for the password-protected key backup: create a new + * backup, or test any existing backup file. The raw private key never reaches + * either flow — the password goes to Rust, which returns only the encrypted + * NIP-49 blob (create) or the derived public identity (test). + */ +export function EncryptedBackupRow() { + const [createOpen, setCreateOpen] = React.useState(false); + const [testOpen, setTestOpen] = React.useState(false); + const [progress, setProgress] = React.useState(initialBackupTestProgress); + return ( + <> + setCreateOpen((open) => !open)} + open={createOpen} + testId="profile-encrypted-backup-row" + title="Create a key backup" + > + +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Creating another backup does not invalidate copies + you saved before. +

+
+ setTestOpen((open) => !open)} + open={testOpen} + testId="profile-backup-test-row" + title="Test a key backup" + > + +

+ Backups use the standard NIP-49 format, so this works for backups from + compatible Nostr apps too. +

+
+ + ); +} diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 8a1283b71c..d82100026d 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -24,6 +24,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; +import { EncryptedBackupRow } from "./EncryptedBackupRow"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SignOutSection } from "./SignOutSection"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; @@ -884,6 +885,7 @@ export function ProfileSettingsCard({ value={nip05Handle} /> + diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..e6ec266bff 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -49,3 +49,52 @@ export async function persistCurrentIdentity(): Promise { export async function signOut(): Promise { await invokeTauri("sign_out"); } + +export type GeneratePassphraseOptions = { + /** Word count; Rust clamps to its allowed range (currently 3–10). */ + words?: number; + /** Separator joined between words. Defaults to a space in Rust. */ + separator?: string; +}; + +/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase( + options?: GeneratePassphraseOptions, +): Promise { + return invokeTauri("generate_backup_passphrase", { + words: options?.words, + separator: options?.separator, + }); +} + +/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** Save a portable backup copy. Returns null when the native dialog is cancelled. */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null + ); +} + +export type BackupVerification = { + pubkey: string; + npub: string; + matchesCurrentIdentity: boolean; +}; + +/** Decrypt locally and return only the backup's public identity and match state. */ +export async function verifyNcryptsecBackup( + ncryptsec: string, + password: string, +): Promise { + return invokeTauri("verify_ncryptsec_backup", { + ncryptsec, + password, + }); +} diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts new file mode 100644 index 0000000000..ab114f7150 --- /dev/null +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +async function openIdentity(page: import("@playwright/test").Page) { + const identity = page.getByTestId("profile-identity-card"); + if ( + !(await identity.evaluate( + (element) => element instanceof HTMLDetailsElement && element.open, + )) + ) { + await page.getByTestId("profile-identity-toggle").click(); + } +} + +test("identity settings expose independent create and test backup tools", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await openSettings(page, "profile"); + await openIdentity(page); + + const createRow = page.getByTestId("profile-encrypted-backup-row"); + const testRow = page.getByTestId("profile-backup-test-row"); + await expect(createRow).toContainText("Create a key backup"); + await expect(createRow).toContainText("password-protected copy"); + await expect(testRow).toContainText("Test a key backup"); + await expect(testRow).toContainText("which identity it unlocks"); + + await createRow.getByTestId("profile-encrypted-backup-row-toggle").click(); + await expect(createRow.getByLabel("Encryption password")).toBeVisible(); + await expect(testRow.getByText("Select your backup file")).toHaveCount(0); + + await testRow.getByTestId("profile-backup-test-row-toggle").click(); + await expect(testRow.getByText("Select your backup file")).toBeVisible(); + await expect(createRow.getByLabel("Encryption password")).toBeVisible(); + + await createRow.getByTestId("profile-encrypted-backup-row-toggle").click(); + await expect(createRow.getByLabel("Encryption password")).toHaveCount(0); + await expect(testRow.getByText("Select your backup file")).toBeVisible(); +}); From c3e5bd1dadb7ecf86932f4163cc3f0ac900d3a63 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Wed, 29 Jul 2026 17:24:31 -0700 Subject: [PATCH 2/5] test(settings): cover key backup regressions Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/testing/e2eBridge.ts | 42 ++++- .../tests/e2e/profile-backup-settings.spec.ts | 160 +++++++++++++++++- desktop/tests/helpers/bridge.ts | 6 + 3 files changed, 201 insertions(+), 7 deletions(-) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 73b564429a..1e6450038a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; -import { decode } from "nostr-tools/nip19"; +import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { @@ -415,6 +415,12 @@ type E2eConfig = { * autosave behaviour while a request is in flight. 0/undefined = instant. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -7195,6 +7201,8 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +let backupVerificationCallCount = 0; +let backupSaveCallCount = 0; // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -9834,6 +9842,38 @@ export function maybeInstallE2eTauriMocks() { // harness there is nothing to wipe; resolving is enough — specs // assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI. return; + case "generate_backup_passphrase": + return "correct horse battery staple"; + case "create_ncryptsec_backup": + return "ncryptsec1mockbackupmaterial"; + case "save_ncryptsec_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/buzz-identity.ncryptsec", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } + case "verify_ncryptsec_backup": { + const errors = activeConfig?.mock?.backupVerificationErrors ?? [null]; + const index = Math.min(backupVerificationCallCount, errors.length - 1); + const error = errors[index]; + if (error) { + backupVerificationCallCount += 1; + throw new Error(error); + } + const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [ + identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ]; + const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)]; + backupVerificationCallCount += 1; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: + pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index ab114f7150..97e823ec26 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -1,9 +1,18 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; -async function openIdentity(page: import("@playwright/test").Page) { +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const DIFFERENT_PUBKEY = "c0ffee00".repeat(8); +const BACKUP_FILE = { + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from("ncryptsec1mockbackupmaterial"), +}; + +async function openIdentity(page: Page) { const identity = page.getByTestId("profile-identity-card"); if ( !(await identity.evaluate( @@ -14,13 +23,32 @@ async function openIdentity(page: import("@playwright/test").Page) { } } -test("identity settings expose independent create and test backup tools", async ({ - page, -}) => { - await installMockBridge(page); +async function openBackupSettings( + page: Page, + mock?: Parameters[1], +) { + await installMockBridge(page, mock); await page.goto("/"); await openSettings(page, "profile"); await openIdentity(page); +} + +async function selectBackupFile(page: Page) { + await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); + await expect(page.getByTestId("backup-test-file-accepted")).toContainText( + BACKUP_FILE.name, + ); +} + +async function verifyBackup(page: Page, password: string) { + await page.getByTestId("backup-test-password").fill(password); + await page.getByTestId("backup-test-verify").click(); +} + +test("identity settings expose independent create and test backup tools", async ({ + page, +}) => { + await openBackupSettings(page); const createRow = page.getByTestId("profile-encrypted-backup-row"); const testRow = page.getByTestId("profile-backup-test-row"); @@ -41,3 +69,123 @@ test("identity settings expose independent create and test backup tools", async await expect(createRow.getByLabel("Encryption password")).toHaveCount(0); await expect(testRow.getByText("Select your backup file")).toBeVisible(); }); + +test("creation requires a sufficiently long password and supports another download", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: [ + "/Users/test/Downloads/identity.ncryptsec", + "/Users/test/Desktop/identity-copy.ncryptsec", + ], + }); + const row = page.getByTestId("profile-encrypted-backup-row"); + await row.getByTestId("profile-encrypted-backup-row-toggle").click(); + + const password = row.getByTestId("backup-passphrase-input"); + const submit = row.getByTestId("encrypted-backup-create"); + await expect(password).toHaveAttribute( + "placeholder", + "Password (min 12 characters)", + ); + await expect(submit).toBeDisabled(); + await password.fill("short"); + await expect(row.getByTestId("backup-passphrase-issue")).toHaveText( + "Use at least 12 characters.", + ); + await expect(submit).toBeDisabled(); + + await password.fill("custom password"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect(row.getByTestId("backup-saved-password-mask")).toBeVisible(); + await expect(row.getByTestId("encrypted-backup-saved-path")).toContainText( + "/Users/test/Downloads/identity.ncryptsec", + ); + await expect(submit).toHaveText("Download backup again"); + + await submit.click(); + await expect(row.getByTestId("encrypted-backup-saved-path")).toContainText( + "/Users/test/Desktop/identity-copy.ncryptsec", + ); +}); + +test("closing and reopening creation clears unsaved and completed state", async ({ + page, +}) => { + await openBackupSettings(page); + const row = page.getByTestId("profile-encrypted-backup-row"); + const toggle = row.getByTestId("profile-encrypted-backup-row-toggle"); + await toggle.click(); + await row.getByTestId("backup-passphrase-input").fill("unsaved password"); + await toggle.click(); + await toggle.click(); + await expect(row.getByTestId("backup-passphrase-input")).toHaveValue(""); + + await row.getByTestId("backup-passphrase-input").fill("completed password"); + await row.getByTestId("encrypted-backup-create").click(); + await expect(row.getByTestId("encrypted-backup-created")).toBeVisible(); + await toggle.click(); + await toggle.click(); + await expect(row.getByTestId("backup-passphrase-input")).toHaveValue(""); + await expect(row.getByTestId("encrypted-backup-created")).toHaveCount(0); + await expect(row.getByTestId("encrypted-backup-create")).toHaveText( + "Backup key", + ); +}); + +test("wrong backup password clears the attempt and permits a successful retry", async ({ + page, +}) => { + await openBackupSettings(page, { + backupVerificationErrors: ["Wrong password.", null], + }); + const row = page.getByTestId("profile-backup-test-row"); + await row.getByTestId("profile-backup-test-row-toggle").click(); + await selectBackupFile(page); + + await verifyBackup(page, "wrong password"); + await expect(page.getByTestId("backup-test-error")).toHaveText( + "Wrong password.", + ); + await expect(page.getByTestId("backup-test-password")).toHaveValue(""); + await expect(page.getByTestId("backup-test-verify")).toBeDisabled(); + + await verifyBackup(page, "correct password"); + await expect(page.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); +}); + +for (const identity of [ + { + label: "current", + pubkey: CURRENT_PUBKEY, + message: "It restores your current Buzz identity.", + }, + { + label: "different", + pubkey: DIFFERENT_PUBKEY, + message: "It restores a different identity than the one signed in here.", + }, +]) { + test(`successful verification identifies the ${identity.label} identity using only its npub`, async ({ + page, + }) => { + await openBackupSettings(page, { + backupVerificationPubkeys: [identity.pubkey], + }); + await page.getByTestId("profile-backup-test-row-toggle").click(); + await selectBackupFile(page); + await verifyBackup(page, "one-time password"); + + const success = page.getByTestId("backup-test-success"); + await expect(success).toContainText(identity.message); + await expect(success.getByTestId("backup-test-npub")).toContainText( + npubEncode(identity.pubkey), + ); + await expect(success).not.toContainText(identity.pubkey); + await expect(success).not.toContainText("one-time password"); + await expect(success).not.toContainText(BACKUP_FILE.buffer.toString()); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd6..5a65d5ac95 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -439,6 +439,12 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. From ef22c01813cbdeb3a318efee8d22b6e2b4956007 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 22:05:21 -0700 Subject: [PATCH 3/5] feat(settings): streamline encrypted key backup creation - Preload NIP-49 encryption silently while preserving the editable password until submission. - Replace queued-download status text with randomized progress and automatically open the native save dialog when encryption completes. - Move the temporary backup workflow into a compact confirmation dialog with aligned guidance and actions. - Preserve encrypted backups after canceled saves so the download dialog can be reopened without repeating encryption. - Expand reducer and browser coverage for preload, retry, progress, modal lifecycle, and repeated downloads. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../settings/lib/encryptedBackup.test.mjs | 46 +- .../features/settings/lib/encryptedBackup.ts | 57 ++- .../settings/ui/EncryptedBackupCreator.tsx | 405 +++++------------- .../settings/ui/EncryptedBackupRow.tsx | 67 +-- desktop/src/testing/e2eBridge.ts | 9 +- .../tests/e2e/profile-backup-settings.spec.ts | 110 +++-- 6 files changed, 298 insertions(+), 396 deletions(-) diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs index 1d98a98541..306e937ec5 100644 --- a/desktop/src/features/settings/lib/encryptedBackup.test.mjs +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -32,16 +32,29 @@ test("valid password requests encryption without copying it into events", () => assert.equal(started.requestId, 1); assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); }); -test("success clears password and retains only encrypted blob", () => { +test("background success retains the password without committing the download", () => { const state = reduce([ { type: "set-passphrase", value: "one-two-three-four" }, { type: "encrypt-started", requestId: 1 }, { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, ]); - assert.equal(state.passphrase, ""); + assert.equal(state.passphrase, "one-two-three-four"); assert.equal(state.encrypted, "ncryptsec1abc"); - assert.equal(state.savedPassword, true); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); assert.equal(state.requestId, null); + assert.equal(downloadDisabled(state), false); +}); +test("submit commits a completed preload immediately", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); }); test("stale async completions cannot replace current request", () => { const state = reduce([ @@ -67,6 +80,21 @@ test("failure clears submitted password", () => { assert.equal(state.downloadPending, false); assert.equal(downloadDisabled(state), true); }); +test("background failure stays silent until submit retries encryption", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(failed.passphrase, "one-two-three-four"); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(pendingEncryptPassphrase(failed), null); + + const retrying = reduce([{ type: "download-clicked" }], failed); + assert.equal(retrying.createError, null); + assert.equal(retrying.downloadPending, true); + assert.equal(pendingEncryptPassphrase(retrying), "one-two-three-four"); +}); test("queued download commits and clears password", () => { const state = reduce([ { type: "set-passphrase", value: "one-two-three-four" }, @@ -78,18 +106,6 @@ test("queued download commits and clears password", () => { assert.equal(state.passphrase, ""); assert.equal(state.savedPassword, true); }); -test("Back preserves blob for immediate re-download without password", () => { - const made = reduce([ - { type: "set-passphrase", value: "one-two-three-four" }, - { type: "encrypt-started", requestId: 1 }, - { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, - { type: "download-clicked" }, - { type: "back-to-password" }, - ]); - assert.equal(made.ncryptsec, "ncryptsec1abc"); - assert.equal(made.passphrase, ""); - assert.equal(downloadDisabled(made), false); -}); test("starting over discards blob and invalidates late requests", () => { const made = { ...initialEncryptedBackupState, diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts index d2aad94422..e54189769a 100644 --- a/desktop/src/features/settings/lib/encryptedBackup.ts +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -29,7 +29,6 @@ export type EncryptedBackupEvent = | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } | { type: "encrypt-failed"; requestId: number; message: string } | { type: "download-clicked" } - | { type: "back-to-password" } | { type: "start-new-backup" }; export function encryptedBackupReducer( @@ -41,6 +40,7 @@ export function encryptedBackupReducer( return { ...state, passphrase: event.value, + requestId: null, encrypted: null, createError: null, }; @@ -53,24 +53,36 @@ export function encryptedBackupReducer( }; case "encrypt-succeeded": if (event.requestId !== state.requestId) return state; - return { - ...state, - passphrase: "", - requestId: null, - encrypted: event.ncryptsec, - ncryptsec: state.downloadPending ? event.ncryptsec : state.ncryptsec, - downloadPending: false, - savedPassword: true, - }; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + } + : { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; case "encrypt-failed": if (event.requestId !== state.requestId) return state; - return { - ...state, - passphrase: "", - requestId: null, - createError: event.message, - downloadPending: false, - }; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + } + : { + ...state, + requestId: null, + createError: event.message, + }; case "download-clicked": if ( state.ncryptsec || @@ -85,9 +97,7 @@ export function encryptedBackupReducer( passphrase: "", savedPassword: true, } - : { ...state, downloadPending: true }; - case "back-to-password": - return { ...state, createError: null }; + : { ...state, createError: null, downloadPending: true }; case "start-new-backup": return { ...initialEncryptedBackupState, @@ -112,7 +122,12 @@ export function effectivePassphrase( export function pendingEncryptPassphrase( state: EncryptedBackupState, ): string | null { - if (state.savedPassword || state.encrypted || state.requestId !== null) + if ( + state.savedPassword || + state.encrypted || + state.requestId !== null || + (state.createError !== null && !state.downloadPending) + ) return null; return effectivePassphrase(state); } diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx index 751a841990..2ffb04f8cc 100644 --- a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -6,25 +6,11 @@ import { generateBackupPassphrase, saveNcryptsecCopy, } from "@/shared/api/tauriIdentity"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; -import { Spinner } from "@/shared/ui/spinner"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { downloadDisabled, - isEncrypting, - passphraseIssue, pendingEncryptPassphrase, encryptedBackupReducer, initialEncryptedBackupState, @@ -51,90 +37,47 @@ const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; */ const ENCRYPT_DEBOUNCE_MS = 400; -const PENDING_TICKER_MESSAGES = [ - "Downloading once finished", - "Encrypting your password", - "Just a bit longer...", -] as const; - -/** How long each ticker message holds before sliding to the next. */ -const PENDING_TICKER_INTERVAL_MS = 2500; - -/** Matches the `duration-300` slide transition on the ticker column. */ -const PENDING_TICKER_SLIDE_MS = 300; - /** - * Vertical ticker for the queued-download button label — cycles through the - * pending messages by sliding a stacked column inside a one-line viewport. - * The column ends with a clone of the first message, so the wrap-around - * slides up from the bottom like every other step; once the clone settles, - * the column snaps (transition disabled) back to the real first row. All - * lines render at all times, so the button keeps the width of the longest - * message instead of resizing on each swap. + * Indeterminate KDF progress. Scrypt does not expose intermediate progress, + * so randomized increments consume a shrinking fraction of the remaining + * distance. The bar moves quickly at first and can never reach completion. */ -function PendingDownloadTicker() { - // Index into the rendered column (messages + trailing clone of the first). - const [position, setPosition] = React.useState(0); - const [snap, setSnap] = React.useState(false); +function FakeKdfProgressBar() { + const [progress, setProgress] = React.useState(0); React.useEffect(() => { - const timer = window.setInterval( - () => setPosition((current) => current + 1), - PENDING_TICKER_INTERVAL_MS, - ); - return () => window.clearInterval(timer); + let animationFrame = 0; + let nextAdvanceAt = 0; + const advance = (now: number) => { + if (now >= nextAdvanceAt) { + setProgress((current) => { + const remaining = 90 - current; + const fraction = 0.08 + Math.random() * 0.22; + return Math.min(90, current + Math.max(0.25, remaining * fraction)); + }); + nextAdvanceAt = now + 180 + Math.random() * 420; + } + animationFrame = window.requestAnimationFrame(advance); + }; + animationFrame = window.requestAnimationFrame(advance); + return () => window.cancelAnimationFrame(animationFrame); }, []); - // The clone is visually identical to the first message: once its slide-in - // finishes, jump back to the real first row without animating. - React.useEffect(() => { - if (position !== PENDING_TICKER_MESSAGES.length) return; - const timer = window.setTimeout(() => { - setSnap(true); - setPosition(0); - }, PENDING_TICKER_SLIDE_MS); - return () => window.clearTimeout(timer); - }, [position]); - - // Re-enable the transition one frame after the snap has painted. - React.useEffect(() => { - if (!snap) return; - const raf = window.requestAnimationFrame(() => setSnap(false)); - return () => window.cancelAnimationFrame(raf); - }, [snap]); - - // The clone row duplicates the first message's text, so it carries its own - // stable key. - const column = [ - ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), - { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, - ]; - return ( - - - {column.map((row) => ( - - {row.message} - - ))} - - +
+
); } @@ -321,12 +264,10 @@ export function EncryptedBackupCreator() { encryptedBackupReducer, initialEncryptedBackupState, ); - const [savedPath, setSavedPath] = React.useState(null); const savedForRef = React.useRef(null); const [isRevealed, setIsRevealed] = React.useState(false); const [saveError, setSaveError] = React.useState(null); const [isSaving, setIsSaving] = React.useState(false); - const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); const mountedRef = React.useRef(true); React.useEffect(() => { @@ -336,15 +277,14 @@ export function EncryptedBackupCreator() { }; }, []); - // A queued download locks the form — mask the password too so it isn't - // left readable on screen while the user waits for the save dialog. + // A queued download hides the form; mask the password before it can return + // in any error state. React.useEffect(() => { if (state.downloadPending) setIsRevealed(false); }, [state.downloadPending]); - // Correlate KDF completion by an opaque request id. The password exists only - // in this short-lived effect closure and is cleared from reducer state once - // Rust returns; stale completions cannot commit. + // Correlate KDF completion by an opaque request id. The password exists in + // this short-lived effect closure; stale completions cannot commit. const pendingPassphrase = pendingEncryptPassphrase(state); const skipDebounce = state.downloadPending; React.useEffect(() => { @@ -380,31 +320,17 @@ export function EncryptedBackupCreator() { }, [pendingPassphrase, skipDebounce, state.nextRequestId]); // Download commit: fires once per committed blob, whether the commit was - // instant (encryption already done) or resolved a queued download. The flow - // only advances to the test view once the file is actually on disk — a - // canceled save dialog or a save failure rolls the commit back to the - // password form so "Download backup" can be clicked again. + // instant (encryption already done) or resolved a queued download. A cancel + // or save failure preserves the encrypted blob so the user can reopen the + // native dialog with "Download backup again". React.useEffect(() => { const ncryptsec = state.ncryptsec; if (!ncryptsec || savedForRef.current === ncryptsec) return; savedForRef.current = ncryptsec; setIsSaving(true); setSaveError(null); - const rollBack = () => { - savedForRef.current = null; - dispatch({ type: "back-to-password" }); - }; void saveNcryptsecCopy(ncryptsec) - .then((path) => { - if (path) { - setSavedPath(path); - } else { - // User canceled the native save dialog — nothing was downloaded. - rollBack(); - } - }) .catch((err: unknown) => { - rollBack(); if (mountedRef.current) setSaveError( err instanceof Error ? err.message : "Failed to save your key.", @@ -420,10 +346,7 @@ export function EncryptedBackupCreator() { setIsSaving(true); setSaveError(null); try { - const path = await saveNcryptsecCopy(state.ncryptsec); - if (mountedRef.current && path) { - setSavedPath(path); - } + await saveNcryptsecCopy(state.ncryptsec); } catch (err) { if (mountedRef.current) setSaveError( @@ -434,133 +357,64 @@ export function EncryptedBackupCreator() { } }, [isSaving, state.ncryptsec]); - const issue = passphraseIssue(state.passphrase); - - // Without the guided test (settings), a completed save keeps the form - // visible in its saved-password state: masked input, instant re-download, - // and the change-password confirmation guarding any edit. + // A completed backup leaves only the re-download action; the password form + // never returns after submission. return (
-
- { - if (state.savedPassword) { - event.preventDefault(); - setConfirmNewPassword(true); + {state.downloadPending ? ( + + ) : !state.savedPassword ? ( +
+ + dispatch({ type: "set-passphrase", value: event.target.value }) } - }} - onPaste={(event) => { - if (state.savedPassword) { - event.preventDefault(); - setConfirmNewPassword(true); - } - }} - onChange={(event) => - dispatch({ type: "set-passphrase", value: event.target.value }) - } - placeholder={ - state.savedPassword - ? "" - : `Password (min ${MIN_PASSPHRASE_LEN} characters)` - } - type={isRevealed ? "text" : "password"} - value={state.passphrase} - /> - {state.savedPassword ? ( -
- •••••••••••••••••••••••••••••••• -
- ) : null} - {state.savedPassword ? ( - - Backup password saved; hidden for security. - - ) : null} - - setConfirmNewPassword(true) : undefined - } - onGenerated={(value) => { - dispatch({ type: "set-passphrase", value }); - // A generated password must be visible so the user can save it. - setIsRevealed(true); - }} - /> - {issue ? ( -

- {issue} -

- ) : null} -
- - {state.savedPassword && state.ncryptsec && savedPath ? ( -
-

+ + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + />

) : null} - {state.createError ? ( + {!state.downloadPending && !state.savedPassword ? ( +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Creating another backup does not invalidate copies + you saved before. +

+ ) : null} + + {state.createError && state.passphrase.length === 0 ? (

) : null} - {(() => { - // Absolute spinner: signals the background encryption without - // shifting the centered button while it appears and disappears. - const createButton = ( -

- {isEncrypting(state) || state.downloadPending || isSaving ? ( - - ) : null} - -
- ); - return
{createButton}
; - })()} - - - - Create a new backup password? - - Starting over lets you pick a new password and download a fresh - backup file. Backups you saved earlier will still work — just use - the password you created them with. - - - - Keep current backup - { - dispatch({ type: "start-new-backup" }); - setSavedPath(null); - savedForRef.current = null; - setIsRevealed(false); - }} - > - Start with a new password - - - - + {!state.downloadPending ? ( +
+ +
+ ) : null}
); } diff --git a/desktop/src/features/settings/ui/EncryptedBackupRow.tsx b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx index 30d50e2a9b..657172f407 100644 --- a/desktop/src/features/settings/ui/EncryptedBackupRow.tsx +++ b/desktop/src/features/settings/ui/EncryptedBackupRow.tsx @@ -5,27 +5,36 @@ import { initialBackupTestProgress, } from "@/features/settings/ui/BackupTestFlow"; import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; /** - * Collapsible settings row shared by the two backup tools. `relative` - * anchors the backup-test drop overlay (BackupTestFlow) so a file drag takes - * over the whole row, mirroring the composer treatment. + * Settings row shared by the two backup tools. `relative` anchors the + * backup-test drop overlay (BackupTestFlow) so a file drag takes over the + * whole row, mirroring the composer treatment. */ function ToolRow({ title, description, action, - open, - onToggle, + expanded, + hasPopup = false, + onAction, children, testId, }: { title: string; description: string; action: string; - open: boolean; - onToggle: () => void; - children: React.ReactNode; + expanded?: boolean; + hasPopup?: boolean; + onAction: () => void; + children?: React.ReactNode; testId: string; }) { return ( @@ -36,17 +45,18 @@ function ToolRow({

{description}

- {open ?
{children}
: null} + {expanded ?
{children}
: null} ); } @@ -66,23 +76,30 @@ export function EncryptedBackupRow() { setCreateOpen((open) => !open)} - open={createOpen} + hasPopup + onAction={() => setCreateOpen(true)} testId="profile-encrypted-backup-row" title="Create a key backup" - > - -

- Keep the file private and save its password somewhere safe — Buzz - cannot reset it. Creating another backup does not invalidate copies - you saved before. -

-
+ /> + + + + Create a key backup + + Download a password-protected copy of your identity key. + + + + + setTestOpen((open) => !open)} - open={testOpen} + expanded={testOpen} + onAction={() => setTestOpen((open) => !open)} testId="profile-backup-test-row" title="Test a key backup" > diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1e6450038a..f92f94de41 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -419,6 +419,8 @@ type E2eConfig = { backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; /** Native paths returned by successive backup saves. */ backupSavePaths?: Array; /** @@ -9844,8 +9846,13 @@ export function maybeInstallE2eTauriMocks() { return; case "generate_backup_passphrase": return "correct horse battery staple"; - case "create_ncryptsec_backup": + case "create_ncryptsec_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } return "ncryptsec1mockbackupmaterial"; + } case "save_ncryptsec_copy": { const paths = activeConfig?.mock?.backupSavePaths ?? [ "/tmp/buzz-identity.ncryptsec", diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts index 97e823ec26..15884049bd 100644 --- a/desktop/tests/e2e/profile-backup-settings.spec.ts +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -33,6 +33,13 @@ async function openBackupSettings( await openIdentity(page); } +async function openCreateBackup(page: Page) { + await page.getByTestId("profile-encrypted-backup-row-toggle").click(); + const dialog = page.getByTestId("encrypted-backup-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + async function selectBackupFile(page: Page) { await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); await expect(page.getByTestId("backup-test-file-accepted")).toContainText( @@ -45,6 +52,15 @@ async function verifyBackup(page: Page, password: string) { await page.getByTestId("backup-test-verify").click(); } +async function backupSaveCallCount(page: Page) { + return page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "save_ncryptsec_copy", + ).length ?? 0, + ); +} + test("identity settings expose independent create and test backup tools", async ({ page, }) => { @@ -57,17 +73,18 @@ test("identity settings expose independent create and test backup tools", async await expect(testRow).toContainText("Test a key backup"); await expect(testRow).toContainText("which identity it unlocks"); - await createRow.getByTestId("profile-encrypted-backup-row-toggle").click(); - await expect(createRow.getByLabel("Encryption password")).toBeVisible(); + const createDialog = await openCreateBackup(page); + await expect(createDialog.getByLabel("Encryption password")).toBeVisible(); await expect(testRow.getByText("Select your backup file")).toHaveCount(0); + await createDialog.getByRole("button", { name: "Close" }).click(); await testRow.getByTestId("profile-backup-test-row-toggle").click(); await expect(testRow.getByText("Select your backup file")).toBeVisible(); - await expect(createRow.getByLabel("Encryption password")).toBeVisible(); - await createRow.getByTestId("profile-encrypted-backup-row-toggle").click(); - await expect(createRow.getByLabel("Encryption password")).toHaveCount(0); + const reopenedDialog = await openCreateBackup(page); + await expect(reopenedDialog.getByLabel("Encryption password")).toBeVisible(); await expect(testRow.getByText("Select your backup file")).toBeVisible(); + await reopenedDialog.getByRole("button", { name: "Close" }).click(); }); test("creation requires a sufficiently long password and supports another download", async ({ @@ -79,57 +96,80 @@ test("creation requires a sufficiently long password and supports another downlo "/Users/test/Desktop/identity-copy.ncryptsec", ], }); - const row = page.getByTestId("profile-encrypted-backup-row"); - await row.getByTestId("profile-encrypted-backup-row-toggle").click(); + const dialog = await openCreateBackup(page); - const password = row.getByTestId("backup-passphrase-input"); - const submit = row.getByTestId("encrypted-backup-create"); + const password = dialog.getByTestId("backup-passphrase-input"); + const submit = dialog.getByTestId("encrypted-backup-create"); await expect(password).toHaveAttribute( "placeholder", "Password (min 12 characters)", ); await expect(submit).toBeDisabled(); await password.fill("short"); - await expect(row.getByTestId("backup-passphrase-issue")).toHaveText( - "Use at least 12 characters.", - ); + await expect(dialog.getByTestId("backup-passphrase-issue")).toHaveCount(0); await expect(submit).toBeDisabled(); await password.fill("custom password"); await expect(submit).toBeEnabled(); await submit.click(); - await expect(row.getByTestId("backup-saved-password-mask")).toBeVisible(); - await expect(row.getByTestId("encrypted-backup-saved-path")).toContainText( - "/Users/test/Downloads/identity.ncryptsec", - ); + await expect(dialog.getByTestId("backup-passphrase-input")).toHaveCount(0); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); await expect(submit).toHaveText("Download backup again"); await submit.click(); - await expect(row.getByTestId("encrypted-backup-saved-path")).toContainText( - "/Users/test/Desktop/identity-copy.ncryptsec", - ); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); +}); + +test("submit replaces the form with progress and automatically opens save", async ({ + page, +}) => { + await openBackupSettings(page, { + backupEncryptionDelayMs: 750, + backupSavePaths: [null, "/Users/test/Downloads/identity-retry.ncryptsec"], + }); + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("progress password"); + await dialog.getByTestId("encrypted-backup-create").click(); + + const progress = dialog.getByTestId("encrypted-backup-progress"); + await expect(progress).toBeVisible(); + await expect(progress).toHaveAttribute("aria-valuemax", "100"); + await expect(dialog.getByTestId("backup-passphrase-input")).toHaveCount(0); + await expect(dialog.getByTestId("encrypted-backup-create")).toHaveCount(0); + + const downloadAgain = dialog.getByTestId("encrypted-backup-create"); + await expect(downloadAgain).toHaveText("Download backup again"); + await expect(progress).toHaveCount(0); + await expect(dialog.getByTestId("backup-passphrase-input")).toHaveCount(0); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + + await downloadAgain.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); }); test("closing and reopening creation clears unsaved and completed state", async ({ page, }) => { await openBackupSettings(page); - const row = page.getByTestId("profile-encrypted-backup-row"); - const toggle = row.getByTestId("profile-encrypted-backup-row-toggle"); - await toggle.click(); - await row.getByTestId("backup-passphrase-input").fill("unsaved password"); - await toggle.click(); - await toggle.click(); - await expect(row.getByTestId("backup-passphrase-input")).toHaveValue(""); - - await row.getByTestId("backup-passphrase-input").fill("completed password"); - await row.getByTestId("encrypted-backup-create").click(); - await expect(row.getByTestId("encrypted-backup-created")).toBeVisible(); - await toggle.click(); - await toggle.click(); - await expect(row.getByTestId("backup-passphrase-input")).toHaveValue(""); - await expect(row.getByTestId("encrypted-backup-created")).toHaveCount(0); - await expect(row.getByTestId("encrypted-backup-create")).toHaveText( + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("unsaved password"); + await dialog.getByRole("button", { name: "Close" }).click(); + + await openCreateBackup(page); + await expect(dialog.getByTestId("backup-passphrase-input")).toHaveValue(""); + + await dialog + .getByTestId("backup-passphrase-input") + .fill("completed password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await expect(dialog.getByTestId("encrypted-backup-create")).toHaveText( + "Download backup again", + ); + await dialog.getByRole("button", { name: "Close" }).click(); + + await openCreateBackup(page); + await expect(dialog.getByTestId("backup-passphrase-input")).toHaveValue(""); + await expect(dialog.getByTestId("encrypted-backup-create")).toHaveText( "Backup key", ); }); From 8e323bfb16ba369c007e7814234cb28c3fa42a58 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 23:28:12 -0700 Subject: [PATCH 4/5] feat(settings): move encrypted backup flow into a background modal - Add an app-level encrypted backup provider to preserve encryption and native save work after the modal or settings screen closes. - Replace the standalone backup settings rows with private-key menu actions and a focused backup confirmation dialog. - Preload NIP-49 encryption silently, show simulated progress after submission, and open the native save dialog automatically. - Keep completed backups available for five minutes with a descending Download backup button and actionable Sonner status updates. - Extend the masked private-key display with reusable overflow-menu actions for creating and testing backups. - Update Playwright coverage for modal lifecycle, background completion, temporary downloads, expiry, and backup verification. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/app/App.tsx | 16 +- .../onboarding/ui/NsecMaskedDisplay.tsx | 100 ++++-- .../settings/EncryptedBackupProvider.tsx | 251 ++++++++++++++ .../settings/ui/EncryptedBackupCreator.tsx | 307 +++++++----------- .../settings/ui/EncryptedBackupRow.tsx | 114 ------- .../settings/ui/PrivateKeyBackupRow.tsx | 215 ++++++++++++ .../settings/ui/ProfileSettingsCard.tsx | 95 +----- .../tests/e2e/profile-backup-settings.spec.ts | 174 +++++----- desktop/tests/helpers/bridge.ts | 2 + 9 files changed, 779 insertions(+), 495 deletions(-) create mode 100644 desktop/src/features/settings/EncryptedBackupProvider.tsx delete mode 100644 desktop/src/features/settings/ui/EncryptedBackupRow.tsx create mode 100644 desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 90b5bf5ebc..44618f2c72 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -56,6 +56,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -270,9 +271,18 @@ function AppReady({ } return ( - - - + + void router.navigate({ + to: "/settings", + search: { section: "profile" }, + }) + } + > + + + + ); } diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index 111bdefd71..26f538da52 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -1,7 +1,23 @@ -import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, MoreHorizontal } from "lucide-react"; import * as React from "react"; import { Button } from "@/shared/ui/button"; -import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { + copyTextToClipboard, + writeTextToClipboard, +} from "@/shared/lib/clipboard"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type NsecAction = { + icon?: React.ReactNode; + label: string; + onSelect: () => void; + testId?: string; +}; type NsecMaskedDisplayProps = { nsec: string; @@ -12,6 +28,8 @@ type NsecMaskedDisplayProps = { * a backup (e.g. sign-out) gate on actual interaction with the key. */ onKeyInteraction?: () => void; + /** Replaces the copy icon with an overflow menu containing Copy plus these actions. */ + actions?: readonly NsecAction[]; }; export const ONBOARDING_KEY_FRAME_CLASS = @@ -31,6 +49,7 @@ export function NsecMaskedDisplay({ nsec, variant = "boxed", onKeyInteraction, + actions, }: NsecMaskedDisplayProps) { const [isRevealed, setIsRevealed] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false); @@ -58,6 +77,11 @@ export function NsecMaskedDisplay({ copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000); } + function handleMenuCopy() { + copyTextToClipboard(nsec); + onKeyInteraction?.(); + } + const isBare = variant === "bare"; // Mask every character (no plaintext prefix leak), matching the real key's // length so toggling reveal never reflows the monospace text (no layout shift). @@ -121,24 +145,60 @@ export function NsecMaskedDisplay({