feat(cli): use Buzz Desktop identity

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
This commit is contained in:
Tom Brow
2026-08-14 09:10:45 -07:00
parent caa64b5e8f
commit a01e2afafd
14 changed files with 1779 additions and 1324 deletions

15
Cargo.lock generated
View File

@@ -977,6 +977,7 @@ dependencies = [
"buzz-core",
"buzz-persona",
"buzz-sdk",
"buzz-secret-store",
"buzz-ws-client",
"bytes",
"chrono",
@@ -997,6 +998,7 @@ dependencies = [
"tokio",
"url",
"uuid",
"zeroize",
]
[[package]]
@@ -1367,6 +1369,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "buzz-secret-store"
version = "0.1.0"
dependencies = [
"keyring",
"libc",
"security-framework 3.7.0",
"serde_json",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "buzz-test-client"
version = "0.1.0"

View File

@@ -22,6 +22,7 @@ members = [
"crates/buzz-pairing-cli",
"crates/buzz-sdk",
"crates/buzz-persona",
"crates/buzz-secret-store",
"crates/git-credential-nostr",
"crates/git-sign-nostr",
"crates/buzz-pair-relay",
@@ -144,6 +145,7 @@ buzz-audit = { path = "crates/buzz-audit" }
buzz-workflow = { path = "crates/buzz-workflow" }
buzz-media = { path = "crates/buzz-media" }
buzz-sdk = { path = "crates/buzz-sdk" }
buzz-secret-store = { path = "crates/buzz-secret-store" }
buzz-ws-client = { path = "crates/buzz-ws-client" }
buzz-relay-mesh = { path = "crates/buzz-relay-mesh" }
buzz-datastore-tracing = { path = "crates/buzz-datastore-tracing" }

View File

@@ -44,6 +44,7 @@ chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
buzz-secret-store = { workspace = true }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
@@ -86,6 +87,9 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
# Random number generation — full jitter for exponential backoff in with_retry
rand = { workspace = true }
# Clear transient private-key strings after parsing.
zeroize = { workspace = true }
[dev-dependencies]
# Scratch files for channel-templates.json fixtures in tests
tempfile = "3"

View File

@@ -14,10 +14,19 @@ cargo install --path crates/buzz-cli
|---------|------|----------|
| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
Use `--use-desktop-identity` to explicitly sign with the identity stored by the
signed Buzz Desktop release. The flag is global, does not change relay
selection, and cannot be combined with `--private-key` or `BUZZ_PRIVATE_KEY`.
The CLI only reads the current secure-storage entry; it never migrates, writes,
deletes, generates, or falls back to Desktop's plaintext identity file.
```bash
# Private key identity (NIP-98 signed requests)
export BUZZ_PRIVATE_KEY="nsec1..."
buzz channels list
# Signed-in Buzz Desktop identity (global flag goes before the command)
env -u BUZZ_PRIVATE_KEY buzz --use-desktop-identity channels list
```
## Usage

View File

@@ -516,7 +516,7 @@ buzz users set-profile 2>&1; echo "exit: $?"
# Exit 3: No auth configured
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key, set the env var, or pass --use-desktop-identity)"}
# exit: 3
# Not-found returns null, not an error (exit 0)
@@ -539,8 +539,12 @@ BUZZ_PRIVATE_KEY="nsec1..." buzz channels list | jq .
# No auth → exit 3
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key, set the env var, or pass --use-desktop-identity)"}
# exit: 3
# Signed-in Desktop identity (read-only secure-storage access)
env -u BUZZ_PRIVATE_KEY buzz --use-desktop-identity channels list | jq .
# Should succeed when the signed Desktop release has an identity stored
```
---

View File

@@ -10,6 +10,7 @@ use client::BuzzClient;
use error::CliError;
use nostr::Keys;
use uuid::Uuid;
use zeroize::Zeroizing;
/// Run the Buzz CLI from raw arguments (including `argv[0]`).
///
@@ -69,7 +70,7 @@ Buzz CLI — interact with a Buzz relay
Configuration (flags override env vars):
BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000]
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required]
BUZZ_PRIVATE_KEY Nostr private key (hex or nsec)
BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional]
The 'pack' subcommand runs locally and does not require a relay connection.
@@ -86,6 +87,10 @@ struct Cli {
#[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)]
private_key: Option<String>,
/// Sign with the identity stored by the signed Buzz Desktop release.
#[arg(long, conflicts_with = "private_key")]
use_desktop_identity: bool,
/// NIP-OA auth tag JSON (owner attestation). Injected into every signed event.
#[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)]
auth_tag: Option<String>,
@@ -1948,6 +1953,70 @@ fn normalize_auth_tag_input(input: &str) -> String {
trimmed.to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PrivateKeyOrigin {
Explicit,
Desktop,
}
fn desktop_identity_auth_error(error: buzz_secret_store::ReadonlySecretError) -> CliError {
use buzz_secret_store::ReadonlySecretError;
let message = match error {
ReadonlySecretError::LockedOrUnavailable => {
"Buzz Desktop secure storage is locked or unavailable; unlock it and retry"
}
ReadonlySecretError::AccessDenied => "access to the Buzz Desktop identity was denied",
ReadonlySecretError::Corrupt => "the Buzz Desktop secure storage entry is corrupt",
ReadonlySecretError::Unavailable | ReadonlySecretError::Disabled => {
"Buzz Desktop secure storage is unavailable in this environment"
}
};
CliError::Auth(message.to_string())
}
fn resolve_private_key<F>(
explicit: Option<String>,
use_desktop_identity: bool,
load_desktop_identity: F,
) -> Result<(Zeroizing<String>, PrivateKeyOrigin), CliError>
where
F: FnOnce() -> Result<Option<String>, buzz_secret_store::ReadonlySecretError>,
{
match (explicit, use_desktop_identity) {
(Some(_), true) => Err(CliError::Usage(
"--use-desktop-identity cannot be combined with --private-key or BUZZ_PRIVATE_KEY"
.to_string(),
)),
(Some(private_key), false) => {
Ok((Zeroizing::new(private_key), PrivateKeyOrigin::Explicit))
}
(None, true) => {
let private_key = load_desktop_identity()
.map_err(desktop_identity_auth_error)?
.ok_or_else(|| {
CliError::Auth(
"Buzz Desktop identity is missing; sign in to the release app first"
.to_string(),
)
})?;
Ok((Zeroizing::new(private_key), PrivateKeyOrigin::Desktop))
}
(None, false) => Err(CliError::Auth(
"BUZZ_PRIVATE_KEY is required (use --private-key, set the env var, or pass --use-desktop-identity)"
.to_string(),
)),
}
}
fn parse_private_key(private_key: &str, origin: PrivateKeyOrigin) -> Result<Keys, CliError> {
Keys::parse(private_key).map_err(|error| match origin {
PrivateKeyOrigin::Explicit => CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {error}")),
PrivateKeyOrigin::Desktop => CliError::Key(format!(
"invalid Buzz Desktop identity in secure storage: {error}"
)),
})
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
@@ -1961,11 +2030,13 @@ async fn run(cli: Cli) -> Result<(), CliError> {
// Auth: private key is required for all relay operations.
// The keypair IS the identity — no tokens, no other auth.
let private_key_str = cli.private_key.ok_or_else(|| {
CliError::Auth("BUZZ_PRIVATE_KEY is required (use --private-key or set env var)".into())
})?;
let keys = Keys::parse(&private_key_str)
.map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?;
let (private_key, private_key_origin) = resolve_private_key(
cli.private_key,
cli.use_desktop_identity,
buzz_secret_store::load_desktop_release_identity,
)?;
let keys = parse_private_key(private_key.as_str(), private_key_origin)?;
drop(private_key);
// NIP-OA: parse and verify the auth tag if provided.
//
@@ -2078,6 +2149,108 @@ mod tests {
Cli::command().debug_assert();
}
#[test]
fn desktop_identity_is_an_explicit_global_flag() {
let cli = Cli::try_parse_from([
"buzz",
"--relay",
"wss://relay.example",
"--use-desktop-identity",
"channels",
"list",
])
.expect("desktop identity flag should parse before the command");
assert!(cli.use_desktop_identity);
assert!(cli.private_key.is_none());
assert_eq!(cli.relay, "wss://relay.example");
}
#[test]
fn desktop_identity_conflicts_with_explicit_private_key_argument() {
assert!(Cli::try_parse_from([
"buzz",
"--use-desktop-identity",
"--private-key",
"not-a-real-key",
"channels",
"list",
])
.is_err());
}
#[test]
fn explicit_private_key_does_not_touch_desktop_storage() {
let (key, origin) = resolve_private_key(Some("explicit".to_string()), false, || {
panic!("desktop loader must not run for explicit credentials")
})
.unwrap();
assert_eq!(key.as_str(), "explicit");
assert_eq!(origin, PrivateKeyOrigin::Explicit);
}
#[test]
fn desktop_private_key_is_loaded_only_when_requested() {
let (key, origin) =
resolve_private_key(None, true, || Ok(Some("desktop-secret".to_string()))).unwrap();
assert_eq!(key.as_str(), "desktop-secret");
assert_eq!(origin, PrivateKeyOrigin::Desktop);
}
#[test]
fn desktop_identity_and_environment_credentials_are_mutually_exclusive() {
let error = resolve_private_key(Some("environment-secret".to_string()), true, || {
panic!("ambiguous auth must fail before keychain access")
})
.unwrap_err();
assert!(matches!(error, CliError::Usage(_)));
assert!(!error.to_string().contains("environment-secret"));
}
#[test]
fn missing_desktop_identity_is_a_redacted_auth_error() {
let error = resolve_private_key(None, true, || Ok(None)).unwrap_err();
assert!(matches!(error, CliError::Auth(_)));
assert!(error.to_string().contains("identity is missing"));
assert_eq!(error::exit_code(&error), 3);
}
#[test]
fn desktop_storage_failures_are_clear_redacted_auth_errors() {
use buzz_secret_store::ReadonlySecretError;
for (storage_error, expected) in [
(ReadonlySecretError::LockedOrUnavailable, "locked"),
(ReadonlySecretError::AccessDenied, "denied"),
(ReadonlySecretError::Corrupt, "corrupt"),
(ReadonlySecretError::Unavailable, "unavailable"),
] {
let error = resolve_private_key(None, true, || Err(storage_error)).unwrap_err();
assert!(matches!(error, CliError::Auth(_)));
assert!(error.to_string().contains(expected), "{error}");
assert_eq!(error::exit_code(&error), 3);
}
}
#[test]
fn missing_all_auth_keeps_a_safe_failure() {
let error = resolve_private_key(None, false, || {
panic!("desktop loader must not run without explicit opt-in")
})
.unwrap_err();
assert!(matches!(error, CliError::Auth(_)));
assert_eq!(error::exit_code(&error), 3);
}
#[test]
fn invalid_desktop_credential_is_a_redacted_key_error() {
let invalid_secret = "not-a-valid-secret";
let error = parse_private_key(invalid_secret, PrivateKeyOrigin::Desktop).unwrap_err();
assert!(matches!(error, CliError::Key(_)));
assert!(error.to_string().contains("invalid Buzz Desktop identity"));
assert!(!error.to_string().contains(invalid_secret));
assert_eq!(error::exit_code(&error), 3);
}
#[test]
fn set_status_clear_rejects_text_and_emoji() {
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {

View File

@@ -0,0 +1,31 @@
use std::process::Command;
#[test]
fn desktop_identity_conflicts_with_environment_private_key() {
let secret = "environment-secret-must-stay-redacted";
let output = Command::new(env!("CARGO_BIN_EXE_buzz"))
.env("BUZZ_PRIVATE_KEY", secret)
.arg("--use-desktop-identity")
.args(["channels", "list"])
.output()
.expect("run buzz CLI");
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
assert!(stderr.contains("cannot be used with"), "{stderr}");
assert!(!stderr.contains(secret));
}
#[test]
fn missing_explicit_auth_does_not_discover_desktop_identity() {
let output = Command::new(env!("CARGO_BIN_EXE_buzz"))
.env_remove("BUZZ_PRIVATE_KEY")
.args(["channels", "list"])
.output()
.expect("run buzz CLI");
assert_eq!(output.status.code(), Some(3));
let stderr = String::from_utf8(output.stderr).expect("stderr is UTF-8");
assert!(stderr.contains("BUZZ_PRIVATE_KEY is required"), "{stderr}");
assert!(!stderr.contains("Desktop identity is missing"));
}

View File

@@ -0,0 +1,31 @@
[package]
name = "buzz-secret-store"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Platform secure storage shared by Buzz clients"
[features]
default = ["system-keyring"]
system-keyring = ["dep:keyring", "dep:security-framework"]
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
zeroize = { workspace = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"], optional = true }
[target.'cfg(windows)'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Threading"] }

File diff suppressed because it is too large Load Diff

View File

@@ -1095,6 +1095,7 @@ dependencies = [
"buzz-media",
"buzz-persona",
"buzz-sdk",
"buzz-secret-store",
"buzz-terminal",
"buzz-voice",
"bytes",
@@ -1111,7 +1112,6 @@ dependencies = [
"image",
"infer",
"iroh",
"keyring",
"libc",
"mesh-llm-client",
"mesh-llm-events",
@@ -1137,7 +1137,6 @@ dependencies = [
"rubato",
"rusqlite",
"rustls",
"security-framework 3.7.0",
"serde",
"serde_json",
"serde_yaml",
@@ -1225,6 +1224,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "buzz-secret-store"
version = "0.1.0"
dependencies = [
"keyring",
"libc",
"security-framework 3.7.0",
"serde_json",
"thiserror 2.0.18",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "buzz-terminal"
version = "0.1.0"

View File

@@ -26,7 +26,7 @@ default = ["system-keyring"]
mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"]
# OS keyring backing for desktop secret storage (nsec private keys). When
# disabled, secrets fall back to 0o600 files. On by default for real builds.
system-keyring = ["dep:keyring"]
system-keyring = ["buzz-secret-store/system-keyring"]
[build-dependencies]
base64 = "0.22"
@@ -39,7 +39,6 @@ libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true }
# Used directly (alongside tauri-plugin-notification) so we can hold the posting
# D-Bus connection open. GNOME 46+ dismisses a notification the moment that
# connection is dropped, which the plugin does immediately. Default features
@@ -56,15 +55,12 @@ objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] }
objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
user-idle = { version = "0.6", default-features = false }
plist = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
[dependencies]
@@ -105,6 +101,7 @@ url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz-secret-store = { path = "../../crates/buzz-secret-store", default-features = false }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" }

View File

@@ -13,12 +13,12 @@ pub(crate) fn keyring_service() -> &'static str {
.get_or_init(|| dev_keyring_service(std::env::var("BUZZ_DEV_KEYRING_SERVICE").ok()))
.as_str()
} else {
"buzz-desktop"
buzz_secret_store::DESKTOP_RELEASE_SERVICE
}
}
pub(super) fn migration_marker_name(service: &str, default_name: &str) -> String {
if service == "buzz-desktop" || service == "buzz-desktop-dev" {
if service == buzz_secret_store::DESKTOP_RELEASE_SERVICE || service == "buzz-desktop-dev" {
default_name.to_string()
} else {
format!("identity.{service}.migrated")

View File

@@ -481,7 +481,8 @@ pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) {
// A fresh non-singleton store for the prod service — its own empty
// cache so reads go to the OS keyring without polluting the dev
// singleton's cache.
let prod_store = crate::secret_store::SecretStore::keyring("buzz-desktop");
let prod_store =
crate::secret_store::SecretStore::keyring(buzz_secret_store::DESKTOP_RELEASE_SERVICE);
let dev_store = crate::secret_store::SecretStore::shared(keyring_service());
copy_agent_keys_between_stores(&pubkeys, &prod_store, dev_store);
}

File diff suppressed because it is too large Load Diff