Skip to content

feat(desktop): store nsec private keys in the OS keyring#1172

Merged
wpfleger96 merged 8 commits into
mainfrom
duncan/desktop-keyring-migration
Jun 24, 2026
Merged

feat(desktop): store nsec private keys in the OS keyring#1172
wpfleger96 merged 8 commits into
mainfrom
duncan/desktop-keyring-migration

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

What

Desktop nsec private keys move from plaintext on disk into the OS keyring, matching goose's model. Previously the human identity lived in identity.key and every managed-agent key was inlined in managed-agents.json — both world-readable plaintext. A new secret_store.rs (keyring 3.6.3, vendored, target-gated apple-native / windows-native / sync-secret-service) backs them with the macOS Keychain, Windows Credential Manager, or the Linux Secret Service.

The system-keyring cargo feature is on by default. When no keyring backend is reachable (headless Linux with no Secret Service), keys fall back to a 0o600 owner-only file. The BUZZ_PRIVATE_KEY env var still takes precedence over both stores everywhere it did before, so harnessed agents and CI are unaffected.

Migration

On first launch after upgrade, existing plaintext keys are imported into the keyring, read back to verify the round-trip, and only then is the plaintext destroyed (identity.key deleted; agent keys stripped from managed-agents.json). Migration runs only when the keyring is reachable — if the backend is unavailable that session, the app keeps the plaintext authoritative and does not migrate, so a transient outage can never resurrect a rotated key from a leftover file.

The private_key_nsec field becomes #[serde(skip)]-style in-memory-only and is hydrated from the keyring on load, so it stops landing in JSON. A single write chokepoint in save_managed_agents covers both migration and fresh agent creation — a newly created agent's key persists to the keyring through the same path, surviving restart.

Plaintext-never-lingers guarantees

  • Human identity: the keyring-Present boot arm cleans up a leftover identity.key only after a successful keyring load and parse — never on a failed read, so a delete can never race ahead of a confirmed key. Closes the window where a prior migration's remove_file failed (AV lock, read-only mount, EPERM) and left plaintext on disk indefinitely.
  • Agent keys: a save during a transient keyring outage keeps the key inline in managed-agents.json for survival; the next reachable boot deterministically re-migrates it via the non-mutating migrate_inline_key helper and the following save writes clean JSON. The no-resurrection guard holds — keyring Unreachable keeps the key inline and never migrates.

Fail-closed on keyring outage

A keyring that is reachable at write time but unreachable on a later boot must never silently rotate or deploy a keyless identity:

  • Silent identity rotation: a fresh install that generates straight into a reachable keyring writes the identity.migrated marker on keyring-success, so a later keyring-Unreachable boot sees the marker (not "no file, no marker", which is indistinguishable from a never-launched machine) and fails closed rather than generating a new key. On the keyring-write-failure → file-fallback arm the marker is deliberately not written, since the key is then authoritative in the 0o600 file.
  • Empty-key provider deploy: build_deploy_payload reuses the same spawn_key_refusal guard that fronts the local spawn path, so a provider deploy with an empty private_key_nsec (left by a keyring outage after hydration) fails closed instead of launching an agent with no identity. Create-deploy records the refusal in the agent's last_error; start-deploy propagates it.

Docs

SECURITY.md gains the keyring storage section and corrects the audit-log description: the chain is a keyless SHA-256 hash chain (buzz-audit/src/hash.rs), tamper-evident not tamper-resistant — the prior "HMAC-chained" claim was false.

@wpfleger96 wpfleger96 marked this pull request as draft June 22, 2026 17:38

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for moving desktop nsec storage toward the OS keyring — that fits Buzz's identity/security direction. I found one blocking availability edge case: after a successful migration, a later keyring outage can silently rotate the human identity or leave managed-agent secrets empty. Please fail closed or add a safe fallback/recovery story for post-migration keyring-unavailable boots.

Comment thread desktop/src-tauri/src/app_state.rs Outdated
Comment thread desktop/src-tauri/src/managed_agents/storage.rs
@wpfleger96 wpfleger96 force-pushed the duncan/desktop-keyring-migration branch 4 times, most recently from 322ab4e to 0e980dc Compare June 23, 2026 14:56
@wpfleger96 wpfleger96 marked this pull request as ready for review June 23, 2026 14:58

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinky adversarial review: requesting changes for two keyring outage safety gaps. Narf!

Comment thread desktop/src-tauri/src/app_state.rs Outdated
Comment thread desktop/src-tauri/src/managed_agents/runtime.rs
Comment thread desktop/src-tauri/src/managed_agents/storage.rs Outdated
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 and others added 8 commits June 24, 2026 16:36
Desktop nsec private keys (the human identity and every managed-agent
key) lived in plaintext on disk — identity.key and inline in
managed-agents.json. Move them into the OS keyring (macOS Keychain,
Windows Credential Manager, Linux Secret Service) behind a default-on
system-keyring feature, mirroring goose's backend matrix.

A new SecretStore wraps the keyring with a KeyringProbe that
distinguishes reachable-but-empty (safe to migrate into) from
unreachable (must not migrate). Migration imports the plaintext key,
read-back-verifies it, then deletes the plaintext — and is skipped
entirely when the keyring is unreachable, so a transient outage cannot
resurrect a rotated key from a leftover file. Keyringless environments
fall back to a 0o600 file.

Agent keys become an in-memory #[serde(skip)] field hydrated in
load_managed_agents and written-back in save_managed_agents, so the
nine existing read sites are untouched and the creation path persists
through the same chokepoint (no silent key loss on restart).

The BUZZ_PRIVATE_KEY env override is left on its upstream path and never
routed through SecretStore, preserving env-first precedence for agents
and CI. SECURITY.md gains a keyring note and the audit-log description
is corrected from HMAC to keyless SHA-256 hash chain.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two narrow failure paths left a plaintext nsec on disk after migration,
defeating the guarantee this series ships (neither lost a key):

- Human identity: a migration whose remove_file failed (AV lock, read-only
  mount, EPERM) left identity.key on disk, and the keyring-Present boot arm
  never retried the delete. The Present arm now best-effort removes a leftover
  file once the keyring is authoritative.
- Agent keys: a save during a transient keyring outage re-inlines the key into
  managed-agents.json, but hydrate_keys skipped non-empty records and so never
  re-stripped it until a later event-driven save. hydrate_keys now
  opportunistically re-migrates an inline key when the keyring is reachable,
  keeping the key in memory for readers while the next save writes clean JSON.

The migrate-vs-keep decision is extracted into migrate_inline_key, the single
source of truth shared by the load-time re-migrate and the save chokepoint,
behind a KeyStore trait so the decision is unit-tested without the live OS
keyring. The no-resurrection guard (keyring-unreachable -> keep inline, never
migrate) holds on both paths.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The inline test module pushed types.rs to 1075 lines, over the 1000-line
file-size limit enforced by check:file-sizes. Extract the #[cfg(test)] mod
into a sibling types/tests.rs, matching the existing convention used by
env_vars.rs, personas.rs, and runtime.rs in this crate. Production code is
unchanged; all 22 type tests still execute by name.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…entity

The keyring-Present-but-corrupt path quarantined identity.key (the file)
and generated a fresh identity, but the corruption is in the keyring, not
the file. A valid leftover identity.key (from a prior migration whose
remove_file failed) would be destroyed and the user silently rotated to a
new key. Now clear the corrupt keyring value, migrate a valid file if one
exists, and generate fresh only as a last resort. Adds an IdentityKeyStore
seam (mirroring managed_agents::storage's KeyStore) so the recovery
decision is unit-tested without a live keyring.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A keyring outage after migration was indistinguishable from a fresh
install (both probe Unreachable with no identity file), so the human
identity could be silently regenerated, and an agent whose key failed to
load was spawned with an empty BUZZ_PRIVATE_KEY/NOSTR_PRIVATE_KEY.

Identity: a migration-completed marker, written and fsynced before the
legacy file is deleted, is the durable signal that a key lives in the
keyring. Unreachable + no file + marker now fails closed rather than
generating; first-ever launch (no marker) still generates to the 0o600
file.

Agent keys: a keyring LOAD error is treated as an outage (key left
empty), distinct from a genuinely absent entry, and an empty key now
reports KeyMigration::Nothing rather than Persisted so it is never
mistaken for a verified entry. The spawn path refuses to start an agent
whose key is unavailable.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A fresh install that generated straight into a reachable keyring stored
the key but never wrote the migration marker, so a later keyring-
unreachable boot saw "no file, no marker" (indistinguishable from a
never-launched machine) and silently rotated the identity. Write the
marker after a keyring-success persist only; on the file-fallback arm
the key is on disk and a marker would wrongly fail closed.

Provider deploy serialized private_key_nsec unconditionally, so an empty
key left by a keyring outage could deploy an agent with no identity. The
local spawn path already refuses this via spawn_key_refusal; reuse the
same guard at the top of build_deploy_payload so all backends fail
closed.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… disk

The keyringless fallback wrote the agent store via std::fs::write +
rename (process umask, typically 0644) and only chmod'd it 0o600 after
the rename. A crash in that window could leave managed-agents.json with
plaintext agent nsecs world-readable, contradicting the SECURITY.md
owner-only fallback guarantee. The new atomic_write_json_restricted
mirrors save_key_file: it sets 0o600 on the temp file before any bytes
are written, then commits. The managed-agents write is now always
owner-only, which retires the any_inline_key plumbing and the
restrict_json_permissions chmod helper.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ile path

patch_json_records rewrote the whole store via atomic_write_json (raw
write+rename, umask ~0o644, no chmod), reopening the SECURITY.md:90
owner-only window on keyringless hosts whenever a launch-time field
reconcile touches managed-agents.json. Route its writeback through
atomic_write_json_restricted unconditionally — all targets live in the
single-user agents/ dir, so 0o600 on personas/teams is harmless and
avoids reintroducing the route-by-filename guard that caused the bug.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96 wpfleger96 force-pushed the duncan/desktop-keyring-migration branch from 59cf70c to fb539bf Compare June 24, 2026 20:38
@wpfleger96 wpfleger96 merged commit 4be43d7 into main Jun 24, 2026
25 checks passed
@wpfleger96 wpfleger96 deleted the duncan/desktop-keyring-migration branch June 24, 2026 21:07
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.

2 participants