mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add commit-time binding mint + orphan journal helpers
Phase-3 §2.5 crash-safe mint protocol construction half (P4-I3, P8-C2). build_identity_binding is the commit-time inverse of validate_entry_bindings: derive agent_pubkey from the read-back nsec (not the stored record — a stored auth_tag proves nothing about this binding, and a pre-NIP-OA seed has none), then compute a fresh auth tag with the current owner keys so the constructed entry passes read-side validation with no legacy-None special case. Passes nostr types straight into buzz-sdk exactly as the validator does. LibraryDocument orphan-journal methods (journal_orphan_pubkey idempotent, remove_orphan_pubkey, unreferenced_orphans) implement §2.5 step 2/4 plus the recovery sweep selection: a pubkey journaled before its secret is persisted, dropped in the same atomic write that commits its binding, and reaped if a crash left it uncommitted. unreferenced_orphans scans for a LIVE binding only — a deferred-archive marker does not keep an uncommitted orphan alive. Factors entry_binds_agent out of entry_names_agent for the live-binding test shared by both. Keyring I/O and full transaction wiring remain DEFER-to-4b. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user