diff --git a/desktop/src-tauri/src/managed_agents/library.rs b/desktop/src-tauri/src/managed_agents/library.rs index 7360d898d..8e24b8687 100644 --- a/desktop/src-tauri/src/managed_agents/library.rs +++ b/desktop/src-tauri/src/managed_agents/library.rs @@ -28,7 +28,7 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; -use nostr::PublicKey; +use nostr::{Keys, PublicKey}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -620,23 +620,29 @@ impl LibraryDocument { /// carries an outstanding `deferred_archives` row for it (§2.5). Field-name /// exact matches on the raw JSON so a quarantined entry still counts. fn entry_names_agent(entry: &Value, agent_pubkey: &str) -> bool { - let bound = entry + entry_binds_agent(entry, agent_pubkey) + || entry + .get("deferred_archives") + .and_then(Value::as_array) + .is_some_and(|rows| { + rows.iter() + .any(|r| r.get("agent_pubkey").and_then(Value::as_str) == Some(agent_pubkey)) + }) +} + +/// Whether one raw entry `Value` holds a live `identity_bindings` binding to +/// `agent_pubkey` (§2.5) — a deferred-archive marker does NOT count. This is the +/// "referenced by a live binding" test that keeps a committed key out of the +/// recovery reap; [`entry_names_agent`] widens it with deferred rows for the +/// deletion-protection predicate. +fn entry_binds_agent(entry: &Value, agent_pubkey: &str) -> bool { + entry .get("identity_bindings") .and_then(Value::as_object) .is_some_and(|bindings| { bindings .values() .any(|b| b.get("agent_pubkey").and_then(Value::as_str) == Some(agent_pubkey)) - }); - if bound { - return true; - } - entry - .get("deferred_archives") - .and_then(Value::as_array) - .is_some_and(|rows| { - rows.iter() - .any(|r| r.get("agent_pubkey").and_then(Value::as_str) == Some(agent_pubkey)) }) } @@ -671,6 +677,80 @@ pub(crate) fn select_binding_seed<'a>( .map(|winner| winner.pubkey) } +/// Construct a verified [`IdentityBinding`] at commit time (§2.5 step 2, P4-I3). +/// The inverse of [`validate_entry_bindings`]: derive `agent_pubkey` from the +/// READ-BACK nsec (never the stored record — a stored `auth_tag` proves nothing +/// about this binding, and a pre-NIP-OA seed legitimately has none), then +/// compute a FRESH auth tag with the current owner keys. Deriving the pubkey +/// from the same secret that was keyring-verified guarantees the binding's +/// keyring entry backs exactly this pubkey, and computing the tag here +/// guarantees the embedded owner equals the map key (`owner_keys.public_key()`) +/// by construction — so the entry passes [`validate_entry_bindings`] on the +/// very next read with no special case for the legacy-`None` seed. +/// +/// Returns `(owner_hex, binding)` — the caller inserts it as +/// `identity_bindings[owner_hex]` inside the insertion transaction (P8-C2, +/// Phase 4b). Pure: no keyring, no library IO. The nsec parse and the NIP-OA +/// self-attestation guard (owner == agent) are the only failure modes; both are +/// fail-closed `Err`. Passes `nostr` types straight into `buzz-sdk` exactly as +/// [`validate_entry_bindings`] does on the read side, so the constructed tag +/// verifies under the same code path. +pub(crate) fn build_identity_binding( + owner_keys: &Keys, + read_back_nsec: &str, +) -> Result<(String, IdentityBinding), String> { + let agent_keys = Keys::parse(read_back_nsec.trim()) + .map_err(|e| format!("read-back nsec did not parse as a keypair: {e}"))?; + let agent_pubkey = agent_keys.public_key(); + let auth_tag = buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, &agent_pubkey, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?; + + Ok(( + owner_keys.public_key().to_hex(), + IdentityBinding { + agent_pubkey: agent_pubkey.to_hex(), + auth_tag, + }, + )) +} + +impl LibraryDocument { + /// Journal a freshly minted pubkey to `orphan_keys` (§2.5 step 2) before its + /// secret is written anywhere. Idempotent — a re-journal after a crashed + /// retry is a no-op, never a duplicate row. Every persisted secret therefore + /// has a prior durable orphan coordinate. Pure document mutation; the caller + /// performs the atomic `save_library_document` (Phase 4b). + pub fn journal_orphan_pubkey(&mut self, agent_pubkey: &str) { + if !self.orphan_keys.iter().any(|k| k == agent_pubkey) { + self.orphan_keys.push(agent_pubkey.to_string()); + } + } + + /// Drop a pubkey's `orphan_keys` row (§2.5 step 4 / recovery). Called in the + /// SAME atomic library write that commits the binding (the pubkey is now + /// referenced by a live binding) or by the recovery sweep after its keyring + /// entry is reaped. Pure; no-op when absent. + pub fn remove_orphan_pubkey(&mut self, agent_pubkey: &str) { + self.orphan_keys.retain(|k| k != agent_pubkey); + } + + /// The `orphan_keys` rows that no binding references (§2.5 step 3 recovery). + /// A crash between the orphan journal (step 2) and the binding commit (step + /// 4) leaves a journaled pubkey whose keyring entry, if any, must be reaped; + /// once reaped, the caller drops the row via [`remove_orphan_pubkey`]. Scans + /// RAW entries for a live binding to the pubkey (binding only — a + /// `deferred_archives` marker is not a live binding and must not keep an + /// uncommitted orphan alive). Pure selection; the keyring delete is the IO + /// half performed by the recovery point (Phase 4b). + pub fn unreferenced_orphans(&self) -> Vec<&str> { + self.orphan_keys + .iter() + .filter(|orphan| !self.entries.iter().any(|e| entry_binds_agent(e, orphan))) + .map(String::as_str) + .collect() + } +} + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/library/binding_tests.rs b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs index b954677b1..26ec8db8d 100644 --- a/desktop/src-tauri/src/managed_agents/library/binding_tests.rs +++ b/desktop/src-tauri/src/managed_agents/library/binding_tests.rs @@ -7,6 +7,7 @@ use serde_json::json; use super::*; +use nostr::{Keys, ToBech32}; // ── key_archive_protected ────────────────────────────────────────────────────── @@ -147,3 +148,118 @@ fn test_seed_single_instance() { fn test_seed_empty_is_none() { assert_eq!(select_binding_seed(Vec::new()), None); } + +// ── build_identity_binding ────────────────────────────────────────────────────── + +#[test] +fn test_build_binding_derives_pubkey_from_nsec_and_verifies_on_read() { + // The binding a fresh mint produces must pass validate_entry_bindings on the + // next read with no special case: agent_pubkey derived from the read-back + // nsec, a fresh tag embedding exactly the owner it is keyed under. + let owner = Keys::generate(); + let agent = Keys::generate(); + let nsec = agent.secret_key().to_bech32().expect("encode nsec"); + + let (owner_hex, binding) = build_identity_binding(&owner, &nsec).expect("build binding"); + + assert_eq!(owner_hex, owner.public_key().to_hex()); + assert_eq!(binding.agent_pubkey, agent.public_key().to_hex()); + // Feed it straight into the read-side validator via a full entry. + let mut bindings = std::collections::BTreeMap::new(); + bindings.insert(owner_hex.clone(), binding.clone()); + let entry = LibraryEntry { + library_id: "lib-1".into(), + origin: OriginKey { + scope_id: "s".into(), + slug: "a".into(), + }, + revision: 1, + deleted: false, + owner_pubkey_at_share: owner_hex, + shared: SharedDefinition { + display_name: "A".into(), + avatar_url: None, + system_prompt: "p".into(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + }, + deferred_archives: vec![], + identity_bindings: bindings, + projections: std::collections::BTreeMap::new(), + }; + validate_entry_bindings(&entry).expect("freshly built binding validates on read"); +} + +#[test] +fn test_build_binding_ignores_stored_tag_uses_read_back_nsec() { + // agent_pubkey comes from the nsec, never a caller-supplied record: two + // different owners over the same agent nsec yield the same agent_pubkey with + // different, each-valid tags. + let owner_a = Keys::generate(); + let owner_b = Keys::generate(); + let agent = Keys::generate(); + let nsec = agent.secret_key().to_bech32().expect("encode nsec"); + + let (_, ba) = build_identity_binding(&owner_a, &nsec).expect("a"); + let (_, bb) = build_identity_binding(&owner_b, &nsec).expect("b"); + + assert_eq!(ba.agent_pubkey, agent.public_key().to_hex()); + assert_eq!(bb.agent_pubkey, agent.public_key().to_hex()); + assert_ne!(ba.auth_tag, bb.auth_tag); +} + +#[test] +fn test_build_binding_rejects_garbage_nsec() { + let owner = Keys::generate(); + assert!(build_identity_binding(&owner, "not-a-real-nsec").is_err()); +} + +// ── orphan-key journal + recovery selection ───────────────────────────────────── + +#[test] +fn test_journal_orphan_is_idempotent() { + let mut doc = library(vec![]); + doc.journal_orphan_pubkey("aa"); + doc.journal_orphan_pubkey("aa"); + assert_eq!(doc.orphan_keys, vec!["aa".to_string()]); +} + +#[test] +fn test_remove_orphan_drops_row_and_is_noop_when_absent() { + let mut doc = library(vec![]); + doc.journal_orphan_pubkey("aa"); + doc.journal_orphan_pubkey("bb"); + doc.remove_orphan_pubkey("aa"); + assert_eq!(doc.orphan_keys, vec!["bb".to_string()]); + doc.remove_orphan_pubkey("missing"); + assert_eq!(doc.orphan_keys, vec!["bb".to_string()]); +} + +#[test] +fn test_unreferenced_orphans_reaps_only_uncommitted() { + // committed: a live binding references it → NOT reaped. + // dangling: journaled but no binding → reaped (crash between step 2 and 4). + let committed = "aa".repeat(32); + let dangling = "bb".repeat(32); + let mut doc = library(vec![bound_entry(&committed, false)]); + doc.journal_orphan_pubkey(&committed); + doc.journal_orphan_pubkey(&dangling); + + let reap = doc.unreferenced_orphans(); + assert_eq!(reap, vec![dangling.as_str()]); +} + +#[test] +fn test_unreferenced_orphans_ignores_deferred_only_reference() { + // A deferred-archive marker is NOT a live binding: a journaled orphan whose + // only mention is a deferred row is still uncommitted and must be reaped. + let target = "cc".repeat(32); + let mut doc = library(vec![deferred_entry(&target)]); + doc.journal_orphan_pubkey(&target); + assert_eq!(doc.unreferenced_orphans(), vec![target.as_str()]); +}