Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/buzz-gpui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>_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
Expand Down
164 changes: 161 additions & 3 deletions apps/buzz-gpui/crates/app/src/community.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Identity>, 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<PathBuf> {
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<Option<Identity>, IdentityError>,
) -> Option<Identity> {
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<Identity, String> {
let Some(path) = dev_identity_file() else {
Expand Down Expand Up @@ -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()),
Expand All @@ -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`.
Expand Down
75 changes: 73 additions & 2 deletions apps/buzz-gpui/crates/identity/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<Option<Identity>, 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<Option<Identity>, IdentityError> {
let mut secrets: std::collections::HashMap<String, String> =
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<Identity, IdentityError> {
let nsec = identity.nsec();
buzz_keychain::add(SERVICE, ACCOUNT, nsec.as_bytes()).map_err(store_error)?;
Expand Down Expand Up @@ -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]
Expand Down
Loading