From e5cff3865d812e7c498b2515f7a45325784818d6 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Sat, 25 Jul 2026 20:42:34 -0400 Subject: [PATCH 01/26] Add NIP-49 encrypted local key backup: Rust layer + egress guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-only ncryptsec backup of the identity key (plan: PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3, approved 9/10 by Wren). - key_backup.rs: NIP-49 codec (nostr nip49, log_n 18), one-artifact-per- action create with decrypt-verify against the live pubkey, atomic 0o600 write + reread/byte-compare, EFF-wordlist passphrase generation, ncryptsec import recovery, stale-backup cleanup on identity change. - commands/identity.rs: create_ncryptsec_backup (whole body under identity_mutation; webview can never supply canonical blob bytes), save_ncryptsec_copy (dialog selection only + secret-file write, never mutates app state), generate_backup_passphrase, import_identity now accepts ncryptsec1 with a passphrase (raw-nsec path byte-for-byte unchanged). - egress_guard.rs: the backup must NEVER be transmitted to a relay — fail-closed runtime guard rejecting ncryptsec1 at all 8 relay egress boundaries (submit funnel, 3x relay.rs, huddle STT, both engram submitters, native websocket send loop). Scope is ncryptsec1 only: pairing intentionally carries the raw nsec inside its encrypted NIP-AB session. - Tests: injection test per boundary (8), /events inventory-completeness tripwire, ncryptsec source-allowlist scan, spec vector, NFKC cross-form, 0600/atomicity/lifecycle, recovery-mode gating, concurrent identity swap vs backup, boot-reset wipes the backup. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- 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 | 257 +++- .../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 | 52 + desktop/src-tauri/src/egress_guard_tests.rs | 304 ++++ desktop/src-tauri/src/huddle/pipeline.rs | 24 +- desktop/src-tauri/src/key_backup.rs | 213 +++ desktop/src-tauri/src/key_backup_tests.rs | 203 +++ desktop/src-tauri/src/lib.rs | 5 + desktop/src-tauri/src/native_websocket.rs | 20 +- desktop/src-tauri/src/relay.rs | 3 + desktop/src-tauri/src/relay/submit.rs | 1 + desktop/src-tauri/src/reset.rs | 20 + 18 files changed, 2483 insertions(+), 20 deletions(-) create mode 100644 desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt 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 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 080584c17f..91d5f4c775 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1031,6 +1031,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 0ed7417333..e91da854af 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -80,7 +80,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 33783c05a5..e723a5637d 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -188,14 +188,130 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } +/// Generate a 6-word passphrase for a new encrypted backup (EFF short +/// wordlist, OS entropy, ≈62 bits before the scrypt work factor). +#[tauri::command] +pub fn generate_backup_passphrase() -> Result { + crate::key_backup::generate_passphrase() +} + +/// 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_and_persist_backup_with_log_n( + state: &AppState, + data_dir: &std::path::Path, + 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()?; + + let ncryptsec = crate::key_backup::create_backup_blob(&keys, password, log_n)?; + + std::fs::create_dir_all(data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let path = crate::key_backup::backup_file_path(data_dir); + crate::key_backup::write_backup_file(&path, &ncryptsec)?; + + Ok(ncryptsec) +} + +/// Create the canonical app-managed NIP-49 backup. +/// +/// Encrypts the live identity under `password`, decrypt-verifies the fresh +/// blob against the live pubkey, atomically persists it to +/// `{app_data_dir}/identity.ncryptsec` (0o600), rereads and byte-compares, +/// and returns the exact persisted `ncryptsec1…` string. The entire body runs +/// under `identity_mutation`, so it serializes against imports and caps KDF +/// concurrency at one. The webview can never supply canonical blob bytes — +/// the trust boundary is the password. +#[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::(); + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + create_and_persist_backup_with_log_n( + &state, + &data_dir, + &password, + crate::key_backup::BACKUP_LOG_N, + ) + }) + .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, + "Encrypted 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, + password: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + // `ncryptsec1…` = NIP-49 encrypted backup: requires the passphrase and + // decrypts in Rust. Everything after key recovery is byte-for-byte the + // raw-nsec path. + let password = password.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input( + &nsec, + password.as_ref().map(|p| p.as_str()), + )?; // Serialize against persist_current_identity: hold this guard for the // full function body so a concurrent stale persist can't overwrite @@ -210,6 +326,11 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); + // Importing a different identity invalidates the app-managed backup: + // it encrypts the previous key and must not linger mislabeled. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + crate::key_backup::cleanup_stale_backup(&previous_pubkey, &keys.public_key(), &data_dir)?; + // Persist into the OS keyring first (store → read-back verify → marker → // delete file). Falls back to the 0o600 file when the keyring is // unavailable; returns Err only when both backends fail. @@ -583,3 +704,135 @@ mod nostr_identity_binding_tests { assert_eq!(error, "expires_at is expired"); } } + +#[cfg(test)] +mod key_backup_command_tests { + use super::create_and_persist_backup_with_log_n; + use crate::app_state::build_app_state; + use nostr::Keys; + + /// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered + /// once in key_backup_tests::round_trip_at_production_cost. + const FAST_LOG_N: u8 = 16; + const PASSWORD: &str = "correct horse battery"; + + #[test] + fn returned_bytes_equal_on_disk_bytes() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let returned = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + + let path = crate::key_backup::backup_file_path(dir.path()); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + returned, on_disk, + "webview must receive the exact persisted bytes" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // And the persisted blob provably recovers the live identity. + let keys = state.keys.lock().unwrap().clone(); + let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + } + + #[test] + fn overwrite_replaces_atomically() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let first = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let second = create_and_persist_backup_with_log_n( + &state, + dir.path(), + "another passphrase", + FAST_LOG_N, + ) + .unwrap(); + assert_ne!(first, second, "fresh salt/nonce per action"); + + let path = crate::key_backup::backup_file_path(dir.path()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), second); + } + + #[test] + fn rejects_short_passphrase() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N) + .unwrap_err(); + assert!(err.contains("at least"), "{err}"); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); + } + + #[test] + fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "lost identity must not be backed up" + ); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); + } + + /// Concurrent identity swap vs backup creation: `identity_mutation` + /// serializes both, so every persisted blob decrypts to the identity that + /// was live for the whole of its create operation — never a torn state. + #[test] + fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + let dir = tempfile::tempdir().unwrap(); + let key_a = state.keys.lock().unwrap().clone(); + let key_b = Keys::generate(); + + let swapper = { + let state = state.clone(); + let key_b = key_b.clone(); + std::thread::spawn(move || { + // Mirrors import_identity's locking: mutation guard held + // across the key swap. + let _guard = state.identity_mutation.lock().unwrap(); + *state.keys.lock().unwrap() = key_b; + }) + }; + + let backup = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + swapper.join().unwrap(); + + let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) + .unwrap() + .public_key(); + assert!( + recovered == key_a.public_key() || recovered == key_b.public_key(), + "backup must match one coherent identity" + ); + // Whichever won, the persisted file equals the returned blob. + let on_disk = + std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap(); + assert_eq!(on_disk, backup); + } +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace6..9dc5150360 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -630,7 +630,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], @@ -640,6 +640,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. @@ -683,3 +685,32 @@ 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}"); + } +} diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a9..1eb8a6c1d4 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -891,7 +891,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], @@ -901,6 +901,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 ca7dc61830..b831f7b857 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -724,3 +724,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..6dc2141a60 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard.rs @@ -0,0 +1,52 @@ +//! 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_event_at_with_keys` (funnel for `submit_event`) | `relay/submit.rs` | +//! | 2 | `sync_managed_agent_profile` | `relay.rs` | +//! | 3 | `submit_signed_event` | `relay.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"; + +/// Reject `text` if it contains NIP-49 key-backup material. +/// +/// Returns `Err` when an `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) { + 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..7a3f8a6492 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -0,0 +1,304 @@ +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(), + ); +} + +#[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.rs` `submit_signed_event`. +#[tokio::test] +async fn boundary_submit_signed_event_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 = state.signing_keys().unwrap(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event(&event, &state) + .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 +} + +/// Inventory completeness: every `/events` URL-construction site in +/// `desktop/src-tauri/src` must be in the guarded set. A future ninth +/// submission path fails this test until its guard is wired and it is added +/// to the allowlist below (with its egress_guard.rs table row + injection +/// test). +#[test] +fn events_url_inventory_is_fully_guarded() { + // (file suffix, guarded construction sites expected in that file) + let allowlist: &[&str] = &[ + "src/relay.rs", // boundaries 2, 3, 4 + "src/relay/submit.rs", // boundary 1 + "src/huddle/pipeline.rs", // boundary 5 + "src/commands/team_snapshot.rs", // boundary 6 + "src/commands/personas/snapshot/import.rs", // boundary 7 + // test-only relay stubs / fixtures (no production egress): + "src/relay_admission.rs", + "src/archive/mod_tests.rs", + "src/managed_agents/persona_events/tests.rs", + "src/commands/team_snapshot/tests.rs", + "src/egress_guard_tests.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('\\', "/"); + let content = std::fs::read_to_string(&path).unwrap(); + for (i, line) in content.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") { + continue; // doc/comment mentions + } + if line.contains("/events") && !allowlist.iter().any(|a| rel.ends_with(a)) { + violations.push(format!("{rel}:{}: {}", i + 1, line.trim())); + } + } + } + assert!( + violations.is_empty(), + "new `/events` egress site(s) outside the guarded inventory — wire \ + crate::egress_guard and add an injection test before allowlisting:\n{}", + violations.join("\n") + ); +} + +/// 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/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..fe7b110fd3 --- /dev/null +++ b/desktop/src-tauri/src/key_backup.rs @@ -0,0 +1,213 @@ +//! NIP-49 encrypted local key backup. +//! +//! Creates a password-encrypted `ncryptsec` backup of the user's identity key +//! and persists it locally. 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. +//! +//! Design (PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3, reviewed by Wren): +//! +//! - **One artifact per action.** [`create_backup_blob`] encrypts once, then +//! decrypt-verifies the fresh blob against the live identity pubkey before +//! anything is persisted or returned. Two sequential scrypt invocations +//! (encrypt + integrity check), one artifact, ~256 MiB peak. +//! - **Canonical file is trusted-path only.** The app-managed backup at +//! `{app_data_dir}/identity.ncryptsec` is written only by +//! `create_ncryptsec_backup` (commands/identity.rs), which derives the blob +//! from the live identity under `identity_mutation`. The webview can never +//! supply canonical blob bytes — the trust boundary is the password. +//! - **Portable copies are user-owned.** `save_ncryptsec_copy` writes a +//! user-selected file with secret-file semantics (atomic + 0o600) and never +//! mutates canonical app state. + +use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; +use nostr::{FromBech32, Keys, ToBech32}; + +/// Bech32 HRP of NIP-49 encrypted secret keys (used by `import_identity` to +/// route encrypted backups to the decrypt path). +pub const NCRYPTSEC_HRP: &str = "ncryptsec1"; + +/// 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"; + +/// Number of words in a generated backup passphrase. Six words from a +/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor. +const PASSPHRASE_WORDS: usize = 6; + +/// 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 passphrase or corrupted backup".to_string())?; + Ok(Keys::new(secret_key)) +} + +/// Recover identity keys from an import input: `ncryptsec1…` (requires the +/// passphrase, decrypted in Rust) or anything `Keys::parse` accepts (raw +/// `nsec1…`/hex — byte-for-byte the pre-NIP-49 path). +pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + let trimmed = input.trim(); + if trimmed.starts_with(NCRYPTSEC_HRP) { + let password = + password.ok_or_else(|| "encrypted backup requires a passphrase".to_string())?; + decrypt_ncryptsec(trimmed, password) + } else { + Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}")) + } +} + +/// Path of the canonical app-managed backup file. +pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { + data_dir.join(BACKUP_FILE_NAME) +} + +/// 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(()) +} + +/// Delete the canonical app-managed backup if present. Used when a different +/// identity is imported — the old blob backs the previous key and must not +/// linger mislabeled. Missing file is not an error. +pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> { + let path = backup_file_path(data_dir); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete stale backup file: {e}")), + } +} + +/// Remove the app-managed backup when the identity changes: the existing blob +/// encrypts `previous` and must not linger mislabeled once `new` is live. +/// No-op when the identity is unchanged. +pub fn cleanup_stale_backup( + previous: &nostr::PublicKey, + new: &nostr::PublicKey, + data_dir: &std::path::Path, +) -> Result<(), String> { + if previous != new { + delete_backup_file(data_dir)?; + } + Ok(()) +} + +/// Generate a 6-word passphrase from the EFF short wordlist using OS entropy. +/// +/// Uses rejection sampling for a uniform distribution over the 1296 words. +pub fn generate_passphrase() -> Result { + 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() + )); + } + + let mut chosen: Vec<&str> = Vec::with_capacity(PASSPHRASE_WORDS); + while chosen.len() < PASSPHRASE_WORDS { + 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]); + } + } + + Ok(chosen.join(" ")) +} + +#[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..f2c77016bf --- /dev/null +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -0,0 +1,203 @@ +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(NCRYPTSEC_HRP)); + 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 passphrase or corrupted 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}"); +} + +// ── Import key recovery ─────────────────────────────────────────────────────── + +#[test] +fn recover_keys_ncryptsec_happy_path() { + let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn recover_keys_ncryptsec_requires_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); + assert_eq!(err, "encrypted backup requires a passphrase"); +} + +#[test] +fn recover_keys_ncryptsec_wrong_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err(); + assert_eq!(err, "wrong passphrase or corrupted backup"); +} + +#[test] +fn recover_keys_raw_nsec_path_unchanged() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + // Password is ignored on the raw path — exactly today's behavior. + let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + let recovered = recover_keys_from_input(&nsec, None).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + assert!(recover_keys_from_input("garbage", None).is_err()); +} + +// ── File lifecycle ──────────────────────────────────────────────────────────── + +#[test] +fn write_backup_file_persists_0600_and_verifies() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + 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 = backup_file_path(dir.path()); + 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 delete_backup_file_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + delete_backup_file(dir.path()).unwrap(); // missing → Ok + let path = backup_file_path(dir.path()); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + delete_backup_file(dir.path()).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn cleanup_stale_backup_removes_only_on_identity_change() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + let a = Keys::generate().public_key(); + let b = Keys::generate().public_key(); + + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + cleanup_stale_backup(&a, &a, dir.path()).unwrap(); + assert!(path.exists(), "same identity must keep the backup"); + + cleanup_stale_backup(&a, &b, dir.path()).unwrap(); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); +} + +// ── Passphrase generation ───────────────────────────────────────────────────── + +#[test] +fn generated_passphrase_is_six_known_words() { + 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 _ in 0..8 { + let phrase = generate_passphrase().unwrap(); + let parts: Vec<&str> = phrase.split(' ').collect(); + assert_eq!(parts.len(), 6); + for w in &parts { + assert!(words.contains(w), "unknown word {w:?}"); + } + assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN); + } +} + +#[test] +fn generated_passphrases_are_not_repeated() { + // 6 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().unwrap())); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c5b71987ce..348e95469c 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 managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -663,6 +665,9 @@ pub fn run() { title_bar_double_click, get_identity, get_nsec, + generate_backup_passphrase, + create_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 1c9ba0095a..5a0243ae7c 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)?; @@ -548,6 +549,7 @@ pub async fn submit_signed_event( 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")?; let auth_header = { let keys = state.signing_keys()?; build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)? @@ -606,6 +608,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 7fb3f94041..442378eb26 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_event_at_with_keys( .sign_with_keys(keys) .map_err(|e| format!("failed to sign event: {e}"))?; 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-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index d2e35e6839..18ddd80eb8 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -463,6 +463,26 @@ mod tests { assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } + // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── + + #[test] + fn test_wipe_removes_app_managed_key_backup() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let backup = crate::key_backup::backup_file_path(&app_data); + std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); + + assert!(outcome.completed); + assert!( + !backup.exists(), + "sign-out wipe must destroy the app-managed key backup" + ); + } + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] From 1c01889929f04a7a8a77483d7f7b6f791b1d99e2 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Sat, 25 Jul 2026 22:42:56 -0400 Subject: [PATCH 02/26] Encrypted-by-default key backup UI + ncryptsec import (frontend half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of PLANS/NIP49_LOCAL_BACKUP_PLAN.md Rev 3 (D3): - BackupStep: encrypted by default. The default path never invokes get_nsec — the webview only ever sees the finished ncryptsec1 blob returned by create_ncryptsec_backup. The raw key path survives behind an explicit 'Show raw key instead' click with its previous loading/error/skip semantics. - EncryptedBackupCreator (shared by onboarding + settings): generated 6-word passphrase default with cannot-be-recovered copy, 'choose my own' = min 12 chars + confirmation, create -> masked display + copy + 'Save a copy…' via the native dialog. - encryptedBackup.ts: pure reducer/validation model, unit-tested without a DOM; keyImportInput.ts: HRP classification + submit gating. - NsecMaskedDisplay: kind='nsec'|'ncryptsec' drives labels/aria/testids; existing nsec call sites unchanged. - NostrKeyImportForm: ncryptsec1 paste switches to encrypted mode (passphrase field, no npub preview possible), decrypt errors surface; raw nsec flow untouched. import_identity plumbs the optional password through OnboardingFlow/MachineOnboardingFlow/KeyringLockedScreen. - ProfileSettingsCard: 'Encrypted backup' row alongside the raw reveal. - ncryptsecSourceScan.test.mjs: TS-side source-allowlist tripwire mirroring the Rust scan (defense-in-depth per plan D4). - e2e: mock bridge learns the three backup commands + ncryptsec import; onboarding-backup.spec.ts covers the encrypted happy path (asserting get_nsec is never called), custom-passphrase validation, raw fallback, and error/retry; onboarding.spec.ts adds encrypted-import happy path including a wrong-passphrase rejection. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../onboarding/lib/encryptedBackup.test.mjs | 156 +++++++++ .../onboarding/lib/encryptedBackup.ts | 117 +++++++ .../onboarding/lib/keyImportInput.test.mjs | 50 +++ .../features/onboarding/lib/keyImportInput.ts | 43 +++ .../src/features/onboarding/ui/BackupStep.tsx | 74 ++++- .../onboarding/ui/EncryptedBackupCreator.tsx | 309 ++++++++++++++++++ .../onboarding/ui/KeyringLockedScreen.tsx | 4 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 4 +- .../onboarding/ui/NostrKeyImportForm.tsx | 85 ++++- .../onboarding/ui/NsecMaskedDisplay.tsx | 31 +- .../features/onboarding/ui/OnboardingFlow.tsx | 4 +- .../ui/onboardingFlowSteps.test.mjs | 57 +++- .../settings/ui/ProfileSettingsCard.tsx | 38 +++ desktop/src/shared/api/tauriIdentity.ts | 38 ++- .../shared/lib/ncryptsecSourceScan.test.mjs | 67 ++++ desktop/src/testing/e2eBridge.ts | 65 +++- desktop/tests/e2e/onboarding-backup.spec.ts | 120 +++++-- desktop/tests/e2e/onboarding.spec.ts | 36 ++ desktop/tests/helpers/onboarding.ts | 8 +- 19 files changed, 1228 insertions(+), 78 deletions(-) create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.test.mjs create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.ts create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.test.mjs create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.ts create mode 100644 desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx create mode 100644 desktop/src/shared/lib/ncryptsecSourceScan.test.mjs diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..f118bbf455 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -0,0 +1,156 @@ +/** + * Pure-logic tests for the encrypted-backup (NIP-49) creation state model. + * These drive the same reducer + validation helpers the BackupStep and + * settings row use, without a DOM. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MIN_CUSTOM_PASSPHRASE_LEN, + createDisabled, + customPassphraseIssue, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; + +function reduce(events, from = initialEncryptedBackupState) { + return events.reduce(encryptedBackupReducer, from); +} + +// ── generated-passphrase mode (default) ───────────────────────────────────── + +test("create_disabled_until_generated_passphrase_arrives", () => { + assert.equal(createDisabled(initialEncryptedBackupState), true); + const ready = reduce([ + { + type: "passphrase-generated", + passphrase: "alpha bravo carbon delta echo fox", + }, + ]); + assert.equal(createDisabled(ready), false); + assert.equal(effectivePassphrase(ready), "alpha bravo carbon delta echo fox"); +}); + +test("regenerate_replaces_passphrase_and_clears_generate_error", () => { + const failed = reduce([ + { type: "passphrase-generate-failed", message: "boom" }, + ]); + assert.equal(failed.generateError, "boom"); + const recovered = reduce( + [{ type: "passphrase-generated", passphrase: "a b c d e f" }], + failed, + ); + assert.equal(recovered.generateError, null); + assert.equal(recovered.generatedPassphrase, "a b c d e f"); +}); + +// ── custom-passphrase mode ─────────────────────────────────────────────────── + +test("custom_mode_requires_min_length_and_matching_confirm", () => { + const base = reduce([ + { type: "passphrase-generated", passphrase: "gen gen gen gen gen gen" }, + { type: "set-mode", mode: "custom" }, + ]); + + // Too short — even though a generated passphrase exists, custom mode must + // not silently fall back to it. + const short = reduce( + [ + { type: "set-custom-passphrase", value: "short" }, + { type: "set-custom-confirm", value: "short" }, + ], + base, + ); + assert.equal(effectivePassphrase(short), null); + assert.equal(createDisabled(short), true); + + // Long enough but mismatched confirm. + const mismatched = reduce( + [ + { + type: "set-custom-passphrase", + value: "a".repeat(MIN_CUSTOM_PASSPHRASE_LEN), + }, + { + type: "set-custom-confirm", + value: "b".repeat(MIN_CUSTOM_PASSPHRASE_LEN), + }, + ], + base, + ); + assert.equal(effectivePassphrase(mismatched), null); + + // Valid. + const ok = reduce( + [ + { type: "set-custom-passphrase", value: "correct horse battery" }, + { type: "set-custom-confirm", value: "correct horse battery" }, + ], + base, + ); + assert.equal(effectivePassphrase(ok), "correct horse battery"); + assert.equal(createDisabled(ok), false); +}); + +test("custom_passphrase_issue_messages", () => { + // Empty input: no scolding while the user hasn't typed anything. + assert.equal(customPassphraseIssue("", ""), null); + assert.match( + customPassphraseIssue("short", ""), + new RegExp(`${MIN_CUSTOM_PASSPHRASE_LEN}`), + ); + // Mismatch is only reported once confirm has content. + assert.equal(customPassphraseIssue("a".repeat(12), ""), null); + assert.match(customPassphraseIssue("a".repeat(12), "b"), /match/); + assert.equal(customPassphraseIssue("a".repeat(12), "a".repeat(12)), null); +}); + +test("min_length_counts_code_points_not_utf16_units", () => { + // 12 astral-plane emoji = 24 UTF-16 units but 12 code points; mirrors the + // Rust chars().count() gate so both sides agree on the boundary. + const emoji = "😀".repeat(MIN_CUSTOM_PASSPHRASE_LEN); + assert.equal(customPassphraseIssue(emoji, emoji), null); + const ready = reduce([ + { type: "set-mode", mode: "custom" }, + { type: "set-custom-passphrase", value: emoji }, + { type: "set-custom-confirm", value: emoji }, + ]); + assert.equal(effectivePassphrase(ready), emoji); +}); + +// ── create lifecycle ───────────────────────────────────────────────────────── + +test("create_lifecycle_happy_path_and_failure", () => { + const ready = reduce([ + { type: "passphrase-generated", passphrase: "one two three four five six" }, + ]); + + const creating = reduce([{ type: "create-started" }], ready); + assert.equal(creating.isCreating, true); + assert.equal( + createDisabled(creating), + true, + "no double-create while KDF runs", + ); + + const failed = reduce( + [{ type: "create-failed", message: "keychain unavailable" }], + creating, + ); + assert.equal(failed.isCreating, false); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(createDisabled(failed), false, "retry allowed after failure"); + + const done = reduce( + [ + { type: "create-started" }, + { type: "create-succeeded", ncryptsec: "ncryptsec1abc" }, + ], + failed, + ); + assert.equal(done.isCreating, false); + assert.equal(done.ncryptsec, "ncryptsec1abc"); + assert.equal(done.createError, null); +}); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts new file mode 100644 index 0000000000..fd448edcf4 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -0,0 +1,117 @@ +/** + * Pure state model for the encrypted-key-backup (NIP-49) creation flow, + * shared by the onboarding BackupStep and the settings Encrypted backup row. + * + * All validation and phase logic lives here so it can be unit-tested without + * React. Hosts wire the reducer to the Tauri commands + * (`generate_backup_passphrase`, `create_ncryptsec_backup`) and dispatch + * events; the model never touches the raw private key — by construction the + * default backup path cannot invoke `get_nsec`. + */ + +export type PassphraseMode = "generated" | "custom"; + +/** Mirrors `MIN_PASSPHRASE_LEN` in `src-tauri/src/key_backup.rs`. */ +export const MIN_CUSTOM_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + /** Six-word passphrase generated in Rust; null until loaded. */ + generatedPassphrase: string | null; + generateError: string | null; + mode: PassphraseMode; + customPassphrase: string; + customConfirm: string; + isCreating: boolean; + createError: string | null; + /** The persisted `ncryptsec1…` blob once the backup exists. */ + ncryptsec: string | null; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + generatedPassphrase: null, + generateError: null, + mode: "generated", + customPassphrase: "", + customConfirm: "", + isCreating: false, + createError: null, + ncryptsec: null, +}; + +export type EncryptedBackupEvent = + | { type: "passphrase-generated"; passphrase: string } + | { type: "passphrase-generate-failed"; message: string } + | { type: "set-mode"; mode: PassphraseMode } + | { type: "set-custom-passphrase"; value: string } + | { type: "set-custom-confirm"; value: string } + | { type: "create-started" } + | { type: "create-succeeded"; ncryptsec: string } + | { type: "create-failed"; message: string }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "passphrase-generated": + return { + ...state, + generatedPassphrase: event.passphrase, + generateError: null, + }; + case "passphrase-generate-failed": + return { ...state, generateError: event.message }; + case "set-mode": + // Editing state carries across toggles; validation re-derives. + return { ...state, mode: event.mode, createError: null }; + case "set-custom-passphrase": + return { ...state, customPassphrase: event.value, createError: null }; + case "set-custom-confirm": + return { ...state, customConfirm: event.value, createError: null }; + case "create-started": + return { ...state, isCreating: true, createError: null }; + case "create-succeeded": + return { ...state, isCreating: false, ncryptsec: event.ncryptsec }; + case "create-failed": + return { ...state, isCreating: false, createError: event.message }; + } +} + +/** + * Validation issue for a custom passphrase, or null when acceptable. + * Confirm mismatch is only reported once the confirm field has content, so + * the user isn't scolded mid-typing. + */ +export function customPassphraseIssue( + passphrase: string, + confirm: string, +): string | null { + if (passphrase.length === 0) return null; + if ([...passphrase].length < MIN_CUSTOM_PASSPHRASE_LEN) { + return `Use at least ${MIN_CUSTOM_PASSPHRASE_LEN} characters.`; + } + if (confirm.length > 0 && passphrase !== confirm) { + return "Passphrases don't match."; + } + return null; +} + +/** The passphrase the Create action would submit, or null when not ready. */ +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + if (state.mode === "generated") return state.generatedPassphrase; + const { customPassphrase, customConfirm } = state; + if ( + [...customPassphrase].length < MIN_CUSTOM_PASSPHRASE_LEN || + customPassphrase !== customConfirm + ) { + return null; + } + return customPassphrase; +} + +/** Whether the "Create backup" action is currently actionable. */ +export function createDisabled(state: EncryptedBackupState): boolean { + return state.isCreating || effectivePassphrase(state) === null; +} diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs new file mode 100644 index 0000000000..8c5d8047f4 --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -0,0 +1,50 @@ +/** + * Pure-logic tests for key-import input classification (nsec vs NIP-49 + * ncryptsec) and submit gating. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey } from "nostr-tools/pure"; +import { + classifyKeyImportInput, + isPlausibleNcryptsec, + keyImportSubmitEnabled, +} from "./keyImportInput.ts"; + +// NIP-49 spec vector — structurally valid encrypted backup. +const NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +const VALID_NSEC = nsecEncode(generateSecretKey()); + +test("classify_by_hrp_with_whitespace_tolerance", () => { + assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); + assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); + assert.equal(classifyKeyImportInput(""), "unknown"); + // nsec must not be shadowed by the longer HRP check. + assert.equal(classifyKeyImportInput("nsec1"), "nsec"); +}); + +test("plausible_ncryptsec_requires_bech32_charset", () => { + assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true); + // '1' and 'b' / 'i' / 'o' are not in the bech32 charset. + assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false); +}); + +test("submit_gating_nsec_path_unchanged", () => { + assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true); + assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false); + assert.equal(keyImportSubmitEnabled("", ""), false); +}); + +test("submit_gating_ncryptsec_requires_passphrase", () => { + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false); + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true); + // Structurally implausible blob never submits, passphrase or not. + assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false); +}); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts new file mode 100644 index 0000000000..2477154c92 --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -0,0 +1,43 @@ +/** + * Pure classification + submit gating for the key-import form, unit-testable + * without a DOM. + * + * `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible + * (the pubkey is inside the encrypted payload) and a passphrase is required. + * Full validation happens in Rust at decrypt time; here we only classify by + * HRP shape so the form can switch modes while the user is mid-paste. + */ + +import { nsecToNpub } from "@/shared/lib/nostrUtils"; + +export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; + +/** Bech32 charset after the `ncryptsec1` HRP; anything else can't decode. */ +const NCRYPTSEC_SHAPE = /^ncryptsec1[02-9ac-hj-np-z]{10,}$/; + +export function classifyKeyImportInput(input: string): KeyImportKind { + const trimmed = input.trim(); + if (trimmed.startsWith("ncryptsec1")) return "ncryptsec"; + if (trimmed.startsWith("nsec1")) return "nsec"; + return "unknown"; +} + +/** Structurally plausible encrypted backup (real validation is in Rust). */ +export function isPlausibleNcryptsec(input: string): boolean { + return NCRYPTSEC_SHAPE.test(input.trim()); +} + +/** + * Whether the import form's submit should be enabled. + * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase. + */ +export function keyImportSubmitEnabled( + input: string, + passphrase: string, +): boolean { + const kind = classifyKeyImportInput(input); + if (kind === "ncryptsec") { + return isPlausibleNcryptsec(input) && passphrase.length > 0; + } + return nsecToNpub(input) !== null; +} diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index ed2184baaa..4b7b176494 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -11,21 +11,33 @@ import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; +import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +export type BackupStepMode = "encrypted" | "raw"; + /** * Pure helper so the disabled logic can be unit-tested without a DOM. * - * Disabled while loading (key not fetched yet) or after a failed load (only - * the explicit "Skip for now" ghost advances past an error). + * Encrypted mode (default): Next unlocks once the backup blob exists — the + * user must either create a backup or explicitly switch to the raw key. + * Raw mode: disabled while loading or after a failed load (only the explicit + * "Skip for now" ghost advances past an error), matching the previous flow. */ export function backupNextDisabled({ + mode, + hasBackup, isLoading, loadError, }: { + mode: BackupStepMode; + hasBackup: boolean; isLoading: boolean; loadError: string | null; }): boolean { + if (mode === "encrypted") { + return !hasBackup; + } return isLoading || loadError !== null; } @@ -36,12 +48,16 @@ type BackupStepProps = { }; /** - * Onboarding backup step — shows the user their freshly created key so they - * can save it somewhere safe. Only shown on the fresh-key path. + * Onboarding backup step — encrypted by default. The user protects their + * freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…` + * backup; the raw key is only fetched (and shown) after an explicit + * "Show raw key instead" click. The default path never invokes `get_nsec`. */ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { + const [mode, setMode] = React.useState("encrypted"); + const [hasBackup, setHasBackup] = React.useState(false); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); + const [isLoading, setIsLoading] = React.useState(false); const [loadError, setLoadError] = React.useState(null); const cancelledRef = React.useRef(false); @@ -65,13 +81,17 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { React.useEffect(() => { cancelledRef.current = false; - void loadNsec(); return () => { // Back-during-fetch: cancel any in-flight setState calls and clear the // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); }; + }, []); + + const showRawKey = React.useCallback(() => { + setMode("raw"); + void loadNsec(); }, [loadNsec]); return ( @@ -86,13 +106,23 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { Your unique identity key has been created

- This key is stored in your system keychain, but save it some place - safe in case you ever need to restore your account. + {mode === "encrypted" + ? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account." + : "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."}

- {isLoading ? ( + {mode === "encrypted" ? ( + +
+ setHasBackup(true)} + variant="spotlight" + /> +
+
+ ) : isLoading ? (
Loading your private key… @@ -134,7 +164,22 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {

)} - {nsec ? ( + {mode === "encrypted" && !hasBackup ? ( +
+ +
+ ) : null} + + {mode === "raw" && nsec ? (

@@ -149,14 +194,19 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { - {loadError ? ( + {mode === "raw" && loadError ? ( + {savedPath ? ( +

+ Saved to {savedPath} +

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

{saveError}

+ ) : null} +

+ This backup can only be unlocked with your passphrase. Without the + passphrase it cannot be recovered — not even by Buzz. +

+
+ ); + } + + return ( +
+ {state.mode === "generated" ? ( +
+ {state.generatedPassphrase ? ( +
+

+ {state.generatedPassphrase} +

+
+ ) : state.generateError ? ( +
+ + + Could not generate a passphrase: {state.generateError} + +
+ ) : ( +
+ + Generating a passphrase… +
+ )} +
+ + +
+

+ Write this passphrase down. It protects your backup and cannot be + recovered if lost. +

+
+ ) : ( +
+
+ + dispatch({ + type: "set-custom-passphrase", + value: event.target.value, + }) + } + placeholder={`Passphrase (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`} + type="password" + value={state.customPassphrase} + /> + + dispatch({ + type: "set-custom-confirm", + value: event.target.value, + }) + } + placeholder="Confirm passphrase" + type="password" + value={state.customConfirm} + /> +
+ {customIssue ? ( +

+ {customIssue} +

+ ) : null} +
+ +
+

+ Your passphrase protects the backup and cannot be recovered if lost. +

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

+ {state.createError} +

+ ) : null} + +
+ +
+
+ ); +} diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx index 0376fc9709..a6a02f38c0 100644 --- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx +++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx @@ -22,8 +22,8 @@ export function KeyringLockedScreen() { }, []); const handleImport = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); // Update the identity query cache so useIdentityQuery observers see // locked: false. The bootedLocked latch in hooks.ts will then route // to RelaunchRequiredScreen via bootedLocked && !identityLocked. diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index e400d07a91..80960ab5de 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -98,8 +98,8 @@ export function MachineOnboardingFlow({ }, [queryClient]); const importExistingIdentity = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); continueWithIdentity(identity.pubkey); queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index daa3f007e9..aed5d0005e 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -3,6 +3,10 @@ import { Check, Eye, EyeOff, KeyRound } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { nsecToNpub } from "@/shared/lib/nostrUtils"; +import { + classifyKeyImportInput, + keyImportSubmitEnabled, +} from "../lib/keyImportInput"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { Input } from "@/shared/ui/input"; @@ -17,7 +21,7 @@ type NostrKeyImportFormProps = { disabled?: boolean; errorMessage?: string | null; onBack: () => void; - onImport: (nsec: string) => Promise; + onImport: (nsec: string, password?: string) => Promise; /** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */ variant?: "default" | "spotlight"; }; @@ -38,6 +42,7 @@ export function NostrKeyImportForm({ variant = "default", }: NostrKeyImportFormProps) { const [nsecInput, setNsecInput] = React.useState(""); + const [passphrase, setPassphrase] = React.useState(""); const [isImporting, setIsImporting] = React.useState(false); const [importError, setImportError] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); @@ -47,6 +52,8 @@ export function NostrKeyImportForm({ const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]); const trimmedInput = nsecInput.trim(); const hasInput = trimmedInput.length > 0; + const inputKind = classifyKeyImportInput(nsecInput); + const isEncryptedInput = inputKind === "ncryptsec"; // Masked-by-default must re-assert whenever the field empties: a sticky // reveal from a previous key must never apply to newly pasted content the @@ -56,9 +63,17 @@ export function NostrKeyImportForm({ setIsRevealed(false); } }, [hasInput]); - const isValid = previewNpub !== null; + // A stale passphrase must never ride along when the input stops being an + // encrypted backup (cleared field, or replaced with a raw nsec). + React.useEffect(() => { + if (!isEncryptedInput) { + setPassphrase(""); + } + }, [isEncryptedInput]); + const isValid = keyImportSubmitEnabled(nsecInput, passphrase); const isInteractionDisabled = disabled || isImporting; - const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5; + const showInvalidHint = + hasInput && !isValid && !isEncryptedInput && trimmedInput.length >= 5; const errorMessage = importError ?? externalErrorMessage; React.useLayoutEffect(() => { @@ -108,9 +123,11 @@ export function NostrKeyImportForm({ return; } - if (!previewNpub) { + if (!isValid) { setImportError( - "That doesn't look like a valid nsec. Paste an nsec1 key.", + isEncryptedInput + ? "Enter the passphrase for this encrypted backup." + : "That doesn't look like a valid nsec. Paste an nsec1 key.", ); return; } @@ -119,7 +136,7 @@ export function NostrKeyImportForm({ setImportError(null); try { - await onImport(trimmedInput); + await onImport(trimmedInput, isEncryptedInput ? passphrase : undefined); } catch (error) { setImportError( error instanceof Error ? error.message : "Couldn't import this key.", @@ -127,7 +144,14 @@ export function NostrKeyImportForm({ } finally { setIsImporting(false); } - }, [isInteractionDisabled, onImport, previewNpub, trimmedInput]); + }, [ + isEncryptedInput, + isInteractionDisabled, + isValid, + onImport, + passphrase, + trimmedInput, + ]); return (
)} + {isEncryptedInput ? ( +
+ + { + setPassphrase(event.target.value); + setImportError(null); + }} + placeholder="Passphrase" + spellCheck={false} + type="password" + value={passphrase} + /> +
+ ) : null} +
- {previewNpub ? ( + {isEncryptedInput ? ( + // No npub preview is possible: the pubkey lives inside the encrypted + // payload and is only recovered by decrypting in Rust. +

+

+ ) : previewNpub ? ( variant === "spotlight" ? ( // Spotlight uses the backup step's quiet caption language: // centered, unboxed, with the npub in the shared olive key ink. diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index 111bdefd71..df4536ef1d 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -7,6 +7,12 @@ type NsecMaskedDisplayProps = { nsec: string; /** "bare" drops the boxed chrome for the onboarding spotlight treatment. */ variant?: "boxed" | "bare"; + /** + * What kind of secret is displayed. Drives labels, aria and testids: + * a raw private key ("nsec", default) can impersonate its holder; an + * encrypted backup ("ncryptsec") is only as sensitive as its passphrase. + */ + kind?: "nsec" | "ncryptsec"; /** * Called when the user reveals or copies the key. Lets flows that require * a backup (e.g. sign-out) gate on actual interaction with the key. @@ -14,6 +20,17 @@ type NsecMaskedDisplayProps = { onKeyInteraction?: () => void; }; +const KIND_LABELS = { + nsec: { + noun: "private key", + testIdPrefix: "nsec", + }, + ncryptsec: { + noun: "encrypted backup", + testIdPrefix: "ncryptsec", + }, +} as const; + export const ONBOARDING_KEY_FRAME_CLASS = "w-full min-w-0 rounded-xl bg-white/50 px-8 py-6"; export const ONBOARDING_KEY_ROW_CLASS = "flex min-w-0 items-center gap-4"; @@ -30,8 +47,10 @@ export const ONBOARDING_KEY_TEXT_CLASS = "buzz-onboarding-key-text"; export function NsecMaskedDisplay({ nsec, variant = "boxed", + kind = "nsec", onKeyInteraction, }: NsecMaskedDisplayProps) { + const labels = KIND_LABELS[kind]; const [isRevealed, setIsRevealed] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false); const copyTimerRef = React.useRef | null>(null); @@ -100,16 +119,18 @@ export function NsecMaskedDisplay({ ? `select-text ${isBare ? "" : "text-foreground"}` : `select-none blur-[4px] ${isBare ? "" : "text-muted-foreground"}` }`} - data-testid="nsec-value" + data-testid={`${labels.testIdPrefix}-value`} > {isRevealed ? nsec : maskedNsec}

+
+ {isOpen ? ( +
+ +
+ ) : null} + + ); +} + function EditProfileMetadataButton({ label, testId, @@ -884,6 +921,7 @@ export function ProfileSettingsCard({ value={nip05Handle} /> + diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..1d961aa16b 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -27,9 +27,43 @@ export async function getNsec(): Promise { return invokeTauri("get_nsec"); } -export async function importIdentity(nsec: string): Promise { +/** + * Import an identity from a raw `nsec1…` key or an encrypted `ncryptsec1…` + * backup. Encrypted backups require `password`; decryption happens in Rust. + */ +export async function importIdentity( + nsec: string, + password?: string, +): Promise { return fromRawIdentity( - await invokeTauri("import_identity", { nsec }), + await invokeTauri("import_identity", { nsec, password }), + ); +} + +/** Generate a 6-word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase(): Promise { + return invokeTauri("generate_backup_passphrase"); +} + +/** + * Create the canonical app-managed NIP-49 backup: encrypts the live identity + * under `password`, persists `identity.ncryptsec` (atomic, 0o600), and + * returns the exact persisted `ncryptsec1…` string. Takes ~2s (scrypt). + */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** + * Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. + * Returns the chosen path, or `null` when the user cancelled. + */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null ); } diff --git a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs new file mode 100644 index 0000000000..a30baa6a0d --- /dev/null +++ b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +// Structural tripwire (plan D4, defense-in-depth): NIP-49 backup material +// handling in the webview is confined to the identity/backup/import UI and +// its API wrappers. Anything else in `desktop/src` touching `ncryptsec` is +// structural drift toward an unguarded egress path and must be reviewed — +// the runtime guarantee lives in src-tauri's egress guard, this scan only +// keeps the blob from quietly spreading through the frontend. +// +// Mirror of the Rust-side scan in +// `src-tauri/src/egress_guard_tests.rs::ncryptsec_handling_is_confined_to_allowlisted_files`. + +const SRC_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); + +const ALLOWLIST = [ + "shared/api/tauriIdentity.ts", + "features/onboarding/lib/encryptedBackup.ts", + "features/onboarding/lib/encryptedBackup.test.mjs", + "features/onboarding/lib/keyImportInput.ts", + "features/onboarding/lib/keyImportInput.test.mjs", + "features/onboarding/ui/BackupStep.tsx", + "features/onboarding/ui/EncryptedBackupCreator.tsx", + "features/onboarding/ui/NostrKeyImportForm.tsx", + "features/onboarding/ui/NsecMaskedDisplay.tsx", + "features/settings/ui/ProfileSettingsCard.tsx", + // e2e-only mock bridge (never in the production bundle): + "testing/e2eBridge.ts", + // this scan: + "shared/lib/ncryptsecSourceScan.test.mjs", +]; + +function* walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + yield* walk(full); + } else if (/\.(ts|tsx|mjs|js|jsx)$/.test(entry.name)) { + yield full; + } + } +} + +test("ncryptsec handling is confined to allowlisted frontend files", () => { + const violations = []; + for (const file of walk(SRC_ROOT)) { + const rel = path.relative(SRC_ROOT, file).replaceAll("\\", "/"); + if (ALLOWLIST.includes(rel)) continue; + const content = fs.readFileSync(file, "utf8"); + if (content.toLowerCase().includes("ncryptsec")) { + violations.push(rel); + } + } + assert.deepEqual( + violations, + [], + `NIP-49 material outside allowlisted files — wire it through the ` + + `identity layer (and its egress-guarded Rust commands) instead:\n` + + violations.join("\n"), + ); +}); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index da398ca4ba..368b329bb7 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, nsecEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; @@ -7089,6 +7089,15 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +// Shape-valid NIP-49 spec-vector blob returned by the mocked +// `create_ncryptsec_backup` (same vector the Rust tests use). It never +// corresponds to the mock identity — browser specs assert flow, not crypto. +const MOCK_NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +// The single passphrase the mocked backup commands accept/emit. +const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit"; + // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -9423,6 +9432,31 @@ export function maybeInstallE2eTauriMocks() { } return "nsec1mock000000000000000000000000000000000000000000000000000000"; } + case "generate_backup_passphrase": + // Deterministic mock: production generates 6 EFF short-wordlist words + // from OS entropy in Rust. Specs only assert display/flow, never + // entropy quality. + return MOCK_BACKUP_PASSPHRASE; + case "create_ncryptsec_backup": { + // Production encrypts the live key under the passphrase, persists + // `identity.ncryptsec`, and returns the exact persisted blob. The + // browser harness has no key material or filesystem — return a + // shape-valid mock blob so the display/copy/save flow can proceed. + const password = (payload as { password?: string } | null)?.password; + if (!password) { + throw new Error("A passphrase is required."); + } + return MOCK_NCRYPTSEC; + } + case "save_ncryptsec_copy": { + const blob = (payload as { ncryptsec?: string } | null)?.ncryptsec; + if (!blob?.startsWith("ncryptsec1")) { + throw new Error("Not a valid encrypted key backup."); + } + // Production opens a native save dialog; the harness pretends the + // user picked a path. + return "/mock/backups/identity.ncryptsec"; + } case "persist_current_identity": { // Persist the ephemeral key: clears only the lost flag. The locked flag // is cleared only by import_identity; production rejects @@ -9438,12 +9472,33 @@ export function maybeInstallE2eTauriMocks() { locked: false, }; } - case "import_identity": + case "import_identity": { + const request = payload as { + nsec?: string; + password?: string; + } | null; + const input = request?.nsec ?? ""; + if (input.trim().startsWith("ncryptsec1")) { + // Production decrypts in Rust and rejects a wrong passphrase. The + // harness has no scrypt: the spec-vector blob + the fixed mock + // passphrase decrypt to the default mock identity's key; anything + // else is a wrong passphrase / corrupted backup. + if ( + input.trim() !== MOCK_NCRYPTSEC || + request?.password !== MOCK_BACKUP_PASSPHRASE + ) { + throw new Error("Wrong passphrase or corrupted backup."); + } + mockIdentityLostCleared = true; + mockIdentityLockedCleared = true; + return importMockIdentity( + nsecEncode(hexToBytes(DEFAULT_REAL_IDENTITY.privateKey)), + ); + } mockIdentityLostCleared = true; mockIdentityLockedCleared = true; - return importMockIdentity( - (payload as { nsec?: string } | null)?.nsec ?? "", - ); + return importMockIdentity(input); + } case "validate_repos_dir": // The browser harness has no host filesystem to validate. Treat the // seeded empty/default path as valid so Add Community can continue to diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 14f50e924f..bb184aaaf5 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -26,10 +26,88 @@ test("backup step appears on fresh-key path after profile submit", async ({ ).toBeVisible(); }); -test("backup step shows masked nsec from mock bridge", async ({ page }) => { +// --------------------------------------------------------------------------- +// Encrypted-by-default path (plan D3): passphrase → create → ncryptsec shown. +// The raw key must never be fetched on this path. +// --------------------------------------------------------------------------- + +test("encrypted backup happy path: generated passphrase, create, save copy, Next", async ({ + page, +}) => { await enterMachineBackup(page); - await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); + // Default mode: generated passphrase shown, Next locked until backup exists. + await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toBeDisabled(); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/02-backup-step-passphrase.png` }); + + await page.getByTestId("encrypted-backup-create").click(); + + // The persisted blob is displayed masked; copy + save-a-copy available. + const blob = page.getByTestId("ncryptsec-value"); + await expect(blob).toBeVisible(); + await expect(blob).toHaveCSS("filter", /blur/); + await page.getByTestId("ncryptsec-reveal-toggle").click(); + await expect(blob).toContainText("ncryptsec1"); + + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/03-backup-step-encrypted.png` }); + + await page.getByTestId("encrypted-backup-save-copy").click(); + await expect(page.getByTestId("encrypted-backup-saved-path")).toContainText( + "identity.ncryptsec", + ); + + // The default path must never have fetched the raw key. + const commands = await page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect(commands).not.toContain("get_nsec"); + expect(commands).toContain("create_ncryptsec_backup"); + + await expect(page.getByTestId("onboarding-next")).toBeEnabled(); + await page.getByTestId("onboarding-next").click(); + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); +}); + +test("custom passphrase requires 12 characters and confirmation", async ({ + page, +}) => { + await enterMachineBackup(page); + + await page.getByTestId("backup-passphrase-choose-own").click(); + const create = page.getByTestId("encrypted-backup-create"); + + await page.getByTestId("backup-passphrase-custom").fill("short"); + await expect(page.getByTestId("backup-passphrase-issue")).toBeVisible(); + await expect(create).toBeDisabled(); + + await page + .getByTestId("backup-passphrase-custom") + .fill("a much longer passphrase"); + await expect(create).toBeDisabled(); // confirm still empty + + await page + .getByTestId("backup-passphrase-confirm") + .fill("a much longer passphrase"); + await expect(create).toBeEnabled(); +}); + +// --------------------------------------------------------------------------- +// Raw-key path: preserved behind an explicit "Show raw key instead" click. +// --------------------------------------------------------------------------- + +test("raw key path is one explicit click away and shows the masked nsec", async ({ + page, +}) => { + await enterMachineBackup(page); + + await page.getByTestId("backup-show-raw-key").click(); + const nsecDisplay = page.getByTestId("nsec-value"); await expect(nsecDisplay).toBeVisible(); @@ -38,42 +116,17 @@ test("backup step shows masked nsec from mock bridge", async ({ page }) => { await expect(revealBtn).toBeVisible(); await expect(nsecDisplay).toHaveCSS("filter", /blur/); - // Take a screenshot of the masked state. Capture the whole viewport: the CTAs - // are portaled into the docked footer outside the step subtree. - await waitForAnimations(page); - await page.screenshot({ - path: `${SHOTS}/02-backup-step-masked.png`, - }); - // Reveal and verify the mock nsec appears. await revealBtn.click(); await expect(nsecDisplay).not.toHaveCSS("filter", /blur/); await expect(nsecDisplay).toContainText("nsec1mock"); - // Take a screenshot of the revealed state. await waitForAnimations(page); - await page.screenshot({ - path: `${SHOTS}/03-backup-step-revealed.png`, - }); -}); + await page.screenshot({ path: `${SHOTS}/04-backup-step-raw-revealed.png` }); -test("backup step Next is enabled once the key is shown", async ({ page }) => { - await enterMachineBackup(page); - - await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - await expect(page.getByTestId("nsec-value")).toBeVisible(); + // Raw mode keeps the previous gating: key shown → Next enabled. await expect(page.getByTestId("onboarding-next")).toBeEnabled(); -}); - -test("backup step advances to machine setup on Next click", async ({ - page, -}) => { - await enterMachineBackup(page); - - await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - await expect(page.getByTestId("nsec-value")).toBeVisible(); await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); @@ -91,10 +144,10 @@ test("backup step back button returns to machine identity choice", async ({ }); // --------------------------------------------------------------------------- -// B4: Error path coverage +// B4: Error path coverage (raw path) // --------------------------------------------------------------------------- -test("backup step shows error banner and retry button when get_nsec fails", async ({ +test("raw path shows error banner and retry button when get_nsec fails", async ({ page, }) => { await installMockBridge( @@ -106,6 +159,8 @@ test("backup step shows error banner and retry button when get_nsec fails", asyn await page.getByRole("button", { name: "Create a new identity key" }).click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); + await page.getByTestId("backup-show-raw-key").click(); + await expect(page.getByTestId("backup-load-error")).toBeVisible(); await expect(page.getByTestId("backup-retry")).toBeVisible(); // Next is blocked on error; Skip for now ghost is shown instead. @@ -117,7 +172,7 @@ test("backup step shows error banner and retry button when get_nsec fails", asyn await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); -test("backup step retry succeeds and shows key after initial failure", async ({ +test("raw path retry succeeds and shows key after initial failure", async ({ page, }) => { // First call fails, second succeeds (sequenced via nsecErrors). @@ -128,6 +183,7 @@ test("backup step retry succeeds and shows key after initial failure", async ({ ); await page.goto("/"); await page.getByRole("button", { name: "Create a new identity key" }).click(); + await page.getByTestId("backup-show-raw-key").click(); await expect(page.getByTestId("backup-load-error")).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index bfbfc6f063..d579dce739 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -621,6 +621,42 @@ test("first-launch key import continues to machine setup", async ({ page }) => { await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); }); +test("first-launch encrypted backup import asks for a passphrase and continues", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + await page.getByRole("button", { name: "Use an existing key" }).click(); + // Spec-vector blob the mock bridge accepts with the mock passphrase. + const mockNcryptsec = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + await page.getByTestId("nostr-import-nsec-input").fill(mockNcryptsec); + + // No npub preview is possible; the form switches to encrypted mode and + // requires a passphrase before submit unlocks. + await expect(page.getByTestId("nostr-import-encrypted-badge")).toBeVisible(); + await expect(page.getByTestId("nostr-import-submit")).toBeDisabled(); + + // Wrong passphrase surfaces the decrypt error and stays on the form. + await page.getByTestId("nostr-import-passphrase").fill("wrong passphrase"); + await page.getByTestId("nostr-import-submit").click(); + await expect(page.getByTestId("nostr-import-feedback")).toContainText( + /wrong passphrase/i, + ); + + await page + .getByTestId("nostr-import-passphrase") + .fill("mock horse battery staple lake orbit"); + await page.getByTestId("nostr-import-submit").click(); + + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); +}); + test("non-local runtime override keeps community selection without release flag", async ({ page, }) => { diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts index 61b31e6e5b..7b35c29f9f 100644 --- a/desktop/tests/helpers/onboarding.ts +++ b/desktop/tests/helpers/onboarding.ts @@ -16,9 +16,13 @@ export async function seedActiveIdentity( ); } -/** Navigate through the backup step (fresh-key path). */ +/** Navigate through the backup step (fresh-key path, encrypted default). */ export async function passThroughBackupStep(page: Page) { await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - await expect(page.getByTestId("nsec-value")).toBeVisible(); + // Encrypted-by-default: create the backup with the generated passphrase, + // then advance. (The raw key path is behind "Show raw key instead".) + await expect(page.getByTestId("backup-passphrase-generated")).toBeVisible(); + await page.getByTestId("encrypted-backup-create").click(); + await expect(page.getByTestId("ncryptsec-value")).toBeVisible(); await page.getByTestId("onboarding-next").click(); } From dde37183e3fe7328d5ae678e09e706b90ef4faec Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Sat, 25 Jul 2026 23:45:09 -0400 Subject: [PATCH 03/26] Address implementation-review blockers: import ordering, site-granular inventory, uppercase bech32 Blocker 1: import_identity persisted-before-cleanup ordering. New commit_imported_identity helper runs durable persistence FIRST; a failed different-key import now leaves both the old in-memory identity and its valid canonical identity.ncryptsec intact. Stale-backup cleanup runs after the durable commit and is deliberately best-effort (logged, not surfaced as a half-applied-import error). Regression tests cover the failure path and prove cleanup cannot precede persist. Blocker 2: the /events inventory tripwire is now site-granular. Each inventoried file pairs an expected non-comment /events occurrence count with an expected egress-guard call count, so an unguarded ninth site in an already-listed file (or a deleted guard call) fails the scan. The scan core is pure over (path, content) pairs, with mutation-style tests demonstrating all three drift classes. Hardening: bech32 permits an all-uppercase encoding, so the egress guard now rejects NCRYPTSEC1... too (mixed case stays unblocked - it cannot decode), and both import classifiers (Rust recover_keys_from_input, TS classifyKeyImportInput/isPlausibleNcryptsec) route uppercase-valid blobs to the encrypted path consistently. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src-tauri/src/commands/identity.rs | 172 ++++++++++++--- desktop/src-tauri/src/egress_guard.rs | 14 +- desktop/src-tauri/src/egress_guard_tests.rs | 202 +++++++++++++++--- desktop/src-tauri/src/key_backup.rs | 11 +- desktop/src-tauri/src/key_backup_tests.rs | 22 ++ .../onboarding/lib/keyImportInput.test.mjs | 16 ++ .../features/onboarding/lib/keyImportInput.ts | 14 +- 7 files changed, 379 insertions(+), 72 deletions(-) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index e723a5637d..bb3ef796bb 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -326,35 +326,14 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - // Importing a different identity invalidates the app-managed backup: - // it encrypts the previous key and must not linger mislabeled. - let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); - crate::key_backup::cleanup_stale_backup(&previous_pubkey, &keys.public_key(), &data_dir)?; - - // Persist into the OS keyring first (store → read-back verify → marker → - // delete file). Falls back to the 0o600 file when the keyring is - // unavailable; returns Err only when both backends fail. - let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - - // Update in-memory keys BEFORE clearing recovery flags. The Release - // stores below pair with Acquire loads in get_identity: a reader - // observing false is guaranteed to see the updated keys. - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - - // Clear both recovery flags — an import is valid in either lost or - // keyring-locked state and resolves both. In the locked case the - // keyring is unreachable, so persist_imported_identity already fell - // back to identity.key; on the next Unreachable boot the file is - // loaded directly and when the keyring returns the adoption path - // picks it up. - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - state - .keyring_locked - .store(false, std::sync::atomic::Ordering::Release); + let pubkey = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Persist into the OS keyring first (store → read-back verify → + // marker → delete file). Falls back to the 0o600 file when the + // keyring is unavailable; returns Err only when both backends fail. + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; @@ -373,6 +352,65 @@ pub async fn import_identity( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Commit an imported identity: durably persist, swap in-memory keys, clear +/// recovery flags, then remove the previous identity's stale app-managed +/// backup. Caller must hold `state.identity_mutation`. +/// +/// Ordering is the contract: +/// +/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file +/// fallback), nothing has changed — the previous identity stays live in +/// memory AND its valid canonical `identity.ncryptsec` stays on disk. +/// 2. Only after durable persistence do we swap `state.keys` and clear the +/// recovery flags. +/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that +/// point the import is durably committed, so reporting a cleanup failure +/// as a command `Err` would claim a half-applied import that actually +/// succeeded. The leftover blob is still passphrase-encrypted and is +/// replaced by the next backup creation; we log and move on. +fn commit_imported_identity( + state: &AppState, + data_dir: &std::path::Path, + keys: nostr::Keys, + persist: impl FnOnce(&nostr::Keys) -> Result<(), String>, +) -> Result { + // Capture the previous pubkey up front for post-commit cleanup. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + + persist(&keys)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so the persist step already fell back to + // identity.key; on the next Unreachable boot the file is loaded + // directly and when the keyring returns the adoption path picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + // Importing a different identity invalidates the app-managed backup: it + // encrypts the previous key and must not linger mislabeled. Best-effort + // per the ordering contract above. + if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) { + eprintln!( + "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \ + the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \ + replaced by the next backup creation" + ); + } + + Ok(pubkey) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -798,6 +836,82 @@ mod key_backup_command_tests { assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); } + /// Blocker-1 regression (Wren, implementation review): a failed + /// different-key import must leave BOTH the old in-memory identity and + /// the old canonical backup intact. Persistence runs before cleanup, so + /// an `Err` from persist means nothing was mutated or deleted. + #[test] + fn failed_import_persistence_preserves_old_identity_and_backup() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let old_pubkey = state.keys.lock().unwrap().public_key(); + + // A valid canonical backup for the live (old) identity. + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + let backup_before = std::fs::read_to_string(&backup_path).unwrap(); + + // Different-key import whose durable persistence fails (both + // keyring and file fallback down). + let _guard = state.identity_mutation.lock().unwrap(); + let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| { + Err("keyring and file both unavailable".to_string()) + }) + .unwrap_err(); + assert!(err.contains("unavailable"), "{err}"); + + // Old identity still live; old backup untouched byte-for-byte. + assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey); + assert_eq!( + std::fs::read_to_string(&backup_path).unwrap(), + backup_before + ); + assert!( + crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD) + .unwrap() + .public_key() + == old_pubkey, + "surviving backup must still recover the still-live identity" + ); + } + + /// Successful different-key import removes the previous identity's + /// backup — cleanup runs after the durable commit, not before. + #[test] + fn successful_import_removes_stale_backup_after_commit() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + assert!(backup_path.exists()); + + let new_keys = Keys::generate(); + let backup_present_at_persist = std::cell::Cell::new(false); + let _guard = state.identity_mutation.lock().unwrap(); + let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { + // Ordering probe: the old backup must still exist while + // persistence is running (cleanup has not happened yet). + backup_present_at_persist.set(backup_path.exists()); + Ok(()) + }) + .unwrap(); + + assert!( + backup_present_at_persist.get(), + "cleanup must not precede persist" + ); + assert_eq!(pubkey, new_keys.public_key()); + assert_eq!( + state.keys.lock().unwrap().public_key(), + new_keys.public_key() + ); + assert!( + !backup_path.exists(), + "stale backup must be removed post-commit" + ); + } + /// Concurrent identity swap vs backup creation: `identity_mutation` /// serializes both, so every persisted blob decrypts to the identity that /// was live for the whole of its create operation — never a torn state. diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index 6dc2141a60..01c04bf70e 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -25,14 +25,20 @@ /// 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…` substring is present. Callers MUST -/// abort the network operation on `Err` — this is a fail-closed guard, not a -/// warning. +/// 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) { + 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" diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 7a3f8a6492..cc8cd483c7 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -24,6 +24,19 @@ fn rejects_ncryptsec_anywhere_in_text() { ); } +/// 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()); @@ -207,55 +220,174 @@ fn src_rust_files() -> Vec { out } -/// Inventory completeness: every `/events` URL-construction site in -/// `desktop/src-tauri/src` must be in the guarded set. A future ninth -/// submission path fails this test until its guard is wired and it is added -/// to the allowlist below (with its egress_guard.rs table row + injection -/// test). -#[test] -fn events_url_inventory_is_fully_guarded() { - // (file suffix, guarded construction sites expected in that file) - let allowlist: &[&str] = &[ - "src/relay.rs", // boundaries 2, 3, 4 - "src/relay/submit.rs", // boundary 1 - "src/huddle/pipeline.rs", // boundary 5 - "src/commands/team_snapshot.rs", // boundary 6 - "src/commands/personas/snapshot/import.rs", // boundary 7 - // test-only relay stubs / fixtures (no production egress): - "src/relay_admission.rs", - "src/archive/mod_tests.rs", - "src/managed_agents/persona_events/tests.rs", - "src/commands/team_snapshot/tests.rs", - "src/egress_guard_tests.rs", - ]; +/// 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", 3, 3), // boundaries 2, 3, 4 + ("src/relay/submit.rs", 1, 1), // boundary 1 + ("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), +]; - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); +// 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 path in src_rust_files() { - let rel = path - .strip_prefix(root) - .unwrap() - .to_string_lossy() - .replace('\\', "/"); - let content = std::fs::read_to_string(&path).unwrap(); + + 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() { - let trimmed = line.trim_start(); - if trimmed.starts_with("//") { + if line.trim_start().starts_with("//") { continue; // doc/comment mentions } - if line.contains("/events") && !allowlist.iter().any(|a| rel.ends_with(a)) { - violations.push(format!("{rel}:{}: {}", i + 1, line.trim())); + 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(), - "new `/events` egress site(s) outside the guarded inventory — wire \ - crate::egress_guard and add an injection test before allowlisting:\n{}", + "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. diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index fe7b110fd3..6db9eaa385 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -107,9 +107,18 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { /// Recover identity keys from an import input: `ncryptsec1…` (requires the /// passphrase, decrypted in Rust) or anything `Keys::parse` accepts (raw /// `nsec1…`/hex — byte-for-byte the pre-NIP-49 path). +/// +/// Classification is case-insensitive on the HRP: bech32 permits an +/// ALL-UPPERCASE encoding, so `NCRYPTSEC1…` routes to the encrypted path +/// (where the bech32 decoder accepts it); mixed case routes there too and +/// fails parsing with the accurate "invalid ncryptsec" error rather than +/// falling through to the raw-key parser. pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { let trimmed = input.trim(); - if trimmed.starts_with(NCRYPTSEC_HRP) { + let hrp_match = trimmed + .get(..NCRYPTSEC_HRP.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(NCRYPTSEC_HRP)); + if hrp_match { let password = password.ok_or_else(|| "encrypted backup requires a passphrase".to_string())?; decrypt_ncryptsec(trimmed, password) diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index f2c77016bf..2f92343f23 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -99,6 +99,28 @@ fn recover_keys_ncryptsec_wrong_password() { assert_eq!(err, "wrong passphrase or corrupted backup"); } +/// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as +/// an encrypted backup (matching the egress guard's blocking scope), never +/// fall through to the raw-key parser. Mixed case classifies encrypted too +/// and fails with the accurate ncryptsec error, not "Invalid private key". +#[test] +fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { + let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); + // Routing proof: encrypted path demands a passphrase. + let err = recover_keys_from_input(&upper, None).unwrap_err(); + assert_eq!(err, "encrypted backup requires a passphrase"); + // With the passphrase, the bech32 decoder accepts the uppercase form. + let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); + + // Mixed case: still routed to the encrypted path, rejected as invalid + // ncryptsec (mixed-case bech32 cannot decode). + let mut mixed = SPEC_NCRYPTSEC.to_string(); + mixed.replace_range(0..1, "N"); + let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err(); + assert!(err.contains("invalid ncryptsec"), "{err}"); +} + #[test] fn recover_keys_raw_nsec_path_unchanged() { let keys = Keys::generate(); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs index 8c5d8047f4..1a01da2198 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -28,6 +28,22 @@ test("classify_by_hrp_with_whitespace_tolerance", () => { assert.equal(classifyKeyImportInput("nsec1"), "nsec"); }); +test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => { + // Bech32 permits an all-uppercase encoding; it must route to the + // encrypted path (matching Rust) and be submit-plausible. + const upper = NCRYPTSEC.toUpperCase(); + assert.equal(classifyKeyImportInput(upper), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(upper), true); + assert.equal(keyImportSubmitEnabled(upper, ""), false); + assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true); + // Mixed case: routed encrypted (Rust reports the accurate error) but + // never plausible/submittable — mixed-case bech32 cannot decode. + const mixed = `N${NCRYPTSEC.slice(1)}`; + assert.equal(classifyKeyImportInput(mixed), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(mixed), false); + assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false); +}); + test("plausible_ncryptsec_requires_bech32_charset", () => { assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true); // '1' and 'b' / 'i' / 'o' are not in the bech32 charset. diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts index 2477154c92..12e76054e3 100644 --- a/desktop/src/features/onboarding/lib/keyImportInput.ts +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -12,12 +12,20 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils"; export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; -/** Bech32 charset after the `ncryptsec1` HRP; anything else can't decode. */ -const NCRYPTSEC_SHAPE = /^ncryptsec1[02-9ac-hj-np-z]{10,}$/; +/** + * Bech32 charset after the `ncryptsec1` HRP; anything else can't decode. + * Bech32 also permits an ALL-UPPERCASE encoding of the same payload, so both + * casings are plausible (mixed case is invalid and stays implausible). + */ +const NCRYPTSEC_SHAPE = + /^(?:ncryptsec1[02-9ac-hj-np-z]{10,}|NCRYPTSEC1[02-9AC-HJ-NP-Z]{10,})$/; export function classifyKeyImportInput(input: string): KeyImportKind { const trimmed = input.trim(); - if (trimmed.startsWith("ncryptsec1")) return "ncryptsec"; + // Case-insensitive on the HRP to match the Rust classifier: an uppercase + // valid backup routes to the encrypted path (and decodes there); mixed + // case routes there too and fails in Rust with the accurate error. + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; if (trimmed.startsWith("nsec1")) return "nsec"; return "unknown"; } From 51ea1b18f37008cc01c9fd9c9a453a054054187d Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 09:35:20 -0400 Subject: [PATCH 04/26] fix(desktop): rename settings row to Password Backup per spec Tyler's spec names the settings entry "Password Backup". Rename the row title and align its description (password, not passphrase) plus the two doc comments that referenced the old row name. No behavior change; test ids unchanged. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/src/features/onboarding/lib/encryptedBackup.ts | 2 +- desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx | 2 +- desktop/src/features/settings/ui/ProfileSettingsCard.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts index fd448edcf4..c8f790591a 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.ts +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -1,6 +1,6 @@ /** * Pure state model for the encrypted-key-backup (NIP-49) creation flow, - * shared by the onboarding BackupStep and the settings Encrypted backup row. + * shared by the onboarding BackupStep and the settings Password Backup row. * * All validation and phase logic lives here so it can be unit-tested without * React. Hosts wire the reducer to the Tauri commands diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index 682797d8dc..ced5e9e449 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -29,7 +29,7 @@ type EncryptedBackupCreatorProps = { /** * Passphrase-first NIP-49 backup creation flow, shared by the onboarding - * BackupStep and the settings Encrypted backup row. + * BackupStep and the settings Password Backup row. * * The raw private key never enters this component: it collects a passphrase, * asks Rust to create + persist the encrypted backup, and displays the diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index 27f6ac6d94..5326243ff4 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -189,9 +189,9 @@ function EncryptedBackupRow() {
-

Encrypted backup

+

Password Backup

- Protect your key with a passphrase and save a recoverable backup. + Protect your key with a password and save a recoverable backup.

- {variant === "spotlight" ? null : ( - <> - { - void handleFiles(event.currentTarget.files); - event.currentTarget.value = ""; - }} - ref={fileInputRef} - tabIndex={-1} - type="file" - /> + {/* Hidden file input shared by both variants: the default drop zone and + the spotlight "Import from a file" button both open it. Accepts the + .ncryptsec archives our own save flow emits alongside raw .key files. */} + { + void handleFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> - - + Import from a file + +
+ ) : ( + )} {isEncryptedInput ? ( diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 9ae6724829..d827654960 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -657,6 +657,48 @@ test("first-launch encrypted backup import asks for a passphrase and continues", await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); }); +test("first-launch import accepts an .ncryptsec backup file", async ({ + page, +}) => { + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + await page.goto("/"); + + await page.getByRole("button", { name: "Use an existing key" }).click(); + + // The spotlight variant must expose a file path: a wiped user returns with + // exactly the identity.ncryptsec our own save dialog produced. The accept + // attribute is asserted explicitly because setInputFiles bypasses it — the + // OS picker is what filters on it in real use. + await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); + const fileInput = page.getByTestId("nostr-import-file-input"); + await expect(fileInput).toHaveAttribute( + "accept", + ".key,.ncryptsec,text/plain", + ); + + // Spec-vector blob the mock bridge accepts with the mock passphrase. + const mockNcryptsec = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + await fileInput.setInputFiles({ + buffer: Buffer.from(`${mockNcryptsec}\n`), + mimeType: "text/plain", + name: "identity.ncryptsec", + }); + + // File contents land in the key field and switch the form to encrypted mode. + await expect(page.getByTestId("nostr-import-encrypted-badge")).toBeVisible(); + await page + .getByTestId("nostr-import-passphrase") + .fill("mock horse battery staple lake orbit"); + await page.getByTestId("nostr-import-submit").click(); + + await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + await expect(page.getByTestId("machine-onboarding-gate")).toBeVisible(); +}); + test("non-local runtime override keeps community selection without release flag", async ({ page, }) => { From d7e52ea1aa84d9ce2a6363c16cd4897a58e79b92 Mon Sep 17 00:00:00 2001 From: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 11:00:45 -0400 Subject: [PATCH 06/26] test(desktop): align main's smoke specs with encrypted-by-default backup step Merging main (7a6a2cda5) brought in two smoke specs written against the old raw-key backup page, which this branch replaces with an encrypted-by-default step: Next stays disabled until a backup exists, and the raw nsec (nsec-reveal-toggle) sits behind an explicit 'Show raw key instead' click. Both specs timed out in CI at 72f00afa8. - harness-management.spec.ts (More-harnesses nav, from #3093): route through the existing passThroughBackupStep helper instead of clicking a disabled Next. - onboarding-docked-cta-screenshots.spec.ts: click backup-show-raw-key before the reveal toggle; the raw-key card is the surface the screenshot exists to capture. Test-only change; no product code touched. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- desktop/tests/e2e/harness-management.spec.ts | 9 ++++----- .../tests/e2e/onboarding-docked-cta-screenshots.spec.ts | 4 ++++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index 50047f8f81..f6bf2afd4f 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -21,6 +21,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { passThroughBackupStep } from "../helpers/onboarding"; // ── Shared catalog fixtures ─────────────────────────────────────────────────── @@ -668,12 +669,10 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a }); await page.goto("/"); - // Reach the setup page: create a new identity key → skip backup step. + // Reach the setup page: create a new identity key → pass the backup step + // (encrypted-by-default: Next is gated on creating the backup). await page.getByRole("button", { name: "Create a new identity key" }).click(); - await expect(page.getByTestId("onboarding-page-backup")).toBeVisible({ - timeout: 10_000, - }); - await page.getByTestId("onboarding-next").click(); + await passThroughBackupStep(page); // Now on the setup page. await expect( diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index dbb8a92a1f..ee6bd3fee6 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -59,6 +59,10 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/02-backup.png` }); + // Encrypted-by-default backup: the raw key sits behind an explicit click. + await page.getByTestId("backup-show-raw-key").click(); + await expect(page.getByTestId("nsec-value")).toBeVisible(); + // Reveal the key: box must not reflow (same-length monospace mask). await page.getByTestId("nsec-reveal-toggle").click(); await expect(page.getByTestId("nsec-value")).toHaveClass(/select-text/); From 88d08e426c47260daa7ee8856506e3e37367d938 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 14:42:55 -0700 Subject: [PATCH 07/26] feat(desktop): guide and verify password-protected backups (#3617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Users can create a password-protected key backup, save it locally, and prove that it works before finishing onboarding or signing out. **Problem:** Tyler's NIP-49 foundation made encrypted local backup possible, but the end-to-end ceremony still needed clearer progression, trustworthy verification, and a Settings treatment consistent with the rest of Identity. **Solution:** This stack adds a guided create/download/test flow, verifies the actual saved backup through Rust without returning secret key material, minimizes password lifetime in the webview, and restores warm, plain-language presentation across onboarding and Settings. > [!NOTE] > This PR is intentionally stacked on #2937 (`eva/nip49-local-backup`). Review only the 19 commits in this stack.
File changes **desktop/src-tauri/src/commands/identity.rs** Adds off-thread verification of NIP-49 backups and returns public identity details only. **desktop/src-tauri/src/commands/identity_key_backup_tests.rs** Covers verification, identity matching, and failure behavior at the command boundary. **desktop/src-tauri/src/egress_guard_tests.rs** Keeps the NIP-49 source inventory aligned with the new verification path. **desktop/src-tauri/src/key_backup.rs** Supports real backup decryption for verification while zeroizing submitted passwords. **desktop/src-tauri/src/key_backup_tests.rs** Exercises valid, wrong-password, damaged, and different-identity backups. **desktop/src-tauri/src/lib.rs** Registers the verification command with the desktop runtime. **desktop/src/features/communities/ui/WelcomeSetup.tsx** Routes community onboarding through the revised backup ceremony. **desktop/src/features/onboarding/lib/encryptedBackup.test.mjs** Covers the hardened backup state model and password-clearing behavior. **desktop/src/features/onboarding/lib/encryptedBackup.ts** Models creation and verification with opaque request correlation and short-lived passwords. **desktop/src/features/onboarding/ui/BackupStep.tsx** Presents the backup choice clearly and advances into the dedicated download step. **desktop/src/features/onboarding/ui/BackupTestFlow.tsx** Adds the polished select-file, enter-password, verification, error, and success experience. **desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx** Connects community onboarding to the updated backup steps. **desktop/src/features/onboarding/ui/DownloadKeyStep.tsx** Adds the dedicated encrypted-backup download step and pre-creation skip escape hatch. **desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx** Guides password generation, backup creation, native saving, safe retry, and re-download. **desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx** Sequences chooser, download, exact-file test, and setup progression. **desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx** Aligns encrypted-key import behavior with the backup flow. **desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx** Improves masked/revealed key presentation and safely wraps long private keys. **desktop/src/features/onboarding/ui/OnboardingChrome.tsx** Supports the revised onboarding layout and transitions. **desktop/src/features/onboarding/ui/SetupStep.tsx** Integrates the completed backup ceremony with final setup. **desktop/src/features/onboarding/ui/onboardingFlowSteps.test.mjs** Updates flow-level assertions for the new step sequence. **desktop/src/features/settings/ui/EncryptedBackupRow.tsx** Adds sibling Create and Test rows that match the surrounding Identity settings rhythm. **desktop/src/features/settings/ui/ProfileSettingsCard.tsx** Places password-backup controls in the Identity card. **desktop/src/features/settings/ui/SignOutSection.tsx** Uses explicit backup self-attestation plus the existing wipe phrase without requiring a raw-key reveal first. **desktop/src/shared/api/tauriIdentity.ts** Exposes typed backup verification to the frontend. **desktop/src/shared/lib/ncryptsecSourceScan.test.mjs** Updates the frontend source allowlist for the verification path. **desktop/src/testing/e2eBridge.ts** Models creation, saving, and verification for browser coverage. **desktop/tests/e2e/onboarding-backup.spec.ts** Covers backup creation, exact-file testing, errors, retries, and completion. **desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts** Updates docked CTA visual coverage for the revised progression. **desktop/tests/e2e/onboarding.spec.ts** Keeps broader onboarding coverage aligned with the backup ceremony. **desktop/tests/e2e/profile-nsec-reveal.spec.ts** Covers Settings create/test behavior and updated success copy. **desktop/tests/e2e/signout-confirmation.spec.ts** Covers backup self-attestation, wipe phrase confirmation, and reset behavior. **desktop/tests/helpers/fileDrag.ts** Adds reusable file-drop support for backup test coverage. **desktop/tests/helpers/onboarding.ts** Routes tests through chooser, download, and skip behavior consistently.
## Reproduction steps 1. Start fresh onboarding and choose the password-protected backup path. 2. Continue to **Backup your key**, create a password, save the `.ncryptsec` file, and confirm that **Next** remains gated until testing succeeds. 3. Select that exact file, enter its password, and verify the **Your backup works!** success state. 4. Open **Settings → Identity** and confirm that **Create password backup** and **Test password backup** appear as sibling rows. 5. Test a valid backup, then test wrong-password and different-identity cases to confirm clear, non-secret-bearing outcomes. 6. Open sign out and confirm that backup self-attestation plus typing `wipe all my data` remain required. ## Screenshots ### Onboarding flow | 1. Creating your key | 2. Key created | 3. Create backup password | |---|---|---| | image | image | image | | **4. Select the saved backup** | **5. Enter its password** | **6. Verification succeeds** | | image | image | image | ### Settings flow | Create and Test tools | Tested backup success | |---|---| | image | image | ## Verification - Desktop typecheck - Full desktop JS: 3,733/3,733 - `pnpm check` - Rust desktop lib: 1,862 passed, 14 ignored; diagnostic: 3/3 - Focused Playwright: 16/16 with one worker Native save/cancel remains the residual smoke-test risk: browser E2E mocks the KDF/native save-dialog boundary, while real decrypt and identity matching are covered in Rust. --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/identity.rs | 266 +---- .../src/commands/identity_key_backup_tests.rs | 235 +++++ desktop/src-tauri/src/egress_guard_tests.rs | 1 + desktop/src-tauri/src/key_backup.rs | 60 +- desktop/src-tauri/src/key_backup_tests.rs | 40 +- desktop/src-tauri/src/lib.rs | 1 + .../features/communities/ui/WelcomeSetup.tsx | 2 +- .../onboarding/lib/encryptedBackup.test.mjs | 231 ++--- .../onboarding/lib/encryptedBackup.ts | 181 ++-- .../src/features/onboarding/ui/BackupStep.tsx | 407 ++++---- .../features/onboarding/ui/BackupTestFlow.tsx | 557 +++++++++++ .../onboarding/ui/CommunityOnboardingFlow.tsx | 2 +- .../onboarding/ui/DownloadKeyStep.tsx | 164 +++ .../onboarding/ui/EncryptedBackupCreator.tsx | 943 +++++++++++++----- .../onboarding/ui/MachineOnboardingFlow.tsx | 39 +- .../onboarding/ui/NostrKeyImportForm.tsx | 20 +- .../onboarding/ui/NsecMaskedDisplay.tsx | 16 +- .../onboarding/ui/OnboardingChrome.tsx | 9 +- .../src/features/onboarding/ui/SetupStep.tsx | 41 +- .../ui/onboardingFlowSteps.test.mjs | 69 +- .../settings/ui/EncryptedBackupRow.tsx | 101 ++ .../settings/ui/ProfileSettingsCard.tsx | 38 +- .../features/settings/ui/SignOutSection.tsx | 36 +- desktop/src/shared/api/tauriIdentity.ts | 35 +- .../shared/lib/ncryptsecSourceScan.test.mjs | 1 + desktop/src/testing/e2eBridge.ts | 56 +- desktop/tests/e2e/onboarding-backup.spec.ts | 339 +++++-- .../onboarding-docked-cta-screenshots.spec.ts | 14 +- desktop/tests/e2e/onboarding.spec.ts | 11 +- desktop/tests/e2e/profile-nsec-reveal.spec.ts | 32 + .../tests/e2e/signout-confirmation.spec.ts | 34 +- desktop/tests/helpers/fileDrag.ts | 52 + desktop/tests/helpers/onboarding.ts | 11 +- 33 files changed, 2878 insertions(+), 1166 deletions(-) create mode 100644 desktop/src-tauri/src/commands/identity_key_backup_tests.rs create mode 100644 desktop/src/features/onboarding/ui/BackupTestFlow.tsx create mode 100644 desktop/src/features/onboarding/ui/DownloadKeyStep.tsx create mode 100644 desktop/src/features/settings/ui/EncryptedBackupRow.tsx create mode 100644 desktop/tests/helpers/fileDrag.ts diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bb3ef796bb..344e519657 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -188,11 +188,18 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } -/// Generate a 6-word passphrase for a new encrypted backup (EFF short -/// wordlist, OS entropy, ≈62 bits before the scrypt work factor). +/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS +/// entropy). `words` is clamped to the range allowed by `key_backup`; +/// `separator` joins the words (defaults to a space). #[tauri::command] -pub fn generate_backup_passphrase() -> Result { - crate::key_backup::generate_passphrase() +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 @@ -259,6 +266,47 @@ pub async fn create_ncryptsec_backup( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupVerification { + pub pubkey: String, + pub npub: String, + pub matches_current_identity: bool, +} + +fn verify_ncryptsec_backup_inner( + state: &AppState, + ncryptsec: &str, + password: &str, +) -> Result { + 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 @@ -278,7 +326,7 @@ pub async fn save_ncryptsec_copy( let dest = match crate::commands::export_util::pick_save_path( &app_handle, crate::key_backup::BACKUP_FILE_NAME, - "Encrypted key backup", + "Password-protected key backup", &["ncryptsec"], ) .await? @@ -744,209 +792,5 @@ mod nostr_identity_binding_tests { } #[cfg(test)] -mod key_backup_command_tests { - use super::create_and_persist_backup_with_log_n; - use crate::app_state::build_app_state; - use nostr::Keys; - - /// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered - /// once in key_backup_tests::round_trip_at_production_cost. - const FAST_LOG_N: u8 = 16; - const PASSWORD: &str = "correct horse battery"; - - #[test] - fn returned_bytes_equal_on_disk_bytes() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let returned = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - - let path = crate::key_backup::backup_file_path(dir.path()); - let on_disk = std::fs::read_to_string(&path).unwrap(); - assert_eq!( - returned, on_disk, - "webview must receive the exact persisted bytes" - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - - // And the persisted blob provably recovers the live identity. - let keys = state.keys.lock().unwrap().clone(); - let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap(); - assert_eq!(recovered.public_key(), keys.public_key()); - } - - #[test] - fn overwrite_replaces_atomically() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let first = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let second = create_and_persist_backup_with_log_n( - &state, - dir.path(), - "another passphrase", - FAST_LOG_N, - ) - .unwrap(); - assert_ne!(first, second, "fresh salt/nonce per action"); - - let path = crate::key_backup::backup_file_path(dir.path()); - assert_eq!(std::fs::read_to_string(&path).unwrap(), second); - } - - #[test] - fn rejects_short_passphrase() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let err = create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N) - .unwrap_err(); - assert!(err.contains("at least"), "{err}"); - assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); - } - - #[test] - fn recovery_mode_blocks_backup_creation() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - - state - .identity_lost - .store(true, std::sync::atomic::Ordering::Release); - assert!( - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), - "lost identity must not be backed up" - ); - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - - state - .keyring_locked - .store(true, std::sync::atomic::Ordering::Release); - assert!( - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), - "locked keyring must not be backed up" - ); - assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); - } - - /// Blocker-1 regression (Wren, implementation review): a failed - /// different-key import must leave BOTH the old in-memory identity and - /// the old canonical backup intact. Persistence runs before cleanup, so - /// an `Err` from persist means nothing was mutated or deleted. - #[test] - fn failed_import_persistence_preserves_old_identity_and_backup() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - let old_pubkey = state.keys.lock().unwrap().public_key(); - - // A valid canonical backup for the live (old) identity. - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let backup_path = crate::key_backup::backup_file_path(dir.path()); - let backup_before = std::fs::read_to_string(&backup_path).unwrap(); - - // Different-key import whose durable persistence fails (both - // keyring and file fallback down). - let _guard = state.identity_mutation.lock().unwrap(); - let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| { - Err("keyring and file both unavailable".to_string()) - }) - .unwrap_err(); - assert!(err.contains("unavailable"), "{err}"); - - // Old identity still live; old backup untouched byte-for-byte. - assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey); - assert_eq!( - std::fs::read_to_string(&backup_path).unwrap(), - backup_before - ); - assert!( - crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD) - .unwrap() - .public_key() - == old_pubkey, - "surviving backup must still recover the still-live identity" - ); - } - - /// Successful different-key import removes the previous identity's - /// backup — cleanup runs after the durable commit, not before. - #[test] - fn successful_import_removes_stale_backup_after_commit() { - let state = build_app_state(); - let dir = tempfile::tempdir().unwrap(); - - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - let backup_path = crate::key_backup::backup_file_path(dir.path()); - assert!(backup_path.exists()); - - let new_keys = Keys::generate(); - let backup_present_at_persist = std::cell::Cell::new(false); - let _guard = state.identity_mutation.lock().unwrap(); - let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { - // Ordering probe: the old backup must still exist while - // persistence is running (cleanup has not happened yet). - backup_present_at_persist.set(backup_path.exists()); - Ok(()) - }) - .unwrap(); - - assert!( - backup_present_at_persist.get(), - "cleanup must not precede persist" - ); - assert_eq!(pubkey, new_keys.public_key()); - assert_eq!( - state.keys.lock().unwrap().public_key(), - new_keys.public_key() - ); - assert!( - !backup_path.exists(), - "stale backup must be removed post-commit" - ); - } - - /// Concurrent identity swap vs backup creation: `identity_mutation` - /// serializes both, so every persisted blob decrypts to the identity that - /// was live for the whole of its create operation — never a torn state. - #[test] - fn concurrent_identity_swap_vs_backup_is_serialized() { - let state = std::sync::Arc::new(build_app_state()); - let dir = tempfile::tempdir().unwrap(); - let key_a = state.keys.lock().unwrap().clone(); - let key_b = Keys::generate(); - - let swapper = { - let state = state.clone(); - let key_b = key_b.clone(); - std::thread::spawn(move || { - // Mirrors import_identity's locking: mutation guard held - // across the key swap. - let _guard = state.identity_mutation.lock().unwrap(); - *state.keys.lock().unwrap() = key_b; - }) - }; - - let backup = - create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); - swapper.join().unwrap(); - - let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) - .unwrap() - .public_key(); - assert!( - recovered == key_a.public_key() || recovered == key_b.public_key(), - "backup must match one coherent identity" - ); - // Whichever won, the persisted file equals the returned blob. - let on_disk = - std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap(); - assert_eq!(on_disk, backup); - } -} +#[path = "identity_key_backup_tests.rs"] +mod key_backup_command_tests; 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..0b31f44bac --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -0,0 +1,235 @@ +use super::{create_and_persist_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 returned_bytes_equal_on_disk_bytes() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let returned = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + + let path = crate::key_backup::backup_file_path(dir.path()); + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!( + returned, on_disk, + "webview must receive the exact persisted bytes" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + // And the persisted blob provably recovers the live identity. + let keys = state.keys.lock().unwrap().clone(); + let recovered = crate::key_backup::decrypt_ncryptsec(&on_disk, PASSWORD).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn verification_returns_only_public_identity_and_match_status() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let backup = + create_and_persist_backup_with_log_n(&state, dir.path(), 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 overwrite_replaces_atomically() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let first = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let second = + create_and_persist_backup_with_log_n(&state, dir.path(), "another passphrase", FAST_LOG_N) + .unwrap(); + assert_ne!(first, second, "fresh salt/nonce per action"); + + let path = crate::key_backup::backup_file_path(dir.path()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), second); +} + +#[test] +fn rejects_short_passphrase() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let err = + create_and_persist_backup_with_log_n(&state, dir.path(), "short", FAST_LOG_N).unwrap_err(); + assert!(err.contains("at least"), "{err}"); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); +} + +#[test] +fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "lost identity must not be backed up" + ); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); + assert!(!crate::key_backup::backup_file_path(dir.path()).exists()); +} + +/// Blocker-1 regression (Wren, implementation review): a failed +/// different-key import must leave BOTH the old in-memory identity and +/// the old canonical backup intact. Persistence runs before cleanup, so +/// an `Err` from persist means nothing was mutated or deleted. +#[test] +fn failed_import_persistence_preserves_old_identity_and_backup() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + let old_pubkey = state.keys.lock().unwrap().public_key(); + + // A valid canonical backup for the live (old) identity. + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + let backup_before = std::fs::read_to_string(&backup_path).unwrap(); + + // Different-key import whose durable persistence fails (both + // keyring and file fallback down). + let _guard = state.identity_mutation.lock().unwrap(); + let err = super::commit_imported_identity(&state, dir.path(), Keys::generate(), |_| { + Err("keyring and file both unavailable".to_string()) + }) + .unwrap_err(); + assert!(err.contains("unavailable"), "{err}"); + + // Old identity still live; old backup untouched byte-for-byte. + assert_eq!(state.keys.lock().unwrap().public_key(), old_pubkey); + assert_eq!( + std::fs::read_to_string(&backup_path).unwrap(), + backup_before + ); + assert!( + crate::key_backup::decrypt_ncryptsec(&backup_before, PASSWORD) + .unwrap() + .public_key() + == old_pubkey, + "surviving backup must still recover the still-live identity" + ); +} + +/// Successful different-key import removes the previous identity's +/// backup — cleanup runs after the durable commit, not before. +#[test] +fn successful_import_removes_stale_backup_after_commit() { + let state = build_app_state(); + let dir = tempfile::tempdir().unwrap(); + + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + let backup_path = crate::key_backup::backup_file_path(dir.path()); + assert!(backup_path.exists()); + + let new_keys = Keys::generate(); + let backup_present_at_persist = std::cell::Cell::new(false); + let _guard = state.identity_mutation.lock().unwrap(); + let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { + // Ordering probe: the old backup must still exist while + // persistence is running (cleanup has not happened yet). + backup_present_at_persist.set(backup_path.exists()); + Ok(()) + }) + .unwrap(); + + assert!( + backup_present_at_persist.get(), + "cleanup must not precede persist" + ); + assert_eq!(pubkey, new_keys.public_key()); + assert_eq!( + state.keys.lock().unwrap().public_key(), + new_keys.public_key() + ); + assert!( + !backup_path.exists(), + "stale backup must be removed post-commit" + ); +} + +/// Concurrent identity swap vs backup creation: `identity_mutation` +/// serializes both, so every persisted blob decrypts to the identity that +/// was live for the whole of its create operation — never a torn state. +#[test] +fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + let dir = tempfile::tempdir().unwrap(); + let key_a = state.keys.lock().unwrap().clone(); + let key_b = Keys::generate(); + + let swapper = { + let state = state.clone(); + let key_b = key_b.clone(); + std::thread::spawn(move || { + // Mirrors import_identity's locking: mutation guard held + // across the key swap. + let _guard = state.identity_mutation.lock().unwrap(); + *state.keys.lock().unwrap() = key_b; + }) + }; + + let backup = + create_and_persist_backup_with_log_n(&state, dir.path(), PASSWORD, FAST_LOG_N).unwrap(); + swapper.join().unwrap(); + + let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) + .unwrap() + .public_key(); + assert!( + recovered == key_a.public_key() || recovered == key_b.public_key(), + "backup must match one coherent identity" + ); + // Whichever won, the persisted file equals the returned blob. + let on_disk = std::fs::read_to_string(crate::key_backup::backup_file_path(dir.path())).unwrap(); + assert_eq!(on_disk, backup); +} diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index cc8cd483c7..65448405e7 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -399,6 +399,7 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "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", diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6db9eaa385..418be3bb06 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -37,9 +37,15 @@ 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"; -/// Number of words in a generated backup passphrase. Six words from a -/// 1296-word list ≈ 62 bits of entropy before the scrypt work factor. -const PASSPHRASE_WORDS: usize = 6; +/// 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"); @@ -100,7 +106,7 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { let encrypted = parse_ncryptsec(input)?; let secret_key = encrypted .decrypt(password) - .map_err(|_| "wrong passphrase or corrupted backup".to_string())?; + .map_err(|_| "wrong backup password or damaged key backup".to_string())?; Ok(Keys::new(secret_key)) } @@ -119,8 +125,7 @@ pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result Result { +/// `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!( @@ -202,19 +214,27 @@ pub fn generate_passphrase() -> Result { )); } - let mut chosen: Vec<&str> = Vec::with_capacity(PASSPHRASE_WORDS); - while chosen.len() < PASSPHRASE_WORDS { - 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]); + // 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); } } - - Ok(chosen.join(" ")) + Err("could not generate a passphrase meeting the minimum length".to_string()) } #[cfg(test)] diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 2f92343f23..2367de80ec 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -41,7 +41,7 @@ fn wrong_password_is_a_friendly_error() { let keys = Keys::generate(); let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap(); let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err(); - assert_eq!(err, "wrong passphrase or corrupted backup"); + assert_eq!(err, "wrong backup password or damaged key backup"); } #[test] @@ -90,13 +90,13 @@ fn recover_keys_ncryptsec_happy_path() { #[test] fn recover_keys_ncryptsec_requires_password() { let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); - assert_eq!(err, "encrypted backup requires a passphrase"); + assert_eq!(err, "key backup requires a password"); } #[test] fn recover_keys_ncryptsec_wrong_password() { let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err(); - assert_eq!(err, "wrong passphrase or corrupted backup"); + assert_eq!(err, "wrong backup password or damaged key backup"); } /// Bech32 permits an all-uppercase encoding: `NCRYPTSEC1…` must classify as @@ -108,7 +108,7 @@ fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); // Routing proof: encrypted path demands a passphrase. let err = recover_keys_from_input(&upper, None).unwrap_err(); - assert_eq!(err, "encrypted backup requires a passphrase"); + assert_eq!(err, "key backup requires a password"); // With the passphrase, the bech32 decoder accepts the uppercase form. let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); @@ -198,28 +198,42 @@ fn cleanup_stale_backup_removes_only_on_identity_change() { // ── Passphrase generation ───────────────────────────────────────────────────── #[test] -fn generated_passphrase_is_six_known_words() { +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 _ in 0..8 { - let phrase = generate_passphrase().unwrap(); - let parts: Vec<&str> = phrase.split(' ').collect(); - assert_eq!(parts.len(), 6); - for w in &parts { - assert!(words.contains(w), "unknown word {w:?}"); + 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() { - // 6 words × ~10.3 bits each — a collision across 8 draws would indicate a + // 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().unwrap())); + 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 c75daafea5..661295fa04 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -682,6 +682,7 @@ pub fn run() { get_nsec, generate_backup_passphrase, create_ncryptsec_backup, + verify_ncryptsec_backup, save_ncryptsec_copy, import_identity, persist_current_identity, diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index bde1458222..71bd382db2 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -103,7 +103,7 @@ export function WelcomeSetup({ data-system-color-scheme={systemColorScheme} > - +
{page === "welcome" ? ( diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs index f118bbf455..1d98a98541 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -1,156 +1,117 @@ -/** - * Pure-logic tests for the encrypted-backup (NIP-49) creation state model. - * These drive the same reducer + validation helpers the BackupStep and - * settings row use, without a DOM. - */ import assert from "node:assert/strict"; import test from "node:test"; - import { - MIN_CUSTOM_PASSPHRASE_LEN, - createDisabled, - customPassphraseIssue, + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, effectivePassphrase, encryptedBackupReducer, initialEncryptedBackupState, } from "./encryptedBackup.ts"; - -function reduce(events, from = initialEncryptedBackupState) { - return events.reduce(encryptedBackupReducer, from); -} - -// ── generated-passphrase mode (default) ───────────────────────────────────── - -test("create_disabled_until_generated_passphrase_arrives", () => { - assert.equal(createDisabled(initialEncryptedBackupState), true); +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: "passphrase-generated", - passphrase: "alpha bravo carbon delta echo fox", - }, + { type: "set-passphrase", value: "one-two-three-four" }, ]); - assert.equal(createDisabled(ready), false); - assert.equal(effectivePassphrase(ready), "alpha bravo carbon delta echo fox"); + 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("regenerate_replaces_passphrase_and_clears_generate_error", () => { - const failed = reduce([ - { type: "passphrase-generate-failed", message: "boom" }, +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(failed.generateError, "boom"); - const recovered = reduce( - [{ type: "passphrase-generated", passphrase: "a b c d e f" }], - failed, - ); - assert.equal(recovered.generateError, null); - assert.equal(recovered.generatedPassphrase, "a b c d e f"); + assert.equal(state.passphrase, ""); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.savedPassword, true); + assert.equal(state.requestId, null); }); - -// ── custom-passphrase mode ─────────────────────────────────────────────────── - -test("custom_mode_requires_min_length_and_matching_confirm", () => { - const base = reduce([ - { type: "passphrase-generated", passphrase: "gen gen gen gen gen gen" }, - { type: "set-mode", mode: "custom" }, +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" }, ]); - - // Too short — even though a generated passphrase exists, custom mode must - // not silently fall back to it. - const short = reduce( - [ - { type: "set-custom-passphrase", value: "short" }, - { type: "set-custom-confirm", value: "short" }, - ], - base, - ); - assert.equal(effectivePassphrase(short), null); - assert.equal(createDisabled(short), true); - - // Long enough but mismatched confirm. - const mismatched = reduce( - [ - { - type: "set-custom-passphrase", - value: "a".repeat(MIN_CUSTOM_PASSPHRASE_LEN), - }, - { - type: "set-custom-confirm", - value: "b".repeat(MIN_CUSTOM_PASSPHRASE_LEN), - }, - ], - base, - ); - assert.equal(effectivePassphrase(mismatched), null); - - // Valid. - const ok = reduce( - [ - { type: "set-custom-passphrase", value: "correct horse battery" }, - { type: "set-custom-confirm", value: "correct horse battery" }, - ], - base, - ); - assert.equal(effectivePassphrase(ok), "correct horse battery"); - assert.equal(createDisabled(ok), false); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); }); - -test("custom_passphrase_issue_messages", () => { - // Empty input: no scolding while the user hasn't typed anything. - assert.equal(customPassphraseIssue("", ""), null); - assert.match( - customPassphraseIssue("short", ""), - new RegExp(`${MIN_CUSTOM_PASSPHRASE_LEN}`), - ); - // Mismatch is only reported once confirm has content. - assert.equal(customPassphraseIssue("a".repeat(12), ""), null); - assert.match(customPassphraseIssue("a".repeat(12), "b"), /match/); - assert.equal(customPassphraseIssue("a".repeat(12), "a".repeat(12)), null); +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("min_length_counts_code_points_not_utf16_units", () => { - // 12 astral-plane emoji = 24 UTF-16 units but 12 code points; mirrors the - // Rust chars().count() gate so both sides agree on the boundary. - const emoji = "😀".repeat(MIN_CUSTOM_PASSPHRASE_LEN); - assert.equal(customPassphraseIssue(emoji, emoji), null); - const ready = reduce([ - { type: "set-mode", mode: "custom" }, - { type: "set-custom-passphrase", value: emoji }, - { type: "set-custom-confirm", value: emoji }, +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(effectivePassphrase(ready), emoji); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); }); - -// ── create lifecycle ───────────────────────────────────────────────────────── - -test("create_lifecycle_happy_path_and_failure", () => { - const ready = reduce([ - { type: "passphrase-generated", passphrase: "one two three four five six" }, +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" }, ]); - - const creating = reduce([{ type: "create-started" }], ready); - assert.equal(creating.isCreating, true); + 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( - createDisabled(creating), - true, - "no double-create while KDF runs", - ); - - const failed = reduce( - [{ type: "create-failed", message: "keychain unavailable" }], - creating, - ); - assert.equal(failed.isCreating, false); - assert.equal(failed.createError, "keychain unavailable"); - assert.equal(createDisabled(failed), false, "retry allowed after failure"); - - const done = reduce( - [ - { type: "create-started" }, - { type: "create-succeeded", ncryptsec: "ncryptsec1abc" }, - ], - failed, + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, ); - assert.equal(done.isCreating, false); - assert.equal(done.ncryptsec, "ncryptsec1abc"); - assert.equal(done.createError, null); }); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts index c8f790591a..d2aad94422 100644 --- a/desktop/src/features/onboarding/lib/encryptedBackup.ts +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -1,117 +1,128 @@ -/** - * Pure state model for the encrypted-key-backup (NIP-49) creation flow, - * shared by the onboarding BackupStep and the settings Password Backup row. - * - * All validation and phase logic lives here so it can be unit-tested without - * React. Hosts wire the reducer to the Tauri commands - * (`generate_backup_passphrase`, `create_ncryptsec_backup`) and dispatch - * events; the model never touches the raw private key — by construction the - * default backup path cannot invoke `get_nsec`. - */ - -export type PassphraseMode = "generated" | "custom"; - -/** Mirrors `MIN_PASSPHRASE_LEN` in `src-tauri/src/key_backup.rs`. */ -export const MIN_CUSTOM_PASSPHRASE_LEN = 12; +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; export type EncryptedBackupState = { - /** Six-word passphrase generated in Rust; null until loaded. */ - generatedPassphrase: string | null; - generateError: string | null; - mode: PassphraseMode; - customPassphrase: string; - customConfirm: string; - isCreating: boolean; + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; createError: string | null; - /** The persisted `ncryptsec1…` blob once the backup exists. */ + downloadPending: boolean; ncryptsec: string | null; + savedPassword: boolean; }; export const initialEncryptedBackupState: EncryptedBackupState = { - generatedPassphrase: null, - generateError: null, - mode: "generated", - customPassphrase: "", - customConfirm: "", - isCreating: false, + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, createError: null, + downloadPending: false, ncryptsec: null, + savedPassword: false, }; export type EncryptedBackupEvent = - | { type: "passphrase-generated"; passphrase: string } - | { type: "passphrase-generate-failed"; message: string } - | { type: "set-mode"; mode: PassphraseMode } - | { type: "set-custom-passphrase"; value: string } - | { type: "set-custom-confirm"; value: string } - | { type: "create-started" } - | { type: "create-succeeded"; ncryptsec: string } - | { type: "create-failed"; message: string }; + | { 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 "passphrase-generated": + 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, - generatedPassphrase: event.passphrase, - generateError: null, + 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, }; - case "passphrase-generate-failed": - return { ...state, generateError: event.message }; - case "set-mode": - // Editing state carries across toggles; validation re-derives. - return { ...state, mode: event.mode, createError: null }; - case "set-custom-passphrase": - return { ...state, customPassphrase: event.value, createError: null }; - case "set-custom-confirm": - return { ...state, customConfirm: event.value, createError: null }; - case "create-started": - return { ...state, isCreating: true, createError: null }; - case "create-succeeded": - return { ...state, isCreating: false, ncryptsec: event.ncryptsec }; - case "create-failed": - return { ...state, isCreating: false, createError: event.message }; } } -/** - * Validation issue for a custom passphrase, or null when acceptable. - * Confirm mismatch is only reported once the confirm field has content, so - * the user isn't scolded mid-typing. - */ -export function customPassphraseIssue( - passphrase: string, - confirm: string, -): string | null { +export function passphraseIssue(passphrase: string): string | null { if (passphrase.length === 0) return null; - if ([...passphrase].length < MIN_CUSTOM_PASSPHRASE_LEN) { - return `Use at least ${MIN_CUSTOM_PASSPHRASE_LEN} characters.`; - } - if (confirm.length > 0 && passphrase !== confirm) { - return "Passphrases don't match."; - } - return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; } - -/** The passphrase the Create action would submit, or null when not ready. */ export function effectivePassphrase( state: EncryptedBackupState, ): string | null { - if (state.mode === "generated") return state.generatedPassphrase; - const { customPassphrase, customConfirm } = state; - if ( - [...customPassphrase].length < MIN_CUSTOM_PASSPHRASE_LEN || - customPassphrase !== customConfirm - ) { + 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 customPassphrase; + return effectivePassphrase(state); } - -/** Whether the "Create backup" action is currently actionable. */ -export function createDisabled(state: EncryptedBackupState): boolean { - return state.isCreating || effectivePassphrase(state) === null; +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/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 4b7b176494..1d1b1d0bcf 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,82 +1,76 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info } from "lucide-react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { EncryptedBackupCreator } from "./EncryptedBackupCreator"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; -export type BackupStepMode = "encrypted" | "raw"; +/** + * How long the "Creating your identity key" loader holds the stage before the + * finished state fades in. Purely perceptual — the key already exists; the + * pause sells the creation moment. + */ +const INTRO_HOLD_MS = 1400; /** - * Pure helper so the disabled logic can be unit-tested without a DOM. - * - * Encrypted mode (default): Next unlocks once the backup blob exists — the - * user must either create a backup or explicitly switch to the raw key. - * Raw mode: disabled while loading or after a failed load (only the explicit - * "Skip for now" ghost advances past an error), matching the previous flow. + * The creation moment should only be sold once per app session. Module-level + * so remounts (e.g. navigating Back and returning to this step) skip the fake + * hold and show the finished state instantly. */ -export function backupNextDisabled({ - mode, - hasBackup, - isLoading, - loadError, -}: { - mode: BackupStepMode; - hasBackup: boolean; - isLoading: boolean; - loadError: string | null; -}): boolean { - if (mode === "encrypted") { - return !hasBackup; - } - return isLoading || loadError !== null; +let introPlayed = false; + +const REVEAL_ANIMATION_CLASS = + "animate-in fade-in duration-700 motion-reduce:animate-none"; + +/** Viewing the key never blocks onboarding — Next is always actionable. */ +export function backupNextDisabled(): boolean { + return false; } type BackupStepProps = { direction: OnboardingTransitionDirection; onBack: () => void; - onNext: () => void; + /** Advances to the dedicated "Download your key" onboarding step. */ + onDownload: () => void; }; /** - * Onboarding backup step — encrypted by default. The user protects their - * freshly created key with a passphrase and gets a NIP-49 `ncryptsec1…` - * backup; the raw key is only fetched (and shown) after an explicit - * "Show raw key instead" click. The default path never invokes `get_nsec`. + * Onboarding backup step — shows the freshly created key and offers a direct + * clipboard copy destined for a password manager. Next leads into the + * encrypted download step (its own onboarding page, via `onDownload`), which + * is skippable there. The raw key is fetched only when the user explicitly + * clicks Copy or Reveal, and is never held in state before that. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { - const [mode, setMode] = React.useState("encrypted"); - const [hasBackup, setHasBackup] = React.useState(false); +export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { + const [created, setCreated] = React.useState(introPlayed); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(false); - const [loadError, setLoadError] = React.useState(null); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(null); - const loadNsec = React.useCallback(async () => { - setIsLoading(true); - setLoadError(null); - try { - const value = await getNsec(); - if (!cancelledRef.current) setNsec(value); - } catch (err) { - if (!cancelledRef.current) - setLoadError( - err instanceof Error - ? err.message - : "Failed to retrieve private key.", - ); - } finally { - if (!cancelledRef.current) setIsLoading(false); - } + React.useEffect(() => { + if (introPlayed) return; + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); + return () => window.clearTimeout(timer); }, []); React.useEffect(() => { @@ -86,13 +80,61 @@ export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); }; }, []); - const showRawKey = React.useCallback(() => { - setMode("raw"); - void loadNsec(); - }, [loadNsec]); + const copyKeyToClipboard = React.useCallback(async () => { + setCopyState("copying"); + setCopyError(null); + try { + const value = nsec ?? (await getNsec()); + await writeTextToClipboard(value); + if (cancelledRef.current) return; + setCopyState("copied"); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => { + if (!cancelledRef.current) setCopyState("idle"); + }, 2000); + } catch (err) { + if (cancelledRef.current) return; + setCopyState("idle"); + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [nsec]); + + const toggleReveal = React.useCallback(async () => { + if (isRevealed) { + setIsRevealed(false); + return; + } + setCopyError(null); + try { + // The raw key enters the DOM only after this explicit reveal action. + const value = nsec ?? (await getNsec()); + if (cancelledRef.current) return; + setNsec(value); + setIsRevealed(true); + } catch (err) { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [isRevealed, nsec]); + + // Fixed-length decorative mask (nsec keys are 63 chars) so no key material + // is fetched just to render the blurred row. Bullets are joined with a + // zero-width space: WebKit won't line-break a run of U+2022 without an + // explicit break opportunity, so the masked row would overflow otherwise. + const maskedKey = React.useMemo( + () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), + [nsec], + ); return (
-

- Your unique identity key has been created + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- {mode === "encrypted" - ? "Protect it with a passphrase and keep an encrypted backup in case you ever need to restore your account." - : "This key is stored in your system keychain, but save it some place safe in case you ever need to restore your account."} -

+ {created ? ( +

+ Your identity key will be saved to your keychain. Back it up + somewhere safe so you can restore your account. Never share your + key. +

+ ) : null}
-
- {mode === "encrypted" ? ( - -
- setHasBackup(true)} - variant="spotlight" - /> -
-
- ) : isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- + {!created ? ( +
+ +
+ ) : ( +
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+
+ + + + + + + Copy your key and save it somewhere safe — a password + manager is a great place for it. + + +
+
+ {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} +
+ +

+ - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. -

- +

- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} - - {mode === "encrypted" && !hasBackup ? ( -
- -
- ) : null} - - {mode === "raw" && nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - -

- ) : null} -
+
+ )} - - + {created ? ( + + - {mode === "raw" && loadError ? ( - ) : null} - - - + + ) : null} ); } diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..dee0ea9405 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -0,0 +1,557 @@ +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 { cn } from "@/shared/lib/cn"; +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"; + +/** + * Durable progress through the test flow. Owned by the host so navigating + * away (e.g. onboarding Back) and returning doesn't force the user to + * re-drop the file. The password attempt is deliberately NOT part of this + * state — it lives only in short-lived component state and is cleared the + * 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 = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When supplied, only this exact just-created file is accepted — the + * onboarding ceremony proves the user saved *that* backup. Without it the + * flow is a general-purpose tester for any key backup file. + */ + expectedNcryptsec?: string; + /** Re-open the native save dialog for another copy of the backup file. */ + onSaveCopy?: () => void; + isSaving?: boolean; + savedPath?: string | null; + saveError?: string | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; + /** Fired once when the user completes the test successfully. */ + onVerified?: () => void; +}; + +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({ + variant = "spotlight", + expectedNcryptsec, + onSaveCopy, + isSaving = false, + savedPath, + saveError, + progress, + onProgressChange, + onVerified, +}: 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( + expectedNcryptsec + ? "That doesn't look like your key backup. Choose the file you just downloaded." + : "That doesn't look like a key backup file.", + ); + return; + } + if (expectedNcryptsec && text !== expectedNcryptsec.trim()) { + setError("That's a key backup, but not the one you just downloaded."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [expectedNcryptsec, 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, + })); + onVerified?.(); + } 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, onVerified]); + + const isSpotlight = variant === "spotlight"; + + if (stage === "success" && result) { + // The onboarding ceremony pins the exact file, so a success there is by + // construction the current identity — celebrate and move on. The general + // tester reports which identity the backup unlocks. + const isCeremony = Boolean(expectedNcryptsec); + return ( +
+ {reduceMotion ? null : } + + + +

+ {isCeremony ? "Your backup works!" : "This backup works"} +

+

+ {isCeremony + ? "File and password verified. Keep them both somewhere safe — that's all you need to restore your identity." + : result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+ {isCeremony ? null : ( +
+ +
+ )} +
+ {isCeremony ? null : ( + + )} +
+ ); + } + + 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 onboarding card / 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} + {onSaveCopy ? ( +
+ + {savedPath ? ( +

+ Saved to {savedPath} +

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

{saveError}

+ ) : 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/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..805d19661a 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({ > {isProfileStage || isTeamStage ? ( - + ) : null}
void; + onNext: () => void; +}; + +/** + * Onboarding download step — the password-first encrypted key download + * flow, promoted to its own page in the machine onboarding flow. + * The raw key never enters this component: Rust builds the NIP-49 payload + * locally and the native save dialog produces the user-owned file. + */ +export function DownloadKeyStep({ + direction, + session, + onBack, + onNext, +}: DownloadKeyStepProps) { + const reduceMotion = useReducedMotion() ?? false; + // True once the encrypted payload exists — the create button (living in the + // footer's primary slot) disappears with the form, so Next takes its place. + const hasCreated = session.created; + // True once the user has passed the backup test — until then Next stays + // disabled and "Skip for now" remains the escape hatch. + const hasVerified = session.verified; + // Footer slot the creator portals its "Download" button into. + const [createButtonSlot, setCreateButtonSlot] = + React.useState(null); + + return ( + +
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {hasCreated + ? "Now, test your backup" + : "Backup your key with a password"} +

+

+ {hasCreated + ? "Make sure your backup works: drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."} +

+
+ +
+
+ + +
+ +
+
+
+
+
+ + + {hasCreated ? ( + hasVerified ? ( + + ) : ( + /* No disabled Next while the test is unfinished — skipping is + the only way forward until verification succeeds. */ + + ) + ) : ( + /* Relative row keeps the Download CTA truly centered while Skip + hangs off its right edge without shifting the center. */ +
+
+ +
+ )} + + + + {hasCreated ? null : ( +

+ You can back up your key anytime in Settings → Profile → + Identity. +

+ )} + + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index ced5e9e449..b080f6808b 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -1,5 +1,6 @@ -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; import * as React from "react"; +import { createPortal } from "react-dom"; import { createNcryptsecBackup, @@ -9,89 +10,530 @@ import { 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 { - createDisabled, - customPassphraseIssue, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, encryptedBackupReducer, initialEncryptedBackupState, - MIN_CUSTOM_PASSPHRASE_LEN, - effectivePassphrase, + MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, } from "../lib/encryptedBackup"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; + +/** 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} + + ))} + + + ); +} + +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Return to a secure saved-password placeholder. The encrypted blob survives + * for instant re-download, while no password or test attempt is retained. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.setTest(initialBackupTestProgress); +} type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; - /** Fired once the backup blob exists (hosts gate Next / show toasts). */ - onCreated?: (ncryptsec: string) => void; + /** + * When set, the "Download" button is portaled into this element + * (e.g. the onboarding footer's primary slot) instead of rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Extra classes for the "Download" button. */ + createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; + /** Fired only after the encrypted key file has been saved successfully. */ + onSaved?: (path: string) => void; + /** Whether creation continues into onboarding's guided test ceremony. */ + guidedTest?: boolean; + /** Fired once when the user completes the backup test successfully. */ + onVerified?: () => void; }; /** - * Passphrase-first NIP-49 backup creation flow, shared by the onboarding - * BackupStep and the settings Password Backup row. + * 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 shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. * - * The raw private key never enters this component: it collects a passphrase, - * asks Rust to create + persist the encrypted backup, and displays the - * returned `ncryptsec1…` blob. A generated 6-word passphrase is the default; - * "choose my own" requires ≥12 characters plus confirmation. + * 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({ variant = "spotlight", + createButtonPortal, + createButtonClassName, + session: sessionProp, onCreated, + onSaved, + guidedTest = true, + onVerified, }: EncryptedBackupCreatorProps) { - const [state, dispatch] = React.useReducer( - encryptedBackupReducer, - initialEncryptedBackupState, - ); - const [savedPath, setSavedPath] = React.useState(null); + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; + 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); - const generate = React.useCallback(async () => { - try { - const passphrase = await generateBackupPassphrase(); - if (mountedRef.current) - dispatch({ type: "passphrase-generated", passphrase }); - } catch (err) { - if (mountedRef.current) - dispatch({ - type: "passphrase-generate-failed", - message: - err instanceof Error - ? err.message - : "Failed to generate a passphrase.", - }); - } - }, []); - React.useEffect(() => { mountedRef.current = true; - void generate(); return () => { mountedRef.current = false; }; - }, [generate]); + }, []); - const handleCreate = React.useCallback(async () => { - const passphrase = effectivePassphrase(state); - if (!passphrase || state.isCreating) return; - dispatch({ type: "create-started" }); - try { - const ncryptsec = await createNcryptsecBackup(passphrase); - if (!mountedRef.current) return; - dispatch({ type: "create-succeeded", ncryptsec }); - onCreated?.(ncryptsec); - } catch (err) { - if (mountedRef.current) - dispatch({ - type: "create-failed", - message: - err instanceof Error ? err.message : "Failed to create backup.", - }); - } - }, [onCreated, state]); + // 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); + }; + }, [dispatch, 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; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + onSaved?.(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); + }); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); const handleSaveCopy = React.useCallback(async () => { if (!state.ncryptsec || isSaving) return; @@ -99,183 +541,170 @@ export function EncryptedBackupCreator({ setSaveError(null); try { const path = await saveNcryptsecCopy(state.ncryptsec); - if (mountedRef.current && path) setSavedPath(path); + if (mountedRef.current && path) { + setSavedPath(path); + onSaved?.(path); + } } catch (err) { if (mountedRef.current) setSaveError( - err instanceof Error ? err.message : "Failed to save a copy.", + err instanceof Error ? err.message : "Failed to save your key.", ); } finally { if (mountedRef.current) setIsSaving(false); } - }, [isSaving, state.ncryptsec]); + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); - const isSpotlight = variant === "spotlight"; - const customIssue = customPassphraseIssue( - state.customPassphrase, - state.customConfirm, - ); + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); + + const issue = passphraseIssue(state.passphrase); - if (state.ncryptsec) { + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath && guidedTest) { return ( -
- + void handleSaveCopy()} + onVerified={handleVerified} + progress={test} + saveError={saveError} + savedPath={savedPath} + variant={variant} /> -
- - {savedPath ? ( -

- Saved to {savedPath} -

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

{saveError}

- ) : null} -

- This backup can only be unlocked with your passphrase. Without the - passphrase it cannot be recovered — not even by Buzz. -

); } + // 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 (
- {state.mode === "generated" ? ( -
- {state.generatedPassphrase ? ( -
-

- {state.generatedPassphrase} -

-
- ) : state.generateError ? ( -
- - - Could not generate a passphrase: {state.generateError} - -
+
+ { + 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} + - -
-

- Write this passphrase down. It protects your backup and cannot be - recovered if lost. + + 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}

-
- ) : ( -
-
- - dispatch({ - type: "set-custom-passphrase", - value: event.target.value, - }) - } - placeholder={`Passphrase (min ${MIN_CUSTOM_PASSPHRASE_LEN} characters)`} - type="password" - value={state.customPassphrase} - /> - - dispatch({ - type: "set-custom-confirm", - value: event.target.value, - }) - } - placeholder="Confirm passphrase" - type="password" - value={state.customConfirm} - /> -
- {customIssue ? ( -

- {customIssue} -

- ) : null} -
- -
-

- Your passphrase protects the backup and cannot be recovered if lost. + ) : 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 ? (

) : null} -

- -
+ {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} + +
+ ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()} + + + + 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; + setTest(initialBackupTestProgress); + setIsRevealed(false); + }} + > + Start with a new password + + + +
); } diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index a93d75f2ef..2576f71e10 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -10,6 +10,11 @@ import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DownloadKeyStep } from "./DownloadKeyStep"; +import { + backupSessionToPasswordEntry, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; import { NostrKeyImportForm } from "./NostrKeyImportForm"; @@ -25,6 +30,7 @@ export type MachineOnboardingPage = | "identity" | "key-import" | "backup" + | "download" | "setup" | "config"; @@ -65,6 +71,9 @@ export function MachineOnboardingFlow({ null, ); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + // Owned here (not by DownloadKeyStep) so Back navigation — which unmounts + // the step — keeps the created backup, entered password, and test progress. + const backupSession = useEncryptedBackupSession(); const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -136,7 +145,15 @@ export function MachineOnboardingFlow({ {page === "identity" ? : null} {page !== "identity" ? ( ) : null} @@ -227,13 +244,29 @@ export function MachineOnboardingFlow({ setPage("identity")} + onDownload={() => setPage("download")} + /> + ) : page === "download" ? ( + setPage("backup")} onNext={() => setPage("setup")} + session={backupSession} /> ) : page === "setup" ? ( - setPage(identityWasImported ? "key-import" : "backup"), + // Fresh-key users return to the "Backup your key with a + // password" form (not the test flow they may have finished); + // imported keys skip that step entirely. + back: () => { + if (identityWasImported) { + setPage("key-import"); + return; + } + backupSessionToPasswordEntry(backupSession); + setPage("download"); + }, next: (runtimeIds) => { const ids = Array.from(runtimeIds); setReadyRuntimeIds(ids); diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index dfa68e7d5b..900b7b932e 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -96,7 +96,7 @@ export function NostrKeyImportForm({ if (file.size > NOSTR_KEY_FILE_MAX_BYTES) { setImportError( - "That file is too large to be a key. Choose a .key or .ncryptsec backup file, or paste your key.", + "That file is too large to be a key backup or private key. Choose another file.", ); return; } @@ -126,7 +126,7 @@ export function NostrKeyImportForm({ if (!isValid) { setImportError( isEncryptedInput - ? "Enter the passphrase for this encrypted backup." + ? "Enter the password for this key backup." : "That doesn't look like a valid nsec. Paste an nsec1 key.", ); return; @@ -244,8 +244,8 @@ export function NostrKeyImportForm({
{/* Hidden file input shared by both variants: the default drop zone and - the spotlight "Import from a file" button both open it. Accepts the - .ncryptsec archives our own save flow emits alongside raw .key files. */} + the spotlight "Choose a backup file" button both open it. Accepts the + .ncryptsec backups our own save flow emits alongside raw .key files. */} - Import from a file + Choose a backup file
) : ( @@ -358,7 +358,7 @@ export function NostrKeyImportForm({ className="text-sm font-medium text-foreground" htmlFor="nostr-import-passphrase" > - Backup passphrase + Backup password +

+ Your backup file and password stay on this device. +

) : null} @@ -393,7 +396,8 @@ export function NostrKeyImportForm({ data-testid="nostr-import-encrypted-badge" >
+ ); +} + +/** + * 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 5326243ff4..d82100026d 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -13,7 +13,6 @@ import { useUpdateProfileMutation, } from "@/features/profile/hooks"; import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; -import { EncryptedBackupCreator } from "@/features/onboarding/ui/EncryptedBackupCreator"; import { getNsec } from "@/shared/api/tauriIdentity"; import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -25,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"; @@ -177,42 +177,6 @@ function NsecRevealRow() { ); } -/** - * Collapsible row for creating an encrypted NIP-49 backup on demand. The raw - * private key never reaches this flow — the passphrase goes to Rust, which - * returns the persisted `ncryptsec1…` blob. - */ -function EncryptedBackupRow() { - const [isOpen, setIsOpen] = React.useState(false); - - return ( -
-
-
-

Password Backup

-

- Protect your key with a password and save a recoverable backup. -

-
- -
- {isOpen ? ( -
- -
- ) : null} -
- ); -} - function EditProfileMetadataButton({ label, testId, diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx index 8d4dc1c481..55479ec5d9 100644 --- a/desktop/src/features/settings/ui/SignOutSection.tsx +++ b/desktop/src/features/settings/ui/SignOutSection.tsx @@ -31,9 +31,9 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data"; * Signing out wipes the identity key and all local data, so the confirm * dialog gates the delete button behind two explicit steps: * - * 1. Back up the key — the nsec is shown inline (masked, with reveal/copy); - * the "I have saved my private key" checkbox unlocks only after the user - * actually reveals or copies the key. + * 1. Confirm recovery — Settings offers a tested password-protected backup; + * the dialog also shows the raw nsec as a last-chance fallback, and the + * user checks a box confirming they can restore their identity. * 2. Typed confirmation — the user must type the exact phrase * "wipe all my data". * @@ -47,7 +47,6 @@ export function SignOutSection() { const [nsec, setNsec] = React.useState(null); const [nsecError, setNsecError] = React.useState(null); const [isNsecLoading, setIsNsecLoading] = React.useState(false); - const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false); const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false); // Guards against a late-resolving getNsec() repopulating state after the // dialog closes. @@ -58,20 +57,13 @@ export function SignOutSection() { const isPhraseConfirmed = confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE; - // The backup checkbox unlocks after real interaction with the key - // (reveal or copy). If the key cannot be loaded at all there is nothing to - // interact with — let the user proceed past the backup step rather than - // locking them out of sign-out entirely. - const isBackupGateSatisfied = hasConfirmedBackup; - const canConfirmBackup = hasInteractedWithKey || nsecError !== null; - const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending; + const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending; function resetDialogState() { fetchCancelledRef.current = true; setNsec(null); setNsecError(null); setIsNsecLoading(false); - setHasInteractedWithKey(false); setHasConfirmedBackup(false); setConfirmText(""); } @@ -137,7 +129,8 @@ export function SignOutSection() {

Sign out

Removes your identity key and all local app data from this device. - Back up your private key (nsec) first — this cannot be undone. + Before signing out, create and test a password-protected key backup + above — this cannot be undone.

+ + + +
+
+ + + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can continue + and find it later in Settings > Profile > Identity. +

+ ) : null} + + + ); + } + return (
{/* Plain string concat: cn()'s tailwind-merge misreads the custom @@ -204,55 +325,37 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { {isRevealed && nsec ? nsec : maskedKey}

-
- - - - - - - Copy your key and save it somewhere safe — a password - manager is a great place for it. - - -
+ + + +
+ {copyError ? (

) : null} - +

-

+

Never share your private key. Anyone with this key can @@ -282,7 +385,7 @@ export function BackupStep({ direction, onBack, onDownload }: BackupStepProps) { className={ONBOARDING_PRIMARY_CTA_CLASS} data-testid="onboarding-next" disabled={backupNextDisabled()} - onClick={onDownload} + onClick={onNext} type="button" > Next diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 805d19661a..4b729ab33f 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -448,7 +448,7 @@ export function CommunityOnboardingFlow({ > {isProfileStage || isTeamStage ? ( - + ) : null}

void; onNext: () => void; }; /** - * Onboarding download step — the password-first encrypted key download - * flow, promoted to its own page in the machine onboarding flow. + * Password-backup security subview within the identity-key onboarding step. * The raw key never enters this component: Rust builds the NIP-49 payload * locally and the native save dialog produces the user-owned file. */ export function DownloadKeyStep({ direction, session, - onBack, onNext, }: DownloadKeyStepProps) { const reduceMotion = useReducedMotion() ?? false; @@ -79,7 +75,7 @@ export function DownloadKeyStep({ initial={reduceMotion ? false : { opacity: 0, y: 12 }} transition={{ delay: 0.12, duration: 0.4, ease: "easeOut" }} > - +
)} - - {hasCreated ? null : (

You can back up your key anytime in Settings → Profile → diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index b080f6808b..ab02838cfa 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -255,10 +255,12 @@ function PassphraseGeneratorPopover({ disabled = false, onRequestGenerate, onGenerated, + securityTheme = false, }: { disabled?: boolean; onRequestGenerate?: () => void; onGenerated: (value: string) => void; + securityTheme?: boolean; }) { const [open, setOpen] = React.useState(false); const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); @@ -335,7 +337,10 @@ function PassphraseGeneratorPopover({ { // Clicking the anchor icon is "outside" the content — keep the // popover open so that click re-rolls instead of closing. @@ -677,6 +682,7 @@ export function EncryptedBackupCreator({ // A generated password must be visible so the user can save it. setIsRevealed(true); }} + securityTheme={variant === "spotlight"} /> {issue ? (

- + Create a new backup password? diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 2576f71e10..c9ac67b7b4 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import type { QueryClient } from "@tanstack/react-query"; +import { ArrowUp } from "lucide-react"; import { getIdentity, @@ -30,10 +31,11 @@ export type MachineOnboardingPage = | "identity" | "key-import" | "backup" - | "download" | "setup" | "config"; +type BackupSubview = "created" | "options" | "password"; + /** A pending navigation the parent should execute after RouterProvider mounts. */ export type PostOnboardingNavigation = { to: string; @@ -71,9 +73,17 @@ export function MachineOnboardingFlow({ null, ); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); - // Owned here (not by DownloadKeyStep) so Back navigation — which unmounts - // the step — keeps the created backup, entered password, and test progress. + const [backupSubview, setBackupSubview] = + React.useState("created"); + const [backupDirection, setBackupDirection] = React.useState< + "forward" | "backward" + >("forward"); + const [returningFromSecurity, setReturningFromSecurity] = + React.useState(false); + // Owned here so switching between the yellow onboarding view and the dark + // security subview keeps the created backup, password, and test progress. const backupSession = useEncryptedBackupSession(); + const isSecuritySubview = page === "backup" && backupSubview !== "created"; const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -88,6 +98,9 @@ export function MachineOnboardingFlow({ const identity = await getIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -110,6 +123,9 @@ export function MachineOnboardingFlow({ const identity = await persistCurrentIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -135,6 +151,8 @@ export function MachineOnboardingFlow({ return (

{page === "identity" ? : null} - {page !== "identity" ? ( + {isSecuritySubview ? ( +
+ +
+ ) : page !== "identity" ? ( ) : null} @@ -241,31 +268,47 @@ export function MachineOnboardingFlow({
) : page === "backup" ? ( - setPage("identity")} - onDownload={() => setPage("download")} - /> - ) : page === "download" ? ( - setPage("backup")} - onNext={() => setPage("setup")} - session={backupSession} - /> + backupSubview === "password" ? ( + setPage("setup")} + session={backupSession} + /> + ) : ( + setPage("identity")} + onNext={() => setPage("setup")} + onOpenPasswordBackup={() => { + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("password"); + }} + onShowOptions={() => { + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("options"); + }} + optionsExpanded={backupSubview === "options"} + returningFromSecurity={returningFromSecurity} + /> + ) ) : page === "setup" ? ( { if (identityWasImported) { setPage("key-import"); return; } - backupSessionToPasswordEntry(backupSession); - setPage("download"); + if (backupSubview === "password") { + backupSessionToPasswordEntry(backupSession); + } + setBackupDirection("backward"); + setReturningFromSecurity(false); + setPage("backup"); }, next: (runtimeIds) => { const ids = Array.from(runtimeIds); diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 24f020a09f..345d3cce42 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -1,12 +1,11 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; /** - * Positions in the first-launch flow: landing, identity/key, key download, - * harness setup, default config, community choice, community profile, meet - * the team. Used as the default pagination length when a flow doesn't pass an - * explicit total. + * Positions in the first-launch flow: landing, identity/key, harness setup, + * default config, community choice, community profile, meet the team. Password + * backup is an optional subview of identity/key, not another position. */ -export const TOTAL_ONBOARDING_PAGES = 8; +export const TOTAL_ONBOARDING_PAGES = 7; /** Shared pill shape (38px tall) for every onboarding primary CTA. */ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6"; diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx index 82d9a0213c..ba5d8b2c87 100644 --- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx @@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward"; export type OnboardingTransitionEffect = | "fade" | "line-slide" + | "mask-reveal-down" | "mask-reveal-up" | "none"; diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index c21e4a617d..036cf925e0 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -241,6 +241,54 @@ --buzz-onboarding-avatar-dialog-shadow: 0 0 0; } + /* + * Backup options and password backup intentionally leave the bright + * onboarding world for a dark security-focused subview. Keep these semantic + * overrides on a reusable class as well as the shell so portaled + * popovers/dialogs can opt into the same treatment. + */ + .buzz-onboarding-security-theme { + color-scheme: dark; + --buzz-onboarding-shell-bottom: #082b49; + --buzz-onboarding-cta-label: #f5f5f5; + --background: 222 45% 4%; + --foreground: 0 0% 96%; + --card: 220 14% 11%; + --card-foreground: 0 0% 96%; + --popover: 220 14% 9%; + --popover-foreground: 0 0% 96%; + --primary: 0 0% 16%; + --primary-foreground: 0 0% 96%; + --secondary: 0 0% 12%; + --secondary-foreground: 0 0% 96%; + --muted: 0 0% 12%; + --muted-foreground: 0 0% 72%; + --accent: 0 0% 16%; + --accent-foreground: 0 0% 96%; + --destructive: 0 84% 70%; + --destructive-foreground: 0 0% 4%; + --border: 0 0% 22%; + --input: 0 0% 22%; + --ring: 0 0% 84%; + } + + .buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-security-theme { + background-color: #010103; + background-image: + radial-gradient(circle, rgb(143 211 255 / 0.11) 1px, transparent 1px), + radial-gradient( + ellipse at 50% 58%, + rgb(28 112 196 / 0.24) 0%, + rgb(18 76 138 / 0.1) 38%, + transparent 66% + ), + linear-gradient(to bottom, #010103 0%, #040914 46%, #082b49 100%); + background-size: + 24px 24px, + auto, + auto; + } + .buzz-onboarding-key-text { @apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key; @@ -376,10 +424,13 @@ animation-name: buzz-onboarding-line-slide-backward; } + .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"], .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] { overflow: visible; } + .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"] + > .buzz-onboarding-transition-content, .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] > .buzz-onboarding-transition-content { /* `backwards` (not `both`): the reveal ends on an identity transform, so @@ -387,12 +438,23 @@ that establishes a containing block and traps `position: fixed` descendants (the bottom-docked onboarding footer). Reverting to no transform at rest looks identical and frees fixed positioning. */ - animation: buzz-onboarding-mask-reveal-up 760ms - cubic-bezier(0.22, 1, 0.36, 1) backwards; + animation-duration: 760ms; + animation-fill-mode: backwards; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); animation-delay: var(--buzz-onboarding-transition-delay, 0ms); transform-origin: 50% 70%; } + .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"] + > .buzz-onboarding-transition-content { + animation-name: buzz-onboarding-mask-reveal-down; + } + + .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] + > .buzz-onboarding-transition-content { + animation-name: buzz-onboarding-mask-reveal-up; + } + .buzz-onboarding-name-placeholder-caret { animation: buzz-onboarding-caret-blink 1.1s steps(1, end) infinite; } @@ -543,6 +605,25 @@ } } + @keyframes buzz-onboarding-mask-reveal-down { + from { + filter: blur(8px); + opacity: 0; + transform: translate3d(0, -30px, 0) scale(0.985); + } + + 56% { + filter: blur(2px); + opacity: 0.86; + } + + to { + filter: blur(0); + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + } + @media (prefers-reduced-motion: reduce) { .buzz-onboarding-runtime-check, .buzz-onboarding-runtime-checkmark { diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index dff9b012b4..56c2807575 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -16,6 +16,20 @@ async function enterMachineBackup(page: import("@playwright/test").Page) { await page.getByRole("button", { name: "Create a new identity key" }).click(); } +async function openBackupOptions(page: import("@playwright/test").Page) { + await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); + await page.getByTestId("backup-options-toggle").click(); + await expect( + page.getByTestId("onboarding-page-backup-options"), + ).toBeVisible(); +} + +async function openPasswordBackup(page: import("@playwright/test").Page) { + await openBackupOptions(page); + await page.getByTestId("backup-option-password").click(); + await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); +} + async function invokedCommands(page: import("@playwright/test").Page) { return page.evaluate( () => @@ -55,11 +69,11 @@ test("backup step appears on fresh-key path after profile submit", async ({ }); // --------------------------------------------------------------------------- -// Chooser: masked key with reveal toggle and inline copy. The raw key is -// fetched only on explicit reveal/copy, and Next is never blocked. +// Key-created view: masked key with reveal toggle. Backup options open the +// dark security view; the raw key is fetched only on explicit reveal/copy. // --------------------------------------------------------------------------- -test("chooser shows masked key; reveal and copy fetch it explicitly", async ({ +test("key view reveals explicitly; options copy explicitly", async ({ page, }) => { await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); @@ -86,18 +100,24 @@ test("chooser shows masked key; reveal and copy fetch it explicitly", async ({ await page.getByTestId("backup-key-reveal-toggle").click(); await expect(key).not.toContainText("nsec1"); - // Inline copy goes straight to the clipboard. + // Copy is available only after opening the dark backup-options view. + await page.getByTestId("backup-options-toggle").click(); + await expect( + page.getByTestId("onboarding-page-backup-options"), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toHaveCount(0); await page.getByTestId("backup-copy-key").click(); await expect .poll(async () => invokedCommands(page)) .toContain("copy_text_to_clipboard"); expect(await invokedCommands(page)).toContain("get_nsec"); - // Next leads into the download step, where backup stays skippable. + // Return restores the yellow key-created view; its Next skips backup and + // continues directly to setup. + await page.getByTestId("backup-return-to-onboarding").click(); + await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); await expect(page.getByTestId("onboarding-next")).toBeEnabled(); await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); - await page.getByTestId("onboarding-skip").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); @@ -112,10 +132,9 @@ test("download happy path: generated password, encrypt, native save, Next", asyn }) => { await enterMachineBackup(page); - // The download flow is its own onboarding step behind the footer's Next. - await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); - await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + // Password backup opens from the dark options state without adding an + // onboarding progress step. + await openPasswordBackup(page); // The password field starts empty; the create button sits in the footer's // primary slot and stays disabled until a valid password exists. @@ -239,28 +258,27 @@ test("download happy path: generated password, encrypt, native save, Next", asyn await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); -test("download step Back returns to the backup chooser", async ({ page }) => { +test("security view returns to the yellow onboarding view", async ({ + page, +}) => { await enterMachineBackup(page); + await openPasswordBackup(page); - await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); await expect(page.getByTestId("backup-passphrase-input")).toBeVisible(); - // The chooser's footer Next belongs to the previous step; the download - // step only mounts its own Next once the backup exists. + await expect(page.getByTestId("onboarding-step-dots")).toHaveCount(0); await expect(page.getByTestId("onboarding-next")).toHaveCount(0); - await page.getByTestId("onboarding-back").click(); + await page.getByTestId("backup-return-to-onboarding").click(); await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); await expect(page.getByTestId("backup-key-value")).toBeVisible(); await expect(page.getByTestId("onboarding-next")).toBeVisible(); }); -test("test-view Back returns to a secure saved-password placeholder", async ({ +test("returning to onboarding preserves password-backup progress", async ({ page, }) => { await enterMachineBackup(page); - await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); + await openPasswordBackup(page); const input = page.getByTestId("backup-passphrase-input"); await input.fill("mock-horse-battery-staple"); @@ -269,30 +287,20 @@ test("test-view Back returns to a secure saved-password placeholder", async ({ page.getByRole("heading", { name: "Now, test your backup" }), ).toBeVisible(); - // Back retains only the encrypted blob and renders a fixed visual mask over - // an empty readonly input. - await page.getByTestId("onboarding-back").click(); - await expect( - page.getByRole("heading", { name: "Backup your key with a password" }), - ).toBeVisible(); - await expect(input).toHaveValue(""); - await expect(input).toHaveAttribute("readonly", ""); - await expect(page.getByTestId("backup-saved-password-mask")).toBeVisible(); + await page.getByTestId("backup-return-to-onboarding").click(); + await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - // Re-downloading needs no password or re-encryption. - await page.getByTestId("encrypted-backup-create").click(); + // Re-entering the security flow restores the in-progress backup test. + await openPasswordBackup(page); await expect( page.getByRole("heading", { name: "Now, test your backup" }), ).toBeVisible(); - - await page.getByTestId("onboarding-back").click(); - await page.getByTestId("onboarding-back").click(); - await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); + await expect(page.getByTestId("backup-test-dropzone")).toBeVisible(); }); test("typed password requires 12 characters", async ({ page }) => { await enterMachineBackup(page); - await page.getByTestId("onboarding-next").click(); + await openPasswordBackup(page); const create = page.getByTestId("encrypted-backup-create"); await expect(create).toBeDisabled(); // empty field @@ -345,12 +353,10 @@ test("reveal shows inline error when get_nsec fails and Next still advances", as await page.getByTestId("backup-key-reveal-toggle").click(); await expect(page.getByTestId("backup-copy-error")).toBeVisible(); - // Keychain failure does not trap the user: Next still advances into the - // download step, and Skip there continues to setup. + // Keychain failure does not trap the user: Next still skips backup and + // advances directly to setup. await expect(page.getByTestId("onboarding-next")).toBeEnabled(); await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); - await page.getByTestId("onboarding-skip").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); }); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 13aaf6e85b..0e535ffb10 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -68,8 +68,17 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/02b-backup-revealed.png` }); - // Next leads into the download step; Skip there continues to setup. - await page.getByTestId("onboarding-next").click(); + // Backup options leave the yellow flow for the dark security view without + // adding a progress step or a generic Next action. + await page.getByTestId("backup-options-toggle").click(); + await expect( + page.getByTestId("onboarding-page-backup-options"), + ).toBeVisible(); + await expect(page.getByTestId("onboarding-next")).toHaveCount(0); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT_DIR}/02c-backup-options.png` }); + + await page.getByTestId("backup-option-password").click(); await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); await page.getByTestId("onboarding-skip").click(); await expect( From ca754a5daa38dc19317d2a5d85762be994d3244d Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 16:50:02 -0700 Subject: [PATCH 09/26] fix(desktop): decouple backup surface navigation - Replace the password-backup surface's onboarding Skip and Next actions with a dedicated Back control. - Keep Backup key and Back in the existing bottom-docked CTA location while preserving inline settings behavior. - Route Back to Backup options with the correct backward transition instead of advancing onboarding. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../onboarding/ui/DownloadKeyStep.tsx | 74 +++++-------------- .../onboarding/ui/EncryptedBackupCreator.tsx | 4 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 6 +- 3 files changed, 25 insertions(+), 59 deletions(-) diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx index fb4a3c1fe6..b99fcf509e 100644 --- a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -22,7 +22,7 @@ type DownloadKeyStepProps = { * the backup-test progress. */ session: EncryptedBackupSession; - onNext: () => void; + onBack: () => void; }; /** @@ -33,16 +33,12 @@ type DownloadKeyStepProps = { export function DownloadKeyStep({ direction, session, - onNext, + onBack, }: DownloadKeyStepProps) { const reduceMotion = useReducedMotion() ?? false; - // True once the encrypted payload exists — the create button (living in the - // footer's primary slot) disappears with the form, so Next takes its place. + // Once the encrypted payload is saved, the creator advances to its guided + // backup test while this surface keeps its own navigation. const hasCreated = session.created; - // True once the user has passed the backup test — until then Next stays - // disabled and "Skip for now" remains the escape hatch. - const hasVerified = session.verified; - // Footer slot the creator portals its "Download" button into. const [createButtonSlot, setCreateButtonSlot] = React.useState(null); @@ -90,56 +86,22 @@ export function DownloadKeyStep({
- {hasCreated ? ( - hasVerified ? ( - - ) : ( - /* No disabled Next while the test is unfinished — skipping is - the only way forward until verification succeeds. */ - - ) - ) : ( - /* Relative row keeps the Download CTA truly centered while Skip - hangs off its right edge without shifting the center. */ -
-
- -
- )} - {hasCreated ? null : ( -

- You can back up your key anytime in Settings → Profile → - Identity. -

+
)} + ); diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx index ab02838cfa..1c567bb7eb 100644 --- a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -221,8 +221,8 @@ type EncryptedBackupCreatorProps = { /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ variant?: "spotlight" | "boxed"; /** - * When set, the "Download" button is portaled into this element - * (e.g. the onboarding footer's primary slot) instead of rendering inline. + * When set, the "Download" button is portaled into this element instead of + * rendering inline. */ createButtonPortal?: HTMLElement | null; /** Extra classes for the "Download" button. */ diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index c9ac67b7b4..ddea7a605c 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -271,7 +271,11 @@ export function MachineOnboardingFlow({ backupSubview === "password" ? ( setPage("setup")} + onBack={() => { + setBackupDirection("backward"); + setReturningFromSecurity(false); + setBackupSubview("options"); + }} session={backupSession} /> ) : ( From 38edd28336bfeb6106b4a7b88da0f8ab807dd3cd Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 29 Jul 2026 17:08:46 -0700 Subject: [PATCH 10/26] feat(desktop): explain system keychain protection during onboarding - Track whether the active identity is stored in the system keyring, local fallback file, environment, or ephemeral memory. - Expose storage metadata through the Tauri identity response and shared TypeScript identity model. - Add a responsive first information card beside the two backup actions without revealing boot-time identity creation. - Show keychain password education only when secure storage succeeded and accurate neutral copy for fallback states. - Extend identity persistence tests and split storage types into focused modules to preserve file-size limits. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src-tauri/src/app_state.rs | 97 +++++++++---------- desktop/src-tauri/src/app_state_tests.rs | 12 +-- desktop/src-tauri/src/commands/identity.rs | 28 ++++-- .../src/commands/identity_key_backup_tests.rs | 18 ++-- desktop/src-tauri/src/identity_storage.rs | 62 ++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/models.rs | 2 + .../src/features/onboarding/ui/BackupStep.tsx | 53 ++++++++-- .../onboarding/ui/MachineOnboardingFlow.tsx | 7 ++ desktop/src/shared/api/identityTypes.ts | 28 ++++++ desktop/src/shared/api/tauriIdentity.ts | 4 +- desktop/src/shared/api/types.ts | 20 +--- 12 files changed, 231 insertions(+), 101 deletions(-) create mode 100644 desktop/src-tauri/src/identity_storage.rs create mode 100644 desktop/src/shared/api/identityTypes.ts diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..abce86202a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicU16, AtomicU8}, Arc, Mutex, }, }; @@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; + pub struct AppState { pub keys: Mutex, + /// Durable backend holding `keys`. Updated after the key write and before + /// recovery flags are cleared so `get_identity` reports a consistent state. + pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL @@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. - let keys = match identity_from_env() { + let (keys, identity_storage) = match identity_from_env() { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - keys + (keys, IdentityStorage::Environment) } - None => Keys::generate(), + None => (Keys::generate(), IdentityStorage::Ephemeral), }; AppState { keys: Mutex::new(keys), + identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(10)) @@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let resolved = load_or_create_identity(&data_dir)?; - // Write keys before setting the recovery flags (Release) so any thread - // that reads a flag as false with Acquire is guaranteed to see the keys. - *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + // Write keys and storage before setting the recovery flags (Release) so + // any thread that reads a flag as false with Acquire sees consistent data. + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = resolved.keys; + state.set_identity_storage(resolved.storage); + } state.identity_lost.store( resolved.recovery == RecoveryState::Lost, std::sync::atomic::Ordering::Release, @@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; -/// Recovery state produced by identity resolution. `None` means the app has -/// a real, usable identity. `Lost` means the keyring was reachable-but-empty -/// despite a prior successful migration — the key vanished externally. `KeyringLocked` -/// means the keyring is unreachable this boot but was used in the past -/// (marker present, no file) — the key still exists but is temporarily -/// inaccessible. Both non-`None` variants boot with an ephemeral key; the -/// frontend shows a different recovery screen for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryState { - None, - Lost, - KeyringLocked, -} - -/// The output of identity resolution. -struct ResolvedIdentity { - keys: Keys, - recovery: RecoveryState, -} - /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> { +) -> Result { match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { - Ok(()) => Ok(()), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(e) => { eprintln!( "buzz-desktop: keyring write failed during import ({e}), \ falling back to identity.key" ); - save_key_file(legacy_path, keys) + save_key_file(legacy_path, keys)?; + Ok(IdentityStorage::LocalFile) } } } @@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity( keys: &Keys, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result<(), String> { +) -> Result { persist_imported_identity_impl(store, keys, legacy_path, data_dir) } @@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes -/// the migration marker only after a keyring success — on the file-fallback arm -/// the key is on disk and a marker would wrongly trip the next Unreachable boot -/// into failing closed. -enum PersistBackend { - Keyring, - File, -} - /// Generate a fresh identity, persist it through the store, return it. /// /// On a keyring-backed persist no file is written, so a later @@ -940,9 +936,10 @@ fn generate_and_persist( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result<(Keys, IdentityStorage), String> { let keys = Keys::generate(); - if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { + let storage = store_key_preferring_keyring(store, &keys, legacy_path)?; + if storage == IdentityStorage::SystemKeyring { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -956,7 +953,7 @@ fn generate_and_persist( "buzz-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); - Ok(keys) + Ok((keys, storage)) } /// Persist `keys` through the store, silently falling back to the `0o600` file @@ -968,17 +965,17 @@ fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, -) -> Result { +) -> Result { let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; match store.store(IDENTITY_KEY_NAME, &nsec) { - Ok(()) => Ok(PersistBackend::Keyring), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(keyring_err) => { eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback"); save_key_file(legacy_path, keys)?; - Ok(PersistBackend::File) + Ok(IdentityStorage::LocalFile) } } } diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea15..751bcf22e5 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() { let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); + assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring); assert!(migration_marker_path(dir.path()).exists()); assert_eq!( store @@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { let from_file = load_key_file(&legacy_path).unwrap(); assert_key_eq(&resolved.keys, &from_file); // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); + assert!( + !migration_marker_path(dir.path()).exists() + && resolved.storage == IdentityStorage::LocalFile + ); } // ── New tests for the three defects fixed in this PR ───────────────────── @@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); // The policy core handles the keyring failure — Ok, not Err. - assert!( - result.is_ok(), - "must not propagate keyring failure when file fallback succeeds" - ); + assert_eq!(result.unwrap(), IdentityStorage::LocalFile); // Key is recoverable from the file on next boot. let from_file = load_key_file(&legacy_path).unwrap(); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 94e23f7442..8c5cc6b3ba 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: state.identity_storage().as_str().to_string(), lost, locked, reset_failed, @@ -380,7 +381,7 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - let pubkey = commit_imported_identity(&state, &data_dir, keys, |keys| { + let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { // Persist into the OS keyring first (store → read-back verify → // marker → delete file). Falls back to the 0o600 file when the // keyring is unavailable; returns Err only when both backends fail. @@ -397,6 +398,7 @@ pub async fn import_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, @@ -426,18 +428,22 @@ fn commit_imported_identity( state: &AppState, data_dir: &std::path::Path, keys: nostr::Keys, - persist: impl FnOnce(&nostr::Keys) -> Result<(), String>, -) -> Result { + persist: impl FnOnce(&nostr::Keys) -> Result, +) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { // Capture the previous pubkey up front for post-commit cleanup. let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); - persist(&keys)?; + let storage = persist(&keys)?; // Update in-memory keys BEFORE clearing recovery flags. The Release // stores below pair with Acquire loads in get_identity: a reader // observing false is guaranteed to see the updated keys. let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = keys; + state.set_identity_storage(storage); + } // Clear both recovery flags — an import is valid in either lost or // keyring-locked state and resolves both. In the locked case the @@ -462,7 +468,7 @@ fn commit_imported_identity( ); } - Ok(pubkey) + Ok((pubkey, storage)) } /// Make the current ephemeral identity durable by persisting it to the OS @@ -508,11 +514,12 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - // Keys are already the live identity — only clear identity_lost. - // Release pairs with Acquire in get_identity so readers see - // consistent state. + // Keys are already the live identity. Record where the durable write + // landed before clearing identity_lost. + state.set_identity_storage(storage); state .identity_lost .store(false, std::sync::atomic::Ordering::Release); @@ -524,6 +531,7 @@ pub async fn persist_current_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs index 0b31f44bac..90fb4e1f9d 100644 --- a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -1,5 +1,5 @@ use super::{create_and_persist_backup_with_log_n, verify_ncryptsec_backup_inner}; -use crate::app_state::build_app_state; +use crate::app_state::{build_app_state, IdentityStorage}; use nostr::Keys; /// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered @@ -174,19 +174,21 @@ fn successful_import_removes_stale_backup_after_commit() { let new_keys = Keys::generate(); let backup_present_at_persist = std::cell::Cell::new(false); let _guard = state.identity_mutation.lock().unwrap(); - let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { - // Ordering probe: the old backup must still exist while - // persistence is running (cleanup has not happened yet). - backup_present_at_persist.set(backup_path.exists()); - Ok(()) - }) - .unwrap(); + let (pubkey, storage) = + super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| { + // Ordering probe: the old backup must still exist while + // persistence is running (cleanup has not happened yet). + backup_present_at_persist.set(backup_path.exists()); + Ok(IdentityStorage::SystemKeyring) + }) + .unwrap(); assert!( backup_present_at_persist.get(), "cleanup must not precede persist" ); assert_eq!(pubkey, new_keys.public_key()); + assert_eq!(storage, IdentityStorage::SystemKeyring); assert_eq!( state.keys.lock().unwrap().public_key(), new_keys.public_key() diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs new file mode 100644 index 0000000000..b39c1a0331 --- /dev/null +++ b/desktop/src-tauri/src/identity_storage.rs @@ -0,0 +1,62 @@ +use nostr::Keys; + +use crate::app_state::AppState; + +/// Durable location of the active human identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum IdentityStorage { + Ephemeral = 0, + SystemKeyring = 1, + LocalFile = 2, + Environment = 3, +} + +impl IdentityStorage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ephemeral => "ephemeral", + Self::SystemKeyring => "system-keyring", + Self::LocalFile => "local-file", + Self::Environment => "environment", + } + } + + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::SystemKeyring, + 2 => Self::LocalFile, + 3 => Self::Environment, + _ => Self::Ephemeral, + } + } +} + +impl AppState { + pub(crate) fn identity_storage(&self) -> IdentityStorage { + IdentityStorage::from_u8( + self.identity_storage + .load(std::sync::atomic::Ordering::Acquire), + ) + } + + pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) { + self.identity_storage + .store(storage as u8, std::sync::atomic::Ordering::Release); + } +} + +/// Recovery state produced by identity resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// Identity and persistence metadata produced by startup resolution. +pub(crate) struct ResolvedIdentity { + pub(crate) keys: Keys, + pub(crate) recovery: RecoveryState, + pub(crate) storage: IdentityStorage, +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 783298c914..cc80719b89 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_storage; mod key_backup; mod managed_agents; mod media_proxy; diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..3f04d3d7a1 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// Durable location of the active identity key. + pub storage: String, /// True when the app booted with an ephemeral key because the OS keyring /// was empty despite a prior successful migration (key was externally /// deleted). The frontend routes to the nsec re-import step when true. diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 5b9b6c7a03..86127525f5 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -10,6 +10,7 @@ import { import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; @@ -48,6 +49,7 @@ export function backupNextDisabled(): boolean { type BackupStepProps = { direction: OnboardingTransitionDirection; + identityStorage?: IdentityStorage; onBack: () => void; onNext: () => void; onOpenPasswordBackup: () => void; @@ -64,6 +66,7 @@ type BackupStepProps = { */ export function BackupStep({ direction, + identityStorage, onBack, onNext, onOpenPasswordBackup, @@ -152,6 +155,24 @@ export function BackupStep({ () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), [nsec], ); + const storageMessage = + identityStorage === "system-keyring" + ? { + title: "Protected by your system keychain", + description: + "Buzz uses your system keychain to protect your identity key. Your system may ask for your computer password when Buzz accesses it.", + } + : identityStorage === "local-file" + ? { + title: "Stored on this device", + description: + "Your system keychain wasn’t available, so Buzz stores your identity key in a private file on this device.", + } + : { + title: "Protected on this device", + description: + "Keep a separate backup so you can restore your identity if you lose access to this device.", + }; if (optionsExpanded) { return ( @@ -171,18 +192,37 @@ export function BackupStep({

-
+
+
+
+
+ ) : ( + { + setNsecInput(event.target.value); + setImportError(null); + }} + placeholder="nsec1..." + ref={inputRef} + spellCheck={false} + type="password" + value={nsecInput} + /> + )} +
+ ) : null} {/* Hidden file input shared by both variants: the default drop zone and the spotlight "Choose a backup file" button both open it. Accepts the @@ -263,7 +300,7 @@ export function NostrKeyImportForm({ type="file" /> - {variant === "spotlight" ? ( + {!isPasswordStage && variant === "spotlight" ? ( // First-launch/wiped-identity treatment: no drop zone, but the file // path must still exist — a backup saved through the OS dialog is // exactly what a wiped user returns with. @@ -279,7 +316,7 @@ export function NostrKeyImportForm({ Choose a backup file
- ) : ( + ) : !isPasswordStage ? ( - )} + ) : null} - {isEncryptedInput ? ( + {isPasswordStage ? (
-
) : null} -
- {isEncryptedInput ? ( - // No npub preview is possible: the pubkey lives inside the encrypted - // payload and is only recovered by decrypting in Rust. -

-

- ) : previewNpub ? ( - variant === "spotlight" ? ( - // Spotlight uses the backup step's quiet caption language: - // centered, unboxed, with the npub in the shared olive key ink. -
-

-

-

- {previewNpub} -

-
- ) : ( -
- -
-

- This will use this Nostr identity: + {!isPasswordStage || errorMessage ? ( +

+ {!isPasswordStage && previewNpub ? ( + variant === "spotlight" ? ( + // Spotlight uses the backup step's quiet caption language: + // centered, unboxed, with the npub in the shared olive key ink. +
+

+

-

+

{previewNpub}

-
- ) - ) : null} + ) : ( +
+ +
+

+ This will use this Nostr identity: +

+

+ {previewNpub} +

+
+
+ ) + ) : null} - {showInvalidHint && !errorMessage ? ( -

- Waiting for a valid nsec1 key -

- ) : null} + {showInvalidHint && !errorMessage ? ( +

+ {isEncryptedInput + ? "Waiting for a complete ncryptsec backup" + : "Waiting for a valid nsec1 key"} +

+ ) : null} - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) : null} diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index cd46dd2d4a..5d01131af0 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -10,6 +10,8 @@ const BLANK_TYLER_IDENTITY = { }; const SHOT_DIR = "test-results/onboarding-docked-cta"; +const NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; test.use({ viewport: { width: 1280, height: 800 } }); @@ -46,6 +48,20 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ await waitForAnimations(page); await page.screenshot({ path: `${SHOT_DIR}/01b-enter-key.png` }); + await page.getByTestId("nostr-import-nsec-input").fill(NCRYPTSEC); + await expect( + page.getByRole("heading", { name: "Unlock your account" }), + ).toBeVisible(); + await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); + await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible(); + await expect(page.getByTestId("restore-unlock-icon")).toBeVisible(); + await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOT_DIR}/01c-restore-backup.png` }); + + // The first Back returns to key selection; the second leaves import. + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect(importCard).toBeVisible(); await page.getByRole("button", { name: "Back", exact: true }).click(); await expect( page.getByRole("button", { name: "Create a new identity key" }), diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index a16e513ece..0c98cb411e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -624,6 +624,7 @@ test("first-launch key import continues to machine setup", async ({ page }) => { test("first-launch encrypted backup import asks for a passphrase and continues", async ({ page, }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); await installMockBridge(page, undefined, { skipCommunitySeed: true, skipOnboardingSeed: true, @@ -634,11 +635,29 @@ test("first-launch encrypted backup import asks for a passphrase and continues", // Spec-vector blob the mock bridge accepts with the mock passphrase. const mockNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; - await page.getByTestId("nostr-import-nsec-input").fill(mockNcryptsec); + await page + .getByTestId("nostr-import-nsec-input") + .fill(mockNcryptsec.slice(0, -1)); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await expect(page.getByTestId("nostr-import-passphrase")).toHaveCount(0); + + await page + .getByTestId("nostr-import-nsec-input") + .pressSequentially(mockNcryptsec.slice(-1)); - // No npub preview is possible; the form switches to encrypted mode and - // requires a passphrase before submit unlocks. - await expect(page.getByTestId("nostr-import-encrypted-badge")).toBeVisible(); + // A complete, checksummed NIP-49 value advances immediately without + // submitting. The password stage updates its copy, illustration, and focus. + await expect( + page.getByRole("heading", { name: "Unlock your account" }), + ).toBeVisible(); + await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); + await expect(page.getByTestId("restore-ncryptsec-affordance")).toBeVisible(); + await expect(page.getByTestId("restore-unlock-icon")).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toHaveCount(0); + await expect(page.getByTestId("nostr-import-file-button")).toHaveCount(0); + await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); await expect(page.getByTestId("nostr-import-submit")).toBeDisabled(); // Wrong passphrase surfaces the decrypt error and stays on the form. @@ -697,8 +716,27 @@ test("first-launch import accepts an .ncryptsec backup file", async ({ name: "identity.ncryptsec", }); - // File contents land in the key field and switch the form to encrypted mode. - await expect(page.getByTestId("nostr-import-encrypted-badge")).toBeVisible(); + // File contents advance to the same focused password stage as manual input. + await expect( + page.getByRole("heading", { name: "Unlock your account" }), + ).toBeVisible(); + await expect(page.getByTestId("backup-password-timeline")).toBeVisible(); + await expect(page.getByTestId("nostr-import-passphrase")).toBeFocused(); + + // Back first returns to key/file selection instead of leaving import. + await page.getByRole("button", { name: "Back", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await expect(page.getByTestId("nostr-import-card")).toBeVisible(); + await expect(page.getByTestId("nostr-import-file-button")).toBeVisible(); + await expect(page.getByTestId("nostr-import-nsec-input")).toHaveValue(""); + + await fileInput.setInputFiles({ + buffer: Buffer.from(`${mockNcryptsec}\n`), + mimeType: "text/plain", + name: "identity.ncryptsec", + }); await page .getByTestId("nostr-import-passphrase") .fill("mock horse battery staple lake orbit"); From 3269fc7cdc57437d8c6a0e497dbb105ff70372fa Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 30 Jul 2026 14:46:12 -0700 Subject: [PATCH 23/26] refactor(desktop): streamline backup options ingress - Replace the standalone backup options button below the identity key with an inline link in the introductory description - Remove redundant backup and private-key warning copy while preserving the dedicated security notice - Update onboarding interaction and screenshot tests to exercise the new inline entry point Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/features/onboarding/ui/BackupStep.tsx | 52 +++++++------------ desktop/tests/e2e/onboarding-backup.spec.ts | 4 +- .../onboarding-docked-cta-screenshots.spec.ts | 4 +- 3 files changed, 24 insertions(+), 36 deletions(-) diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index 3d517da59c..99d9c6324d 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,12 +1,4 @@ -import { - Check, - ChevronDown, - Copy, - Eye, - EyeOff, - Info, - ShieldCheck, -} from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react"; import { useReducedMotion } from "motion/react"; import * as React from "react"; @@ -326,8 +318,16 @@ export function BackupStep({ REVEAL_ANIMATION_CLASS, )} > - {introStorageDescription} Back it up somewhere safe so you can - restore your account. Never share your key. + {introStorageDescription} You can continue now, or{" "} + {" "} + for ways to restore your account.

) : null}
@@ -388,28 +388,16 @@ export function BackupStep({
-
- - {copyError ? ( -

- Could not retrieve your private key: {copyError}. You can - continue and find it later in Settings > Profile > - Identity. -

- ) : null} -
+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null}

diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 42d4c18bde..df3528c8bd 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -18,7 +18,7 @@ async function enterMachineBackup(page: import("@playwright/test").Page) { async function openBackupOptions(page: import("@playwright/test").Page) { await expect(page.getByTestId("backup-intro-logo")).toHaveCount(0); - await page.getByTestId("backup-options-toggle").click(); + await page.getByTestId("backup-options-link").click(); await expect( page.getByTestId("onboarding-page-backup-options"), ).toBeVisible(); @@ -101,7 +101,7 @@ test("key view reveals explicitly; options copy explicitly", async ({ await expect(key).not.toContainText("nsec1"); // Copy is available only after opening the dark backup-options view. - await page.getByTestId("backup-options-toggle").click(); + await page.getByTestId("backup-options-link").click(); await expect( page.getByTestId("onboarding-page-backup-options"), ).toBeVisible(); diff --git a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts index 5d01131af0..efae7c3784 100644 --- a/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts +++ b/desktop/tests/e2e/onboarding-docked-cta-screenshots.spec.ts @@ -86,7 +86,7 @@ test("machine onboarding: landing, backup, setup docked CTAs", async ({ // Backup options leave the yellow flow for the dark security view without // adding a progress step or a generic Next action. - await page.getByTestId("backup-options-toggle").click(); + await page.getByTestId("backup-options-link").click(); await expect( page.getByTestId("onboarding-page-backup-options"), ).toBeVisible(); @@ -190,7 +190,7 @@ test("backup options keep one-column geometry on narrow windows", async ({ name: "Your unique identity key has been created", }), ).toBeVisible(); - await page.getByTestId("backup-options-toggle").click(); + await page.getByTestId("backup-options-link").click(); const panels = page.getByTestId("backup-option-panel"); await expect(panels).toHaveCount(3); From d74384fa0ca6c37673c3e982ecf6cc5f780eaeae Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 15:28:26 -0700 Subject: [PATCH 24/26] test(desktop): allow backup restore affordance in nip49 scan Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/shared/lib/ncryptsecSourceScan.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs index 1269c588b1..9ca8b9acf5 100644 --- a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs +++ b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs @@ -26,6 +26,7 @@ const ALLOWLIST = [ "features/onboarding/lib/keyImportInput.ts", "features/onboarding/lib/keyImportInput.test.mjs", "features/onboarding/ui/BackupStep.tsx", + "features/onboarding/ui/BackupPasswordTimeline.tsx", "features/onboarding/ui/BackupTestFlow.tsx", "features/onboarding/ui/EncryptedBackupCreator.tsx", "features/onboarding/ui/NostrKeyImportForm.tsx", From c30be77a40ac22736f50b8c9ff1d1ce5e3d2a453 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 16:23:12 -0700 Subject: [PATCH 25/26] style(desktop): apply canonical rustfmt wrapping Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src-tauri/src/key_backup_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 0e187be55d..b9713201e1 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -182,7 +182,10 @@ fn cleanup_stale_backup_removes_only_on_identity_change() { assert!(path.exists(), "same identity must keep the backup"); cleanup_stale_backup(&a, &b, dir.path()).unwrap(); - assert!(!path.exists(), "identity change must remove the stale backup"); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); } #[test] From e8da1f3e24bee7b3d0d53a243fdba3003a2a2475 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 16:48:34 -0700 Subject: [PATCH 26/26] fix(desktop): follow optional backup onboarding flow Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/tests/e2e/harness-management.spec.ts | 4 ++-- desktop/tests/helpers/onboarding.ts | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index f6bf2afd4f..c5b441ad21 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -669,8 +669,8 @@ test("onboarding setup More-harnesses click navigates to Settings → Agents", a }); await page.goto("/"); - // Reach the setup page: create a new identity key → pass the backup step - // (encrypted-by-default: Next is gated on creating the backup). + // Reach setup by creating a new identity key and continuing past the + // created-key page without opening the optional backup options. await page.getByRole("button", { name: "Create a new identity key" }).click(); await passThroughBackupStep(page); diff --git a/desktop/tests/helpers/onboarding.ts b/desktop/tests/helpers/onboarding.ts index afcb3e64f9..d7f61bafe6 100644 --- a/desktop/tests/helpers/onboarding.ts +++ b/desktop/tests/helpers/onboarding.ts @@ -16,12 +16,8 @@ export async function seedActiveIdentity( ); } -/** Navigate through the backup steps (fresh-key path). */ +/** Continue past the created-key page without opening optional backup options. */ export async function passThroughBackupStep(page: Page) { await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); - // Next always leads into the "Backup your key" step; backing up is - // recommended, never required — "Skip for now" there advances to setup. await page.getByTestId("onboarding-next").click(); - await expect(page.getByTestId("onboarding-page-download")).toBeVisible(); - await page.getByTestId("onboarding-skip").click(); }