9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1307 lines
56 KiB
Rust
1307 lines
56 KiB
Rust
//! 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-<uid>-<service>.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-<uid>-<service>.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<BlobLockGuard, String> {
|
||
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<Self, String> {
|
||
#[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<u16> = 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::<SECURITY_ATTRIBUTES>(),
|
||
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<Option<HashMap<String, String>>>,
|
||
}
|
||
|
||
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<String>) -> 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<SecretStore> = 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, keyring::Error> {
|
||
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<Option<HashMap<String, String>>, 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::<HashMap<String, String>>(&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<Option<Vec<u8>>, String> {
|
||
self.read_blob_raw_keyring()
|
||
}
|
||
|
||
#[cfg(all(feature = "system-keyring", not(target_os = "macos")))]
|
||
fn read_blob_raw(&self) -> Result<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<F>(&self, f: F) -> Result<(), String>
|
||
where
|
||
F: FnOnce(&mut HashMap<String, String>),
|
||
{
|
||
// Acquire the interprocess advisory lock first. All Buzz processes
|
||
// using the same service name contend on the same lockfile at
|
||
// /tmp/buzz-keychain-<uid>-<service>.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<String, String> = match raw {
|
||
None => HashMap::new(),
|
||
Some(bytes) => {
|
||
let json = String::from_utf8(bytes).map_err(|e| format!("blob utf8: {e}"))?;
|
||
serde_json::from_str::<HashMap<String, String>>(&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<Option<String>, 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<Option<HashMap<String, String>>, 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<String, String>) -> 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<Option<String>, 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::<HashMap<String, String>>(&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<Option<String>, 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<Option<String>, 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<bool, String> {
|
||
#[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::<std::collections::HashMap<String, String>>(&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<String> = match self.read_blob_raw() {
|
||
Ok(Some(bytes)) => {
|
||
let json = String::from_utf8(bytes).unwrap_or_default();
|
||
serde_json::from_str::<std::collections::HashMap<String, String>>(&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<HashMap<String, String>>) -> 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);
|
||
}
|
||
}
|