Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions desktop/src-tauri/src/commands/agent_attestation.rs
Original file line number Diff line number Diff line change
@@ -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<AgentPersistenceAttestation, String> {
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
// 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}"))?
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod agent_access;
mod agent_attestation;
mod agent_auth;
mod agent_config;
mod agent_discovery;
Expand Down Expand Up @@ -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::*;
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
170 changes: 170 additions & 0 deletions desktop/src-tauri/src/managed_agents/persistence_attestation.rs
Original file line number Diff line number Diff line change
@@ -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<KeyringProbe>,
pub(crate) parallelism: u32,
pub(crate) auth_tag: Option<String>,
}

/// 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<KeyringProbe>,
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<AgentPersistenceAttestation, String> {
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(&copy) {
Ok(preimage) => sha256_hex(&preimage) == attestation.attestation_hash,
Err(_) => false,
}
}

#[cfg(test)]
#[path = "persistence_attestation_tests.rs"]
mod tests;
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use super::*;
use crate::secret_store::KeyringProbe;
const PUBKEY: &str = "b7c6f2f6e0a94d5f8f2f0c8f4e9a1b2c3d4e5f60718293a4b5c6d7e8f9012ab";

fn inputs(inline: bool, probe: Option<KeyringProbe>) -> 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"));
}
Loading