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..b09913f0398 100644 --- a/apps/buzz-gpui/crates/app/src/community.rs +++ b/apps/buzz-gpui/crates/app/src/community.rs @@ -672,17 +672,90 @@ 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(), + buzz_identity::adopt_buzz_app, + )), + }; }; 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 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 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) = mark_adopted(marker) { + 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 + } + + 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 { @@ -711,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()), @@ -730,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`. 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]