From 96b4b06c20c68bd464371edb0340832f2d1abfc1 Mon Sep 17 00:00:00 2001 From: Ned Malki Date: Sat, 8 Aug 2026 17:01:20 +0700 Subject: [PATCH] feat(desktop): add non-secret exact-agent credential persistence attestation External controllers that assign work to a named managed agent need to verify that its credential is OS-keyring-backed, bound to exactly that agent, and not sitting in the inline JSON fallback - without any access to key material and without scraping managed-agents.json or the OS keychain. Adds buzz.desktop.exact_agent_credential_persistence.v1: a pure builder whose inputs cannot carry the nsec by construction, a read-only storage observation (raw pre-hydration store for inline detection, presence via load_all_readonly so it can never trigger migrate_legacy_key), one read-only Tauri command following the get_identity non-secret-projection precedent, a typed TS wrapper, and docs. Fails closed with attestation_keyring_unreachable / attestation_credential_missing rather than guessing; attestation_hash makes the object tamper-evident; the backend enum is extensible so a future secrets-provider backend becomes a new variant without breaking consumers. Signed-off-by: Ned Malki --- .../src/commands/agent_attestation.rs | 58 ++++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../managed_agents/persistence_attestation.rs | 170 ++++++++++++++++++ .../persistence_attestation_tests.rs | 125 +++++++++++++ .../src-tauri/src/managed_agents/storage.rs | 44 +++++ .../src/managed_agents/storage_tests.rs | 41 +++++ desktop/src/shared/api/agentAttestation.ts | 38 ++++ ...gent-credential-persistence-attestation.md | 73 ++++++++ 10 files changed, 553 insertions(+) create mode 100644 desktop/src-tauri/src/commands/agent_attestation.rs create mode 100644 desktop/src-tauri/src/managed_agents/persistence_attestation.rs create mode 100644 desktop/src-tauri/src/managed_agents/persistence_attestation_tests.rs create mode 100644 desktop/src/shared/api/agentAttestation.ts create mode 100644 docs/agent-credential-persistence-attestation.md diff --git a/desktop/src-tauri/src/commands/agent_attestation.rs b/desktop/src-tauri/src/commands/agent_attestation.rs new file mode 100644 index 0000000000..7735f9545b --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_attestation.rs @@ -0,0 +1,58 @@ +//! Per-agent credential-persistence attestation command. +//! +//! Returns the non-secret `buzz.desktop.exact_agent_credential_persistence.v1` +//! object for one managed agent, so external controllers (or the user, via +//! copy/paste) can verify keyring-backed exact-agent credential persistence +//! without any access to key material. See +//! `managed_agents::persistence_attestation` for the schema and guarantees. + +use tauri::{AppHandle, Manager as _}; + +use crate::app_state::AppState; +use crate::managed_agents::persistence_attestation::{ + build_agent_persistence_attestation, verify_attestation_hash, AgentPersistenceAttestation, + AttestationInputs, +}; +use crate::managed_agents::storage::observe_agent_credential_persistence; + +/// Issue the persistence attestation for `pubkey`. +/// +/// Read-only: observes the raw persisted store and the keyring via the +/// side-effect-free path; never migrates, writes, or touches key material. +/// Fails closed with `attestation_keyring_unreachable` / +/// `attestation_credential_missing` instead of guessing. +#[tauri::command] +pub async fn get_agent_persistence_attestation( + app: AppHandle, + pubkey: String, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + // Hold the store lock for a consistent read against concurrent saves. + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let observation = observe_agent_credential_persistence(&app, &pubkey)?; + let package = app.package_info(); + let stock_release_id = format!("{}@{}", package.name, package.version); + let issued_at = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let attestation = build_agent_persistence_attestation(&AttestationInputs { + agent_pubkey: &pubkey, + auth_tag: observation.auth_tag.as_deref(), + inline_key_present: observation.inline_key_present, + keyring_probe: observation.keyring_probe, + parallelism: observation.parallelism, + stock_release_id: &stock_release_id, + issued_at: &issued_at, + })?; + // Self-check the tamper-evidence invariant before handing the object + // to external verifiers. + if !verify_attestation_hash(&attestation) { + return Err("attestation_hash_self_check_failed".to_string()); + } + Ok(attestation) + }) + .await + .map_err(|error| format!("attestation task join failed: {error}"))? +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a..6473a2f475 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ mod agent_access; +mod agent_attestation; mod agent_auth; mod agent_config; mod agent_discovery; @@ -65,6 +66,7 @@ mod workflows; mod workspace; pub use agent_access::*; +pub use agent_attestation::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 66816f8b98..c8e56b6ac0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -758,6 +758,7 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + get_agent_persistence_attestation, list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index fe90ce430f..81ff904c75 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod global_config; mod managed_node_paths; mod nest; pub(crate) mod parallelism; +pub(crate) mod persistence_attestation; mod persona_avatars; pub(crate) mod persona_events; mod personas; diff --git a/desktop/src-tauri/src/managed_agents/persistence_attestation.rs b/desktop/src-tauri/src/managed_agents/persistence_attestation.rs new file mode 100644 index 0000000000..ce2301d5ca --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persistence_attestation.rs @@ -0,0 +1,170 @@ +//! Non-secret credential-persistence attestation for managed agents. +//! +//! External controllers that assign work to a named Buzz agent need to verify +//! — without ever reading key material — that the agent's credential is +//! durably held by the OS keyring, bound to exactly that agent, and not +//! sitting in the inline JSON fallback. This module produces a public, +//! deterministic attestation object for one managed agent. +//! +//! Guarantees, by construction: +//! - No secret ever enters this module: the builder takes only a boolean +//! ("is an inline key present in the persisted record"), a keyring probe +//! result, and public identity material. There is no field, parameter, or +//! code path that carries the nsec. +//! - Fail closed: when the keyring is unreachable, or no credential can be +//! located at all, the builder returns an error instead of guessing. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +use crate::secret_store::KeyringProbe; + +/// Schema identifier for the v1 attestation object. +pub const AGENT_PERSISTENCE_ATTESTATION_SCHEMA_V1: &str = + "buzz.desktop.exact_agent_credential_persistence.v1"; + +/// Where the agent's credential currently lives. +/// +/// Extensible: additional backends (for example a secrets-provider protocol) +/// can be added as variants without breaking consumers, which are expected to +/// treat unknown strings as "not the backend I require". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PersistenceBackend { + /// Credential is held by the OS keyring and absent from the JSON store. + OsKeyring, + /// Credential is serialized inline in the `0o600` JSON fallback file. + InlineFile, +} + +/// Public attestation of one managed agent's credential persistence state. +/// +/// Field order is part of the hash contract: `attestation_hash` is the +/// SHA-256 of this struct serialized with `attestation_hash` set to the empty +/// string, so serialization must stay deterministic (serde struct-field +/// order, no maps). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AgentPersistenceAttestation { + pub schema_version: String, + /// Agent identity pubkey (hex). + pub agent_pubkey: String, + pub persistence_backend: PersistenceBackend, + /// True when the credential is inline in the JSON store rather than in + /// the OS keyring. Always the negation of `persistence_backend == + /// os_keyring` in v1; kept explicit so consumers can gate on it directly. + pub inline_fallback: bool, + /// The agent record's configured parallelism (requested value). + pub parallelism: u32, + /// SHA-256 (hex) over the public identity material: the agent pubkey and + /// its NIP-OA auth tag (empty string when the agent predates NIP-OA). + pub public_identity_hash: String, + /// SHA-256 (hex) of this attestation serialized with this field empty. + pub attestation_hash: String, + /// Desktop release identifier, e.g. `buzz-desktop@0.5.7`. + pub stock_release_id: String, + /// RFC 3339 timestamp of attestation issuance. + pub issued_at: String, +} + +/// Read-only observation of one agent's persisted credential state, collected +/// by [`crate::managed_agents::storage::observe_agent_credential_persistence`] +/// without migration side effects. Carries no secret: only presence booleans +/// and public identity material. +#[derive(Debug, Clone)] +pub(crate) struct CredentialPersistenceObservation { + pub(crate) inline_key_present: bool, + /// `None` when the build has no keyring backend (inline-only builds). + pub(crate) keyring_probe: Option, + pub(crate) parallelism: u32, + pub(crate) auth_tag: Option, +} + +/// Inputs to the pure attestation builder. Deliberately contains no secret: +/// callers report only whether an inline key is present, never its value. +#[derive(Debug, Clone)] +pub struct AttestationInputs<'a> { + pub agent_pubkey: &'a str, + /// The record's NIP-OA auth tag JSON, if the agent has one. + pub auth_tag: Option<&'a str>, + /// Whether the persisted record still carries an inline private key. + pub inline_key_present: bool, + /// Keyring probe for this agent's entry, or `None` when the build has no + /// keyring backend at all (inline-only builds). + pub keyring_probe: Option, + pub parallelism: u32, + pub stock_release_id: &'a str, + /// RFC 3339 issuance time, injected for determinism in tests. + pub issued_at: &'a str, +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +/// Hash of the public identity material. The auth tag is public NIP-OA JSON; +/// agents that predate NIP-OA hash the empty string in its place. +fn public_identity_hash(agent_pubkey: &str, auth_tag: Option<&str>) -> String { + let mut material = String::with_capacity(agent_pubkey.len() + 1); + material.push_str(agent_pubkey); + material.push('\n'); + material.push_str(auth_tag.unwrap_or("")); + sha256_hex(material.as_bytes()) +} + +/// Build the v1 attestation for one managed agent, or fail closed. +/// +/// Errors (stable strings, suitable for surfacing to callers): +/// - `attestation_keyring_unreachable` — keyring backend exists but could not +/// be reached this boot and no inline key is present; presence cannot be +/// proven either way. +/// - `attestation_credential_missing` — no inline key and the keyring is +/// reachable but holds no entry for this agent. +pub fn build_agent_persistence_attestation( + inputs: &AttestationInputs<'_>, +) -> Result { + let (backend, inline_fallback) = if inputs.inline_key_present { + (PersistenceBackend::InlineFile, true) + } else { + match inputs.keyring_probe { + Some(KeyringProbe::Present) => (PersistenceBackend::OsKeyring, false), + Some(KeyringProbe::ReachableButEmpty) | None => { + return Err("attestation_credential_missing".to_string()); + } + Some(KeyringProbe::Unreachable) => { + return Err("attestation_keyring_unreachable".to_string()); + } + } + }; + + let mut attestation = AgentPersistenceAttestation { + schema_version: AGENT_PERSISTENCE_ATTESTATION_SCHEMA_V1.to_string(), + agent_pubkey: inputs.agent_pubkey.to_string(), + persistence_backend: backend, + inline_fallback, + parallelism: inputs.parallelism, + public_identity_hash: public_identity_hash(inputs.agent_pubkey, inputs.auth_tag), + attestation_hash: String::new(), + stock_release_id: inputs.stock_release_id.to_string(), + issued_at: inputs.issued_at.to_string(), + }; + let preimage = serde_json::to_vec(&attestation) + .map_err(|error| format!("attestation_serialize_failed: {error}"))?; + attestation.attestation_hash = sha256_hex(&preimage); + Ok(attestation) +} + +/// Verify that `attestation.attestation_hash` matches its own payload. +/// External consumers can re-implement this from the schema; it is exposed +/// here so desktop tests and callers share one definition. +pub fn verify_attestation_hash(attestation: &AgentPersistenceAttestation) -> bool { + let mut copy = attestation.clone(); + copy.attestation_hash = String::new(); + match serde_json::to_vec(©) { + Ok(preimage) => sha256_hex(&preimage) == attestation.attestation_hash, + Err(_) => false, + } +} + +#[cfg(test)] +#[path = "persistence_attestation_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persistence_attestation_tests.rs b/desktop/src-tauri/src/managed_agents/persistence_attestation_tests.rs new file mode 100644 index 0000000000..810a63a941 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persistence_attestation_tests.rs @@ -0,0 +1,125 @@ +use super::*; +use crate::secret_store::KeyringProbe; +const PUBKEY: &str = "b7c6f2f6e0a94d5f8f2f0c8f4e9a1b2c3d4e5f60718293a4b5c6d7e8f9012ab"; + +fn inputs(inline: bool, probe: Option) -> AttestationInputs<'static> { + AttestationInputs { + agent_pubkey: PUBKEY, + auth_tag: Some(r#"{"kind":"nip-oa","sig":"public"}"#), + inline_key_present: inline, + keyring_probe: probe, + parallelism: 1, + stock_release_id: "buzz-desktop@0.5.7", + issued_at: "2026-08-08T12:00:00Z", + } +} + +#[test] +fn keyring_backed_agent_attests_os_keyring_without_inline_fallback() { + let attestation = + build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::Present))) + .expect("attestation"); + assert_eq!( + attestation.persistence_backend, + PersistenceBackend::OsKeyring + ); + assert!(!attestation.inline_fallback); + assert_eq!( + attestation.schema_version, + AGENT_PERSISTENCE_ATTESTATION_SCHEMA_V1 + ); + assert!(verify_attestation_hash(&attestation)); +} + +#[test] +fn inline_key_attests_inline_file_regardless_of_probe() { + for probe in [ + Some(KeyringProbe::Present), + Some(KeyringProbe::ReachableButEmpty), + Some(KeyringProbe::Unreachable), + None, + ] { + let attestation = + build_agent_persistence_attestation(&inputs(true, probe)).expect("inline attestation"); + assert_eq!( + attestation.persistence_backend, + PersistenceBackend::InlineFile + ); + assert!(attestation.inline_fallback); + } +} + +#[test] +fn missing_credential_fails_closed() { + let error = + build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::ReachableButEmpty))) + .expect_err("must fail"); + assert_eq!(error, "attestation_credential_missing"); + let error = build_agent_persistence_attestation(&inputs(false, None)) + .expect_err("must fail without keyring backend"); + assert_eq!(error, "attestation_credential_missing"); +} + +#[test] +fn unreachable_keyring_fails_closed_instead_of_guessing() { + let error = + build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::Unreachable))) + .expect_err("must fail"); + assert_eq!(error, "attestation_keyring_unreachable"); +} + +#[test] +fn attestation_hash_binds_the_payload() { + let attestation = + build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::Present))) + .expect("attestation"); + assert!(verify_attestation_hash(&attestation)); + let mut tampered = attestation.clone(); + tampered.parallelism = 8; + assert!(!verify_attestation_hash(&tampered)); + let mut substituted = attestation; + substituted.agent_pubkey = + "0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!(!verify_attestation_hash(&substituted)); +} + +#[test] +fn public_identity_hash_tracks_pubkey_and_auth_tag() { + let with_tag = build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::Present))) + .expect("attestation"); + let mut no_tag_inputs = inputs(false, Some(KeyringProbe::Present)); + no_tag_inputs.auth_tag = None; + let without_tag = build_agent_persistence_attestation(&no_tag_inputs).expect("attestation"); + assert_ne!( + with_tag.public_identity_hash, + without_tag.public_identity_hash + ); +} + +#[test] +fn serialized_attestation_exposes_only_the_public_schema_fields() { + let attestation = + build_agent_persistence_attestation(&inputs(false, Some(KeyringProbe::Present))) + .expect("attestation"); + let value: serde_json::Value = + serde_json::to_value(&attestation).expect("serialize attestation"); + let object = value.as_object().expect("attestation is an object"); + let mut keys: Vec<&str> = object.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + "agent_pubkey", + "attestation_hash", + "inline_fallback", + "issued_at", + "parallelism", + "persistence_backend", + "public_identity_hash", + "schema_version", + "stock_release_id", + ] + ); + let serialized = value.to_string(); + assert!(!serialized.contains("nsec")); +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..1277b5494b 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -904,6 +904,50 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { }) } +/// Read-only keyring probe for one agent entry. Uses `load_all_readonly`, so +/// unlike [`SecretStore::probe`] it can never trigger `migrate_legacy_key` +/// side effects — attestation must observe, never mutate. +fn readonly_agent_key_probe(store: &impl KeyStore, pubkey: &str) -> KeyringProbe { + match store.load_all_readonly() { + Err(_) => KeyringProbe::Unreachable, + Ok(None) => KeyringProbe::ReachableButEmpty, + Ok(Some(blob)) => { + if blob.contains_key(&agent_keyring_name(pubkey)) { + KeyringProbe::Present + } else { + KeyringProbe::ReachableButEmpty + } + } + } +} + +/// Collect the read-only credential-persistence observation for `pubkey`. +/// +/// Reads the RAW persisted store, not the hydrated records: after +/// [`hydrate_keys`] an in-memory record carries the nsec even in the normal +/// keyring-backed case, so only the pre-hydration JSON can distinguish the +/// inline fallback from keyring-backed storage. +pub(crate) fn observe_agent_credential_persistence( + app: &AppHandle, + pubkey: &str, +) -> Result +{ + let records = load_agent_store(app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("unknown managed agent: {pubkey}"))?; + let keyring_probe = agent_secret_store().map(|store| readonly_agent_key_probe(store, pubkey)); + Ok( + crate::managed_agents::persistence_attestation::CredentialPersistenceObservation { + inline_key_present: !record.private_key_nsec.is_empty(), + keyring_probe, + parallelism: record.parallelism, + auth_tag: record.auth_tag.clone(), + }, + ) +} + #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac..34ee077916 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -830,3 +830,44 @@ fn install_log_filename_accepts_ordinary_runtime_ids() { ); } } + +// ── Read-only attestation probe ──────────────────────────────────────────── + +#[test] +fn readonly_probe_reports_present_only_for_this_agents_entry() { + let store = FakeKeyStore::reachable().with_key(&agent_keyring_name("pubkey-a"), "nsec1a"); + assert_eq!( + super::readonly_agent_key_probe(&store, "pubkey-a"), + KeyringProbe::Present + ); + assert_eq!( + super::readonly_agent_key_probe(&store, "pubkey-b"), + KeyringProbe::ReachableButEmpty + ); +} + +#[test] +fn readonly_probe_reports_unreachable_on_backend_outage() { + let store = FakeKeyStore::unreachable(); + assert_eq!( + super::readonly_agent_key_probe(&store, "pubkey-a"), + KeyringProbe::Unreachable + ); +} + +#[test] +fn readonly_probe_reports_empty_when_no_blob_exists() { + let store = FakeKeyStore::reachable(); + assert_eq!( + super::readonly_agent_key_probe(&store, "pubkey-a"), + KeyringProbe::ReachableButEmpty + ); +} + +#[test] +fn readonly_probe_never_writes() { + let store = FakeKeyStore::reachable().with_key(&agent_keyring_name("pubkey-a"), "nsec1a"); + let _ = super::readonly_agent_key_probe(&store, "pubkey-a"); + let _ = super::readonly_agent_key_probe(&store, "pubkey-b"); + assert_eq!(*store.write_count.borrow(), 0); +} diff --git a/desktop/src/shared/api/agentAttestation.ts b/desktop/src/shared/api/agentAttestation.ts new file mode 100644 index 0000000000..441b3f9071 --- /dev/null +++ b/desktop/src/shared/api/agentAttestation.ts @@ -0,0 +1,38 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * Non-secret credential-persistence attestation for one managed agent + * (`buzz.desktop.exact_agent_credential_persistence.v1`). + * + * The wire shape is intentionally snake_case and kept verbatim: the object is + * an interop document whose `attestation_hash` binds the exact serialized + * payload, so remapping field names client-side would break external + * verification. Consumers treat unknown `persistence_backend` strings as + * "not the backend I require". + */ +export type AgentPersistenceAttestation = { + schema_version: string; + agent_pubkey: string; + persistence_backend: "os_keyring" | "inline_file"; + inline_fallback: boolean; + parallelism: number; + public_identity_hash: string; + attestation_hash: string; + stock_release_id: string; + issued_at: string; +}; + +/** + * Fetch the persistence attestation for a managed agent. Fails (rejects) with + * `attestation_keyring_unreachable` or `attestation_credential_missing` when + * persistence cannot be proven — callers must treat that as "not attested", + * never as os_keyring. + */ +export async function getAgentPersistenceAttestation( + pubkey: string, +): Promise { + return invokeTauri( + "get_agent_persistence_attestation", + { pubkey }, + ); +} diff --git a/docs/agent-credential-persistence-attestation.md b/docs/agent-credential-persistence-attestation.md new file mode 100644 index 0000000000..e90b3cda0c --- /dev/null +++ b/docs/agent-credential-persistence-attestation.md @@ -0,0 +1,73 @@ +# Agent Credential Persistence Attestation (v1) + +Schema id: `buzz.desktop.exact_agent_credential_persistence.v1` + +External controllers that assign work to a named Buzz Desktop agent often +need to prove — without any access to key material — that the agent's +credential is durably held by the OS keyring, bound to exactly that agent, +and not sitting in the inline `0o600` JSON fallback. This document defines +the public attestation object Buzz Desktop can issue for one managed agent. + +## Invocation + +Tauri command (desktop IPC): + +```ts +import { getAgentPersistenceAttestation } from "@/shared/api/agentAttestation"; + +const attestation = await getAgentPersistenceAttestation(agentPubkey); +``` + +The command is strictly read-only: it observes the raw persisted agent store +and probes the keyring through the side-effect-free `load_all_readonly` path. +It never migrates keys, never writes, and no code path carries the nsec. + +## Object shape + +```json +{ + "schema_version": "buzz.desktop.exact_agent_credential_persistence.v1", + "agent_pubkey": "", + "persistence_backend": "os_keyring", + "inline_fallback": false, + "parallelism": 1, + "public_identity_hash": "", + "attestation_hash": "", + "stock_release_id": "buzz-desktop@0.5.7", + "issued_at": "2026-08-08T12:00:00Z" +} +``` + +| Field | Meaning | +|---|---| +| `schema_version` | Exactly the schema id above. | +| `agent_pubkey` | The managed agent's identity pubkey (hex). | +| `persistence_backend` | `os_keyring` when the credential is in the OS keyring and absent from the JSON store; `inline_file` when it is serialized in the `0o600` fallback file. Consumers must treat unknown values as "not the backend I require" — future backends may be added. | +| `inline_fallback` | Explicit boolean mirror of `persistence_backend != os_keyring` (v1). | +| `parallelism` | The record's requested parallelism. Controllers requiring exact-agent binding gate on `1`. | +| `public_identity_hash` | SHA-256 (hex) of `agent_pubkey + "\n" + auth_tag`, where `auth_tag` is the agent's public NIP-OA auth-tag JSON, or the empty string for agents that predate NIP-OA. | +| `attestation_hash` | SHA-256 (hex) of this object serialized (serde struct-field order) with `attestation_hash` set to the empty string. | +| `stock_release_id` | `"@"` of the issuing desktop build. | +| `issued_at` | RFC 3339 issuance time. | + +## Fail-closed semantics + +The command errors — it never guesses — when persistence cannot be proven: + +- `attestation_keyring_unreachable`: a keyring backend exists but was + unreachable this boot and no inline key is present. The credential may + exist; presence cannot be proven either way. +- `attestation_credential_missing`: no inline key, keyring reachable, no + entry for this agent. + +Builds compiled without the `system-keyring` feature keep agent keys inline +and attest `inline_file` / `inline_fallback: true` honestly. + +## What this is not + +- Not a trust or capability attestation (see NIP-TR discussions for that + layer); this only describes *where the credential lives*. +- Not a replacement for NIP-OA: the auth tag remains the ownership proof. + This object only hashes it as public identity material. +- Not a secret channel: no field ever contains key material, and the + implementation's builder cannot receive the nsec by construction.