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:
Duncan
2026-08-17 15:37:41 -04:00
co-authored by Will Pfleger
parent 072b08ef6f
commit 0ec342f8dd
2 changed files with 208 additions and 12 deletions
+92 -12
View File
@@ -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()]);
}