Skip to content

perf(desktop): consolidate keychain secrets into a single blob entry#1267

Merged
wpfleger96 merged 3 commits into
mainfrom
duncan/keychain-blob
Jun 25, 2026
Merged

perf(desktop): consolidate keychain secrets into a single blob entry#1267
wpfleger96 merged 3 commits into
mainfrom
duncan/keychain-blob

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

Buzz stores each secret as a separate keychain item — the identity key plus one entry per managed agent (6 agents = 7 items total). On dev builds (old keychain, null ACL) every read and write triggers a prompt: 15–20+ on first launch. On release builds (DPK) the first launch prompts once per key (7 total). This is the root cause of the excessive prompting reported in the security channel.

Solution

Consolidate all secrets into a single JSON blob entry (service = "buzz-desktop", username = "secrets"). One keychain access per process lifetime on read, one on write. One prompt on first launch, zero after — the same pattern Goose uses.

SecretStore gains an in-memory Mutex<Option<HashMap<String,String>>> cache. After the first read the blob is cached; subsequent probe/load/store/delete calls within the same process never touch the keychain again. agent_secret_store() is cached via OnceLock so the blob cache survives across call sites (previously each call constructed a fresh SecretStore with a cold cache).

Migration

On first launch after upgrading from the per-key DPK format, the blob doesn't exist yet. probe() checks old per-key DPK/keyring entries via probe_legacy_key() so callers that gate load() on Present (like resolve_identity_with_store) still trigger migration — without this, probe() would return ReachableButEmpty for keys that exist only in the old format, causing silent identity rotation. load() then falls back to reading the old per-key DPK entry (macOS) or legacy keyring entry (all platforms), writes it into the new blob, and deletes the old item — one-time per key, transparent to callers. Agent keys that aren't found are re-populated from inline JSON by hydrate_keys on the same launch.

Behavior after this change

Scenario Prompts
First launch (new install) 1 (blob write)
First launch after upgrade 1 (blob write during migration)
Every subsequent launch 0
Dev build (unsigned, tauri dev) ≤ 1 (legacy keyring blob), 0 after

Relationship to #1266

The is_dpk_unavailable (-34018) fallback from #1266 is incorporated here — unsigned dev builds fall back to the legacy keyring crate for the single blob entry. This PR supersedes #1266 for the prompt-reduction goal; #1266 still fixes the dev-build panic independently and should merge first (this PR is based on main and is compatible either way).

Stack: #1266 → this PR

Tests

Four integration tests are marked #[ignore] — they require the real OS keychain and can't run in CI on unsigned builds. Run locally with:

cargo test -p buzz-desktop -- --ignored blob_
  • blob_stores_and_retrieves_multiple_keys — multi-key store/load round-trip
  • blob_probe_present_absent_unreachable — probe semantics with and without blob
  • blob_delete_removes_key_not_others — key isolation on delete
  • blob_migration_from_per_key_entry — end-to-end legacy key migration (seed old per-key entry → probe finds it → load migrates into blob → old entry deleted)

Files changed

  • desktop/src-tauri/src/secret_store.rs — rewritten: blob storage, in-memory cache, legacy key probe/migration
  • desktop/src-tauri/src/managed_agents/storage.rsagent_secret_store() cached via OnceLock
  • desktop/src-tauri/Cargo.locksecurity-framework promoted to direct dep (was already transitive)

Caller interface

IdentityKeyStore and KeyStore traits in app_state.rs and managed_agents/storage.rs are unchanged. probe/load/store/delete semantics are preserved exactly.

@wpfleger96 wpfleger96 force-pushed the duncan/keychain-blob branch from 787f073 to a71bd5c Compare June 25, 2026 02:59
Buzz stored each secret as a separate keychain item — the identity key
plus one entry per managed agent (6 agents = 7 items total). On dev
builds (old keychain, null ACL) every read and write triggers a prompt:
15-20+ on first launch. On release builds (DPK) the first launch prompts
once per key (7 total). Goose solved this identically: one entry, all
secrets as a JSON map, one prompt total.

Switch SecretStore to the same pattern. A single blob entry (service
= the store's service name, username = "secrets") holds all keys as a
JSON map. The map is cached in-memory after the first read, so subsequent
probe/load/store/delete calls within the same process never touch the
keychain again. One prompt on first launch, zero after.

Migration: on first launch after upgrade the blob doesn't exist yet.
load() falls back to reading the old per-key DPK entry (macOS) or
legacy keyring entry (all platforms), writes it into the new blob, and
deletes the old item — one-time per key, transparent to callers.

The is_dpk_unavailable (-34018) fallback from #1266 is incorporated
here: unsigned dev builds fall back to the legacy keyring crate for the
single blob entry. The probe/load/store/delete semantics seen by callers
(IdentityKeyStore, KeyStore) are unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/keychain-blob branch from a71bd5c to 2e700a6 Compare June 25, 2026 03:15
wpfleger96 and others added 2 commits June 24, 2026 23:28
probe() only checked the new blob format, so users upgrading from
per-key DPK (#1264) would get silent identity rotation — probe returned
ReachableButEmpty, resolve_identity_with_store never called load(), and
generate_and_persist created a new key. Add probe_legacy_key() to check
old per-key DPK/keyring entries when the blob is absent.

Also: save_blob() now updates the cache instead of invalidating it
(avoids unnecessary keychain re-reads on sequential operations),
non-availability errors in probe() map to ReachableButEmpty (matching
old behavior instead of triggering fail-close), agent_secret_store()
caches its SecretStore via OnceLock so the blob cache survives across
call sites, Arc dropped from the cache field (SecretStore is never
cloned), mutex locks recover from poisoning instead of panicking, and
a migration integration test is added.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 merged commit fa942cb into main Jun 25, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/keychain-blob branch June 25, 2026 03:39
wpfleger96 added a commit that referenced this pull request Jun 25, 2026
Three more correctness fixes to the blob-based secret store:

1. mutate_blob() holds the cache mutex across the full read-from-keyring /
   mutate / write-to-keyring / update-cache sequence, closing the last-writer-
   wins race that persisted after SecretStore::shared() was added.

2. migrate_legacy_key() now checks for a DPK blob entry (key = "secrets")
   before checking per-key DPK entries, migrating anyone who ran main while
   #1267 was present and has a DPK blob instead of per-key entries.

3. Adds a with_cache() test constructor and a non-ignored probe() cache-hit
   test that runs in CI without the OS keychain.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant