From a01e2afafdb7a77c8765c9874447be5845ef1a3a Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 14 Aug 2026 09:10:45 -0700 Subject: [PATCH] feat(cli): use Buzz Desktop identity Signed-off-by: Tom Brow Co-authored-by: Codex Ai-assisted: true --- Cargo.lock | 15 + Cargo.toml | 2 + crates/buzz-cli/Cargo.toml | 4 + crates/buzz-cli/README.md | 9 + crates/buzz-cli/TESTING.md | 8 +- crates/buzz-cli/src/lib.rs | 185 +- .../buzz-cli/tests/desktop_identity_args.rs | 31 + crates/buzz-secret-store/Cargo.toml | 31 + crates/buzz-secret-store/src/lib.rs | 1481 +++++++++++++++++ desktop/src-tauri/Cargo.lock | 16 +- desktop/src-tauri/Cargo.toml | 7 +- desktop/src-tauri/src/app_state_keyring.rs | 4 +- .../src-tauri/src/managed_agents/storage.rs | 3 +- desktop/src-tauri/src/secret_store.rs | 1307 +-------------- 14 files changed, 1779 insertions(+), 1324 deletions(-) create mode 100644 crates/buzz-cli/tests/desktop_identity_args.rs create mode 100644 crates/buzz-secret-store/Cargo.toml create mode 100644 crates/buzz-secret-store/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2..a141395b8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 78816ff482..2389012b3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..023abaddbc 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -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" diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d2..8351106402 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -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 diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faa..d24e99f8a9 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -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 ``` --- diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3893c5b642..25db3cff96 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -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, + /// 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, @@ -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( + explicit: Option, + use_desktop_identity: bool, + load_desktop_identity: F, +) -> Result<(Zeroizing, PrivateKeyOrigin), CliError> +where + F: FnOnce() -> Result, 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::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", "🎶"]] { diff --git a/crates/buzz-cli/tests/desktop_identity_args.rs b/crates/buzz-cli/tests/desktop_identity_args.rs new file mode 100644 index 0000000000..ffb18a0e82 --- /dev/null +++ b/crates/buzz-cli/tests/desktop_identity_args.rs @@ -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")); +} diff --git a/crates/buzz-secret-store/Cargo.toml b/crates/buzz-secret-store/Cargo.toml new file mode 100644 index 0000000000..92806cce12 --- /dev/null +++ b/crates/buzz-secret-store/Cargo.toml @@ -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"] } diff --git a/crates/buzz-secret-store/src/lib.rs b/crates/buzz-secret-store/src/lib.rs new file mode 100644 index 0000000000..edfa6b447f --- /dev/null +++ b/crates/buzz-secret-store/src/lib.rs @@ -0,0 +1,1481 @@ +//! OS keyring access for Buzz nsec private keys. +//! +//! All secrets are stored as a single JSON blob under one keychain entry +//! (service = the store's service name, username = `"secrets"`). This means +//! exactly one OS prompt per process lifetime regardless of how many keys are +//! stored — the same pattern used by Goose. +//! +//! The chosen backend is selected at compile time by the per-target feature in +//! `Cargo.toml`. On macOS the legacy `keyring` crate (SecKeychain API) is used +//! for the blob entry so that signed release builds and unsigned dev builds +//! share the same store. DPK (Data Protection Keychain) is used only by the +//! one-time migration path that reads old per-key entries written by #1264. +//! Windows and Linux use the `keyring` crate directly. The `system-keyring` +//! feature gates the whole store; when it is off, [`SecretStore`] is unusable +//! and reads return [`ReadonlySecretError::Disabled`]. Desktop may then apply +//! its own file-storage policy; the CLI does not. +//! +//! The store is deliberately NOT on any env-read path. `BUZZ_PRIVATE_KEY` +//! resolution for harnessed agents and CI is handled upstream (an env +//! short-circuit for the human key, child-process env injection for agents); +//! adding an env tier here would duplicate that precedence and create a +//! divergent-behavior trap. + +use std::collections::HashMap; +#[cfg(feature = "system-keyring")] +use std::path::PathBuf; +use std::sync::Mutex; +#[cfg(any(feature = "system-keyring", test))] +use zeroize::{Zeroize, Zeroizing}; + +/// Keychain service used by signed Buzz Desktop releases. +pub const DESKTOP_RELEASE_SERVICE: &str = "buzz-desktop"; + +/// Key within the Desktop secret blob that holds the user's Nostr identity. +pub const DESKTOP_IDENTITY_KEY: &str = "identity"; + +/// A redacted failure reading a secret without changing secure storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ReadonlySecretError { + /// The OS credential store is locked or otherwise inaccessible this boot. + #[error("secure storage is locked or unavailable")] + LockedOrUnavailable, + /// The user or an access-control policy denied this process access. + #[error("secure storage access was denied")] + AccessDenied, + /// The stored blob cannot be decoded using Buzz's JSON-map schema. + #[error("secure storage entry is corrupt")] + Corrupt, + /// The platform credential backend failed for another reason. + #[error("secure storage is unavailable")] + Unavailable, + /// This build does not include an OS credential-store backend. + #[error("system keyring support is disabled")] + Disabled, +} + +/// Result of probing the keyring before a migration: distinguishes "reachable +/// but holds no entry" (safe to migrate into) from "unreachable this boot" +/// (must NOT migrate — re-importing from a leftover plaintext file could +/// resurrect a rotated/stale key). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyringProbe { + /// Keyring is reachable and an entry for the key already exists. + Present, + /// Keyring is reachable but has no entry for the key. + ReachableButEmpty, + /// Keyring backend is unavailable this boot (no Secret Service, dbus + /// failure, etc.). Migration must be skipped. + Unreachable, +} + +/// Username used for the single blob keychain entry. All secrets are stored +/// as a JSON map under this name within the service. +#[cfg(feature = "system-keyring")] +const BLOB_KEY: &str = "secrets"; + +/// Read one value from a serialized Buzz secret blob. +#[cfg(any(feature = "system-keyring", test))] +fn decode_readonly_secret(raw: String, key: &str) -> Result, ReadonlySecretError> { + let raw = Zeroizing::new(raw); + let mut map = serde_json::from_str::>(&raw) + .map_err(|_| ReadonlySecretError::Corrupt)?; + let requested = map.remove(key); + for secret in map.values_mut() { + secret.zeroize(); + } + Ok(requested) +} + +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn classify_platform_read_failure( + error: &(dyn std::error::Error + Send + Sync + 'static), +) -> ReadonlySecretError { + let Some(error) = error.downcast_ref::() else { + return ReadonlySecretError::Unavailable; + }; + match error.code() { + // errSecAuthFailed and errSecUserCanceled. + -25293 | -128 => ReadonlySecretError::AccessDenied, + // errSecInteractionNotAllowed and inaccessible-keychain errors. + -25308 | -25291 | -25292 | -25294 | -25295 => ReadonlySecretError::LockedOrUnavailable, + _ => ReadonlySecretError::Unavailable, + } +} + +#[cfg(all(feature = "system-keyring", not(target_os = "macos")))] +fn classify_platform_read_failure( + _error: &(dyn std::error::Error + Send + Sync + 'static), +) -> ReadonlySecretError { + ReadonlySecretError::Unavailable +} + +#[cfg(feature = "system-keyring")] +fn classify_read_error(error: &keyring::Error) -> ReadonlySecretError { + match error { + keyring::Error::NoStorageAccess(_) => ReadonlySecretError::LockedOrUnavailable, + keyring::Error::BadEncoding(_) => ReadonlySecretError::Corrupt, + keyring::Error::PlatformFailure(error) => classify_platform_read_failure(error.as_ref()), + _ => ReadonlySecretError::Unavailable, + } +} + +/// Read a secret from the current JSON blob without migration or mutation. +/// +/// This path never writes, deletes, probes legacy entries, or falls back to a +/// file. It is intended for consumers such as the CLI that may borrow a +/// Desktop identity but must not participate in Desktop's persistence policy. +pub fn load_secret_readonly( + service: &str, + key: &str, +) -> Result, ReadonlySecretError> { + #[cfg(feature = "system-keyring")] + { + let entry = + keyring_entry(service, BLOB_KEY).map_err(|_| ReadonlySecretError::Unavailable)?; + match entry.get_password() { + Ok(raw) => decode_readonly_secret(raw, key), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(classify_read_error(&error)), + } + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = (service, key); + Err(ReadonlySecretError::Disabled) + } +} + +/// Read the identity stored by the signed Buzz Desktop release. +/// +/// This function is strictly read-only. In particular, it does not inspect or +/// migrate old per-key entries and does not use Desktop's plaintext fallback. +pub fn load_desktop_release_identity() -> Result, ReadonlySecretError> { + load_secret_readonly(DESKTOP_RELEASE_SERVICE, DESKTOP_IDENTITY_KEY) +} + +// ── Interprocess advisory lock ───────────────────────────────────────────── +// +// Two concurrent Buzz processes (e.g. the signed DMG build and an unsigned dev +// build via `just staging`) share the same OS keychain blob because the +// service name `"buzz-desktop"` is a constant — it does not key off the bundle +// identifier. Each process holds its own in-memory cache, so without an +// interprocess lock a warm-cache write in process A drops keys added by process +// B between A's last cache-warming read and A's write. +// +// The fix: `mutate_blob` acquires an exclusive advisory file lock, then always +// performs a fresh `read_blob_raw()` inside the lock, applies the mutation, +// writes back, and releases. The cache is still updated after a successful +// write, so same-process reads remain fast. The lock is file-based at a fixed +// per-user path `/tmp/buzz-keychain--.lock` on Unix — a path +// that is invariant to `$TMPDIR`/process environment, so both the GUI-launched +// signed DMG and a terminal-launched dev build always take the same lock. + +/// Return the path of the advisory lockfile for `service`. +/// +/// The path is `/tmp/buzz-keychain--.lock` on Unix — a +/// deterministic per-user path that is invariant to `$TMPDIR`/process +/// environment. Both a GUI-launched signed DMG (`launchd`, env-stripped) and a +/// terminal-launched dev build resolve `/tmp` to the same inode, so they +/// contend on the same lockfile and achieve mutual exclusion. +/// +/// On Windows the same name used for the kernel mutex is derived from the +/// lockfile path, so the service-keyed uniqueness is preserved. +#[cfg(feature = "system-keyring")] +fn blob_lockfile_path(service: &str) -> PathBuf { + #[cfg(unix)] + { + // Use the real UID so distinct users get distinct lockfiles. + // SAFETY: getuid() is always safe on Unix — it never fails. + let uid = unsafe { libc::getuid() }; + PathBuf::from(format!("/tmp/buzz-keychain-{uid}-{service}.lock")) + } + #[cfg(not(unix))] + { + // Windows: no lockfile used (named mutex instead); this path is only + // used to derive the mutex name and for test assertions. + std::env::temp_dir().join(format!("buzz-keychain-{service}.lock")) + } +} + +/// Acquire an exclusive advisory file lock for the blob identified by `service`. +/// +/// Opens (or creates) the lockfile and blocks until the lock is acquired. +/// Returns the open `File`; the lock is released when the file is dropped. +/// +/// On non-Unix/non-Windows platforms this is a no-op that returns a stub. +#[cfg(feature = "system-keyring")] +fn acquire_blob_lock(service: &str) -> Result { + let path = blob_lockfile_path(service); + BlobLockGuard::acquire(&path) +} + +/// RAII guard that holds an exclusive advisory file lock. +/// +/// On Unix, implemented via `flock(2)` on a lockfile in the system temp dir. +/// On Windows, implemented via a named kernel mutex (cross-process, no file I/O +/// needed). The Windows mutex handle is released on drop. +#[cfg(feature = "system-keyring")] +struct BlobLockGuard { + /// The open lockfile. Never read — held purely for RAII: closing the fd + /// releases the `flock(LOCK_EX)` on Unix. + #[cfg(unix)] + #[allow(dead_code)] + file: std::fs::File, + #[cfg(windows)] + mutex_handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(feature = "system-keyring")] +impl BlobLockGuard { + fn acquire(path: &std::path::Path) -> Result { + #[cfg(unix)] + { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|e| format!("blob lock open {}: {e}", path.display()))?; + use std::os::unix::io::AsRawFd; + // LOCK_EX blocks until the lock is acquired (no LOCK_NB). + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + return Err(format!("blob lock flock: {err}")); + } + return Ok(BlobLockGuard { file }); + } + + #[cfg(windows)] + { + // Named kernel mutexes are cross-process on Windows — no lockfile + // needed. Derive a unique mutex name from the lockfile path so + // distinct services get distinct mutexes. + let name_str = format!( + "Local\\BuzzKeychain-{}", + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("default") + ); + // Encode as null-terminated UTF-16. + let name_wide: Vec = name_str + .encode_utf16() + .chain(std::iter::once(0u16)) + .collect(); + use windows_sys::Win32::Foundation::WAIT_OBJECT_0; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::System::Threading::{ + CreateMutexW, WaitForSingleObject, INFINITE, + }; + // CreateMutexW: lpMutexAttributes = null (default security), + // bInitialOwner = FALSE (0), lpName = our mutex name. + let handle = unsafe { + CreateMutexW( + std::ptr::null::(), + 0, + name_wide.as_ptr(), + ) + }; + // HANDLE = *mut c_void; null means creation failed. + if handle.is_null() { + let err = std::io::Error::last_os_error(); + return Err(format!("blob lock CreateMutexW: {err}")); + } + let wait_result = unsafe { WaitForSingleObject(handle, INFINITE) }; + if wait_result != WAIT_OBJECT_0 { + // Also accept WAIT_ABANDONED (0x80) — previous holder crashed; + // the mutex is still acquired and we own it. + if wait_result != windows_sys::Win32::Foundation::WAIT_ABANDONED { + let err = std::io::Error::last_os_error(); + unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; + return Err(format!( + "blob lock WaitForSingleObject: {wait_result} / {err}" + )); + } + } + return Ok(BlobLockGuard { + mutex_handle: handle, + }); + } + + // Fallback for exotic platforms: no-op lock (only Unix/Windows ship). + #[allow(unreachable_code)] + Err("blob lock: unsupported platform".to_string()) + } +} + +#[cfg(feature = "system-keyring")] +impl Drop for BlobLockGuard { + fn drop(&mut self) { + #[cfg(unix)] + { + // Dropping `self.file` closes the fd, which releases flock on Unix. + // Nothing explicit needed. + } + #[cfg(windows)] + { + unsafe { + windows_sys::Win32::System::Threading::ReleaseMutex(self.mutex_handle); + windows_sys::Win32::Foundation::CloseHandle(self.mutex_handle); + } + } + } +} + +// ── End interprocess advisory lock ──────────────────────────────────────── + +/// An OS keyring, addressed by service name. All secrets are stored in a +/// single JSON blob entry (one OS prompt per process lifetime). +pub struct SecretStore { + service: String, + /// In-memory cache of the deserialized blob. `None` means "not yet loaded". + #[cfg_attr(not(feature = "system-keyring"), allow(dead_code))] + cache: Mutex>>, +} + +impl SecretStore { + /// Keyring-backed store under `service`. The active platform backend + /// (apple-native / windows-native / sync-secret-service) is chosen at + /// compile time. + pub fn keyring(service: impl Into) -> Self { + SecretStore { + service: service.into(), + cache: Mutex::new(None), + } + } + + /// Read `key` from this store's current blob without migration or mutation. + pub fn load_readonly(&self, key: &str) -> Result, ReadonlySecretError> { + load_secret_readonly(&self.service, key) + } + + /// Return a process-global `SecretStore` for `service`. All callers with + /// the same service name share one instance — and therefore one in-memory + /// cache and one mutex — so concurrent blob read-modify-write operations + /// see each other's writes and the last-writer-wins race is closed. + /// + /// Only one service name (`"buzz-desktop"`) is used in practice. If a + /// second service name is ever needed, this can be extended to a registry. + pub fn shared(service: &'static str) -> &'static SecretStore { + use std::sync::OnceLock; + static INSTANCE: OnceLock = OnceLock::new(); + INSTANCE.get_or_init(|| SecretStore::keyring(service)) + } +} + +/// Whether a keyring error string indicates the backend itself is unavailable +/// (vs. a per-entry error like "not found"). Mirrors goose's discriminator +/// (`crates/goose/src/config/base.rs`): treat dbus / Secret Service / platform +/// secure-storage failures as "keyring unavailable, fall back to file". +#[cfg(feature = "system-keyring")] +fn is_keyring_availability_error(error_str: &str) -> bool { + let lower = error_str.to_lowercase(); + lower.contains("keyring") + || lower.contains("dbus") + || lower.contains("org.freedesktop.secrets") + || lower.contains("platform secure storage") + || lower.contains("no secret service") +} + +#[cfg(feature = "system-keyring")] +fn keyring_entry(service: &str, key: &str) -> Result { + keyring::Entry::new(service, key) +} + +// macOS-specific imports for the Data Protection Keychain backend. +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +use security_framework::base::Error as SFError; +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +use security_framework::passwords::{ + delete_generic_password_options, generic_password, PasswordOptions, +}; + +/// Returns true when the security-framework error is "item not found" (-25300). +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn is_not_found(e: &SFError) -> bool { + e.code() == -25300 +} + +/// Returns true when DPK is unavailable because the binary lacks the required +/// entitlement (`errSecMissingEntitlement`, -34018). This happens for unsigned +/// dev builds (`tauri dev` / `cargo run`). The caller should fall back to the +/// legacy `keyring` crate path, which uses the old-style keychain and does not +/// require hardened-runtime entitlements. +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn is_dpk_unavailable(e: &SFError) -> bool { + e.code() == -34018 +} + +/// Build a `PasswordOptions` for the Data Protection Keychain. +#[cfg(all(feature = "system-keyring", target_os = "macos"))] +fn dpk_opts(service: &str, key: &str) -> PasswordOptions { + let mut opts = PasswordOptions::new_generic_password(service, key); + opts.use_protected_keychain(); + opts +} + +impl SecretStore { + /// Read the blob from the keychain and return the deserialized map. + /// + /// Returns `Ok(None)` when no blob entry exists yet (first launch or + /// fresh install). Returns `Err` when the backend is unavailable or the + /// stored JSON is corrupt. + /// + /// On success the result is stored in `self.cache` so subsequent calls + /// within the same process return immediately without a keychain round-trip. + #[cfg(feature = "system-keyring")] + fn load_blob(&self) -> Result>, String> { + { + let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(ref map) = *guard { + return Ok(Some(map.clone())); + } + } + + let raw = self.read_blob_raw()?; + let map = match raw { + None => return Ok(None), + Some(bytes) => { + let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; + serde_json::from_str::>(&json) + .map_err(|e| format!("blob json: {e}"))? + } + }; + + // Only populate the cache if it is still empty — a concurrent + // mutate_blob() may have written a newer value while we were reading. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_none() { + *guard = Some(map.clone()); + } + Ok(Some(map)) + } + + /// Read the raw blob bytes from the keychain. `Ok(None)` = not found. + /// + /// Always uses the legacy keyring crate on macOS so that signed and + /// unsigned (dev) builds share the same store. DPK is only used by + /// `migrate_legacy_key` to read old per-key entries written by #1264. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + fn read_blob_raw(&self) -> Result>, String> { + self.read_blob_raw_keyring() + } + + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] + fn read_blob_raw(&self) -> Result>, String> { + self.read_blob_raw_keyring() + } + + /// Read blob via the legacy `keyring` crate (Windows, Linux, or macOS dev + /// builds that lack hardened-runtime entitlements). + #[cfg(feature = "system-keyring")] + fn read_blob_raw_keyring(&self) -> Result>, String> { + let entry = + keyring_entry(&self.service, BLOB_KEY).map_err(|e| format!("keyring entry: {e}"))?; + match entry.get_password() { + Ok(s) => Ok(Some(s.into_bytes())), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) if is_keyring_availability_error(&e.to_string()) => { + Err(format!("keyring unavailable: {e}")) + } + Err(e) => Err(format!("keyring read: {e}")), + } + } + + /// Atomically load the blob, apply `f` to a candidate map, write back if + /// changed, and only then advance the cache. + /// + /// **Cross-process safety**: acquires an exclusive advisory file lock + /// (`flock(2)` on Unix, `LockFileEx` on Windows) before reading, mutating, + /// and writing. The lock is keyed by service name and stored in the system + /// temp directory, making it reachable from both the signed DMG build and + /// unsigned dev builds. Inside the lock a fresh `read_blob_raw()` is always + /// performed (even when the cache is warm) so a concurrent process's write + /// is never silently dropped. + /// + /// **Idempotent**: when `f` leaves the candidate equal to the freshly-read + /// map, `write_blob_raw` is skipped entirely. On macOS the legacy + /// `SecKeychain` API treats a write as a distinct ACL operation from the + /// "Always Allow"-ed read, so skipping no-op writes eliminates the keychain + /// prompt that fires when saving an agent whose model changed but whose key + /// did not. + /// + /// **Copy-on-write**: the candidate `next` is a separate allocation from + /// `current`. The cache is only replaced with `next` after `write_blob_raw` + /// succeeds. On write failure the cache is cleared to `None` so the next + /// caller re-reads from the keychain rather than building on a stale state. + /// + /// Deadlock-free: `read_blob_raw` and `write_blob_raw` do not acquire the + /// cache mutex. `load_blob` does acquire it, but `mutate_blob` does not call + /// `load_blob` — it reads from the keyring directly inside the file lock. + #[cfg(feature = "system-keyring")] + fn mutate_blob(&self, f: F) -> Result<(), String> + where + F: FnOnce(&mut HashMap), + { + // Acquire the interprocess advisory lock first. All Buzz processes + // using the same service name contend on the same lockfile at + // /tmp/buzz-keychain--.lock (a deterministic per-user + // path invariant to $TMPDIR), so only one process performs a + // read-modify-write at a time. + let _lock = acquire_blob_lock(&self.service)?; + + // Always do a fresh read from the keychain while holding the lock — + // this is the critical correction over the prior warm-cache path. A + // stale warm cache would make us build our candidate on an outdated + // baseline and drop keys written by another process. + let raw = self.read_blob_raw()?; + let current: HashMap = match raw { + None => HashMap::new(), + Some(bytes) => { + let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; + serde_json::from_str::>(&json) + .map_err(|e| format!("blob json: {e}"))? + } + }; + + // Build the candidate state in a separate allocation so that a write + // failure below cannot leave the cache ahead of durable storage. + let mut next = current.clone(); + f(&mut next); + + // Skip the keychain write when the candidate equals the freshly-read + // durable state — no I/O needed and no keychain ACL prompt on macOS. + if next == current { + // Update the cache to the fresh read even on no-op so subsequent + // reads in this process see any keys another process may have added. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(current); + return Ok(()); + } + + // Write to keyring while still holding the file lock. + let json = serde_json::to_string(&next).map_err(|e| format!("blob serialize: {e}"))?; + match self.write_blob_raw(json.as_bytes()) { + Ok(()) => { + // Advance the cache to `next` only after the durable write succeeds. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(next); + Ok(()) + } + Err(e) => { + // On write failure, clear the cache so the next caller re-reads + // from the keychain rather than building on a stale state. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + Err(e) + } + } + } + + /// Always uses the legacy keyring crate on macOS — see `read_blob_raw`. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + fn write_blob_raw(&self, bytes: &[u8]) -> Result<(), String> { + self.write_blob_raw_keyring(bytes) + } + + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] + fn write_blob_raw(&self, bytes: &[u8]) -> Result<(), String> { + self.write_blob_raw_keyring(bytes) + } + + #[cfg(feature = "system-keyring")] + fn write_blob_raw_keyring(&self, bytes: &[u8]) -> Result<(), String> { + let value = std::str::from_utf8(bytes).map_err(|e| format!("blob utf8 encode: {e}"))?; + let entry = + keyring_entry(&self.service, BLOB_KEY).map_err(|e| format!("keyring entry: {e}"))?; + entry + .set_password(value) + .map_err(|e| format!("keyring write: {e}")) + } + + /// Probe whether `key` exists and whether the backend is reachable. + pub fn probe(&self, key: &str) -> KeyringProbe { + #[cfg(feature = "system-keyring")] + { + match self.load_blob() { + Ok(Some(map)) => { + if map.contains_key(key) { + KeyringProbe::Present + } else { + // Blob exists but key absent — still check old per-key + // entries so a partial migration (e.g. identity migrated + // first) doesn't silently drop agent keys. + self.probe_legacy_key(key) + } + } + // No blob yet — check old per-key entries so callers that + // gate `load()` on `Present` still trigger migration. + Ok(None) => self.probe_legacy_key(key), + Err(e) if is_keyring_availability_error(&e) => KeyringProbe::Unreachable, + Err(_) => KeyringProbe::Unreachable, // corrupt blob — fail closed + } + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = key; + KeyringProbe::Unreachable + } + } + + /// Check old per-key DPK/keyring entries for `key`. Used by `probe()` when + /// the blob doesn't exist yet (first launch after upgrade). + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + fn probe_legacy_key(&self, key: &str) -> KeyringProbe { + match generic_password(dpk_opts(&self.service, key)) { + Ok(_) => KeyringProbe::Present, + Err(ref e) if is_not_found(e) => self.probe_legacy_key_keyring(key), + Err(ref e) if is_dpk_unavailable(e) => self.probe_legacy_key_keyring(key), + Err(ref e) if is_keyring_availability_error(&e.to_string()) => { + KeyringProbe::Unreachable + } + Err(_) => KeyringProbe::ReachableButEmpty, + } + } + + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] + fn probe_legacy_key(&self, key: &str) -> KeyringProbe { + self.probe_legacy_key_keyring(key) + } + + #[cfg(feature = "system-keyring")] + fn probe_legacy_key_keyring(&self, key: &str) -> KeyringProbe { + match keyring_entry(&self.service, key) { + Ok(entry) => match entry.get_password() { + Ok(_) => KeyringProbe::Present, + Err(keyring::Error::NoEntry) => KeyringProbe::ReachableButEmpty, + Err(e) if is_keyring_availability_error(&e.to_string()) => { + KeyringProbe::Unreachable + } + Err(_) => KeyringProbe::ReachableButEmpty, + }, + Err(e) if is_keyring_availability_error(&e.to_string()) => KeyringProbe::Unreachable, + Err(_) => KeyringProbe::Unreachable, + } + } + + /// Load the secret for `key`. `Ok(None)` when there is no entry; `Err` only + /// when the backend errored in a way that is not "missing". + /// + /// On first launch after an upgrade from the per-key DPK format, the blob + /// will not exist yet. In that case the macOS path falls back to reading the + /// old per-key DPK entry for `key` specifically, writes it into a new blob, + /// and deletes the old item — a one-time migration per key. The same + /// migration fires when the blob exists but the key is absent, covering + /// partial-migration scenarios (e.g. identity migrated first, agents not yet). + pub fn load(&self, key: &str) -> Result, String> { + #[cfg(feature = "system-keyring")] + { + match self.load_blob() { + Ok(Some(map)) => { + if let Some(value) = map.get(key) { + Ok(Some(value.clone())) + } else { + // Blob exists but key absent — attempt migration from old + // per-key entry. migrate_legacy_key writes the result into + // the blob if found, so subsequent loads hit the cache. + self.migrate_legacy_key(key) + } + } + Ok(None) => { + // No blob yet — attempt one-time migration from old per-key + // DPK entry (macOS) or return Ok(None) (other platforms). + self.migrate_legacy_key(key) + } + Err(e) => Err(e), + } + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = key; + Err("system-keyring feature disabled".to_string()) + } + } + + /// Read the secret for `key` without any legacy-migration side effects. + /// + /// Read the entire blob without any legacy-migration side effects. + /// + /// Returns the full key→value map when a blob exists, `Ok(None)` when no + /// blob has been written yet, and `Err` only when the backend is + /// unavailable. Never calls `migrate_legacy_key`. + pub fn load_all_readonly(&self) -> Result>, String> { + #[cfg(feature = "system-keyring")] + { + self.load_blob() + } + #[cfg(not(feature = "system-keyring"))] + { + Err("system-keyring feature disabled".to_string()) + } + } + + /// Insert all entries from `entries` into the blob in a single mutation. + /// + /// Entries that already exist in the blob are overwritten; entries not + /// present in `entries` are left unchanged. If the resulting blob is + /// identical to what is already stored, no keychain write occurs. + pub fn store_all(&self, entries: &HashMap) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + self.mutate_blob(|map| { + for (k, v) in entries { + map.insert(k.clone(), v.clone()); + } + }) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = entries; + Err("system-keyring feature disabled".to_string()) + } + } + + /// On first launch after upgrading from the per-key DPK format, read the + /// old DPK entry for `key`, write it into a new blob, and delete the old + /// item. Returns `Ok(None)` when no old entry exists. + /// + /// Also handles a one-time migration from the DPK blob format written by + /// #1267 (before the dev/release split was fixed). Anyone who ran main + /// while #1267 was present has a DPK blob instead of per-key entries; this + /// reads it, merges all keys into the legacy blob, and deletes the DPK blob. + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + fn migrate_legacy_key(&self, key: &str) -> Result, String> { + // One-time migration: check for a DPK blob (key = BLOB_KEY = "secrets") + // written by #1267 before the dev/release split was fixed. + match generic_password(dpk_opts(&self.service, BLOB_KEY)) { + Ok(bytes) => { + let json = String::from_utf8(bytes).map_err(|e| format!("dpk blob utf8: {e}"))?; + let dpk_map = serde_json::from_str::>(&json) + .map_err(|e| format!("dpk blob json: {e}"))?; + // Merge all keys from the DPK blob into the legacy blob. + self.mutate_blob(|map| { + for (k, v) in &dpk_map { + map.entry(k.clone()).or_insert_with(|| v.clone()); + } + })?; + // Best-effort delete the DPK blob. + let _ = delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)); + return Ok(dpk_map.get(key).cloned()); + } + Err(ref e) if is_not_found(e) => { + // No DPK blob — fall through to per-key migration. + } + Err(ref e) if is_dpk_unavailable(e) => { + // Unsigned dev build — DPK inaccessible, fall through. + } + Err(e) => return Err(format!("dpk blob read: {e}")), + } + + // Try the old per-key DPK entry. + match generic_password(dpk_opts(&self.service, key)) { + Ok(bytes) => { + let value = String::from_utf8(bytes).map_err(|e| format!("keyring utf8: {e}"))?; + // Write into blob (creates the blob if it doesn't exist). + self.store(key, &value)?; + // Best-effort cleanup of the old per-key entry. + let _ = delete_generic_password_options(dpk_opts(&self.service, key)); + Ok(Some(value)) + } + Err(ref e) if is_not_found(e) => { + // Also check the old keyring-crate entry (pre-#1264 installs). + self.migrate_legacy_key_keyring(key) + } + Err(ref e) if is_dpk_unavailable(e) => { + // Unsigned dev build — check old keyring-crate entry only. + self.migrate_legacy_key_keyring(key) + } + Err(e) => Err(format!("keyring get: {e}")), + } + } + + #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] + fn migrate_legacy_key(&self, key: &str) -> Result, String> { + // Non-macOS: no DPK, just check the old keyring-crate per-key entry. + self.migrate_legacy_key_keyring(key) + } + + /// Check the old per-key `keyring` crate entry (pre-#1264 format) and + /// migrate it into the blob if found. + #[cfg(feature = "system-keyring")] + fn migrate_legacy_key_keyring(&self, key: &str) -> Result, String> { + let entry = keyring_entry(&self.service, key).map_err(|e| format!("keyring entry: {e}"))?; + match entry.get_password() { + Ok(value) => { + self.store(key, &value)?; + let _ = entry.delete_credential(); + Ok(Some(value)) + } + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("keyring get: {e}")), + } + } + + /// Verify that `key` holds `expected` by reading directly from the OS + /// backend, bypassing the in-process cache. This is the key innovation for + /// read-back verification: it proves the OS keyring round-trip, not just + /// that the in-process cache was updated. + /// + /// Returns `Ok(true)` when the stored value matches `expected`, `Ok(false)` + /// when the entry is absent or holds a different value, and `Err` when the + /// backend is unavailable. + pub fn verify_stored_raw(&self, key: &str, expected: &str) -> Result { + #[cfg(feature = "system-keyring")] + { + let raw = self.read_blob_raw()?; + match raw { + None => Ok(false), + Some(bytes) => { + let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; + let map = + serde_json::from_str::>(&json) + .map_err(|e| format!("blob json: {e}"))?; + Ok(map.get(key).is_some_and(|v| v == expected)) + } + } + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = (key, expected); + Err("system-keyring feature disabled".to_string()) + } + } + + /// Store `value` for `key`. Reports `Err` on availability failures — callers + /// decide whether to fall back to file storage. + pub fn store(&self, key: &str, value: &str) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + self.mutate_blob(|map| { + map.insert(key.to_string(), value.to_string()); + }) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = (key, value); + Err("system-keyring feature disabled".to_string()) + } + } + + /// Delete the entire keychain blob for this service, plus all legacy per-key + /// entries that could resurrect an identity on next boot. + /// + /// Order of operations: + /// 1. Read the blob to collect every key name (e.g. `identity`, agent keys). + /// 2. Delete legacy per-key DPK entries for every key + the DPK blob itself. + /// 3. Delete legacy per-key keyring entries for every key. + /// 4. Delete the blob entry. + /// 5. Clear the in-memory cache. + /// + /// This is the correct wipe path for sign-out: the old `delete_all` skipped + /// step 1–3 so stale per-key entries could be re-imported on the next launch + /// via `migrate_legacy_key`. This method prevents that resurrection. + pub fn delete_all_with_legacy_cleanup(&self) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + let _lock = acquire_blob_lock(&self.service)?; + + // Step 1: read current blob keys (best-effort; no entry = empty set). + let blob_keys: Vec = match self.read_blob_raw() { + Ok(Some(bytes)) => { + let json = String::from_utf8(bytes).unwrap_or_default(); + serde_json::from_str::>(&json) + .map(|m| m.into_keys().collect()) + .unwrap_or_default() + } + _ => vec![], + }; + + // Always include "identity" even if the blob is empty or absent — + // it may exist only as a legacy per-key entry. + let mut all_keys = blob_keys; + if !all_keys.contains(&"identity".to_string()) { + all_keys.push("identity".to_string()); + } + + // Steps 2 & 3: delete legacy per-key entries for every key. + for key in &all_keys { + #[cfg(target_os = "macos")] + { + match delete_generic_password_options(dpk_opts(&self.service, key)) { + Ok(()) => {} + Err(ref e) if is_not_found(e) => {} + Err(ref e) if is_dpk_unavailable(e) => {} + Err(e) => return Err(format!("dpk per-key delete {key}: {e}")), + } + } + { + let entry = keyring_entry(&self.service, key) + .map_err(|e| format!("keyring entry constructor {key}: {e}"))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) if is_keyring_availability_error(&e.to_string()) => { + return Err(format!("keyring unavailable deleting {key}: {e}")); + } + Err(e) => { + return Err(format!("keyring per-key delete {key}: {e}")); + } + } + } + } + // Step 2 (cont.): also delete the legacy DPK blob written by #1267. + #[cfg(target_os = "macos")] + { + match delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)) { + Ok(()) => {} + Err(ref e) if is_not_found(e) => {} + Err(ref e) if is_dpk_unavailable(e) => {} + Err(e) => return Err(format!("dpk blob delete: {e}")), + } + } + + // Step 4: delete the main blob entry. + { + let entry = keyring_entry(&self.service, BLOB_KEY) + .map_err(|e| format!("keyring entry constructor blob: {e}"))?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) if is_keyring_availability_error(&e.to_string()) => { + return Err(format!("keyring unavailable: {e}")); + } + Err(e) => { + return Err(format!("keyring blob delete: {e}")); + } + } + } + + // Step 5: clear the in-memory cache. + let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + Ok(()) + } + #[cfg(not(feature = "system-keyring"))] + { + Ok(()) // No-op: no keyring, nothing to delete. + } + } + + /// Verify no identity-bearing keychain entry survives in any shape + /// that `load("identity")` → `migrate_legacy_key` can consume: + /// main blob, DPK blob (`BLOB_KEY`), and per-key `"identity"`. + /// + /// Returns `true` when all three shapes are absent (or inaccessible in an + /// expected way), `false` when any entry is found or the keychain is + /// unavailable (fail-closed). + pub fn verify_fully_wiped(&self) -> bool { + #[cfg(feature = "system-keyring")] + { + // 1. Main blob must be absent. + match self.read_blob_raw() { + Ok(None) => {} + Ok(Some(_)) => return false, + Err(_) => return false, + } + // 2. Per-key "identity" via legacy keyring must be absent. + match keyring_entry(&self.service, "identity") { + Ok(entry) => match entry.get_password() { + Err(keyring::Error::NoEntry) => {} + Ok(_) => return false, + // Any other error (availability, unknown, transient) → fail closed. + // Only explicit NoEntry is proof of absence. + Err(_) => return false, + }, + // Constructor failure → cannot verify → fail closed. + Err(_) => return false, + } + // 3. DPK blob (macOS only). + #[cfg(target_os = "macos")] + { + match generic_password(dpk_opts(&self.service, BLOB_KEY)) { + Err(ref e) if is_not_found(e) => {} + // dpk-unavailable is symmetric with load(): if load() can't + // consume DPK in this state, a surviving entry is harmless. + Err(ref e) if is_dpk_unavailable(e) => {} + Ok(_) => return false, + // Any other error → fail closed (not proof of absence). + Err(_) => return false, + } + // 4. Per-key DPK "identity" (macOS only). + match generic_password(dpk_opts(&self.service, "identity")) { + Err(ref e) if is_not_found(e) => {} + // dpk-unavailable: symmetric with load() — if load() can't + // read DPK, a surviving entry can't resurrect identity. + Err(ref e) if is_dpk_unavailable(e) => {} + Ok(_) => return false, + // Any other error → fail closed. + Err(_) => return false, + } + } + true + } + #[cfg(not(feature = "system-keyring"))] + { + true // No keyring = nothing to verify. + } + } + + /// Delete the secret for `key`. A missing entry is not an error. + pub fn delete(&self, key: &str) -> Result<(), String> { + #[cfg(feature = "system-keyring")] + { + self.mutate_blob(|map| { + map.remove(key); + })?; + // Best-effort: also delete any old per-key entry for this key to + // prevent resurrection on the next probe/load (migration path). + #[cfg(target_os = "macos")] + let _ = delete_generic_password_options(dpk_opts(&self.service, key)); + if let Ok(entry) = keyring_entry(&self.service, key) { + let _ = entry.delete_credential(); + } + Ok(()) + } + #[cfg(not(feature = "system-keyring"))] + { + let _ = key; + Err("system-keyring feature disabled".to_string()) + } + } +} + +#[cfg(test)] +mod readonly_tests { + use super::*; + + #[test] + fn readonly_decode_returns_requested_secret() { + let raw = r#"{"identity":"nsec1example","agent:abc":"other-secret"}"#.to_string(); + assert_eq!( + decode_readonly_secret(raw, DESKTOP_IDENTITY_KEY).unwrap(), + Some("nsec1example".to_string()) + ); + } + + #[test] + fn readonly_decode_reports_missing_secret_without_error() { + let raw = r#"{"agent:abc":"other-secret"}"#.to_string(); + assert_eq!( + decode_readonly_secret(raw, DESKTOP_IDENTITY_KEY).unwrap(), + None + ); + } + + #[test] + fn readonly_decode_redacts_corrupt_blob() { + let secret = "nsec1must-not-appear"; + let error = + decode_readonly_secret(format!(r#"{{"identity":"{secret}""#), "identity").unwrap_err(); + assert_eq!(error, ReadonlySecretError::Corrupt); + assert!(!error.to_string().contains(secret)); + } + + #[test] + fn desktop_storage_coordinates_are_stable() { + assert_eq!(DESKTOP_RELEASE_SERVICE, "buzz-desktop"); + assert_eq!(DESKTOP_IDENTITY_KEY, "identity"); + } + + #[cfg(all(feature = "system-keyring", target_os = "macos"))] + #[test] + fn macos_access_failures_are_classified_without_platform_details() { + for code in [-25293, -128] { + let error = security_framework::base::Error::from_code(code); + assert_eq!( + classify_platform_read_failure(&error), + ReadonlySecretError::AccessDenied + ); + } + for code in [-25308, -25291, -25292, -25294, -25295] { + let error = security_framework::base::Error::from_code(code); + assert_eq!( + classify_platform_read_failure(&error), + ReadonlySecretError::LockedOrUnavailable + ); + } + } +} + +#[cfg(all(test, feature = "system-keyring"))] +mod tests { + use super::*; + + // Test-only constructor: pre-seed the cache without touching the OS keychain. + impl SecretStore { + fn with_cache(service: &str, cache: Option>) -> Self { + SecretStore { + service: service.to_string(), + cache: Mutex::new(cache), + } + } + } + + #[test] + fn probe_returns_present_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); + // Cache is warm and contains "identity" — probe must return Present + // without touching the keychain. + assert_eq!(store.probe("identity"), KeyringProbe::Present); + } + + #[test] + fn load_returns_value_when_key_in_cache() { + let mut map = HashMap::new(); + map.insert("identity".to_string(), "nsec1test".to_string()); + let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); + // Cache is warm and contains "identity" — load must return the value + // without touching the keychain. + assert_eq!( + store.load("identity").unwrap(), + Some("nsec1test".to_string()) + ); + } + + // ── Cross-process race tests (require real OS keychain) ──────────────── + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn test_stale_warm_cache_add_observes_prior_write() { + // Simulates the cross-process race that stranded Will's agent keys. + // + // Setup: two SecretStore instances for the same service (= two + // "processes" with separate caches). Process A warms its cache to + // {k1}. Process B then writes {k1, k2}. Without the fix, A's next + // mutate_blob would build from its stale {k1} cache and write + // {k1, k3}, silently dropping k2. With the fix, A always re-reads + // from the keychain inside the lock, so the result is {k1, k2, k3}. + let svc = "buzz-test-race-stale-cache"; + + // Clean state. + let setup = SecretStore::keyring(svc); + let _ = setup.delete("k1"); + let _ = setup.delete("k2"); + let _ = setup.delete("k3"); + + // Process A: write k1, warming its cache. + let store_a = SecretStore::keyring(svc); + store_a.store("k1", "v1").unwrap(); + + // Process B: write k2 (separate instance = separate cache). + let store_b = SecretStore::keyring(svc); + store_b.store("k2", "v2").unwrap(); + + // Process A: write k3. With the fix, A re-reads inside the lock and + // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. + store_a.store("k3", "v3").unwrap(); + + // Verify via a third reader (clean cache). + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("k1").unwrap(), + Some("v1".to_string()), + "k1 must survive" + ); + assert_eq!( + reader.load("k2").unwrap(), + Some("v2".to_string()), + "k2 must not be dropped" + ); + assert_eq!( + reader.load("k3").unwrap(), + Some("v3".to_string()), + "k3 must be written" + ); + + // Cleanup. + let _ = reader.delete("k1"); + let _ = reader.delete("k2"); + let _ = reader.delete("k3"); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn test_concurrent_adds_neither_key_dropped() { + // Two sequential stores from distinct instances (simulating two + // processes each adding one key) must both be durably visible. + let svc = "buzz-test-race-concurrent-add"; + + let setup = SecretStore::keyring(svc); + let _ = setup.delete("agent_a"); + let _ = setup.delete("agent_b"); + + let store1 = SecretStore::keyring(svc); + store1.store("agent_a", "nsec1aaa").unwrap(); + + let store2 = SecretStore::keyring(svc); + store2.store("agent_b", "nsec1bbb").unwrap(); + + let reader = SecretStore::keyring(svc); + assert_eq!( + reader.load("agent_a").unwrap(), + Some("nsec1aaa".to_string()), + "agent_a must not be dropped" + ); + assert_eq!( + reader.load("agent_b").unwrap(), + Some("nsec1bbb".to_string()), + "agent_b must not be dropped" + ); + + // Cleanup. + let _ = reader.delete("agent_a"); + let _ = reader.delete("agent_b"); + } + + #[test] + fn test_blob_lockfile_path_is_in_tmp_with_uid() { + // The lockfile must be at a deterministic per-user path under /tmp — + // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by + // launchd) and a terminal-launched dev build resolve the same inode and + // achieve mutual exclusion. + let path = blob_lockfile_path("buzz-desktop"); + #[cfg(unix)] + { + let uid = unsafe { libc::getuid() }; + assert!( + path.starts_with("/tmp"), + "lockfile {path:?} must start with /tmp (not $TMPDIR)" + ); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + assert!( + name.contains(&uid.to_string()), + "lockfile {path:?} must contain uid {uid}" + ); + assert!( + name.contains("buzz-keychain"), + "lockfile name must contain 'buzz-keychain'" + ); + } + #[cfg(not(unix))] + { + assert!( + path.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains("buzz-keychain")), + "lockfile name must contain 'buzz-keychain'" + ); + } + } + + #[test] + fn test_blob_lock_acquire_and_release() { + // Verify the advisory lock can be acquired and released without errors. + // This exercises the real flock/mutex path on the current platform. + let guard = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard.is_ok(), + "advisory lock acquire must succeed: {:?}", + guard.err() + ); + // Drop the guard — lock is released. A second acquire must succeed. + drop(guard); + let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); + assert!( + guard2.is_ok(), + "advisory lock re-acquire after release must succeed: {:?}", + guard2.err() + ); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn mutate_blob_does_not_advance_cache_on_write_failure() { + // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, + // transient outage, ACL rejection), the cache must stay at the last + // known durable state. A subsequent `store()` for the same key/value + // must NOT be skipped as a no-op — the equality check must compare + // against the durable cache, not an unpersisted candidate. + // + // This is a real-keychain integration test. Run locally with: + // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance + // + // On a machine with a reachable keychain the `store()` call succeeds + // (result.is_ok()) and the write-failure branch is skipped — the test + // still passes. On a machine where the write is denied (e.g., user + // clicks Deny in the macOS prompt) result.is_err() and the assertions + // below verify the cache invariant. We verify that after an error: + // 1. The cache is not advanced (the previously cached key is intact). + // 2. The failed key is not present (the dirty candidate was discarded). + let mut map = HashMap::new(); + map.insert("existing".to_string(), "durable_val".to_string()); + let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); + + // Attempt to add a new key — this calls write_blob_raw against the + // real keychain; with copy-on-write the cache must remain at {existing} + // if the write fails. + let result = store.store("new_key", "new_val"); + + if result.is_err() { + // Write failed (e.g., user denied the keychain prompt): confirm + // cache was not advanced — the existing key is still intact and + // the new key was never committed to the in-memory state. + assert_eq!( + store.load("existing").unwrap(), + Some("durable_val".to_string()), + "cache must remain at last durable state after write failure" + ); + // load("new_key") goes through the unchanged cache (no entry), + // then attempts migrate_legacy_key which also fails on a denied + // keychain, returning either Ok(None) or Err — either is correct + // since the key was never durably stored. + let after = store.load("new_key"); + assert!( + matches!(after, Ok(None) | Err(_)), + "a key whose write failed must not be visible via load: {after:?}" + ); + } + // If result.is_ok() the write succeeded — the cache-integrity invariant + // does not apply to the success path; no assertion needed here. + } + + #[test] + fn availability_error_discriminator() { + assert!(is_keyring_availability_error("dbus connection failed")); + assert!(is_keyring_availability_error( + "org.freedesktop.secrets not provided" + )); + assert!(is_keyring_availability_error("No Secret Service")); + assert!(is_keyring_availability_error( + "Platform secure storage failure" + )); + // A plain "not found" is per-entry, not an availability failure. + assert!(!is_keyring_availability_error("entry not found")); + } + + #[cfg(target_os = "macos")] + #[test] + fn dpk_error_discriminators() { + // errSecMissingEntitlement = -34018 signals unsigned dev build. + let e = SFError::from_code(-34018); + assert!(is_dpk_unavailable(&e)); + assert!(!is_not_found(&e)); + // errSecItemNotFound = -25300 is not a DPK-unavailable error. + let e = SFError::from_code(-25300); + assert!(is_not_found(&e)); + assert!(!is_dpk_unavailable(&e)); + } + + // Integration tests that exercise the real OS keychain. Skipped in CI + // (unsigned builds lack keychain entitlements); run locally with: + // cargo test -p buzz-desktop -- --ignored blob_ + // + // Each test uses a unique service name to avoid cross-test pollution. + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn blob_stores_and_retrieves_multiple_keys() { + let store = SecretStore::keyring("buzz-test-blob-multi"); + store.store("key_a", "val_a").unwrap(); + store.store("key_b", "val_b").unwrap(); + assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); + assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); + assert_eq!(store.load("key_c").unwrap(), None); + // Cleanup. + let _ = store.delete("key_a"); + let _ = store.delete("key_b"); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn blob_probe_present_absent_unreachable() { + let store = SecretStore::keyring("buzz-test-blob-probe"); + // No blob yet — key absent, backend reachable. + assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); + store.store("identity", "nsec1test").unwrap(); + // Key now present. + assert_eq!(store.probe("identity"), KeyringProbe::Present); + // Different key — blob exists but key absent. + assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); + // Cleanup. + let _ = store.delete("identity"); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn blob_delete_removes_key_not_others() { + let store = SecretStore::keyring("buzz-test-blob-delete"); + store.store("keep", "keep_val").unwrap(); + store.store("remove", "remove_val").unwrap(); + store.delete("remove").unwrap(); + assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); + assert_eq!(store.load("remove").unwrap(), None); + // Cleanup. + let _ = store.delete("keep"); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn blob_migration_from_per_key_entry() { + let svc = "buzz-test-blob-migration"; + let key = "identity"; + let value = "nsec1migrationtest"; + + // Seed a per-key entry (old format) — no blob exists. + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Fresh store — no blob in the keychain yet. + let store = SecretStore::keyring(svc); + + // probe should find the legacy key. + assert_eq!(store.probe(key), KeyringProbe::Present); + + // load should migrate it into the blob and return the value. + assert_eq!(store.load(key).unwrap(), Some(value.to_string())); + + // Old per-key entry should be cleaned up. + let entry = keyring_entry(svc, key).unwrap(); + assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); + + // Key is now in the blob — probe confirms. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); + + // Cleanup. + let _ = store2.delete(key); + } + + #[ignore = "requires real OS keychain (run locally)"] + #[test] + fn delete_all_with_legacy_cleanup_removes_per_key_identity() { + let svc = "buzz-test-delete-all-legacy"; + let key = "identity"; + let value = "nsec1legacytest"; + + // Seed a legacy per-key entry (old format, pre-blob migration). + let entry = keyring_entry(svc, key).unwrap(); + entry.set_password(value).unwrap(); + + // Also seed a blob with a different key to exercise the full path. + let store = SecretStore::keyring(svc); + store.store("agent:abc123", "nsec1agent").unwrap(); + + // Legacy per-key identity should be discoverable via probe. + let store2 = SecretStore::keyring(svc); + assert_eq!(store2.probe(key), KeyringProbe::Present); + + // Wipe everything via the sign-out path. + store2.delete_all_with_legacy_cleanup().unwrap(); + + // Fresh store — neither the blob nor the per-key entry should remain. + let store3 = SecretStore::keyring(svc); + assert_eq!( + store3.probe(key), + KeyringProbe::ReachableButEmpty, + "per-key identity must not survive delete_all_with_legacy_cleanup" + ); + assert_eq!( + store3.load(key).unwrap(), + None, + "load must not resurrect the legacy per-key identity" + ); + // Agent key should also be gone. + assert_eq!(store3.load("agent:abc123").unwrap(), None); + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7fa6c4cb7e..5db0291e00 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -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" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5467645873..819663b497 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -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" } diff --git a/desktop/src-tauri/src/app_state_keyring.rs b/desktop/src-tauri/src/app_state_keyring.rs index 68d24e87f5..0fdbb4f726 100644 --- a/desktop/src-tauri/src/app_state_keyring.rs +++ b/desktop/src-tauri/src/app_state_keyring.rs @@ -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") diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..c550e3ad3d 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -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); } diff --git a/desktop/src-tauri/src/secret_store.rs b/desktop/src-tauri/src/secret_store.rs index 43854761b5..cb1edd7c07 100644 --- a/desktop/src-tauri/src/secret_store.rs +++ b/desktop/src-tauri/src/secret_store.rs @@ -1,1306 +1 @@ -//! OS keyring access for desktop nsec private keys. -//! -//! All secrets are stored as a single JSON blob under one keychain entry -//! (service = the store's service name, username = `"secrets"`). This means -//! exactly one OS prompt per process lifetime regardless of how many keys are -//! stored — the same pattern used by Goose. -//! -//! The chosen backend is selected at compile time by the per-target feature in -//! `Cargo.toml`. On macOS the legacy `keyring` crate (SecKeychain API) is used -//! for the blob entry so that signed release builds and unsigned dev builds -//! share the same store. DPK (Data Protection Keychain) is used only by the -//! one-time migration path that reads old per-key entries written by #1264. -//! Windows and Linux use the `keyring` crate directly. The `system-keyring` -//! feature gates the whole store; when it is off, [`SecretStore`] is unusable -//! and callers fall back to their own `0o600` file storage. -//! -//! The store is deliberately NOT on any env-read path. `BUZZ_PRIVATE_KEY` -//! resolution for harnessed agents and CI is handled upstream (an env -//! short-circuit for the human key, child-process env injection for agents); -//! adding an env tier here would duplicate that precedence and create a -//! divergent-behavior trap. - -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Mutex; - -/// Result of probing the keyring before a migration: distinguishes "reachable -/// but holds no entry" (safe to migrate into) from "unreachable this boot" -/// (must NOT migrate — re-importing from a leftover plaintext file could -/// resurrect a rotated/stale key). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeyringProbe { - /// Keyring is reachable and an entry for the key already exists. - Present, - /// Keyring is reachable but has no entry for the key. - ReachableButEmpty, - /// Keyring backend is unavailable this boot (no Secret Service, dbus - /// failure, etc.). Migration must be skipped. - Unreachable, -} - -/// Username used for the single blob keychain entry. All secrets are stored -/// as a JSON map under this name within the service. -const BLOB_KEY: &str = "secrets"; - -// ── Interprocess advisory lock ───────────────────────────────────────────── -// -// Two concurrent Buzz processes (e.g. the signed DMG build and an unsigned dev -// build via `just staging`) share the same OS keychain blob because the -// service name `"buzz-desktop"` is a constant — it does not key off the bundle -// identifier. Each process holds its own in-memory cache, so without an -// interprocess lock a warm-cache write in process A drops keys added by process -// B between A's last cache-warming read and A's write. -// -// The fix: `mutate_blob` acquires an exclusive advisory file lock, then always -// performs a fresh `read_blob_raw()` inside the lock, applies the mutation, -// writes back, and releases. The cache is still updated after a successful -// write, so same-process reads remain fast. The lock is file-based at a fixed -// per-user path `/tmp/buzz-keychain--.lock` on Unix — a path -// that is invariant to `$TMPDIR`/process environment, so both the GUI-launched -// signed DMG and a terminal-launched dev build always take the same lock. - -/// Return the path of the advisory lockfile for `service`. -/// -/// The path is `/tmp/buzz-keychain--.lock` on Unix — a -/// deterministic per-user path that is invariant to `$TMPDIR`/process -/// environment. Both a GUI-launched signed DMG (`launchd`, env-stripped) and a -/// terminal-launched dev build resolve `/tmp` to the same inode, so they -/// contend on the same lockfile and achieve mutual exclusion. -/// -/// On Windows the same name used for the kernel mutex is derived from the -/// lockfile path, so the service-keyed uniqueness is preserved. -fn blob_lockfile_path(service: &str) -> PathBuf { - #[cfg(unix)] - { - // Use the real UID so distinct users get distinct lockfiles. - // SAFETY: getuid() is always safe on Unix — it never fails. - let uid = unsafe { libc::getuid() }; - PathBuf::from(format!("/tmp/buzz-keychain-{uid}-{service}.lock")) - } - #[cfg(not(unix))] - { - // Windows: no lockfile used (named mutex instead); this path is only - // used to derive the mutex name and for test assertions. - std::env::temp_dir().join(format!("buzz-keychain-{service}.lock")) - } -} - -/// Acquire an exclusive advisory file lock for the blob identified by `service`. -/// -/// Opens (or creates) the lockfile and blocks until the lock is acquired. -/// Returns the open `File`; the lock is released when the file is dropped. -/// -/// On non-Unix/non-Windows platforms this is a no-op that returns a stub. -#[cfg(feature = "system-keyring")] -fn acquire_blob_lock(service: &str) -> Result { - let path = blob_lockfile_path(service); - BlobLockGuard::acquire(&path) -} - -/// RAII guard that holds an exclusive advisory file lock. -/// -/// On Unix, implemented via `flock(2)` on a lockfile in the system temp dir. -/// On Windows, implemented via a named kernel mutex (cross-process, no file I/O -/// needed). The Windows mutex handle is released on drop. -#[cfg(feature = "system-keyring")] -struct BlobLockGuard { - /// The open lockfile. Never read — held purely for RAII: closing the fd - /// releases the `flock(LOCK_EX)` on Unix. - #[cfg(unix)] - #[allow(dead_code)] - file: std::fs::File, - #[cfg(windows)] - mutex_handle: windows_sys::Win32::Foundation::HANDLE, -} - -#[cfg(feature = "system-keyring")] -impl BlobLockGuard { - fn acquire(path: &std::path::Path) -> Result { - #[cfg(unix)] - { - let file = std::fs::OpenOptions::new() - .create(true) - .truncate(false) - .write(true) - .open(path) - .map_err(|e| format!("blob lock open {}: {e}", path.display()))?; - use std::os::unix::io::AsRawFd; - // LOCK_EX blocks until the lock is acquired (no LOCK_NB). - let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; - if ret != 0 { - let err = std::io::Error::last_os_error(); - return Err(format!("blob lock flock: {err}")); - } - return Ok(BlobLockGuard { file }); - } - - #[cfg(windows)] - { - // Named kernel mutexes are cross-process on Windows — no lockfile - // needed. Derive a unique mutex name from the lockfile path so - // distinct services get distinct mutexes. - let name_str = format!( - "Local\\BuzzKeychain-{}", - path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("default") - ); - // Encode as null-terminated UTF-16. - let name_wide: Vec = name_str - .encode_utf16() - .chain(std::iter::once(0u16)) - .collect(); - use windows_sys::Win32::Foundation::WAIT_OBJECT_0; - use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; - use windows_sys::Win32::System::Threading::{ - CreateMutexW, WaitForSingleObject, INFINITE, - }; - // CreateMutexW: lpMutexAttributes = null (default security), - // bInitialOwner = FALSE (0), lpName = our mutex name. - let handle = unsafe { - CreateMutexW( - std::ptr::null::(), - 0, - name_wide.as_ptr(), - ) - }; - // HANDLE = *mut c_void; null means creation failed. - if handle.is_null() { - let err = std::io::Error::last_os_error(); - return Err(format!("blob lock CreateMutexW: {err}")); - } - let wait_result = unsafe { WaitForSingleObject(handle, INFINITE) }; - if wait_result != WAIT_OBJECT_0 { - // Also accept WAIT_ABANDONED (0x80) — previous holder crashed; - // the mutex is still acquired and we own it. - if wait_result != windows_sys::Win32::Foundation::WAIT_ABANDONED { - let err = std::io::Error::last_os_error(); - unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; - return Err(format!( - "blob lock WaitForSingleObject: {wait_result} / {err}" - )); - } - } - return Ok(BlobLockGuard { - mutex_handle: handle, - }); - } - - // Fallback for exotic platforms: no-op lock (only Unix/Windows ship). - #[allow(unreachable_code)] - Err("blob lock: unsupported platform".to_string()) - } -} - -#[cfg(feature = "system-keyring")] -impl Drop for BlobLockGuard { - fn drop(&mut self) { - #[cfg(unix)] - { - // Dropping `self.file` closes the fd, which releases flock on Unix. - // Nothing explicit needed. - } - #[cfg(windows)] - { - unsafe { - windows_sys::Win32::System::Threading::ReleaseMutex(self.mutex_handle); - windows_sys::Win32::Foundation::CloseHandle(self.mutex_handle); - } - } - } -} - -// ── End interprocess advisory lock ──────────────────────────────────────── - -/// An OS keyring, addressed by service name. All secrets are stored in a -/// single JSON blob entry (one OS prompt per process lifetime). -pub struct SecretStore { - service: String, - /// In-memory cache of the deserialized blob. `None` means "not yet loaded". - cache: Mutex>>, -} - -impl SecretStore { - /// Keyring-backed store under `service`. The active platform backend - /// (apple-native / windows-native / sync-secret-service) is chosen at - /// compile time. - pub fn keyring(service: impl Into) -> Self { - SecretStore { - service: service.into(), - cache: Mutex::new(None), - } - } - - /// Return a process-global `SecretStore` for `service`. All callers with - /// the same service name share one instance — and therefore one in-memory - /// cache and one mutex — so concurrent blob read-modify-write operations - /// see each other's writes and the last-writer-wins race is closed. - /// - /// Only one service name (`"buzz-desktop"`) is used in practice. If a - /// second service name is ever needed, this can be extended to a registry. - pub fn shared(service: &'static str) -> &'static SecretStore { - use std::sync::OnceLock; - static INSTANCE: OnceLock = OnceLock::new(); - INSTANCE.get_or_init(|| SecretStore::keyring(service)) - } -} - -/// Whether a keyring error string indicates the backend itself is unavailable -/// (vs. a per-entry error like "not found"). Mirrors goose's discriminator -/// (`crates/goose/src/config/base.rs`): treat dbus / Secret Service / platform -/// secure-storage failures as "keyring unavailable, fall back to file". -#[cfg(feature = "system-keyring")] -fn is_keyring_availability_error(error_str: &str) -> bool { - let lower = error_str.to_lowercase(); - lower.contains("keyring") - || lower.contains("dbus") - || lower.contains("org.freedesktop.secrets") - || lower.contains("platform secure storage") - || lower.contains("no secret service") -} - -#[cfg(feature = "system-keyring")] -fn keyring_entry(service: &str, key: &str) -> Result { - keyring::Entry::new(service, key) -} - -// macOS-specific imports for the Data Protection Keychain backend. -#[cfg(all(feature = "system-keyring", target_os = "macos"))] -use security_framework::base::Error as SFError; -#[cfg(all(feature = "system-keyring", target_os = "macos"))] -use security_framework::passwords::{ - delete_generic_password_options, generic_password, PasswordOptions, -}; - -/// Returns true when the security-framework error is "item not found" (-25300). -#[cfg(all(feature = "system-keyring", target_os = "macos"))] -fn is_not_found(e: &SFError) -> bool { - e.code() == -25300 -} - -/// Returns true when DPK is unavailable because the binary lacks the required -/// entitlement (`errSecMissingEntitlement`, -34018). This happens for unsigned -/// dev builds (`tauri dev` / `cargo run`). The caller should fall back to the -/// legacy `keyring` crate path, which uses the old-style keychain and does not -/// require hardened-runtime entitlements. -#[cfg(all(feature = "system-keyring", target_os = "macos"))] -fn is_dpk_unavailable(e: &SFError) -> bool { - e.code() == -34018 -} - -/// Build a `PasswordOptions` for the Data Protection Keychain. -#[cfg(all(feature = "system-keyring", target_os = "macos"))] -fn dpk_opts(service: &str, key: &str) -> PasswordOptions { - let mut opts = PasswordOptions::new_generic_password(service, key); - opts.use_protected_keychain(); - opts -} - -impl SecretStore { - /// Read the blob from the keychain and return the deserialized map. - /// - /// Returns `Ok(None)` when no blob entry exists yet (first launch or - /// fresh install). Returns `Err` when the backend is unavailable or the - /// stored JSON is corrupt. - /// - /// On success the result is stored in `self.cache` so subsequent calls - /// within the same process return immediately without a keychain round-trip. - #[cfg(feature = "system-keyring")] - fn load_blob(&self) -> Result>, String> { - { - let guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(ref map) = *guard { - return Ok(Some(map.clone())); - } - } - - let raw = self.read_blob_raw()?; - let map = match raw { - None => return Ok(None), - Some(bytes) => { - let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; - serde_json::from_str::>(&json) - .map_err(|e| format!("blob json: {e}"))? - } - }; - - // Only populate the cache if it is still empty — a concurrent - // mutate_blob() may have written a newer value while we were reading. - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - if guard.is_none() { - *guard = Some(map.clone()); - } - Ok(Some(map)) - } - - /// Read the raw blob bytes from the keychain. `Ok(None)` = not found. - /// - /// Always uses the legacy keyring crate on macOS so that signed and - /// unsigned (dev) builds share the same store. DPK is only used by - /// `migrate_legacy_key` to read old per-key entries written by #1264. - #[cfg(all(feature = "system-keyring", target_os = "macos"))] - fn read_blob_raw(&self) -> Result>, String> { - self.read_blob_raw_keyring() - } - - #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] - fn read_blob_raw(&self) -> Result>, String> { - self.read_blob_raw_keyring() - } - - /// Read blob via the legacy `keyring` crate (Windows, Linux, or macOS dev - /// builds that lack hardened-runtime entitlements). - #[cfg(feature = "system-keyring")] - fn read_blob_raw_keyring(&self) -> Result>, String> { - let entry = - keyring_entry(&self.service, BLOB_KEY).map_err(|e| format!("keyring entry: {e}"))?; - match entry.get_password() { - Ok(s) => Ok(Some(s.into_bytes())), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) if is_keyring_availability_error(&e.to_string()) => { - Err(format!("keyring unavailable: {e}")) - } - Err(e) => Err(format!("keyring read: {e}")), - } - } - - /// Atomically load the blob, apply `f` to a candidate map, write back if - /// changed, and only then advance the cache. - /// - /// **Cross-process safety**: acquires an exclusive advisory file lock - /// (`flock(2)` on Unix, `LockFileEx` on Windows) before reading, mutating, - /// and writing. The lock is keyed by service name and stored in the system - /// temp directory, making it reachable from both the signed DMG build and - /// unsigned dev builds. Inside the lock a fresh `read_blob_raw()` is always - /// performed (even when the cache is warm) so a concurrent process's write - /// is never silently dropped. - /// - /// **Idempotent**: when `f` leaves the candidate equal to the freshly-read - /// map, `write_blob_raw` is skipped entirely. On macOS the legacy - /// `SecKeychain` API treats a write as a distinct ACL operation from the - /// "Always Allow"-ed read, so skipping no-op writes eliminates the keychain - /// prompt that fires when saving an agent whose model changed but whose key - /// did not. - /// - /// **Copy-on-write**: the candidate `next` is a separate allocation from - /// `current`. The cache is only replaced with `next` after `write_blob_raw` - /// succeeds. On write failure the cache is cleared to `None` so the next - /// caller re-reads from the keychain rather than building on a stale state. - /// - /// Deadlock-free: `read_blob_raw` and `write_blob_raw` do not acquire the - /// cache mutex. `load_blob` does acquire it, but `mutate_blob` does not call - /// `load_blob` — it reads from the keyring directly inside the file lock. - #[cfg(feature = "system-keyring")] - fn mutate_blob(&self, f: F) -> Result<(), String> - where - F: FnOnce(&mut HashMap), - { - // Acquire the interprocess advisory lock first. All Buzz processes - // using the same service name contend on the same lockfile at - // /tmp/buzz-keychain--.lock (a deterministic per-user - // path invariant to $TMPDIR), so only one process performs a - // read-modify-write at a time. - let _lock = acquire_blob_lock(&self.service)?; - - // Always do a fresh read from the keychain while holding the lock — - // this is the critical correction over the prior warm-cache path. A - // stale warm cache would make us build our candidate on an outdated - // baseline and drop keys written by another process. - let raw = self.read_blob_raw()?; - let current: HashMap = match raw { - None => HashMap::new(), - Some(bytes) => { - let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; - serde_json::from_str::>(&json) - .map_err(|e| format!("blob json: {e}"))? - } - }; - - // Build the candidate state in a separate allocation so that a write - // failure below cannot leave the cache ahead of durable storage. - let mut next = current.clone(); - f(&mut next); - - // Skip the keychain write when the candidate equals the freshly-read - // durable state — no I/O needed and no keychain ACL prompt on macOS. - if next == current { - // Update the cache to the fresh read even on no-op so subsequent - // reads in this process see any keys another process may have added. - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(current); - return Ok(()); - } - - // Write to keyring while still holding the file lock. - let json = serde_json::to_string(&next).map_err(|e| format!("blob serialize: {e}"))?; - match self.write_blob_raw(json.as_bytes()) { - Ok(()) => { - // Advance the cache to `next` only after the durable write succeeds. - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(next); - Ok(()) - } - Err(e) => { - // On write failure, clear the cache so the next caller re-reads - // from the keychain rather than building on a stale state. - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; - Err(e) - } - } - } - - /// Always uses the legacy keyring crate on macOS — see `read_blob_raw`. - #[cfg(all(feature = "system-keyring", target_os = "macos"))] - fn write_blob_raw(&self, bytes: &[u8]) -> Result<(), String> { - self.write_blob_raw_keyring(bytes) - } - - #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] - fn write_blob_raw(&self, bytes: &[u8]) -> Result<(), String> { - self.write_blob_raw_keyring(bytes) - } - - #[cfg(feature = "system-keyring")] - fn write_blob_raw_keyring(&self, bytes: &[u8]) -> Result<(), String> { - let value = std::str::from_utf8(bytes).map_err(|e| format!("blob utf8 encode: {e}"))?; - let entry = - keyring_entry(&self.service, BLOB_KEY).map_err(|e| format!("keyring entry: {e}"))?; - entry - .set_password(value) - .map_err(|e| format!("keyring write: {e}")) - } - - /// Probe whether `key` exists and whether the backend is reachable. - pub fn probe(&self, key: &str) -> KeyringProbe { - #[cfg(feature = "system-keyring")] - { - match self.load_blob() { - Ok(Some(map)) => { - if map.contains_key(key) { - KeyringProbe::Present - } else { - // Blob exists but key absent — still check old per-key - // entries so a partial migration (e.g. identity migrated - // first) doesn't silently drop agent keys. - self.probe_legacy_key(key) - } - } - // No blob yet — check old per-key entries so callers that - // gate `load()` on `Present` still trigger migration. - Ok(None) => self.probe_legacy_key(key), - Err(e) if is_keyring_availability_error(&e) => KeyringProbe::Unreachable, - Err(_) => KeyringProbe::Unreachable, // corrupt blob — fail closed - } - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = key; - KeyringProbe::Unreachable - } - } - - /// Check old per-key DPK/keyring entries for `key`. Used by `probe()` when - /// the blob doesn't exist yet (first launch after upgrade). - #[cfg(all(feature = "system-keyring", target_os = "macos"))] - fn probe_legacy_key(&self, key: &str) -> KeyringProbe { - match generic_password(dpk_opts(&self.service, key)) { - Ok(_) => KeyringProbe::Present, - Err(ref e) if is_not_found(e) => self.probe_legacy_key_keyring(key), - Err(ref e) if is_dpk_unavailable(e) => self.probe_legacy_key_keyring(key), - Err(ref e) if is_keyring_availability_error(&e.to_string()) => { - KeyringProbe::Unreachable - } - Err(_) => KeyringProbe::ReachableButEmpty, - } - } - - #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] - fn probe_legacy_key(&self, key: &str) -> KeyringProbe { - self.probe_legacy_key_keyring(key) - } - - #[cfg(feature = "system-keyring")] - fn probe_legacy_key_keyring(&self, key: &str) -> KeyringProbe { - match keyring_entry(&self.service, key) { - Ok(entry) => match entry.get_password() { - Ok(_) => KeyringProbe::Present, - Err(keyring::Error::NoEntry) => KeyringProbe::ReachableButEmpty, - Err(e) if is_keyring_availability_error(&e.to_string()) => { - KeyringProbe::Unreachable - } - Err(_) => KeyringProbe::ReachableButEmpty, - }, - Err(e) if is_keyring_availability_error(&e.to_string()) => KeyringProbe::Unreachable, - Err(_) => KeyringProbe::Unreachable, - } - } - - /// Load the secret for `key`. `Ok(None)` when there is no entry; `Err` only - /// when the backend errored in a way that is not "missing". - /// - /// On first launch after an upgrade from the per-key DPK format, the blob - /// will not exist yet. In that case the macOS path falls back to reading the - /// old per-key DPK entry for `key` specifically, writes it into a new blob, - /// and deletes the old item — a one-time migration per key. The same - /// migration fires when the blob exists but the key is absent, covering - /// partial-migration scenarios (e.g. identity migrated first, agents not yet). - pub fn load(&self, key: &str) -> Result, String> { - #[cfg(feature = "system-keyring")] - { - match self.load_blob() { - Ok(Some(map)) => { - if let Some(value) = map.get(key) { - Ok(Some(value.clone())) - } else { - // Blob exists but key absent — attempt migration from old - // per-key entry. migrate_legacy_key writes the result into - // the blob if found, so subsequent loads hit the cache. - self.migrate_legacy_key(key) - } - } - Ok(None) => { - // No blob yet — attempt one-time migration from old per-key - // DPK entry (macOS) or return Ok(None) (other platforms). - self.migrate_legacy_key(key) - } - Err(e) => Err(e), - } - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = key; - Err("system-keyring feature disabled".to_string()) - } - } - - /// Read the secret for `key` without any legacy-migration side effects. - /// - /// Read the entire blob without any legacy-migration side effects. - /// - /// Returns the full key→value map when a blob exists, `Ok(None)` when no - /// blob has been written yet, and `Err` only when the backend is - /// unavailable. Never calls `migrate_legacy_key`. - pub fn load_all_readonly(&self) -> Result>, String> { - #[cfg(feature = "system-keyring")] - { - self.load_blob() - } - #[cfg(not(feature = "system-keyring"))] - { - Err("system-keyring feature disabled".to_string()) - } - } - - /// Insert all entries from `entries` into the blob in a single mutation. - /// - /// Entries that already exist in the blob are overwritten; entries not - /// present in `entries` are left unchanged. If the resulting blob is - /// identical to what is already stored, no keychain write occurs. - pub fn store_all(&self, entries: &HashMap) -> Result<(), String> { - #[cfg(feature = "system-keyring")] - { - self.mutate_blob(|map| { - for (k, v) in entries { - map.insert(k.clone(), v.clone()); - } - }) - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = entries; - Err("system-keyring feature disabled".to_string()) - } - } - - /// On first launch after upgrading from the per-key DPK format, read the - /// old DPK entry for `key`, write it into a new blob, and delete the old - /// item. Returns `Ok(None)` when no old entry exists. - /// - /// Also handles a one-time migration from the DPK blob format written by - /// #1267 (before the dev/release split was fixed). Anyone who ran main - /// while #1267 was present has a DPK blob instead of per-key entries; this - /// reads it, merges all keys into the legacy blob, and deletes the DPK blob. - #[cfg(all(feature = "system-keyring", target_os = "macos"))] - fn migrate_legacy_key(&self, key: &str) -> Result, String> { - // One-time migration: check for a DPK blob (key = BLOB_KEY = "secrets") - // written by #1267 before the dev/release split was fixed. - match generic_password(dpk_opts(&self.service, BLOB_KEY)) { - Ok(bytes) => { - let json = String::from_utf8(bytes).map_err(|e| format!("dpk blob utf8: {e}"))?; - let dpk_map = serde_json::from_str::>(&json) - .map_err(|e| format!("dpk blob json: {e}"))?; - // Merge all keys from the DPK blob into the legacy blob. - self.mutate_blob(|map| { - for (k, v) in &dpk_map { - map.entry(k.clone()).or_insert_with(|| v.clone()); - } - })?; - // Best-effort delete the DPK blob. - let _ = delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)); - return Ok(dpk_map.get(key).cloned()); - } - Err(ref e) if is_not_found(e) => { - // No DPK blob — fall through to per-key migration. - } - Err(ref e) if is_dpk_unavailable(e) => { - // Unsigned dev build — DPK inaccessible, fall through. - } - Err(e) => return Err(format!("dpk blob read: {e}")), - } - - // Try the old per-key DPK entry. - match generic_password(dpk_opts(&self.service, key)) { - Ok(bytes) => { - let value = String::from_utf8(bytes).map_err(|e| format!("keyring utf8: {e}"))?; - // Write into blob (creates the blob if it doesn't exist). - self.store(key, &value)?; - // Best-effort cleanup of the old per-key entry. - let _ = delete_generic_password_options(dpk_opts(&self.service, key)); - Ok(Some(value)) - } - Err(ref e) if is_not_found(e) => { - // Also check the old keyring-crate entry (pre-#1264 installs). - self.migrate_legacy_key_keyring(key) - } - Err(ref e) if is_dpk_unavailable(e) => { - // Unsigned dev build — check old keyring-crate entry only. - self.migrate_legacy_key_keyring(key) - } - Err(e) => Err(format!("keyring get: {e}")), - } - } - - #[cfg(all(feature = "system-keyring", not(target_os = "macos")))] - fn migrate_legacy_key(&self, key: &str) -> Result, String> { - // Non-macOS: no DPK, just check the old keyring-crate per-key entry. - self.migrate_legacy_key_keyring(key) - } - - /// Check the old per-key `keyring` crate entry (pre-#1264 format) and - /// migrate it into the blob if found. - #[cfg(feature = "system-keyring")] - fn migrate_legacy_key_keyring(&self, key: &str) -> Result, String> { - let entry = keyring_entry(&self.service, key).map_err(|e| format!("keyring entry: {e}"))?; - match entry.get_password() { - Ok(value) => { - self.store(key, &value)?; - let _ = entry.delete_credential(); - Ok(Some(value)) - } - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => Err(format!("keyring get: {e}")), - } - } - - /// Verify that `key` holds `expected` by reading directly from the OS - /// backend, bypassing the in-process cache. This is the key innovation for - /// read-back verification: it proves the OS keyring round-trip, not just - /// that the in-process cache was updated. - /// - /// Returns `Ok(true)` when the stored value matches `expected`, `Ok(false)` - /// when the entry is absent or holds a different value, and `Err` when the - /// backend is unavailable. - pub fn verify_stored_raw(&self, key: &str, expected: &str) -> Result { - #[cfg(feature = "system-keyring")] - { - let raw = self.read_blob_raw()?; - match raw { - None => Ok(false), - Some(bytes) => { - let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?; - let map = - serde_json::from_str::>(&json) - .map_err(|e| format!("blob json: {e}"))?; - Ok(map.get(key).is_some_and(|v| v == expected)) - } - } - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = (key, expected); - Err("system-keyring feature disabled".to_string()) - } - } - - /// Store `value` for `key`. Reports `Err` on availability failures — callers - /// decide whether to fall back to file storage. - pub fn store(&self, key: &str, value: &str) -> Result<(), String> { - #[cfg(feature = "system-keyring")] - { - self.mutate_blob(|map| { - map.insert(key.to_string(), value.to_string()); - }) - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = (key, value); - Err("system-keyring feature disabled".to_string()) - } - } - - /// Delete the entire keychain blob for this service, plus all legacy per-key - /// entries that could resurrect an identity on next boot. - /// - /// Order of operations: - /// 1. Read the blob to collect every key name (e.g. `identity`, agent keys). - /// 2. Delete legacy per-key DPK entries for every key + the DPK blob itself. - /// 3. Delete legacy per-key keyring entries for every key. - /// 4. Delete the blob entry. - /// 5. Clear the in-memory cache. - /// - /// This is the correct wipe path for sign-out: the old `delete_all` skipped - /// step 1–3 so stale per-key entries could be re-imported on the next launch - /// via `migrate_legacy_key`. This method prevents that resurrection. - pub fn delete_all_with_legacy_cleanup(&self) -> Result<(), String> { - #[cfg(feature = "system-keyring")] - { - let _lock = acquire_blob_lock(&self.service)?; - - // Step 1: read current blob keys (best-effort; no entry = empty set). - let blob_keys: Vec = match self.read_blob_raw() { - Ok(Some(bytes)) => { - let json = String::from_utf8(bytes).unwrap_or_default(); - serde_json::from_str::>(&json) - .map(|m| m.into_keys().collect()) - .unwrap_or_default() - } - _ => vec![], - }; - - // Always include "identity" even if the blob is empty or absent — - // it may exist only as a legacy per-key entry. - let mut all_keys = blob_keys; - if !all_keys.contains(&"identity".to_string()) { - all_keys.push("identity".to_string()); - } - - // Steps 2 & 3: delete legacy per-key entries for every key. - for key in &all_keys { - #[cfg(target_os = "macos")] - { - match delete_generic_password_options(dpk_opts(&self.service, key)) { - Ok(()) => {} - Err(ref e) if is_not_found(e) => {} - Err(ref e) if is_dpk_unavailable(e) => {} - Err(e) => return Err(format!("dpk per-key delete {key}: {e}")), - } - } - { - let entry = keyring_entry(&self.service, key) - .map_err(|e| format!("keyring entry constructor {key}: {e}"))?; - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} - Err(e) if is_keyring_availability_error(&e.to_string()) => { - return Err(format!("keyring unavailable deleting {key}: {e}")); - } - Err(e) => { - return Err(format!("keyring per-key delete {key}: {e}")); - } - } - } - } - // Step 2 (cont.): also delete the legacy DPK blob written by #1267. - #[cfg(target_os = "macos")] - { - match delete_generic_password_options(dpk_opts(&self.service, BLOB_KEY)) { - Ok(()) => {} - Err(ref e) if is_not_found(e) => {} - Err(ref e) if is_dpk_unavailable(e) => {} - Err(e) => return Err(format!("dpk blob delete: {e}")), - } - } - - // Step 4: delete the main blob entry. - { - let entry = keyring_entry(&self.service, BLOB_KEY) - .map_err(|e| format!("keyring entry constructor blob: {e}"))?; - match entry.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} - Err(e) if is_keyring_availability_error(&e.to_string()) => { - return Err(format!("keyring unavailable: {e}")); - } - Err(e) => { - return Err(format!("keyring blob delete: {e}")); - } - } - } - - // Step 5: clear the in-memory cache. - let mut guard = self.cache.lock().unwrap_or_else(|e| e.into_inner()); - *guard = None; - Ok(()) - } - #[cfg(not(feature = "system-keyring"))] - { - Ok(()) // No-op: no keyring, nothing to delete. - } - } - - /// Verify no identity-bearing keychain entry survives in any shape - /// that `load("identity")` → `migrate_legacy_key` can consume: - /// main blob, DPK blob (`BLOB_KEY`), and per-key `"identity"`. - /// - /// Returns `true` when all three shapes are absent (or inaccessible in an - /// expected way), `false` when any entry is found or the keychain is - /// unavailable (fail-closed). - pub fn verify_fully_wiped(&self) -> bool { - #[cfg(feature = "system-keyring")] - { - // 1. Main blob must be absent. - match self.read_blob_raw() { - Ok(None) => {} - Ok(Some(_)) => return false, - Err(_) => return false, - } - // 2. Per-key "identity" via legacy keyring must be absent. - match keyring_entry(&self.service, "identity") { - Ok(entry) => match entry.get_password() { - Err(keyring::Error::NoEntry) => {} - Ok(_) => return false, - // Any other error (availability, unknown, transient) → fail closed. - // Only explicit NoEntry is proof of absence. - Err(_) => return false, - }, - // Constructor failure → cannot verify → fail closed. - Err(_) => return false, - } - // 3. DPK blob (macOS only). - #[cfg(target_os = "macos")] - { - match generic_password(dpk_opts(&self.service, BLOB_KEY)) { - Err(ref e) if is_not_found(e) => {} - // dpk-unavailable is symmetric with load(): if load() can't - // consume DPK in this state, a surviving entry is harmless. - Err(ref e) if is_dpk_unavailable(e) => {} - Ok(_) => return false, - // Any other error → fail closed (not proof of absence). - Err(_) => return false, - } - // 4. Per-key DPK "identity" (macOS only). - match generic_password(dpk_opts(&self.service, "identity")) { - Err(ref e) if is_not_found(e) => {} - // dpk-unavailable: symmetric with load() — if load() can't - // read DPK, a surviving entry can't resurrect identity. - Err(ref e) if is_dpk_unavailable(e) => {} - Ok(_) => return false, - // Any other error → fail closed. - Err(_) => return false, - } - } - true - } - #[cfg(not(feature = "system-keyring"))] - { - true // No keyring = nothing to verify. - } - } - - /// Delete the secret for `key`. A missing entry is not an error. - pub fn delete(&self, key: &str) -> Result<(), String> { - #[cfg(feature = "system-keyring")] - { - self.mutate_blob(|map| { - map.remove(key); - })?; - // Best-effort: also delete any old per-key entry for this key to - // prevent resurrection on the next probe/load (migration path). - #[cfg(target_os = "macos")] - let _ = delete_generic_password_options(dpk_opts(&self.service, key)); - if let Ok(entry) = keyring_entry(&self.service, key) { - let _ = entry.delete_credential(); - } - Ok(()) - } - #[cfg(not(feature = "system-keyring"))] - { - let _ = key; - Err("system-keyring feature disabled".to_string()) - } - } -} - -#[cfg(all(test, feature = "system-keyring"))] -mod tests { - use super::*; - - // Test-only constructor: pre-seed the cache without touching the OS keychain. - impl SecretStore { - fn with_cache(service: &str, cache: Option>) -> Self { - SecretStore { - service: service.to_string(), - cache: Mutex::new(cache), - } - } - } - - #[test] - fn probe_returns_present_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-cache-hit", Some(map)); - // Cache is warm and contains "identity" — probe must return Present - // without touching the keychain. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - } - - #[test] - fn load_returns_value_when_key_in_cache() { - let mut map = HashMap::new(); - map.insert("identity".to_string(), "nsec1test".to_string()); - let store = SecretStore::with_cache("buzz-test-load-cache-hit", Some(map)); - // Cache is warm and contains "identity" — load must return the value - // without touching the keychain. - assert_eq!( - store.load("identity").unwrap(), - Some("nsec1test".to_string()) - ); - } - - // ── Cross-process race tests (require real OS keychain) ──────────────── - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_stale_warm_cache_add_observes_prior_write() { - // Simulates the cross-process race that stranded Will's agent keys. - // - // Setup: two SecretStore instances for the same service (= two - // "processes" with separate caches). Process A warms its cache to - // {k1}. Process B then writes {k1, k2}. Without the fix, A's next - // mutate_blob would build from its stale {k1} cache and write - // {k1, k3}, silently dropping k2. With the fix, A always re-reads - // from the keychain inside the lock, so the result is {k1, k2, k3}. - let svc = "buzz-test-race-stale-cache"; - - // Clean state. - let setup = SecretStore::keyring(svc); - let _ = setup.delete("k1"); - let _ = setup.delete("k2"); - let _ = setup.delete("k3"); - - // Process A: write k1, warming its cache. - let store_a = SecretStore::keyring(svc); - store_a.store("k1", "v1").unwrap(); - - // Process B: write k2 (separate instance = separate cache). - let store_b = SecretStore::keyring(svc); - store_b.store("k2", "v2").unwrap(); - - // Process A: write k3. With the fix, A re-reads inside the lock and - // sees {k1, k2} before appending k3 — result must be {k1, k2, k3}. - store_a.store("k3", "v3").unwrap(); - - // Verify via a third reader (clean cache). - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("k1").unwrap(), - Some("v1".to_string()), - "k1 must survive" - ); - assert_eq!( - reader.load("k2").unwrap(), - Some("v2".to_string()), - "k2 must not be dropped" - ); - assert_eq!( - reader.load("k3").unwrap(), - Some("v3".to_string()), - "k3 must be written" - ); - - // Cleanup. - let _ = reader.delete("k1"); - let _ = reader.delete("k2"); - let _ = reader.delete("k3"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn test_concurrent_adds_neither_key_dropped() { - // Two sequential stores from distinct instances (simulating two - // processes each adding one key) must both be durably visible. - let svc = "buzz-test-race-concurrent-add"; - - let setup = SecretStore::keyring(svc); - let _ = setup.delete("agent_a"); - let _ = setup.delete("agent_b"); - - let store1 = SecretStore::keyring(svc); - store1.store("agent_a", "nsec1aaa").unwrap(); - - let store2 = SecretStore::keyring(svc); - store2.store("agent_b", "nsec1bbb").unwrap(); - - let reader = SecretStore::keyring(svc); - assert_eq!( - reader.load("agent_a").unwrap(), - Some("nsec1aaa".to_string()), - "agent_a must not be dropped" - ); - assert_eq!( - reader.load("agent_b").unwrap(), - Some("nsec1bbb".to_string()), - "agent_b must not be dropped" - ); - - // Cleanup. - let _ = reader.delete("agent_a"); - let _ = reader.delete("agent_b"); - } - - #[test] - fn test_blob_lockfile_path_is_in_tmp_with_uid() { - // The lockfile must be at a deterministic per-user path under /tmp — - // invariant to $TMPDIR — so both a GUI-launched DMG (env-stripped by - // launchd) and a terminal-launched dev build resolve the same inode and - // achieve mutual exclusion. - let path = blob_lockfile_path("buzz-desktop"); - #[cfg(unix)] - { - let uid = unsafe { libc::getuid() }; - assert!( - path.starts_with("/tmp"), - "lockfile {path:?} must start with /tmp (not $TMPDIR)" - ); - let name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - assert!( - name.contains(&uid.to_string()), - "lockfile {path:?} must contain uid {uid}" - ); - assert!( - name.contains("buzz-keychain"), - "lockfile name must contain 'buzz-keychain'" - ); - } - #[cfg(not(unix))] - { - assert!( - path.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.contains("buzz-keychain")), - "lockfile name must contain 'buzz-keychain'" - ); - } - } - - #[test] - fn test_blob_lock_acquire_and_release() { - // Verify the advisory lock can be acquired and released without errors. - // This exercises the real flock/mutex path on the current platform. - let guard = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard.is_ok(), - "advisory lock acquire must succeed: {:?}", - guard.err() - ); - // Drop the guard — lock is released. A second acquire must succeed. - drop(guard); - let guard2 = acquire_blob_lock("buzz-test-lock-smoke"); - assert!( - guard2.is_ok(), - "advisory lock re-acquire after release must succeed: {:?}", - guard2.err() - ); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn mutate_blob_does_not_advance_cache_on_write_failure() { - // Copy-on-write safety: if `write_blob_raw` fails (denied prompt, - // transient outage, ACL rejection), the cache must stay at the last - // known durable state. A subsequent `store()` for the same key/value - // must NOT be skipped as a no-op — the equality check must compare - // against the durable cache, not an unpersisted candidate. - // - // This is a real-keychain integration test. Run locally with: - // cargo test -p buzz-desktop -- --ignored mutate_blob_does_not_advance - // - // On a machine with a reachable keychain the `store()` call succeeds - // (result.is_ok()) and the write-failure branch is skipped — the test - // still passes. On a machine where the write is denied (e.g., user - // clicks Deny in the macOS prompt) result.is_err() and the assertions - // below verify the cache invariant. We verify that after an error: - // 1. The cache is not advanced (the previously cached key is intact). - // 2. The failed key is not present (the dirty candidate was discarded). - let mut map = HashMap::new(); - map.insert("existing".to_string(), "durable_val".to_string()); - let store = SecretStore::with_cache("buzz-test-cow-write-fail", Some(map)); - - // Attempt to add a new key — this calls write_blob_raw against the - // real keychain; with copy-on-write the cache must remain at {existing} - // if the write fails. - let result = store.store("new_key", "new_val"); - - if result.is_err() { - // Write failed (e.g., user denied the keychain prompt): confirm - // cache was not advanced — the existing key is still intact and - // the new key was never committed to the in-memory state. - assert_eq!( - store.load("existing").unwrap(), - Some("durable_val".to_string()), - "cache must remain at last durable state after write failure" - ); - // load("new_key") goes through the unchanged cache (no entry), - // then attempts migrate_legacy_key which also fails on a denied - // keychain, returning either Ok(None) or Err — either is correct - // since the key was never durably stored. - let after = store.load("new_key"); - assert!( - matches!(after, Ok(None) | Err(_)), - "a key whose write failed must not be visible via load: {after:?}" - ); - } - // If result.is_ok() the write succeeded — the cache-integrity invariant - // does not apply to the success path; no assertion needed here. - } - - #[test] - fn availability_error_discriminator() { - assert!(is_keyring_availability_error("dbus connection failed")); - assert!(is_keyring_availability_error( - "org.freedesktop.secrets not provided" - )); - assert!(is_keyring_availability_error("No Secret Service")); - assert!(is_keyring_availability_error( - "Platform secure storage failure" - )); - // A plain "not found" is per-entry, not an availability failure. - assert!(!is_keyring_availability_error("entry not found")); - } - - #[cfg(target_os = "macos")] - #[test] - fn dpk_error_discriminators() { - // errSecMissingEntitlement = -34018 signals unsigned dev build. - let e = SFError::from_code(-34018); - assert!(is_dpk_unavailable(&e)); - assert!(!is_not_found(&e)); - // errSecItemNotFound = -25300 is not a DPK-unavailable error. - let e = SFError::from_code(-25300); - assert!(is_not_found(&e)); - assert!(!is_dpk_unavailable(&e)); - } - - // Integration tests that exercise the real OS keychain. Skipped in CI - // (unsigned builds lack keychain entitlements); run locally with: - // cargo test -p buzz-desktop -- --ignored blob_ - // - // Each test uses a unique service name to avoid cross-test pollution. - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_stores_and_retrieves_multiple_keys() { - let store = SecretStore::keyring("buzz-test-blob-multi"); - store.store("key_a", "val_a").unwrap(); - store.store("key_b", "val_b").unwrap(); - assert_eq!(store.load("key_a").unwrap(), Some("val_a".to_string())); - assert_eq!(store.load("key_b").unwrap(), Some("val_b".to_string())); - assert_eq!(store.load("key_c").unwrap(), None); - // Cleanup. - let _ = store.delete("key_a"); - let _ = store.delete("key_b"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_probe_present_absent_unreachable() { - let store = SecretStore::keyring("buzz-test-blob-probe"); - // No blob yet — key absent, backend reachable. - assert_eq!(store.probe("identity"), KeyringProbe::ReachableButEmpty); - store.store("identity", "nsec1test").unwrap(); - // Key now present. - assert_eq!(store.probe("identity"), KeyringProbe::Present); - // Different key — blob exists but key absent. - assert_eq!(store.probe("other"), KeyringProbe::ReachableButEmpty); - // Cleanup. - let _ = store.delete("identity"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_delete_removes_key_not_others() { - let store = SecretStore::keyring("buzz-test-blob-delete"); - store.store("keep", "keep_val").unwrap(); - store.store("remove", "remove_val").unwrap(); - store.delete("remove").unwrap(); - assert_eq!(store.load("keep").unwrap(), Some("keep_val".to_string())); - assert_eq!(store.load("remove").unwrap(), None); - // Cleanup. - let _ = store.delete("keep"); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn blob_migration_from_per_key_entry() { - let svc = "buzz-test-blob-migration"; - let key = "identity"; - let value = "nsec1migrationtest"; - - // Seed a per-key entry (old format) — no blob exists. - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Fresh store — no blob in the keychain yet. - let store = SecretStore::keyring(svc); - - // probe should find the legacy key. - assert_eq!(store.probe(key), KeyringProbe::Present); - - // load should migrate it into the blob and return the value. - assert_eq!(store.load(key).unwrap(), Some(value.to_string())); - - // Old per-key entry should be cleaned up. - let entry = keyring_entry(svc, key).unwrap(); - assert!(matches!(entry.get_password(), Err(keyring::Error::NoEntry))); - - // Key is now in the blob — probe confirms. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - assert_eq!(store2.load(key).unwrap(), Some(value.to_string())); - - // Cleanup. - let _ = store2.delete(key); - } - - #[ignore = "requires real OS keychain (run locally)"] - #[test] - fn delete_all_with_legacy_cleanup_removes_per_key_identity() { - let svc = "buzz-test-delete-all-legacy"; - let key = "identity"; - let value = "nsec1legacytest"; - - // Seed a legacy per-key entry (old format, pre-blob migration). - let entry = keyring_entry(svc, key).unwrap(); - entry.set_password(value).unwrap(); - - // Also seed a blob with a different key to exercise the full path. - let store = SecretStore::keyring(svc); - store.store("agent:abc123", "nsec1agent").unwrap(); - - // Legacy per-key identity should be discoverable via probe. - let store2 = SecretStore::keyring(svc); - assert_eq!(store2.probe(key), KeyringProbe::Present); - - // Wipe everything via the sign-out path. - store2.delete_all_with_legacy_cleanup().unwrap(); - - // Fresh store — neither the blob nor the per-key entry should remain. - let store3 = SecretStore::keyring(svc); - assert_eq!( - store3.probe(key), - KeyringProbe::ReachableButEmpty, - "per-key identity must not survive delete_all_with_legacy_cleanup" - ); - assert_eq!( - store3.load(key).unwrap(), - None, - "load must not resurrect the legacy per-key identity" - ); - // Agent key should also be gone. - assert_eq!(store3.load("agent:abc123").unwrap(), None); - } -} +pub(crate) use buzz_secret_store::{KeyringProbe, SecretStore};