From 416f1818aab457af1197e97ac909b3aa4679e49e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 20 Jul 2026 11:43:02 -0400 Subject: [PATCH] feat(cli): add agents archive/unarchive/archived subcommands Implements the NIP-IA identity archive CLI surface per the Rev 3 plan: archive (kind:9035), unarchive (kind:9036), and archived (kind:13535 snapshot query). Includes non-ambient signing seam (sign_event_unchecked), NIP-OA auth-tag resolution with structural validation, NIP-11 relay info fetch with Accept: application/nostr+json, and fail-closed tri-state verification of the archived-identities snapshot. --- crates/buzz-cli/src/client.rs | 159 +++++- crates/buzz-cli/src/commands/agents.rs | 726 +++++++++++++++++++++++-- crates/buzz-cli/src/lib.rs | 63 ++- crates/buzz-sdk/src/builders.rs | 273 +++++++++- 4 files changed, 1169 insertions(+), 52 deletions(-) diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ad26d24b3a..85a8563056 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -499,6 +499,39 @@ impl BuzzClient { self.query_pages(filter, None).await } + /// Sign an event builder verbatim: no NIP-OA auth-tag injection, and none + /// of [`sign_event`]'s "callers must not add auth tags" enforcement. + /// + /// Used only for NIP-IA identity archive/unarchive requests (kind + /// 9035/9036), whose optional `auth` tag is a *content-level* + /// owner-of-agent attestation about the *target* identity — unrelated to + /// this client's own NIP-OA membership delegation (`self.auth_tag`, + /// which [`sign_event`] injects into every other event and which + /// `submit_event` separately attaches via the `x-auth-tag` HTTP header). + /// Routing an identity archive request through `sign_event` would either + /// silently drop the caller's owner attestation or double up an + /// unrelated tag. + pub fn sign_event_unchecked(&self, builder: EventBuilder) -> Result { + builder + .sign_with_keys(&self.keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}"))) + } + + /// GET a public, unauthenticated relay endpoint (e.g. the NIP-11 `/info` + /// document), returning the raw JSON body. No NIP-98 Authorization and no + /// `x-auth-tag` header — the endpoint is public relay metadata, not a + /// membership-scoped resource. + pub async fn get_public(&self, path: &str) -> Result { + let url = format!("{}{path}", self.relay_url); + let resp = self + .http + .get(&url) + .header("Accept", "application/nostr+json") + .send() + .await?; + self.handle_response(resp).await + } + /// Execute a one-shot query via the HTTP bridge. /// `filter` is a Nostr filter object (will be wrapped in an array). /// Returns the raw JSON response (array of events). @@ -920,7 +953,10 @@ pub fn normalize_write_response(raw: &str) -> String { #[cfg(test)] mod tests { - use super::{advance_query_cursor, create_response_with_id, extract_relay_response_field}; + use super::{ + advance_query_cursor, create_response_with_id, extract_relay_response_field, BuzzClient, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; #[test] fn query_cursor_uses_last_events_composite_sort_key() { @@ -974,4 +1010,125 @@ mod tests { assert_eq!(v["event_id"].as_str(), Some("abc")); assert_eq!(v["accepted"].as_bool(), Some(true)); } + + // --- (a) auth-suppression regression pair --- + + fn make_auth_tag() -> (Tag, String) { + let owner_hex = "a".repeat(64); + let sig_hex = "b".repeat(128); + let tag_vec = vec![ + "auth".to_string(), + owner_hex, + "conditions".to_string(), + sig_hex, + ]; + let json = serde_json::to_string(&tag_vec).unwrap(); + let tag = Tag::parse(tag_vec).unwrap(); + (tag, json) + } + + #[test] + fn sign_event_unchecked_does_not_inject_ambient_auth_tag() { + let keys = Keys::generate(); + let (auth_tag, auth_json) = make_auth_tag(); + let client = BuzzClient::new( + "https://test.relay".into(), + keys, + Some(auth_tag), + Some(auth_json), + ) + .unwrap(); + + let builder = + EventBuilder::new(Kind::Custom(9035), "archive").tags([Tag::parse(["-"]).unwrap()]); + let event = client.sign_event_unchecked(builder).unwrap(); + + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert!( + auth_tags.is_empty(), + "sign_event_unchecked must not inject the ambient NIP-OA auth tag \ + into identity archive events; found {auth_tags:?}" + ); + } + + #[test] + fn sign_event_unchecked_preserves_callers_content_auth_tag() { + let keys = Keys::generate(); + let (auth_tag, auth_json) = make_auth_tag(); + let client = BuzzClient::new( + "https://test.relay".into(), + keys, + Some(auth_tag), + Some(auth_json), + ) + .unwrap(); + + let content_auth = Tag::parse([ + "auth", + &"c".repeat(64), + "owner-attestation", + &"d".repeat(128), + ]) + .unwrap(); + + let builder = EventBuilder::new(Kind::Custom(9035), "archive") + .tags([Tag::parse(["-"]).unwrap(), content_auth]); + let event = client.sign_event_unchecked(builder).unwrap(); + + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!( + auth_tags.len(), + 1, + "content-level auth tag must survive sign_event_unchecked; found {auth_tags:?}" + ); + assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64)); + } + + #[test] + fn with_auth_tag_sets_header_when_configured() { + let keys = Keys::generate(); + let (auth_tag, auth_json) = make_auth_tag(); + let client = BuzzClient::new( + "https://test.relay".into(), + keys, + Some(auth_tag), + Some(auth_json.clone()), + ) + .unwrap(); + + let req = client.http.post("https://test.relay/events"); + let req = client.with_auth_tag(req); + let built = req.build().unwrap(); + let header = built + .headers() + .get("x-auth-tag") + .expect("x-auth-tag header must be present"); + assert_eq!( + header.to_str().unwrap(), + &auth_json, + "x-auth-tag header must carry the raw auth tag JSON" + ); + } + + #[test] + fn with_auth_tag_omits_header_when_not_configured() { + let keys = Keys::generate(); + let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap(); + + let req = client.http.post("https://test.relay/events"); + let req = client.with_auth_tag(req); + let built = req.build().unwrap(); + assert!( + built.headers().get("x-auth-tag").is_none(), + "x-auth-tag header must not be present when no auth tag is configured" + ); + } } diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 7947ae7e77..71f869dcdc 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -1,32 +1,48 @@ +use buzz_core::kind::KIND_IA_ARCHIVED_LIST; +use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identity_request}; use nostr::PublicKey; +use serde_json::json; use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::read_or_stdin; +use crate::validate::{read_or_stdin, validate_hex64}; use crate::{AgentsCmd, RespondToArg}; pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { - let owner = client - .auth_tag_owner_hex() - .ok_or_else(|| CliError::Auth("agent draft requests require BUZZ_AUTH_TAG".into()))?; - let owner = PublicKey::parse(&owner) - .map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?; - - let built = match command { + match command { AgentsCmd::DraftCreate { channel, display_name, system_prompt, - } => build_create( - client.keys(), - &owner, - CreateAgentDraft { - channel_id: channel, - display_name, - system_prompt: read_or_stdin(&system_prompt)?, - }, - )?, + } => { + let owner = require_owner(client)?; + let built = build_create( + client.keys(), + &owner, + CreateAgentDraft { + channel_id: channel, + display_name, + system_prompt: read_or_stdin(&system_prompt)?, + }, + )?; + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("request_id".into(), built.request_id.into()); + obj.insert("action".into(), built.action.into()); + obj.insert("saved".into(), false.into()); + obj.insert( + "message".into(), + "Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it." + .into(), + ); + } + println!("{output}"); + Ok(()) + } + AgentsCmd::DraftUpdate { channel, agent_name, @@ -36,37 +52,655 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli provider, model, respond_to, - } => build_update( - client.keys(), - &owner, - UpdateAgentDraft { - channel_id: channel, - agent_name, - display_name, - system_prompt: system_prompt - .map(|value| read_or_stdin(&value)) - .transpose()?, - runtime, - provider, - model, - respond_to: respond_to.map(RespondToArg::to_wire), - }, - )?, + } => { + let owner = require_owner(client)?; + let built = build_update( + client.keys(), + &owner, + UpdateAgentDraft { + channel_id: channel, + agent_name, + display_name, + system_prompt: system_prompt.map(|v| read_or_stdin(&v)).transpose()?, + runtime, + provider, + model, + respond_to: respond_to.map(RespondToArg::to_wire), + }, + )?; + let response = client.publish_ephemeral_event(built.event).await?; + let mut output: serde_json::Value = serde_json::from_str(&response) + .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; + if let Some(obj) = output.as_object_mut() { + obj.insert("request_id".into(), built.request_id.into()); + obj.insert("action".into(), built.action.into()); + obj.insert("saved".into(), false.into()); + obj.insert( + "message".into(), + "Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it." + .into(), + ); + } + println!("{output}"); + Ok(()) + } + + AgentsCmd::Archive { + target_pubkey, + reason, + replaced_by, + content, + } => { + validate_hex64(&target_pubkey)?; + let signer_hex = client.keys().public_key().to_hex(); + let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let builder = build_archive_identity_request( + &target_pubkey, + &content, + reason.as_deref(), + replaced_by.as_deref(), + auth.as_ref(), + ) + .map_err(|e| CliError::Usage(format!("invalid archive request: {e}")))?; + let event = client.sign_event_unchecked(builder)?; + let event_id = event.id.to_hex(); + client.submit_event(event).await?; + println!( + "{}", + json!({ + "ok": true, + "event_id": event_id, + "action": "archive", + "target": target_pubkey, + }) + ); + Ok(()) + } + + AgentsCmd::Unarchive { + target_pubkey, + reason, + content, + } => { + validate_hex64(&target_pubkey)?; + let signer_hex = client.keys().public_key().to_hex(); + let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?; + let builder = build_unarchive_identity_request( + &target_pubkey, + &content, + reason.as_deref(), + auth.as_ref(), + ) + .map_err(|e| CliError::Usage(format!("invalid unarchive request: {e}")))?; + let event = client.sign_event_unchecked(builder)?; + let event_id = event.id.to_hex(); + client.submit_event(event).await?; + println!( + "{}", + json!({ + "ok": true, + "event_id": event_id, + "action": "unarchive", + "target": target_pubkey, + }) + ); + Ok(()) + } + + AgentsCmd::Archived => cmd_archived(client).await, + } +} + +/// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by +/// the `draft-create` and `draft-update` paths. +fn require_owner(client: &BuzzClient) -> Result { + let hex = client + .auth_tag_owner_hex() + .ok_or_else(|| CliError::Auth("agent draft requests require BUZZ_AUTH_TAG".into()))?; + PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}"))) +} + +/// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests. +/// +/// Mirrors the desktop's `maybe_owner_auth_tag`: +/// - `target == signer`: self path — no auth needed → `Ok(None)`. +/// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner +/// (index 1) matches the signer. Return it when present; `Ok(None)` when +/// absent or structurally malformed. Query/network failures surface as +/// `Err` — silent degradation to bare would make the relay reject the +/// request with a misleading error. +async fn resolve_auth( + client: &BuzzClient, + target_hex: &str, + signer_hex: &str, +) -> Result, CliError> { + if target_hex.eq_ignore_ascii_case(signer_hex) { + return Ok(None); + } + let filter = json!({"kinds": [0], "authors": [target_hex], "limit": 1}); + let raw = client + .query(&filter) + .await + .map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?; + let event = match events.into_iter().next() { + Some(e) => e, + None => return Ok(None), }; + let tags = match event.get("tags").and_then(|v| v.as_array()) { + Some(t) => t, + None => return Ok(None), + }; + Ok(extract_owner_auth_tag(tags, signer_hex)) +} - let response = client.publish_ephemeral_event(built.event).await?; - let mut output: serde_json::Value = serde_json::from_str(&response) - .map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?; - if let Some(object) = output.as_object_mut() { - object.insert("request_id".into(), built.request_id.into()); - object.insert("action".into(), built.action.into()); - object.insert("saved".into(), false.into()); - object.insert( - "message".into(), - "Draft sent to Buzz Desktop for owner review. Nothing changes until the owner saves it." - .into(), - ); +/// Pure extraction helper: require exactly one kind:0 tag whose first +/// element is `"auth"` (a set-level rule — a valid tag alongside a second +/// malformed or duplicate `auth`-labeled tag is bare, not the valid one), +/// then structurally validate that sole tag as +/// `["auth", owner, conditions, sig]` matching `signer_hex`. +/// +/// Malformed tags (wrong arity, non-string elements, non-hex fields) are +/// silently skipped — the contract is "bare" (None), not error. +fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> { + let auth_tags: Vec<&serde_json::Value> = tags + .iter() + .filter(|tag| { + tag.as_array() + .and_then(|elems| elems.first()) + .and_then(|v| v.as_str()) + == Some("auth") + }) + .collect(); + if auth_tags.len() != 1 { + return None; + } + + let elems = auth_tags[0].as_array()?; + if elems.len() != 4 { + return None; + } + let label = elems[0].as_str()?; + let owner = elems[1].as_str()?; + if !owner.eq_ignore_ascii_case(signer_hex) { + return None; + } + let conditions = elems[2].as_str()?; + let sig = elems[3].as_str()?; + if owner.len() != 64 + || !owner.chars().all(|c| c.is_ascii_hexdigit()) + || sig.len() != 128 + || !sig.chars().all(|c| c.is_ascii_hexdigit()) + { + return None; + } + Some([ + label.to_owned(), + owner.to_owned(), + conditions.to_owned(), + sig.to_owned(), + ]) +} + +/// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and +/// normalize it to lowercase, so the archived-identities query filter and +/// the author comparison in [`verify_archived_event`] agree regardless of +/// the case the relay published `self` in. +fn normalize_relay_self_hex(self_hex: &str) -> Result { + if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(CliError::Other(format!( + "relay 'self' field is not a valid 64-hex pubkey: {self_hex}" + ))); + } + Ok(self_hex.to_ascii_lowercase()) +} + +/// Read and verify the relay's NIP-IA archived-identities snapshot (kind 13535). +/// +/// Three trust states: +/// - State 1: no events — `{"archived": []}`, exit 0 +/// - State 2: event passes all checks — `{"archived": [...]}`, exit 0 +/// - State 3: trust failure — error, exit nonzero +async fn cmd_archived(client: &BuzzClient) -> Result<(), CliError> { + // Fetch NIP-11 info to get the relay's self pubkey. + let nip11_raw = client + .get_public("/") + .await + .map_err(|e| CliError::Other(format!("failed to fetch relay info document: {e}")))?; + let nip11: serde_json::Value = serde_json::from_str(&nip11_raw) + .map_err(|e| CliError::Other(format!("relay info document is not valid JSON: {e}")))?; + let self_hex = nip11 + .get("self") + .and_then(|v| v.as_str()) + .ok_or_else(|| CliError::Other("relay info document missing 'self' field".into()))?; + let self_hex = normalize_relay_self_hex(self_hex)?; + + // Query for the archived-identities list. + let filter = json!({"kinds": [KIND_IA_ARCHIVED_LIST], "authors": [self_hex], "limit": 1}); + let raw = client + .query(&filter) + .await + .map_err(|e| CliError::Other(format!("failed to query archived-identities list: {e}")))?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid query response: {e}")))?; + + // State 1: no events. + if events.is_empty() { + println!("{}", json!({"archived": []})); + return Ok(()); } - println!("{output}"); + + // State 2 or 3: verify then collect. + let raw_event = events.into_iter().next().unwrap(); + let event: nostr::Event = serde_json::from_value(raw_event) + .map_err(|e| CliError::Other(format!("archived-identities event is malformed: {e}")))?; + let archived = verify_archived_event(&event, &self_hex)?; + + println!("{}", json!({"archived": archived})); Ok(()) } + +/// Pure verification of a kind:13535 archived-identities event. +/// +/// Returns the list of valid hex64 pubkeys from `p` tags on success, or a +/// named trust-failure error (State 3). +fn verify_archived_event<'a>( + event: &'a nostr::Event, + relay_self_hex: &str, +) -> Result, CliError> { + if event.kind != nostr::Kind::Custom(KIND_IA_ARCHIVED_LIST as u16) { + return Err(CliError::Other(format!( + "archived-identities event has wrong kind: {}", + event.kind.as_u16() + ))); + } + + if event.pubkey.to_hex() != relay_self_hex { + return Err(CliError::Other(format!( + "archived-identities event author {} does not match relay self {}", + event.pubkey.to_hex(), + relay_self_hex + ))); + } + + let mut nip70_count = 0usize; + for t in event.tags.iter() { + let s = t.as_slice(); + if s.first().map(String::as_str) != Some("-") { + continue; + } + if s.len() != 1 { + return Err(CliError::Other( + "archived-identities event has a malformed NIP-70 '-' tag (expected arity 1)" + .into(), + )); + } + nip70_count += 1; + } + if nip70_count != 1 { + return Err(CliError::Other(format!( + "archived-identities event must have exactly one NIP-70 '-' tag, found {nip70_count}" + ))); + } + + event.verify().map_err(|e| { + CliError::Other(format!( + "archived-identities event failed cryptographic verification: {e}" + )) + })?; + + let archived: Vec<&str> = event + .tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + if s.first().map(String::as_str) == Some("p") { + let pk = s.get(1).map(String::as_str)?; + if pk.len() == 64 && pk.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(pk); + } + } + None + }) + .collect(); + + Ok(archived) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::kind::KIND_IA_ARCHIVED_LIST; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use serde_json::json; + + fn hex64(c: char) -> String { + std::iter::repeat_n(c, 64).collect() + } + + fn hex128(c: char) -> String { + std::iter::repeat_n(c, 128).collect() + } + + // --- (b) auth-selection matrix: extract_owner_auth_tag --- + + #[test] + fn auth_selection_owner_match_returns_tag() { + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![json!(["auth", signer, "conditions", sig])]; + let result = extract_owner_auth_tag(&tags, &signer); + assert!(result.is_some()); + let tag = result.unwrap(); + assert_eq!(tag[0], "auth"); + assert_eq!(tag[1], signer); + assert_eq!(tag[2], "conditions"); + assert_eq!(tag[3], sig); + } + + #[test] + fn auth_selection_non_owner_returns_none() { + let signer = hex64('a'); + let other_owner = hex64('b'); + let tags = vec![json!(["auth", other_owner, "", hex128('c')])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_malformed_three_elements_returns_none() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, "conditions"])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_malformed_five_elements_returns_none() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, "conditions", hex128('b'), "extra"])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_malformed_non_hex_owner_returns_none() { + let signer = "z".repeat(64); + let tags = vec![json!(["auth", signer, "", hex128('a')])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_malformed_non_hex_sig_returns_none() { + let signer = hex64('a'); + let bad_sig = "z".repeat(128); + let tags = vec![json!(["auth", signer, "", bad_sig])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_malformed_short_sig_returns_none() { + let signer = hex64('a'); + let short_sig = hex128('a')[..64].to_string(); + let tags = vec![json!(["auth", signer, "", short_sig])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_case_insensitive_owner_match() { + let signer_lower = hex64('a'); + let signer_upper = signer_lower.to_uppercase(); + let sig = hex128('b'); + let tags = vec![json!(["auth", signer_upper, "cond", sig])]; + let result = extract_owner_auth_tag(&tags, &signer_lower); + assert!(result.is_some()); + } + + #[test] + fn auth_selection_non_string_elements_returns_none() { + let signer = hex64('a'); + let tags = vec![json!(["auth", signer, 42, hex128('b')])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_non_array_tag_skipped() { + let signer = hex64('a'); + let tags = vec![ + json!("not an array"), + json!(["auth", signer, "", hex128('b')]), + ]; + let result = extract_owner_auth_tag(&tags, &signer); + assert!(result.is_some()); + } + + #[test] + fn auth_selection_no_tags_returns_none() { + assert!(extract_owner_auth_tag(&[], &hex64('a')).is_none()); + } + + #[test] + fn auth_selection_wrong_label_returns_none() { + let signer = hex64('a'); + let tags = vec![json!(["delegation", signer, "", hex128('b')])]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_valid_plus_duplicate_auth_tag_returns_none() { + // Set-level rule (F6): a structurally valid, owner-matching `auth` + // tag alongside a second `auth`-labeled tag (malformed or a + // duplicate) must not be selected — the whole kind:0 is bare. + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![ + json!(["auth", signer, "conditions", sig]), + json!(["auth", signer, "conditions", sig]), + ]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + #[test] + fn auth_selection_valid_plus_malformed_second_auth_tag_returns_none() { + let signer = hex64('a'); + let sig = hex128('b'); + let tags = vec![ + json!(["auth", signer, "conditions", sig]), + json!(["auth", "not-hex", "conditions"]), + ]; + assert!(extract_owner_auth_tag(&tags, &signer).is_none()); + } + + // --- (d) NIP-11 self normalization: normalize_relay_self_hex --- + + #[test] + fn normalize_self_lowercases_uppercase_hex() { + let upper = hex64('A'); + let result = normalize_relay_self_hex(&upper).expect("should pass"); + assert_eq!(result, hex64('a')); + } + + #[test] + fn normalize_self_rejects_wrong_length() { + assert!(normalize_relay_self_hex(&hex64('a')[..63]).is_err()); + } + + #[test] + fn normalize_self_rejects_non_hex() { + assert!(normalize_relay_self_hex(&"z".repeat(64)).is_err()); + } + + #[test] + fn archived_uppercase_self_matches_lowercase_event_author() { + // F7: an uppercase NIP-11 `self` must still resolve to the same + // relay identity as the event's (always-lowercase) author hex once + // normalized — before the fix this was a case-sensitive mismatch. + let keys = Keys::generate(); + let self_hex_lower = keys.public_key().to_hex(); + let self_hex_upper = self_hex_lower.to_uppercase(); + let normalized = normalize_relay_self_hex(&self_hex_upper).expect("valid hex"); + let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], true); + let result = verify_archived_event(&event, &normalized).expect("should pass"); + assert!(result.is_empty()); + } + + // --- (c) snapshot tri-state: verify_archived_event --- + + fn build_archived_event( + keys: &Keys, + kind: u16, + p_tags: &[&str], + include_nip70: bool, + ) -> nostr::Event { + let mut tags: Vec = Vec::new(); + if include_nip70 { + tags.push(Tag::parse(["-"]).unwrap()); + } + for pk in p_tags { + tags.push(Tag::parse(["p", pk]).unwrap()); + } + EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign") + } + + #[test] + fn archived_state2_valid_event_returns_pubkeys() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let pk1 = hex64('a'); + let pk2 = hex64('b'); + let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[&pk1, &pk2], true); + let result = verify_archived_event(&event, &self_hex).expect("should pass"); + assert_eq!(result, vec![pk1.as_str(), pk2.as_str()]); + } + + #[test] + fn archived_state2_empty_p_tags_returns_empty() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], true); + let result = verify_archived_event(&event, &self_hex).expect("should pass"); + assert!(result.is_empty()); + } + + #[test] + fn archived_state3_wrong_kind_errors() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = build_archived_event(&keys, 9999, &[], true); + let err = verify_archived_event(&event, &self_hex).unwrap_err(); + assert!( + err.to_string().contains("wrong kind"), + "error should name wrong kind: {err}" + ); + } + + #[test] + fn archived_state3_wrong_author_errors() { + let event_keys = Keys::generate(); + let other_self = hex64('f'); + let event = build_archived_event(&event_keys, KIND_IA_ARCHIVED_LIST as u16, &[], true); + let err = verify_archived_event(&event, &other_self).unwrap_err(); + assert!( + err.to_string().contains("does not match relay self"), + "error should name author mismatch: {err}" + ); + } + + #[test] + fn archived_state3_no_nip70_tag_errors() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = build_archived_event(&keys, KIND_IA_ARCHIVED_LIST as u16, &[], false); + let err = verify_archived_event(&event, &self_hex).unwrap_err(); + assert!( + err.to_string().contains("NIP-70"), + "error should name missing NIP-70 tag: {err}" + ); + } + + #[test] + fn archived_state3_duplicate_nip70_tags_errors() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags([Tag::parse(["-"]).unwrap(), Tag::parse(["-"]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + let err = verify_archived_event(&event, &self_hex).unwrap_err(); + assert!( + err.to_string().contains("found 2"), + "error should report 2 NIP-70 tags: {err}" + ); + } + + #[test] + fn archived_state3_lone_malformed_nip70_tag_errors() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags([Tag::parse(["-", "extra"]).unwrap()]) + .sign_with_keys(&keys) + .expect("sign"); + let err = verify_archived_event(&event, &self_hex).unwrap_err(); + assert!( + err.to_string().contains("malformed NIP-70"), + "error should name the malformed NIP-70 tag: {err}" + ); + } + + #[test] + fn archived_state3_exact_marker_plus_malformed_marker_errors() { + // F5 (IMPORTANT, discriminating): a valid `["-"]` alongside a + // malformed `["-", "extra"]` must still poison the snapshot — the + // old count-of-exact-shape-only check let this bypass through with + // nip70_count == 1. + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["-", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let err = verify_archived_event(&event, &self_hex).unwrap_err(); + assert!( + err.to_string().contains("malformed NIP-70"), + "error should name the malformed NIP-70 tag: {err}" + ); + } + + #[test] + fn archived_non_hex_p_tag_dropped() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let valid_pk = hex64('a'); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &valid_pk]).unwrap(), + Tag::parse(["p", "not-hex-at-all"]).unwrap(), + Tag::parse(["p", &"z".repeat(64)]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let result = verify_archived_event(&event, &self_hex).expect("should pass"); + assert_eq!(result, vec![valid_pk.as_str()]); + } + + #[test] + fn archived_short_p_tag_dropped() { + let keys = Keys::generate(); + let self_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &hex64('a')[..32]]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let result = verify_archived_event(&event, &self_hex).expect("should pass"); + assert!(result.is_empty()); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 262334b23e..446db37617 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -280,6 +280,56 @@ pub enum AgentsCmd { #[arg(long, value_enum)] respond_to: Option, }, + /// Submit a NIP-IA archive request for an identity (kind 9035) + #[command( + after_help = "The relay chooses the consent path (self / admin / owner) from the \ +submitted request; this command does not retry with a different shape.\n\n\ +Suggested --reason codes (unknown values are allowed): rotated, retired, \ +bot-rebuilt, left-organization, spam\n\n\ +Archiving a third-party identity is a human owner/admin action: an agent \ +running under BUZZ_AUTH_TAG signs as itself, so it can only ever satisfy \ +the self path (target == signer) — not the owner-of-agent path for another \ +identity.\n\n\ +Examples:\n \ +buzz agents archive --reason retired\n \ +buzz agents archive --reason bot-rebuilt --replaced-by " + )] + Archive { + /// Target identity pubkey (hex) + target_pubkey: String, + /// Machine-readable reason code, max 64 UTF-8 bytes + #[arg(long)] + reason: Option, + /// Rotation pointer pubkey (hex); must differ from the target + #[arg(long)] + replaced_by: Option, + /// Optional human-readable note (not parsed for authorization) + #[arg(long, default_value = "")] + content: String, + }, + /// Submit a NIP-IA unarchive request for an identity (kind 9036) + #[command(after_help = "Examples:\n \ +buzz agents unarchive --reason returned")] + Unarchive { + /// Target identity pubkey (hex) + target_pubkey: String, + /// Machine-readable reason code, max 64 UTF-8 bytes + #[arg(long)] + reason: Option, + /// Optional human-readable note (not parsed for authorization) + #[arg(long, default_value = "")] + content: String, + }, + /// Read the relay's current NIP-IA archive snapshot (kind 13535) + #[command( + after_help = "Verifies the snapshot's NIP-11 `self` authorship, event id, signature, \ +and NIP-70 `-` protection tag before trusting it. Any trust failure is a \ +nonzero-exit error, never a false-empty success — this command's whole \ +purpose is verification.\n\n\ +Examples:\n \ +buzz agents archived" + )] + Archived, } #[derive(Subcommand)] @@ -1748,7 +1798,16 @@ mod tests { } let cmd = Cli::command(); - assert_eq!(names(&cmd, "agents"), vec!["draft-create", "draft-update"]); + assert_eq!( + names(&cmd, "agents"), + vec![ + "archive", + "archived", + "draft-create", + "draft-update", + "unarchive" + ] + ); assert_eq!( names(&cmd, "messages"), vec![ @@ -1848,7 +1907,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 2), + ("agents", 5), ("canvas", 2), ("channels", 16), ("dms", 4), diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 31493c8985..abe9d8aedc 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -9,9 +9,10 @@ use buzz_core::{ KIND_DM_ADD_MEMBER, KIND_DM_OPEN, KIND_EMOJI_SET, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, - KIND_GIT_STATUS_OPEN, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, - KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, + KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1647,6 +1648,143 @@ pub fn build_moderation_resolve_report( Ok(EventBuilder::new(Kind::Custom(KIND_MODERATION_RESOLVE_REPORT as u16), "").tags(tags)) } +// --------------------------------------------------------------------------- +// NIP-IA identity archival (kinds 9035/9036). +// +// kind:9035 archive request, kind:9036 unarchive request. Both protected by +// NIP-70 (`["-"]`), p-tag the target, and may carry an optional machine- +// readable `reason` code, a `replaced-by` rotation pointer (9035 only), and a +// NIP-OA `auth` tag for owner-of-agent requests. The relay verifies; this +// builder's job is to produce a well-formed, signed request — the relay +// selects the consent path (self / admin / owner). Mirrors the desktop's +// `identity_archive_tags`/`build_archive_identity_request` (events.rs:624-743) +// so both clients emit the same wire form. +// --------------------------------------------------------------------------- + +/// Maximum `reason` length in UTF-8 bytes (not chars — see +/// `desktop/src-tauri/src/events.rs:635-647`, whose `.len()` check is +/// already byte-based despite its error text saying "chars"). +const MAX_REASON_BYTES: usize = 64; + +fn check_reason(reason: &str) -> Result<(), SdkError> { + if reason.len() > MAX_REASON_BYTES { + return Err(SdkError::InvalidInput(format!( + "reason code exceeds maximum length of {MAX_REASON_BYTES} UTF-8 bytes (got {})", + reason.len() + ))); + } + if reason.chars().any(|c| c.is_control()) { + return Err(SdkError::InvalidInput( + "reason code must not contain control characters".into(), + )); + } + Ok(()) +} + +/// Structural check only — the relay performs full NIP-OA verification. +/// Requires the `auth` label, a 64-hex owner pubkey, and a 128-hex signature. +fn check_auth_tag_shape(auth: &[String; 4]) -> Result<(), SdkError> { + if auth[0] != "auth" { + return Err(SdkError::InvalidInput(format!( + "auth tag label must be \"auth\" (got \"{}\")", + auth[0] + ))); + } + check_pubkey_hex(&auth[1], "auth tag owner pubkey")?; + if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput( + "auth tag signature must be 128-character hex".into(), + )); + } + Ok(()) +} + +fn identity_archive_tags( + target_pubkey: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result, SdkError> { + let target_lower = check_pubkey_hex(target_pubkey, "target_pubkey")?; + + // NIP-70: mark as protected administrative state. + let mut tags = vec![tag(&["-"])?, tag(&["p", &target_lower])?]; + + if let Some(r) = reason { + check_reason(r)?; + tags.push(tag(&["reason", r])?); + } + + if let Some(rb) = replaced_by { + let rb_lower = check_pubkey_hex(rb, "replaced_by")?; + if rb_lower == target_lower { + return Err(SdkError::InvalidInput( + "replaced-by must differ from the target".into(), + )); + } + tags.push(tag(&["replaced-by", &rb_lower])?); + } + + if let Some(auth_tag) = auth { + check_auth_tag_shape(auth_tag)?; + tags.push(tag(&["auth", &auth_tag[1], &auth_tag[2], &auth_tag[3]])?); + } + + Ok(tags) +} + +/// Build a NIP-IA archive request (kind 9035). +/// +/// `content` is an optional human-readable reason (clients MUST NOT parse +/// authorization semantics from it; capped at 64 KiB like other content +/// fields). `reason` is a machine-readable code (`rotated`, `retired`, +/// `bot-rebuilt`, `left-organization`, `spam`, ... — unknown codes are +/// allowed per spec), capped at 64 UTF-8 bytes. `replaced_by` is the +/// rotation pointer and must differ from `target_pubkey`. `auth` is a +/// NIP-OA owner-attestation tag required only for the owner-of-agent +/// consent path; full verification happens relay-side. +/// +/// `.allow_self_tagging()` is required: NIP-IA's self path has +/// `actor == target`, so the request's `["p", target]` matches the signer. +/// nostr 0.44 strips matching `p` tags by default — this keeps the wire +/// form intact. +pub fn build_archive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content, 64 * 1024)?; + let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + +/// Build a NIP-IA unarchive request (kind 9036). +/// +/// Same shape as [`build_archive_identity_request`] minus `replaced-by` +/// (which has no defined meaning on unarchive per spec). `auth` is used for +/// owner-of-agent unarchive paths. See that function for the rationale on +/// `.allow_self_tagging()`. +pub fn build_unarchive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content, 64 * 1024)?; + let tags = identity_archive_tags(target_pubkey, reason, None, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -3413,4 +3551,133 @@ mod tests { build_moderation_resolve_report(&"a".repeat(65), "resolved", "ban", None).unwrap_err(); assert!(matches!(err, SdkError::InvalidInput(_))); } + + // ── NIP-IA identity archival (kinds 9035/9036) ──────────────────────── + // + // Mirrors `desktop/src-tauri/src/events.rs`'s own tests so both clients + // are pinned to the same wire form. See NIP-IA.md §Vector 1. + + const IA_OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const IA_TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const IA_CONDITIONS: &str = "kind=1&created_at<1713957000"; + const IA_SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + #[test] + fn archive_identity_request_matches_spec_vector_1_layout() { + let auth: [String; 4] = [ + "auth".into(), + IA_OWNER_HEX.into(), + IA_CONDITIONS.into(), + IA_SIG.into(), + ]; + let ev = sign( + build_archive_identity_request( + IA_TARGET_HEX, + "Archiving zombie agent after rebuild.", + Some("bot-rebuilt"), + None, + Some(&auth), + ) + .unwrap(), + ); + + let tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(ev.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); + // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); + assert_eq!(tags[3], vec!["auth", IA_OWNER_HEX, IA_CONDITIONS, IA_SIG]); + assert_eq!(tags.len(), 4); + } + + #[test] + fn archive_request_rejects_replaced_by_equal_target() { + let err = + build_archive_identity_request(IA_TARGET_HEX, "", None, Some(IA_TARGET_HEX), None) + .unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn unarchive_request_layout_self_path() { + // Self-unarchive: actor == target, so the `p` tag points at the + // signer. Pins that `.allow_self_tagging()` survives nostr 0.44's + // default same-pubkey `p`-tag scrub, and that no `auth` tag rides + // along on the self path. + let builder = build_unarchive_identity_request( + IA_TARGET_HEX, + "I am active again.", + Some("returned"), + None, + ) + .unwrap(); + let target_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let ev = builder + .sign_with_keys(&Keys::new(target_secret)) + .expect("sign"); + let tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(ev.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", IA_TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "returned"]); + assert_eq!(tags.len(), 3, "self unarchive must not carry an auth tag"); + assert_eq!(ev.pubkey.to_hex(), IA_TARGET_HEX); + } + + #[test] + fn identity_archive_reason_accepts_64_bytes() { + // check_reason compares `.len()` (bytes), not chars — use a + // multi-byte char so a chars-based off-by-one would slip through. + let reason: String = "é".repeat(32); // 32 * 2 bytes = 64 bytes exactly + assert_eq!(reason.len(), 64); + build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None) + .expect("64-byte reason must be accepted"); + } + + #[test] + fn identity_archive_reason_rejects_65_bytes() { + let reason: String = "a".repeat(65); + let err = build_archive_identity_request(IA_TARGET_HEX, "", Some(&reason), None, None) + .unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn identity_archive_reason_rejects_control_chars() { + let err = build_unarchive_identity_request(IA_TARGET_HEX, "", Some("bad\nreason"), None) + .unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn identity_archive_rejects_malformed_auth_tag() { + let bad_auth: [String; 4] = [ + "auth".into(), + IA_OWNER_HEX.into(), + IA_CONDITIONS.into(), + "not-hex".into(), + ]; + let err = build_archive_identity_request(IA_TARGET_HEX, "", None, None, Some(&bad_auth)) + .unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn unarchive_request_has_no_replaced_by_param() { + // 9036 has no `replaced_by` parameter at all (unlike 9035) — this is + // a compile-time guarantee, but pin the tag count so a future + // signature change that reintroduces it doesn't silently widen the + // wire form without a test failing. + let ev = sign( + build_unarchive_identity_request(IA_TARGET_HEX, "", Some("returned"), None).unwrap(), + ); + assert!(!ev + .tags + .iter() + .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); + } }