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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 6/6] 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/);