mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
373 lines
16 KiB
Rust
373 lines
16 KiB
Rust
//! Owner-gated engram (NIP-AE memory) reader for the desktop.
|
|
//!
|
|
//! IXI-7 phase 1: read-only memory surface inside the agent profile panel.
|
|
//! One Tauri call per panel open returns the entire decrypted listing —
|
|
//! `core` (if present), every non-tombstoned `mem/...` entry, and the
|
|
//! outgoing `[[slug]]` refs extracted from each body. The UI computes
|
|
//! reachability + orphans from this single payload; no incremental sync.
|
|
//!
|
|
//! Why this shape:
|
|
//! - Owner secret key never leaves Rust. The desktop signs in as the owner
|
|
//! and decrypts via `agent ↔ owner` NIP-44 conversation key.
|
|
//! - Owner gating is enforced here: the request is accepted if the agent is
|
|
//! in the local `managed_agents` store OR the agent's live `kind:0`
|
|
//! cryptographically declares the viewer as its NIP-OA owner. Either way
|
|
//! the engrams are NIP-44 encrypted to the viewer's own pubkey, so the
|
|
//! encryption is the real boundary; this gate just decides whether to try.
|
|
//! The UI hides the section for non-owners anyway, but defense in depth.
|
|
//! - One call returns everything because the orphans view requires the
|
|
//! full set anyway. Lazy/per-node decrypt is deferred to IXI-60.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::SystemTime;
|
|
|
|
use nostr::PublicKey;
|
|
use serde::Serialize;
|
|
use tauri::{AppHandle, State};
|
|
|
|
use buzz_core_pkg::engram::{self, extract_refs, select_head, validate_and_decrypt, Body};
|
|
use buzz_core_pkg::kind::KIND_AGENT_ENGRAM;
|
|
|
|
use crate::commands::identity_archive::{extract_oa_owner, fetch_kind0};
|
|
use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay};
|
|
|
|
/// Hard cap on engrams returned per (agent, owner) pair. Matches the CLI
|
|
/// `mem ls` reference. If the relay returns this many we set
|
|
/// `truncated = true` so the UI can warn that the list may be incomplete.
|
|
const ENGRAM_FETCH_LIMIT: u32 = 5000;
|
|
|
|
/// One memory entry returned to the UI.
|
|
///
|
|
/// `slug` is the canonical slug (`core` or `mem/foo/bar`). `body` is the
|
|
/// decrypted UTF-8 payload (profile text for core, value for memory).
|
|
/// `outgoing_refs` is the list of `[[slug]]` references extracted from the
|
|
/// body — used by the UI to BFS reachability from `core`.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct EngramEntry {
|
|
pub slug: String,
|
|
pub body: String,
|
|
pub event_id: String,
|
|
pub created_at: u64,
|
|
pub outgoing_refs: Vec<String>,
|
|
}
|
|
|
|
/// Single-payload response for one panel open. `core` is split out because
|
|
/// the UI roots the reachability tree there; `memories` excludes core (and
|
|
/// tombstones) so it maps 1:1 to the `mem/...` set.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct AgentMemoryListing {
|
|
/// `core` entry, if the agent has one.
|
|
pub core: Option<EngramEntry>,
|
|
/// All non-core, non-tombstoned memories. Sorted by slug.
|
|
pub memories: Vec<EngramEntry>,
|
|
/// True if the relay returned `>= ENGRAM_FETCH_LIMIT` events — list may
|
|
/// be incomplete. UI surfaces a warning. Tracked for follow-up in
|
|
/// IXI-60 (pagination + lazy decrypt).
|
|
pub truncated: bool,
|
|
/// Unix seconds when the response was assembled. UI uses this for
|
|
/// "last loaded" copy on the refetch affordance.
|
|
pub fetched_at: u64,
|
|
}
|
|
|
|
/// Does `kind0` cryptographically declare `viewer_pubkey` as the NIP-OA owner
|
|
/// of the agent it belongs to?
|
|
///
|
|
/// This is the remote-owner gate predicate for [`get_agent_memory`]. It runs
|
|
/// the live `kind:0` through [`extract_oa_owner`] (which verifies the auth tag
|
|
/// against the agent's own pubkey via `nip_oa::verify_auth_tag`) and compares
|
|
/// the recovered owner to the viewer, case-insensitively. Returns `false` for
|
|
/// a missing `kind:0`, a `kind:0` without a verifiable auth tag, or an owner
|
|
/// that doesn't match the viewer — so a forged/mismatched declaration never
|
|
/// opens the gate. Pure over its inputs so the auth branch can be unit-tested
|
|
/// without the relay/Tauri machinery.
|
|
fn kind0_declares_viewer_owner(kind0: Option<&nostr::Event>, viewer_pubkey: &str) -> bool {
|
|
match kind0 {
|
|
Some(kind0) => extract_oa_owner(kind0)
|
|
.map(|(owner_hex, _tag)| owner_hex.eq_ignore_ascii_case(viewer_pubkey))
|
|
.unwrap_or(false),
|
|
None => false,
|
|
}
|
|
}
|
|
|
|
/// `get_agent_memory` — owner-gated single-payload engram listing.
|
|
///
|
|
/// Returns the full decrypted set for the (agent, owner) pair where
|
|
/// `owner = current viewer`. Refuses if the agent isn't in this desktop's
|
|
/// `managed_agents` store (i.e. the viewer is not its owner). This mirrors
|
|
/// the relay's hard refusal of cross-owner reads.
|
|
///
|
|
/// Errors are stringified for the Tauri bridge. The UI distinguishes
|
|
/// fetch error vs empty success vs success-with-data; an `Err(_)` return
|
|
/// is the "couldn't load" path. An empty `memories` Vec with `core: None`
|
|
/// is the legitimate "no memories" empty state.
|
|
#[tauri::command]
|
|
pub async fn get_agent_memory(
|
|
agent_pubkey: String,
|
|
app: AppHandle,
|
|
state: State<'_, AppState>,
|
|
) -> Result<AgentMemoryListing, String> {
|
|
// ── Owner gating ────────────────────────────────────────────────────
|
|
// The viewer (this desktop's identity) is the prospective owner. The
|
|
// relay query below is `#p`-tagged for the viewer's OWN pubkey and every
|
|
// engram is NIP-44 encrypted to that pubkey, so the viewer decrypts with
|
|
// their own key — the agent's seckey is never needed. Encryption + the
|
|
// relay's server-side `#p` scoping are the real boundary; this gate only
|
|
// decides whether we bother attempting (and avoids a needless roundtrip
|
|
// for agents the viewer plainly doesn't own).
|
|
//
|
|
// We accept the request on either of two owner signals, mirroring the UI's
|
|
// `viewerIsOwner = isCurrentUserOwner || isOwner`:
|
|
// 1. The agent is in this desktop's `managed_agents` store — local
|
|
// fast-path, no roundtrip. Covers locally-run agents (and older
|
|
// agents that never advertised an owner).
|
|
// 2. The agent's live `kind:0` cryptographically declares the viewer as
|
|
// its NIP-OA owner (verified via `extract_oa_owner`). This is the
|
|
// remote-owner case: the owner runs the agent on another desktop, so
|
|
// holds no local seckey, but legitimately owns it.
|
|
//
|
|
// (Historical note: this gate used to refuse anything not in
|
|
// `managed_agents`, on the theory that key-custody was the only safe
|
|
// proxy for ownership. That conflated "do I hold the seckey?" with "am I
|
|
// the owner?" — the two diverge exactly for a remote-owned agent, which
|
|
// wrongly locked legitimate owners out of their own memory. The declared-
|
|
// owner path is cleared (PR #917 author signed off); decryption still
|
|
// does the real guarding.)
|
|
let agent = PublicKey::from_hex(&agent_pubkey)
|
|
.map_err(|e| format!("agent pubkey must be 64-hex: {e}"))?;
|
|
|
|
let viewer_pubkey = {
|
|
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
|
keys.public_key().to_hex()
|
|
};
|
|
|
|
let managed = load_managed_agents(&app)?;
|
|
let is_managed = managed.iter().any(|m| m.pubkey == agent_pubkey);
|
|
let is_declared_owner = if is_managed {
|
|
false // already authorized; skip the relay roundtrip
|
|
} else {
|
|
// Verify the agent's live `kind:0` declares the viewer as owner.
|
|
let kind0 = fetch_kind0(&state, &agent_pubkey).await?;
|
|
kind0_declares_viewer_owner(kind0.as_ref(), &viewer_pubkey)
|
|
};
|
|
|
|
if !is_managed && !is_declared_owner {
|
|
return Err(format!(
|
|
"not the owner of agent {agent_pubkey} (no managed-agent record \
|
|
and no verified NIP-OA owner declaration)"
|
|
));
|
|
}
|
|
|
|
// ── Resolve owner key material ──────────────────────────────────────
|
|
// Owner = viewer. Clone the secret key out of the lock immediately so
|
|
// we don't hold the mutex across the relay round trip.
|
|
let (owner_pubkey, owner_seckey) = {
|
|
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
|
(keys.public_key(), keys.secret_key().clone())
|
|
};
|
|
|
|
// ── Relay query ─────────────────────────────────────────────────────
|
|
// Mirrors the CLI `mem ls` filter: kind 30174, authored by the agent,
|
|
// p-tagged for the owner. The relay enforces the same access shape.
|
|
let filter = serde_json::json!({
|
|
"kinds": [KIND_AGENT_ENGRAM],
|
|
"authors": [agent.to_hex()],
|
|
"#p": [owner_pubkey.to_hex()],
|
|
"limit": ENGRAM_FETCH_LIMIT,
|
|
});
|
|
let events = query_relay(&state, &[filter]).await?;
|
|
// `>=` is intentional and accepts a false-positive at exactly
|
|
// ENGRAM_FETCH_LIMIT events: if the relay returned the cap, we can't
|
|
// distinguish "exactly cap" from "cap because clipped". The banner copy
|
|
// says "may be incomplete" which covers the off-by-one. Switch to a
|
|
// delta-cursor sync in IXI-60 if this matters in practice.
|
|
let truncated = events.len() as u32 >= ENGRAM_FETCH_LIMIT;
|
|
|
|
// ── Validate, decrypt, group by `d` (NIP-AE Listing) ────────────────
|
|
// Pattern is the CLI's: drop bad apples silently rather than fail the
|
|
// whole listing. A single corrupt event must not deny-of-service the
|
|
// panel.
|
|
let mut groups: HashMap<String, Vec<(nostr::Event, Body)>> = HashMap::new();
|
|
for ev in events {
|
|
if ev.verify().is_err() {
|
|
continue;
|
|
}
|
|
let Some(d_value) = ev
|
|
.tags
|
|
.iter()
|
|
.find(|t| t.kind().to_string() == "d")
|
|
.and_then(|t| t.content())
|
|
.map(|s| s.to_string())
|
|
else {
|
|
continue;
|
|
};
|
|
let body = match validate_and_decrypt(
|
|
&ev,
|
|
&agent,
|
|
&owner_pubkey,
|
|
&owner_seckey,
|
|
&agent, // viewer (owner) decrypts with agent as the conversation peer
|
|
) {
|
|
Ok(b) => b,
|
|
Err(_) => continue,
|
|
};
|
|
groups.entry(d_value).or_default().push((ev, body));
|
|
}
|
|
|
|
// ── Pick head per d-group, drop tombstones, split core vs memories ──
|
|
let mut core: Option<EngramEntry> = None;
|
|
let mut memories: Vec<EngramEntry> = Vec::new();
|
|
for (_d, members) in groups {
|
|
let events: Vec<nostr::Event> = members.iter().map(|(e, _)| e.clone()).collect();
|
|
let Some(head) = select_head(events) else {
|
|
continue;
|
|
};
|
|
let Some((_, body)) = members.into_iter().find(|(e, _)| e.id == head.id) else {
|
|
continue;
|
|
};
|
|
let event_id = head.id.to_hex();
|
|
let created_at = head.created_at.as_secs();
|
|
match body {
|
|
Body::Memory { value: None, .. } => {
|
|
// Tombstone — exclude from the listing.
|
|
continue;
|
|
}
|
|
Body::Core { profile } => {
|
|
let outgoing_refs = extract_refs(&profile);
|
|
core = Some(EngramEntry {
|
|
slug: engram::CORE_SLUG.to_string(),
|
|
body: profile,
|
|
event_id,
|
|
created_at,
|
|
outgoing_refs,
|
|
});
|
|
}
|
|
Body::Memory {
|
|
slug,
|
|
value: Some(value),
|
|
} => {
|
|
let outgoing_refs = extract_refs(&value);
|
|
memories.push(EngramEntry {
|
|
slug,
|
|
body: value,
|
|
event_id,
|
|
created_at,
|
|
outgoing_refs,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
memories.sort_by(|a, b| a.slug.cmp(&b.slug));
|
|
|
|
let fetched_at = SystemTime::now()
|
|
.duration_since(SystemTime::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
|
|
Ok(AgentMemoryListing {
|
|
core,
|
|
memories,
|
|
truncated,
|
|
fetched_at,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
|
|
/// Build a `kind:0` carrying a valid NIP-OA `auth` tag declaring `owner`
|
|
/// as the owner of `agent`. Mirrors the helper in `identity_archive`'s
|
|
/// tests — same `compute_auth_tag` → `parse_auth_tag` bridge.
|
|
fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event {
|
|
let agent_hex = agent.public_key().to_hex();
|
|
let agent_compat = nostr::PublicKey::from_hex(&agent_hex).unwrap();
|
|
let owner_compat_secret =
|
|
nostr::SecretKey::from_slice(owner.secret_key().as_secret_bytes()).unwrap();
|
|
let owner_compat_keys = nostr::Keys::new(owner_compat_secret);
|
|
let tag_json =
|
|
buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_compat_keys, &agent_compat, "")
|
|
.expect("compute_auth_tag");
|
|
let compat_tag = buzz_sdk_pkg::nip_oa::parse_auth_tag(&tag_json).unwrap();
|
|
let tag = Tag::parse(compat_tag.as_slice()).unwrap();
|
|
EventBuilder::new(Kind::Metadata, "{}")
|
|
.tags([tag])
|
|
.sign_with_keys(agent)
|
|
.unwrap()
|
|
}
|
|
|
|
/// The declared-owner gate branch opens when a verified `kind:0` names the
|
|
/// viewer as the agent's NIP-OA owner. This is the remote-owner case that
|
|
/// the old key-custody gate wrongly refused (PR #917 migration).
|
|
#[test]
|
|
fn declared_owner_gate_opens_for_verified_owner_kind0() {
|
|
let owner = Keys::generate();
|
|
let agent = Keys::generate();
|
|
let kind0 = kind0_with_auth(&agent, &owner);
|
|
|
|
assert!(kind0_declares_viewer_owner(
|
|
Some(&kind0),
|
|
&owner.public_key().to_hex(),
|
|
));
|
|
}
|
|
|
|
/// Owner match is case-insensitive — a verified declaration still opens the
|
|
/// gate when the viewer pubkey is supplied in a different hex case.
|
|
#[test]
|
|
fn declared_owner_gate_is_case_insensitive() {
|
|
let owner = Keys::generate();
|
|
let agent = Keys::generate();
|
|
let kind0 = kind0_with_auth(&agent, &owner);
|
|
|
|
assert!(kind0_declares_viewer_owner(
|
|
Some(&kind0),
|
|
&owner.public_key().to_hex().to_uppercase(),
|
|
));
|
|
}
|
|
|
|
/// A verified declaration for a *different* owner must NOT open the gate for
|
|
/// some other viewer — the recovered owner has to equal the viewer.
|
|
#[test]
|
|
fn declared_owner_gate_refuses_non_owner_viewer() {
|
|
let owner = Keys::generate();
|
|
let agent = Keys::generate();
|
|
let stranger = Keys::generate();
|
|
let kind0 = kind0_with_auth(&agent, &owner);
|
|
|
|
assert!(!kind0_declares_viewer_owner(
|
|
Some(&kind0),
|
|
&stranger.public_key().to_hex(),
|
|
));
|
|
}
|
|
|
|
/// A `kind:0` with no auth tag carries no owner claim, so the gate stays
|
|
/// shut even for the agent's own would-be owner.
|
|
#[test]
|
|
fn declared_owner_gate_refuses_kind0_without_auth_tag() {
|
|
let agent = Keys::generate();
|
|
let viewer = Keys::generate();
|
|
let kind0 = EventBuilder::new(Kind::Metadata, "{}")
|
|
.sign_with_keys(&agent)
|
|
.unwrap();
|
|
|
|
assert!(!kind0_declares_viewer_owner(
|
|
Some(&kind0),
|
|
&viewer.public_key().to_hex(),
|
|
));
|
|
}
|
|
|
|
/// No live `kind:0` at all (relay returned nothing) means no declared
|
|
/// owner — the gate can only open via the local managed-agent fast path.
|
|
#[test]
|
|
fn declared_owner_gate_refuses_missing_kind0() {
|
|
let viewer = Keys::generate();
|
|
assert!(!kind0_declares_viewer_owner(
|
|
None,
|
|
&viewer.public_key().to_hex(),
|
|
));
|
|
}
|
|
}
|