From b1f7aad7ad8cea760e413cd0950eca9ddb2d0eb6 Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:59:42 -0500 Subject: [PATCH 1/2] fix(gpui): read the identity that buzz-app now stores buzz-app moved its keys from dev.local.buzz.foundation.identity into one combined keychain entry (buzz-desktop / secrets, a JSON map), so the GPUI app no longer found the user's identity and asked for the nsec. On the first launch without a saved identity, copy the nsec from buzz-app's "identity" map entry into this app's entry. buzz-app's entry is only read. A marker beside the sign-out markers, outside app data, records the copy, so signing out or erasing does not bring the key back. Fixes #86 --- apps/buzz-gpui/README.md | 9 ++- apps/buzz-gpui/crates/app/src/community.rs | 43 ++++++++++++- apps/buzz-gpui/crates/identity/src/lib.rs | 75 +++++++++++++++++++++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/apps/buzz-gpui/README.md b/apps/buzz-gpui/README.md index 9c58ae85145..00ee5224979 100644 --- a/apps/buzz-gpui/README.md +++ b/apps/buzz-gpui/README.md @@ -17,9 +17,12 @@ sudo apt install ./buzz-gpui_0.1.0-1_amd64.deb Build the package with `scripts/package-deb.sh`. It builds inside an `ubuntu:26.04` container and writes `dist/buzz-gpui__amd64.deb`. -On first start the app reads your identity from the same keyring entry buzz-app -uses (`dev.local.buzz.foundation.identity`). If you have never used buzz-app on -this machine, import your `nsec` or create a new identity. Then add your +On first start the app reads your identity from the keyring. It uses the +`dev.local.buzz.foundation.identity` entry. If that entry is empty, it copies +the identity once from buzz-app's combined entry (`buzz-desktop`), and leaves +buzz-app's entry as it was. macOS can ask you to allow access to that entry. +If you have never used buzz-app on this machine, import your `nsec` or create +a new identity. Then add your community by its relay origin, for example `wss://relay.example.com`. Settings → Appearance → System tray puts a Buzz icon in the panel and can diff --git a/apps/buzz-gpui/crates/app/src/community.rs b/apps/buzz-gpui/crates/app/src/community.rs index 0bed1c1e5c5..103469fa368 100644 --- a/apps/buzz-gpui/crates/app/src/community.rs +++ b/apps/buzz-gpui/crates/app/src/community.rs @@ -672,17 +672,56 @@ pub mod identity { use super::dev_identity_file; use buzz_identity::{Identity, IdentityError}; use std::io::ErrorKind; - use std::path::Path; + use std::path::{Path, PathBuf}; /// The saved identity; `None` when none is saved. pub fn restore() -> Result, String> { let Some(path) = dev_identity_file() else { - return buzz_identity::load().map_err(|error| error.to_string()); + return match buzz_identity::load().map_err(|error| error.to_string())? { + Some(identity) => Ok(Some(identity)), + None => Ok(adopt_once(adopted_marker().as_deref())), + }; }; tracing::warn!(path = %path.display(), "using a development identity file"); read_file(&path).map_err(|error| error.to_string()) } + /// Present once this app has looked for buzz-app's identity. It sits + /// beside the sign-out markers, outside app data, so neither signing + /// out nor erasing brings buzz-app's key back. + fn adopted_marker() -> Option { + dirs::data_dir().map(|data| { + data.join(format!( + ".buzz-gpui--{}.identity-adopted", + buzz_identity::BUZZ_APP_SERVICE + )) + }) + } + + /// buzz-app's identity, copied into this app's entry on the first + /// launch without one. A failure is retried next launch; until then + /// the user can still import or create a key. + fn adopt_once(marker: Option<&Path>) -> Option { + let marker = marker?; + if marker.exists() { + return None; + } + let adopted = match buzz_identity::adopt_buzz_app() { + Ok(adopted) => adopted, + Err(error) => { + tracing::warn!(%error, "could not read buzz-app's identity"); + return None; + } + }; + if let Err(error) = std::fs::write(marker, b"") { + tracing::warn!(%error, "could not record that buzz-app's identity was read"); + } + if let Some(identity) = &adopted { + tracing::info!(pubkey = %identity.pubkey_hex(), "using buzz-app's identity"); + } + adopted + } + /// Saves a new random identity, or the imported `nsec`. pub fn save(nsec: Option<&str>) -> Result { let Some(path) = dev_identity_file() else { diff --git a/apps/buzz-gpui/crates/identity/src/lib.rs b/apps/buzz-gpui/crates/identity/src/lib.rs index 9c109fc2bc5..ee411099dfc 100644 --- a/apps/buzz-gpui/crates/identity/src/lib.rs +++ b/apps/buzz-gpui/crates/identity/src/lib.rs @@ -1,7 +1,9 @@ //! The human's Buzz identity. //! -//! The key is the same OS keyring entry buzz-app uses, so both apps share -//! one identity. Nothing outside this crate sees the secret key. Callers +//! The key is in the OS keyring entry buzz-app used before it moved its keys +//! into one combined entry; [`adopt_buzz_app`] copies the key from that +//! entry, so both apps share one identity. Nothing outside this crate sees +//! the secret key. Callers //! ask for a specific purpose (relay auth, an HTTP request, a chat event) //! rather than "sign this", so a bug elsewhere cannot mint HTTP or media //! tokens or forge kinds the app never writes. @@ -145,6 +147,45 @@ pub fn forget() -> Result<(), IdentityError> { } } +/// buzz-app's store since it moved its keys out of [`SERVICE`]: one entry +/// whose value is a JSON map of every key it holds. The human key is under +/// [`BUZZ_APP_IDENTITY`]. +pub const BUZZ_APP_SERVICE: &str = "buzz-desktop"; +const BUZZ_APP_ACCOUNT: &str = "secrets"; +const BUZZ_APP_IDENTITY: &str = "identity"; + +/// Copies buzz-app's identity into this app's entry. `Ok(None)` means +/// buzz-app keeps no identity. buzz-app's entry is only read, so both apps +/// keep working with the same key. +pub fn adopt_buzz_app() -> Result, IdentityError> { + let bytes = match buzz_keychain::read(BUZZ_APP_SERVICE, BUZZ_APP_ACCOUNT) { + Ok(bytes) => bytes, + Err(StoreError::Absent) => return Ok(None), + Err(error) => return Err(store_error(error)), + }; + let Some(identity) = buzz_app_identity(&bytes)? else { + return Ok(None); + }; + match save(identity) { + // Another process saved a key first; that one stands. + Err(IdentityError::Occupied) => load(), + saved => saved.map(Some), + } +} + +/// The human key in buzz-app's map. The other keys are its agents'; they +/// are wiped, not kept. +fn buzz_app_identity(bytes: &[u8]) -> Result, IdentityError> { + let mut secrets: std::collections::HashMap = + serde_json::from_slice(bytes).map_err(|_| IdentityError::Malformed)?; + let nsec = secrets.remove(BUZZ_APP_IDENTITY).map(Zeroizing::new); + for value in secrets.values_mut() { + zeroize::Zeroize::zeroize(value); + } + nsec.map(|nsec| Identity::from_nsec(&nsec).map_err(|_| IdentityError::Malformed)) + .transpose() +} + fn save(identity: Identity) -> Result { let nsec = identity.nsec(); buzz_keychain::add(SERVICE, ACCOUNT, nsec.as_bytes()).map_err(store_error)?; @@ -680,6 +721,36 @@ fn header(event: &Event) -> String { mod tests { use super::*; + /// buzz-app's map gives up its human key and nothing else. + #[test] + fn buzz_app_identity_is_read_from_its_secrets_map() { + let human = Identity::ephemeral(); + let agent = Identity::ephemeral(); + let map = serde_json::json!({ + "identity": *human.nsec(), + "agent.0123": *agent.nsec(), + }); + let found = buzz_app_identity(map.to_string().as_bytes()) + .unwrap() + .unwrap(); + assert_eq!(found.pubkey_hex(), human.pubkey_hex()); + + let agents_only = serde_json::json!({ "agent.0123": *agent.nsec() }); + assert!( + buzz_app_identity(agents_only.to_string().as_bytes()) + .unwrap() + .is_none() + ); + assert_eq!( + buzz_app_identity(b"not json").unwrap_err(), + IdentityError::Malformed + ); + assert_eq!( + buzz_app_identity(br#"{"identity":"nsec1bad"}"#).unwrap_err(), + IdentityError::Malformed + ); + } + /// An observer frame an agent encrypted to its owner opens only for /// that owner, through buzz-desktop's decoder. #[test] From cde632befbf434028c17dcb57c746171447ca33d Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:39:29 -0500 Subject: [PATCH 2/2] fix(gpui): keep buzz-app's key from returning after sign-out The adopted marker was written only when restore() found no saved key, so users who already had a key in this app (pasted in, or older than buzz-app's move) never got it. After they signed out, the next launch found no key and no marker and copied buzz-app's identity back. Sign-out now writes the marker before it deletes the key; if the write fails, the removal fails, so the sign-out stays pending and is retried. A malformed buzz-app entry is recorded as read instead of retried every launch, which prompted for the keychain each time on macOS. Store errors are still retried. adopt_once and the keychain removal take their storage step as a parameter, and temp-dir tests cover the marker. --- apps/buzz-gpui/crates/app/src/community.rs | 133 +++++++++++++++++++-- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/apps/buzz-gpui/crates/app/src/community.rs b/apps/buzz-gpui/crates/app/src/community.rs index 103469fa368..b09913f0398 100644 --- a/apps/buzz-gpui/crates/app/src/community.rs +++ b/apps/buzz-gpui/crates/app/src/community.rs @@ -679,7 +679,10 @@ pub mod identity { let Some(path) = dev_identity_file() else { return match buzz_identity::load().map_err(|error| error.to_string())? { Some(identity) => Ok(Some(identity)), - None => Ok(adopt_once(adopted_marker().as_deref())), + None => Ok(adopt_once( + adopted_marker().as_deref(), + buzz_identity::adopt_buzz_app, + )), }; }; tracing::warn!(path = %path.display(), "using a development identity file"); @@ -699,21 +702,30 @@ pub mod identity { } /// buzz-app's identity, copied into this app's entry on the first - /// launch without one. A failure is retried next launch; until then - /// the user can still import or create a key. - fn adopt_once(marker: Option<&Path>) -> Option { + /// launch without one. A storage failure (denied, busy) is retried next + /// launch; until then the user can still import or create a key. A + /// malformed buzz-app entry will not fix itself, so it is recorded as + /// read rather than retried, which on macOS would prompt every launch. + fn adopt_once( + marker: Option<&Path>, + adopt: impl FnOnce() -> Result, IdentityError>, + ) -> Option { let marker = marker?; if marker.exists() { return None; } - let adopted = match buzz_identity::adopt_buzz_app() { + let adopted = match adopt() { Ok(adopted) => adopted, + Err(IdentityError::Malformed) => { + tracing::warn!("buzz-app's identity is malformed; not reading it again"); + None + } Err(error) => { tracing::warn!(%error, "could not read buzz-app's identity"); return None; } }; - if let Err(error) = std::fs::write(marker, b"") { + if let Err(error) = mark_adopted(marker) { tracing::warn!(%error, "could not record that buzz-app's identity was read"); } if let Some(identity) = &adopted { @@ -722,6 +734,28 @@ pub mod identity { adopted } + fn mark_adopted(marker: &Path) -> std::io::Result<()> { + std::fs::write(marker, b"") + } + + /// Records the adoption before sign-out deletes the key. Without it, a + /// user whose key was never adopted (it predates buzz-app's move, or was + /// pasted in) would get buzz-app's key copied back at the next launch. + /// A failed write fails the removal, so the sign-out stays pending and + /// the next launch retries. + fn remove_keychain_key( + marker: Option<&Path>, + forget: impl FnOnce() -> Result<(), IdentityError>, + ) -> Result<(), String> { + if let Some(marker) = marker { + mark_adopted(marker).map_err(|error| { + tracing::error!(%error, "could not record that buzz-app's identity was read"); + error.to_string() + })?; + } + forget().map_err(|error| error.to_string()) + } + /// Saves a new random identity, or the imported `nsec`. pub fn save(nsec: Option<&str>) -> Result { let Some(path) = dev_identity_file() else { @@ -750,7 +784,7 @@ pub mod identity { /// confirms a fresh read finds nothing. An absent key is success. pub fn remove() -> Result<(), String> { let Some(path) = dev_identity_file() else { - return buzz_identity::forget().map_err(|error| error.to_string()); + return remove_keychain_key(adopted_marker().as_deref(), buzz_identity::forget); }; match std::fs::remove_file(&path) { Err(error) if error.kind() != ErrorKind::NotFound => Err(error.to_string()), @@ -769,6 +803,91 @@ pub mod identity { .map_err(|_| IdentityError::Malformed), } } + + #[cfg(test)] + mod tests { + use super::*; + use std::cell::Cell; + + fn marker_in(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join("identity-adopted") + } + + #[test] + fn a_recorded_adoption_is_not_repeated() { + let dir = tempfile::tempdir().unwrap(); + let marker = marker_in(&dir); + mark_adopted(&marker).unwrap(); + let called = Cell::new(false); + let adopted = adopt_once(Some(&marker), || { + called.set(true); + Ok(Some(Identity::ephemeral())) + }); + assert!(adopted.is_none()); + assert!(!called.get()); + } + + #[test] + fn adoption_is_recorded_whether_or_not_buzz_app_has_a_key() { + let dir = tempfile::tempdir().unwrap(); + let marker = marker_in(&dir); + let human = Identity::ephemeral(); + let expected = human.pubkey_hex(); + let adopted = adopt_once(Some(&marker), move || Ok(Some(human))).unwrap(); + assert_eq!(adopted.pubkey_hex(), expected); + assert!(marker.exists()); + + let empty = tempfile::tempdir().unwrap(); + let marker = marker_in(&empty); + assert!(adopt_once(Some(&marker), || Ok(None)).is_none()); + assert!(marker.exists()); + } + + #[test] + fn a_storage_error_is_retried_but_malformed_data_is_not() { + let dir = tempfile::tempdir().unwrap(); + let marker = marker_in(&dir); + for error in [IdentityError::Denied, IdentityError::Busy] { + assert!(adopt_once(Some(&marker), || Err(error)).is_none()); + assert!(!marker.exists()); + } + assert!(adopt_once(Some(&marker), || Err(IdentityError::Malformed)).is_none()); + assert!(marker.exists()); + } + + #[test] + fn sign_out_records_the_adoption_before_the_key_is_deleted() { + let dir = tempfile::tempdir().unwrap(); + let marker = marker_in(&dir); + let forgot = Cell::new(false); + remove_keychain_key(Some(&marker), || { + assert!( + marker.exists(), + "the marker must exist before the key is deleted" + ); + forgot.set(true); + Ok(()) + }) + .unwrap(); + assert!(forgot.get()); + // The next launch finds no key and does not adopt buzz-app's. + let adopted = adopt_once(Some(&marker), || Ok(Some(Identity::ephemeral()))); + assert!(adopted.is_none()); + } + + #[test] + fn sign_out_fails_and_keeps_the_key_when_the_marker_cannot_be_written() { + let dir = tempfile::tempdir().unwrap(); + let marker = dir.path().join("missing").join("identity-adopted"); + let forgot = Cell::new(false); + let result = remove_keychain_key(Some(&marker), || { + forgot.set(true); + Ok(()) + }); + assert!(result.is_err()); + assert!(!forgot.get()); + } + } } /// Where the item at `index` ends up when the one at `from` moves to `to`.