mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
feat(serverless): agents receive NIP-17 encrypted messages (sprout-acp)
The agent harness now subscribes to its own gift-wrap inbox (kind 1059, #p = agent) alongside membership notifications, unwraps each, recovers the inner kind-9 rumor (verified sender + h tag), and forwards it as a normal SproutEvent. The respond-to allowlist gate and rule matching run unchanged — so an agent in an encrypted private channel only responds to blessed npubs, exactly as on the Sprout server. - send_gift_wrap_subscribe (mirrors observer-control), tied to membership sub (active + reconnect restore) - dispatch: 1059 on GIFT_WRAP_SUB_ID -> UnwrappedGift -> rumor_to_event - rumor_to_event: UnsignedEvent -> Event (seal already verified sender; pipeline never reads .sig) - nip59 enabled workspace-wide; unit test covers unwrap->channel event
This commit is contained in:
@@ -56,7 +56,7 @@ redis = { version = "1.0", features = ["tokio-comp", "connection-manager"
|
||||
deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] }
|
||||
|
||||
# Nostr
|
||||
nostr = { version = "0.44", features = ["nip44", "nip98"] }
|
||||
nostr = { version = "0.44", features = ["nip44", "nip59", "nip98"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -567,6 +567,12 @@ enum RelayMessage {
|
||||
const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif";
|
||||
/// Subscription ID for encrypted owner-to-agent observer control frames.
|
||||
const OBSERVER_CONTROL_SUB_ID: &str = "agent-observer-control";
|
||||
/// Subscription ID for the agent's NIP-17 gift-wrap inbox (encrypted serverless
|
||||
/// private channels / DMs). The relay only stores `kind 1059` blobs addressed
|
||||
/// by `#p`; the agent unwraps each and routes the inner message by its `h` tag.
|
||||
const GIFT_WRAP_SUB_ID: &str = "agent-gift-wrap-inbox";
|
||||
/// NIP-59 gift wrap kind.
|
||||
const KIND_GIFT_WRAP: u16 = 1059;
|
||||
|
||||
/// Commands sent from `HarnessRelay` to the background WebSocket task.
|
||||
enum RelayCommand {
|
||||
@@ -1328,6 +1334,10 @@ async fn execute_connected_command(
|
||||
RelayCommand::SubscribeMembership => {
|
||||
let since = state.membership_last_seen.or(state.startup_watermark);
|
||||
let sent = send_membership_subscribe(ws, agent_pubkey_hex, since).await;
|
||||
// Also bring up the NIP-17 gift-wrap inbox so the agent can receive
|
||||
// encrypted messages in serverless private channels / DMs. Tied to
|
||||
// membership so it's active whenever the agent is listening.
|
||||
let _ = send_gift_wrap_subscribe(ws, agent_pubkey_hex).await;
|
||||
if sent {
|
||||
state.membership_sub_active = true;
|
||||
if state.membership_last_seen.is_none() {
|
||||
@@ -1817,7 +1827,45 @@ async fn handle_ws_message(
|
||||
subscription_id,
|
||||
event,
|
||||
} => {
|
||||
if subscription_id == OBSERVER_CONTROL_SUB_ID {
|
||||
if subscription_id == GIFT_WRAP_SUB_ID {
|
||||
// NIP-17 encrypted message in a serverless private channel
|
||||
// or DM. Unwrap to recover the inner kind-9 rumor (which
|
||||
// carries the channel `h` tag), then forward it as a normal
|
||||
// SproutEvent so the respond-to gate + rule matching run
|
||||
// unchanged.
|
||||
let wrap_id = event.id.to_hex();
|
||||
if !state.seen_ids.insert(wrap_id.clone()) {
|
||||
return true; // duplicate wrapper
|
||||
}
|
||||
match nostr::nips::nip59::UnwrappedGift::from_gift_wrap(keys, &event).await
|
||||
{
|
||||
Ok(unwrapped) => {
|
||||
if let Some(inner) =
|
||||
rumor_to_event(&unwrapped.rumor, unwrapped.sender)
|
||||
{
|
||||
if let Some(channel_uuid) = extract_h_tag_uuid(&inner) {
|
||||
let sprout_event = SproutEvent {
|
||||
channel_id: channel_uuid,
|
||||
event: inner,
|
||||
};
|
||||
match event_tx.try_send(Some(sprout_event)) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
state.seen_ids.remove(&wrap_id);
|
||||
warn!("encrypted event dropped (backpressure)");
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("gift wrap not for us / undecryptable: {e}");
|
||||
}
|
||||
}
|
||||
} else if subscription_id == OBSERVER_CONTROL_SUB_ID {
|
||||
match observer_control_tx.try_send(*event) {
|
||||
Ok(()) => {}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
@@ -2216,6 +2264,11 @@ async fn resubscribe_after_reconnect(
|
||||
warn!("failed to resubscribe membership after reconnect");
|
||||
all_ok = false;
|
||||
}
|
||||
// Restore the gift-wrap inbox alongside membership.
|
||||
if !send_gift_wrap_subscribe(ws, agent_pubkey_hex).await {
|
||||
warn!("failed to resubscribe gift-wrap inbox after reconnect");
|
||||
all_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
if state.observer_control_sub_active
|
||||
@@ -2631,6 +2684,45 @@ async fn send_membership_subscribe(
|
||||
}
|
||||
|
||||
/// Send a NIP-01 REQ for owner-to-agent observer control frames.
|
||||
/// Subscribe to the agent's NIP-17 gift-wrap inbox (kind 1059, `#p` = agent).
|
||||
/// Gift wraps have randomized timestamps (up to ~2 days in the past per NIP-59),
|
||||
/// so we don't apply a tight `since` — a small look-back avoids replaying the
|
||||
/// entire history while still catching freshly-wrapped messages.
|
||||
async fn send_gift_wrap_subscribe(ws: &mut WsStream, agent_pubkey_hex: &str) -> bool {
|
||||
let since = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.saturating_sub(2 * 24 * 60 * 60); // NIP-59 timestamp tweak window
|
||||
let req = json!([
|
||||
"REQ",
|
||||
GIFT_WRAP_SUB_ID,
|
||||
{
|
||||
"kinds": [KIND_GIFT_WRAP],
|
||||
"#p": [agent_pubkey_hex],
|
||||
"since": since,
|
||||
}
|
||||
]);
|
||||
match serde_json::to_string(&req) {
|
||||
Ok(text) => {
|
||||
match ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await {
|
||||
Ok(()) => {
|
||||
debug!("subscribed to gift-wrap inbox");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to send gift-wrap REQ: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("failed to serialize gift-wrap REQ: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_observer_control_subscribe(ws: &mut WsStream, agent_pubkey_hex: &str) -> bool {
|
||||
let req = json!([
|
||||
"REQ",
|
||||
@@ -2694,6 +2786,28 @@ fn jittered_duration(base: Duration) -> Duration {
|
||||
}
|
||||
|
||||
/// Extract a channel UUID from the h tag of a Nostr event.
|
||||
/// Convert an unwrapped NIP-17 rumor (`UnsignedEvent`, no signature) into a
|
||||
/// concrete `Event` for the harness pipeline. The gift-wrap seal already
|
||||
/// cryptographically verified `sender`, so we trust the rumor's author; the
|
||||
/// downstream pipeline reads only id/pubkey/kind/created_at/tags/content (never
|
||||
/// `.sig`). We materialize an `Event` via JSON with an empty signature.
|
||||
fn rumor_to_event(rumor: &nostr::UnsignedEvent, sender: nostr::PublicKey) -> Option<nostr::Event> {
|
||||
let id = rumor.id?;
|
||||
// Rumors carry no signature (NIP-17). The seal already verified `sender`,
|
||||
// and the downstream pipeline never inspects `.sig`, so we attach an
|
||||
// all-zero placeholder signature. `Event::new` does not verify.
|
||||
let sig = nostr::secp256k1::schnorr::Signature::from_slice(&[0u8; 64]).ok()?;
|
||||
Some(nostr::Event::new(
|
||||
id,
|
||||
sender,
|
||||
rumor.created_at,
|
||||
rumor.kind,
|
||||
rumor.tags.iter().cloned(),
|
||||
rumor.content.clone(),
|
||||
sig,
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_h_tag_uuid(event: &nostr::Event) -> Option<Uuid> {
|
||||
event.tags.iter().find_map(|tag| {
|
||||
let tag_vec = tag.as_slice();
|
||||
@@ -3658,4 +3772,40 @@ mod tests {
|
||||
"after backpressure removal, replay must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gift_wrap_unwraps_to_usable_channel_event() {
|
||||
// End-to-end of the agent's inbound encrypted path: a sender gift-wraps
|
||||
// a kind-9 message (with an h tag) to the agent; the agent unwraps it,
|
||||
// converts the rumor to an Event, and recovers the channel + content.
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
let sender = Keys::generate();
|
||||
let agent = Keys::generate();
|
||||
let channel = uuid::Uuid::new_v4();
|
||||
|
||||
let rumor = EventBuilder::new(Kind::Custom(9), "hello agent")
|
||||
.tags([Tag::parse(vec!["h", &channel.to_string()]).unwrap()])
|
||||
.build(sender.public_key());
|
||||
|
||||
let wrap = EventBuilder::gift_wrap(&sender, &agent.public_key(), rumor, [])
|
||||
.await
|
||||
.expect("wrap");
|
||||
|
||||
// Agent unwraps with its own key.
|
||||
let unwrapped = nostr::nips::nip59::UnwrappedGift::from_gift_wrap(&agent, &wrap)
|
||||
.await
|
||||
.expect("unwrap");
|
||||
let event = rumor_to_event(&unwrapped.rumor, unwrapped.sender).expect("rumor -> event");
|
||||
|
||||
// The recovered event is usable by the harness pipeline.
|
||||
assert_eq!(event.kind, Kind::Custom(9));
|
||||
assert_eq!(event.content, "hello agent");
|
||||
assert_eq!(
|
||||
event.pubkey,
|
||||
sender.public_key(),
|
||||
"verified sender preserved"
|
||||
);
|
||||
assert_eq!(extract_h_tag_uuid(&event), Some(channel));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ pub async fn decrypt_gift_wrap(
|
||||
Ok(DecryptedEvent {
|
||||
id: rumor.id.map(|id| id.to_hex()).unwrap_or_default(),
|
||||
pubkey: unwrapped.sender.to_hex(),
|
||||
created_at: rumor.created_at.as_u64() as i64,
|
||||
created_at: rumor.created_at.as_secs() as i64,
|
||||
kind: rumor.kind.as_u16(),
|
||||
tags,
|
||||
content: rumor.content.clone(),
|
||||
|
||||
@@ -27,6 +27,7 @@ use nostr::nips::nip59::UnwrappedGift;
|
||||
use nostr::{Event, EventBuilder, Keys, PublicKey, UnsignedEvent};
|
||||
|
||||
/// Kind 1059 — NIP-59 gift wrap.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub const KIND_GIFT_WRAP: u16 = 1059;
|
||||
|
||||
/// Build the gift-wrapped events for `rumor`, one per recipient pubkey.
|
||||
@@ -70,7 +71,6 @@ pub async fn unwrap_gift(
|
||||
Ok(UnwrappedRumor {
|
||||
sender: unwrapped.sender,
|
||||
rumor,
|
||||
wrap_id: gift_wrap.id.to_hex(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ pub struct UnwrappedRumor {
|
||||
pub sender: PublicKey,
|
||||
/// The inner message event (kind 9, carries the channel `h` tag).
|
||||
pub rumor: UnsignedEvent,
|
||||
/// The outer gift-wrap event id (used for relay-side dedup of the wrapper).
|
||||
pub wrap_id: String,
|
||||
}
|
||||
|
||||
impl UnwrappedRumor {
|
||||
|
||||
Reference in New Issue
Block a user