From ced8e62e2c59bc63c78a060ed7fb202685ebc44d Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:39:12 -0500 Subject: [PATCH] feat(sdk): define owner-approved relay terminal protocol Signed-off-by: Tee8z --- Cargo.lock | 1 + apps/buzz-gpui/Cargo.lock | 1 + crates/buzz-core/src/kind.rs | 8 + crates/buzz-relay/src/handlers/ingest.rs | 3 +- crates/buzz-sdk/Cargo.toml | 2 + crates/buzz-sdk/src/lib.rs | 1 + crates/buzz-sdk/src/sandbox_attach.rs | 629 ++++++++++++++++++++ crates/buzz-sdk/src/sandbox_attach/tests.rs | 292 +++++++++ desktop/src-tauri/Cargo.lock | 1 + docs/nips/NIP-TA.md | 50 ++ mobile/lib/shared/relay/nostr_models.dart | 2 + 11 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-sdk/src/sandbox_attach.rs create mode 100644 crates/buzz-sdk/src/sandbox_attach/tests.rs create mode 100644 docs/nips/NIP-TA.md diff --git a/Cargo.lock b/Cargo.lock index b7e7e325c85..7a2332cf4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2138,6 +2138,7 @@ dependencies = [ name = "buzz-sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "buzz-core", "nostr 0.44.7", "serde", diff --git a/apps/buzz-gpui/Cargo.lock b/apps/buzz-gpui/Cargo.lock index 93d22d536ea..bd971bb7141 100644 --- a/apps/buzz-gpui/Cargo.lock +++ b/apps/buzz-gpui/Cargo.lock @@ -1304,6 +1304,7 @@ dependencies = [ name = "buzz-sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "buzz-core", "nostr 0.44.8", "serde", diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 23bbb207535..38dac649ede 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -204,6 +204,7 @@ pub const RESULT_GATED_KINDS: &[u32] = &[ /// included for filter-layer enforcement but are never stored, so the /// storage-layer search defense does not apply to them. pub const P_GATED_KINDS: &[u32] = &[ + KIND_SANDBOX_TERMINAL_FRAME, KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, @@ -713,8 +714,15 @@ pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; /// announcement, never a project. See `docs/nips/NIP-MP.md`. pub const KIND_PROJECT: u32 = 30621; +/// Owner/gateway terminal grants and metadata-only acceptance/end records. +pub const KIND_SANDBOX_ATTACH: u32 = 30624; +/// NIP-44 encrypted, ephemeral terminal frames; never persisted. +pub const KIND_SANDBOX_TERMINAL_FRAME: u32 = 24203; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ + KIND_SANDBOX_ATTACH, + KIND_SANDBOX_TERMINAL_FRAME, KIND_PROFILE, KIND_TEXT_NOTE, KIND_CONTACT_LIST, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1608fd3d0f8..57f98bdb7eb 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -551,7 +551,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { - buzz_core::kind::KIND_AGENT_CHANNEL_GRANT => Ok(Scope::MessagesWrite), + buzz_core::kind::KIND_AGENT_CHANNEL_GRANT | buzz_core::kind::KIND_SANDBOX_ATTACH => Ok(Scope::MessagesWrite), // Owner-managed global authority, like a profile or managed-agent record. buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION => Ok(Scope::UsersWrite), KIND_PROFILE => Ok(Scope::UsersWrite), @@ -4503,6 +4503,7 @@ mod postgres_tests { fn per_kind_scope_allowlist_covers_all_migrated_kinds() { let dummy = make_dummy_event(); let migrated = [ + buzz_core::kind::KIND_SANDBOX_ATTACH, KIND_PROFILE, KIND_DELETION, KIND_REACTION, diff --git a/crates/buzz-sdk/Cargo.toml b/crates/buzz-sdk/Cargo.toml index da8fd0bd0e2..4665da2ef2c 100644 --- a/crates/buzz-sdk/Cargo.toml +++ b/crates/buzz-sdk/Cargo.toml @@ -14,3 +14,5 @@ uuid = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } + +base64 = { workspace = true } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4b47b066bc2..f60cbf3728f 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -17,6 +17,7 @@ pub mod builders; pub mod gateway; pub mod mentions; pub mod nip_oa; +pub mod sandbox_attach; pub mod sharing; pub mod workflow; diff --git a/crates/buzz-sdk/src/sandbox_attach.rs b/crates/buzz-sdk/src/sandbox_attach.rs new file mode 100644 index 00000000000..d7daeee1dc7 --- /dev/null +++ b/crates/buzz-sdk/src/sandbox_attach.rs @@ -0,0 +1,629 @@ +//! Owner-authorized terminal attachment carried only by relay events. +//! +//! This module defines the wire boundary. Consumers must read current grants +//! and delegations through a strong relay query before opening a terminal and +//! periodically while it remains open. No shell or network access occurs here. + +use std::collections::VecDeque; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use buzz_core::observer::{decrypt_observer_payload, encrypt_observer_payload}; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{kind, SdkError}; + +/// Longest terminal grant lifetime, in seconds. +pub const MAX_GRANT_SECS: u64 = 3600; +/// Largest decoded data frame, in bytes. +pub const MAX_DATA_BYTES: usize = 16 * 1024; +/// Frames retained for acknowledgement, per direction. +pub const MAX_IN_FLIGHT: usize = 4; +/// Maximum cumulative unacknowledged data bytes, per direction. +pub const MAX_IN_FLIGHT_BYTES: usize = MAX_IN_FLIGHT * MAX_DATA_BYTES; +/// Frames older than this many seconds are refused. +pub const MAX_FRAME_AGE_SECS: u64 = 15; +/// Delay before an unacknowledged frame is retried. +pub const RETRY_MS: u64 = 1000; +/// Retries before the transport reports a terminal failure. +pub const MAX_RETRIES: u8 = 5; +/// Largest frame rate, including retries and acknowledgements, per direction. +pub const MAX_FRAMES_PER_SECOND: u16 = 64; + +/// Frame admission budget. Apply before cryptography; backpressure sends and drop excess input. +#[derive(Default)] +pub struct Rate { + window_ms: u64, + frames: u16, +} +impl Rate { + /// Admit one frame using a monotonic elapsed-millisecond clock. + pub fn allow(&mut self, now_ms: u64) -> bool { + if now_ms.saturating_sub(self.window_ms) >= 1000 { + self.window_ms = now_ms; + self.frames = 0; + } + if self.frames >= MAX_FRAMES_PER_SECOND { + return false; + } + self.frames += 1; + true + } +} + +fn invalid(message: &str) -> SdkError { + SdkError::InvalidInput(message.to_owned()) +} + +fn tag<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + let mut matches = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|key| key == name)); + let value = matches.next()?.as_slice().get(1)?.as_str(); + matches.next().is_none().then_some(value) +} + +fn tags(values: Vec>) -> Result, SdkError> { + values + .into_iter() + .map(|value| Tag::parse(value).map_err(|_| invalid("invalid attach tag"))) + .collect() +} + +/// Immutable sandbox identity; the harness compares all three fields to its binding. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Scope { + /// Channel containing the sandbox thread. + pub channel: Uuid, + /// Thread root event ID. + pub thread: EventId, + /// Remote agent's public key. + pub agent: PublicKey, +} + +impl Scope { + fn tags(self, recipient: PublicKey) -> Vec> { + vec![ + vec!["h".into(), self.channel.to_string()], + vec!["e".into(), self.thread.to_hex()], + vec!["p".into(), recipient.to_hex()], + ] + } + + fn matches(self, event: &Event, recipient: PublicKey) -> bool { + tag(event, "h") == Some(self.channel.to_string().as_str()) + && tag(event, "e") == Some(self.thread.to_hex().as_str()) + && tag(event, "p") == Some(recipient.to_hex().as_str()) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Request { + version: u8, + mode: String, + session: Uuid, + scope: Scope, + expires_at: u64, + requester: PublicKey, + request_id: Option, +} + +/// Current authority snapshot, fetched strongly from the same community relay. +pub struct Authority<'a> { + /// Immutable sandbox owner. + pub owner: PublicKey, + /// Trusted community relay signing key. + pub relay: PublicKey, + /// Current relay-signed channel member roster. + pub roster: &'a Event, + /// Current owner-signed delegation heads. + pub delegations: &'a [Event], + /// Current candidate gateway profiles, used to exclude agent keys. + pub profiles: &'a [Event], +} + +/// Verified, bounded request metadata. Signature validation alone does not authorize a shell. +#[derive(Clone, Debug)] +pub struct Grant { + /// Original owner-signed approval, retained in audit records. + event: Event, + /// Unique terminal session ID. + session: Uuid, + /// Exact sandbox identity. + scope: Scope, + /// Requesting identity explicitly named by the owner. + requester: PublicKey, + /// Authorization expiry in Unix seconds. + expires_at: u64, + request_id: EventId, +} + +impl Grant { + /// Original signed request metadata. + pub fn event(&self) -> &Event { + &self.event + } + /// Signed request ID binding every transport frame. + pub fn id(&self) -> EventId { + self.event.id + } + /// Unique terminal session ID. + pub fn session(&self) -> Uuid { + self.session + } + /// Immutable sandbox scope. + pub fn scope(&self) -> Scope { + self.scope + } + /// Requesting key; consumers must check current authority. + pub fn requester(&self) -> PublicKey { + self.requester + } + /// Authorization expiry, in Unix seconds. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + /// Verify an owner-approval envelope and its one-hour-or-shorter lifetime. + /// [`Self::authorized`] separately checks the expected owner and current authority. + pub fn parse(event: &Event, scope: Scope, now: u64) -> Result { + let request = read_request(event, Some(scope), "grant", now)?; + let request_id = request + .request_id + .ok_or_else(|| invalid("terminal approval has no request"))?; + Ok(Self { + event: event.clone(), + session: request.session, + scope, + requester: request.requester, + expires_at: request.expires_at, + request_id, + }) + } + + /// Pending request this owner approved. + pub fn request_id(&self) -> EventId { + self.request_id + } + + /// Check current posting membership and owner/live-gateway authority. + /// The latest relay-signed roster and authority records must come from strong reads. + pub fn authorized(&self, pending: &Pending, authority: Authority<'_>, now: u64) -> bool { + self.event.pubkey == authority.owner + && now < self.expires_at + && self.request_id == pending.id() + && self.session == pending.session() + && self.scope == pending.scope() + && self.requester == pending.requester() + && self.expires_at <= pending.expires_at() + && [authority.owner, self.scope.agent, self.requester] + .iter() + .all(|key| { + crate::gateway::channel_member( + authority.roster, + authority.relay, + self.scope.channel, + *key, + ) + }) + && self.requester != self.scope.agent + && (self.requester == authority.owner + || crate::gateway::is_live_delegate( + authority.owner, + self.requester, + authority.delegations, + authority.profiles, + now, + )) + } +} + +/// A signed request awaiting an explicit per-session owner approval. +/// A request or a global gateway delegation can never authorize a shell by itself. +#[derive(Clone, Debug)] +pub struct Pending { + event: Event, + body: Request, +} + +impl Pending { + /// Verify a bounded request, without treating its sender as the sandbox owner. + pub fn parse(event: &Event, now: u64) -> Result { + let body = read_request(event, None, "request", now)?; + if body.requester != event.pubkey || body.request_id.is_some() { + return Err(invalid("invalid pending terminal requester")); + } + Ok(Self { + event: event.clone(), + body, + }) + } + /// Pending signed request ID. + pub fn id(&self) -> EventId { + self.event.id + } + /// Requester waiting for owner approval. + pub fn requester(&self) -> PublicKey { + self.body.requester + } + /// Requested immutable sandbox scope. + pub fn scope(&self) -> Scope { + self.body.scope + } + /// Requested session identifier. + pub fn session(&self) -> Uuid { + self.body.session + } + /// Latest possible approval expiry, in Unix seconds. + pub fn expires_at(&self) -> u64 { + self.body.expires_at + } +} + +fn read_request( + event: &Event, + scope: Option, + phase: &str, + now: u64, +) -> Result { + if event.kind.as_u16() as u32 != kind::KIND_SANDBOX_ATTACH + || event.verify().is_err() + || event.content.len() > 2048 + { + return Err(invalid("invalid terminal request")); + } + let request: Request = + serde_json::from_str(&event.content).map_err(|_| invalid("invalid terminal request"))?; + if request.version != 1 + || request.mode != "terminal" + || scope.is_some_and(|scope| scope != request.scope) + || event.created_at.as_secs() > now + || request.expires_at <= now + || request + .expires_at + .saturating_sub(event.created_at.as_secs()) + > MAX_GRANT_SECS + || tag(event, "expiration").and_then(|value| value.parse::().ok()) + != Some(request.expires_at) + || tag(event, "d") != Some(format!("terminal:{}:{phase}", request.session).as_str()) + || !request.scope.matches(event, request.scope.agent) + { + return Err(invalid("expired or incorrectly scoped terminal request")); + } + Ok(request) +} + +fn signed_request(keys: &Keys, body: Request, phase: &str) -> Result { + let now = Timestamp::now().as_secs(); + if body.expires_at <= now || body.expires_at.saturating_sub(now) > MAX_GRANT_SECS { + return Err(invalid("terminal expiry must be within one hour")); + } + let mut values = body.scope.tags(body.scope.agent); + values.push(vec![ + "d".into(), + format!("terminal:{}:{phase}", body.session), + ]); + values.push(vec!["expiration".into(), body.expires_at.to_string()]); + let content = serde_json::to_string(&body).map_err(|_| invalid("invalid terminal request"))?; + EventBuilder::new(Kind::from(kind::KIND_SANDBOX_ATTACH as u16), content) + .tags(tags(values)?) + .sign_with_keys(keys) + .map_err(|_| invalid("could not sign terminal request")) +} + +/// Publishable pending request. Only a separate owner-signed approval permits attachment. +pub fn request( + keys: &Keys, + scope: Scope, + session: Uuid, + expires_at: u64, +) -> Result { + signed_request( + keys, + Request { + version: 1, + mode: "terminal".into(), + session, + scope, + expires_at, + requester: keys.public_key(), + request_id: None, + }, + "request", + ) +} + +/// Approve exactly this request with the owner's key, never beyond its requested expiry. +/// The harness checks the signature against its immutable owner identity. +pub fn approve(owner: &Keys, pending: &Pending, expires_at: u64) -> Result { + if expires_at > pending.expires_at() { + return Err(invalid("approval exceeds requested expiry")); + } + signed_request( + owner, + Request { + expires_at, + request_id: Some(pending.id()), + ..pending.body.clone() + }, + "grant", + ) +} + +/// Store acceptance/end metadata, including the original signed metadata-only grant. +/// `status` is either `accepted` or `ended`; no terminal bytes belong in this record. +pub fn audit( + keys: &Keys, + grant: &Grant, + status: &str, + bytes_in: u64, + bytes_out: u64, +) -> Result { + if keys.public_key() != grant.scope.agent || !matches!(status, "accepted" | "ended") { + return Err(invalid("invalid terminal audit signer or status")); + } + let content = serde_json::json!({"version":1,"mode":"terminal","session":grant.session,"status":status,"grant":grant.event,"bytes_in":bytes_in,"bytes_out":bytes_out}).to_string(); + let mut values = grant.scope.tags(grant.requester); + values.push(vec![ + "d".into(), + format!("terminal:{}:{status}", grant.session), + ]); + EventBuilder::new(Kind::from(kind::KIND_SANDBOX_ATTACH as u16), content) + .tags(tags(values)?) + .sign_with_keys(keys) + .map_err(|_| invalid("could not sign terminal audit")) +} + +/// Encrypted terminal message. Data is base64; acknowledgements contain no data. +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Packet { + /// Start the shell with terminal dimensions. + Open { + /// Terminal rows, 1–1000. + rows: u16, + /// Terminal columns, 1–1000. + cols: u16, + }, + /// Terminal input or output, at most [`MAX_DATA_BYTES`] decoded bytes. + Data { + /// Canonical base64-encoded bytes. + data: String, + }, + /// Change terminal dimensions. + Resize { + /// Terminal rows, 1–1000. + rows: u16, + /// Terminal columns, 1–1000. + cols: u16, + }, + /// Close the attachment. + Close, + /// Acknowledge the highest contiguous incoming sequence number. + Ack, +} + +impl std::fmt::Debug for Packet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Data { data } => f + .debug_struct("Data") + .field("encoded_bytes", &data.len()) + .finish(), + Self::Open { rows, cols } => f.debug_tuple("Open").field(rows).field(cols).finish(), + Self::Resize { rows, cols } => f.debug_tuple("Resize").field(rows).field(cols).finish(), + Self::Close => f.write_str("Close"), + Self::Ack => f.write_str("Ack"), + } + } +} + +impl Packet { + /// Encode bounded terminal bytes without interpreting their text. + pub fn data(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DATA_BYTES { + return Err(invalid("terminal data exceeds 16 KiB")); + } + Ok(Self::Data { + data: STANDARD.encode(bytes), + }) + } + + /// Validate and decode a data packet; other packets return no bytes. + pub fn bytes(&self) -> Result, SdkError> { + match self { + Self::Data { data } => { + if data.len() > MAX_DATA_BYTES.div_ceil(3) * 4 { + return Err(invalid("terminal frame exceeds bound")); + } + let bytes = STANDARD + .decode(data) + .map_err(|_| invalid("invalid terminal base64"))?; + if bytes.len() > MAX_DATA_BYTES || STANDARD.encode(&bytes) != *data { + return Err(invalid("invalid terminal data")); + } + Ok(bytes) + } + Self::Open { rows, cols } | Self::Resize { rows, cols } + if !(1..=1000).contains(rows) || !(1..=1000).contains(cols) => + { + Err(invalid("invalid terminal dimensions")) + } + _ => Ok(Vec::new()), + } + } +} + +/// One NIP-44 encrypted transport frame, bound to an accepted grant. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Frame { + /// Owner-signed approval ID; frames cannot migrate between grants. + pub grant: EventId, + /// Data/control sequence, or zero for acknowledgement-only frames. + pub seq: u64, + /// Highest contiguous incoming sequence observed by the sender. + pub ack: u64, + /// Terminal operation. + pub packet: Packet, +} + +/// Encrypt a frame to its peer. Retries re-encrypt, creating a fresh Nostr event ID. +pub fn encrypt( + keys: &Keys, + peer: PublicKey, + scope: Scope, + frame: &Frame, +) -> Result { + frame.packet.bytes()?; + if (frame.seq == 0) != matches!(frame.packet, Packet::Ack) { + return Err(invalid("invalid terminal sequence")); + } + let content = encrypt_observer_payload(keys, &peer, frame) + .map_err(|_| invalid("could not encrypt terminal frame"))?; + EventBuilder::new( + Kind::from(kind::KIND_SANDBOX_TERMINAL_FRAME as u16), + content, + ) + .tags(tags(scope.tags(peer))?) + .sign_with_keys(keys) + .map_err(|_| invalid("could not sign terminal frame")) +} + +/// Verify sender, recipient, freshness, immutable scope and grant before decrypting bytes. +pub fn decrypt( + keys: &Keys, + peer: PublicKey, + scope: Scope, + grant: EventId, + event: &Event, + now: u64, +) -> Result { + let created = event.created_at.as_secs(); + if event.kind.as_u16() as u32 != kind::KIND_SANDBOX_TERMINAL_FRAME + || event.pubkey != peer + || event.verify().is_err() + || !scope.matches(event, keys.public_key()) + || created > now + || now.saturating_sub(created) > MAX_FRAME_AGE_SECS + { + return Err(invalid("invalid terminal frame envelope")); + } + let frame: Frame = decrypt_observer_payload(keys, event) + .map_err(|_| invalid("invalid terminal ciphertext"))?; + if frame.grant != grant || (frame.seq == 0) != matches!(frame.packet, Packet::Ack) { + return Err(invalid("terminal frame belongs to another grant")); + } + frame.packet.bytes()?; + Ok(frame) +} + +struct InFlight { + frame: Frame, + sent_ms: u64, + retries: u8, +} + +/// Bounded stop-and-retry window. A consumer must close on any returned error. +pub struct Flow { + grant: EventId, + sent: u64, + received: u64, + pending: VecDeque, +} + +impl Flow { + /// Create a fresh transport for one accepted owner approval. + pub fn new(grant: EventId) -> Self { + Self { + grant, + sent: 0, + received: 0, + pending: VecDeque::new(), + } + } + /// Whether another packet fits the four-frame/64-KiB window. + pub fn writable(&self) -> bool { + self.pending.len() < MAX_IN_FLIGHT + } + /// Queue one sequenced packet; window exhaustion is explicit backpressure. + pub fn send(&mut self, packet: Packet, now_ms: u64) -> Result { + packet.bytes()?; + if !self.writable() || matches!(packet, Packet::Ack) { + return Err(invalid("terminal send window is full")); + } + self.sent = self + .sent + .checked_add(1) + .ok_or_else(|| invalid("terminal sequence exhausted"))?; + let frame = Frame { + grant: self.grant, + seq: self.sent, + ack: self.received, + packet, + }; + self.pending.push_back(InFlight { + frame: frame.clone(), + sent_ms: now_ms, + retries: 0, + }); + Ok(frame) + } + /// Acknowledge contiguous traffic without causing acknowledgement loops. + pub fn acknowledgement(&self) -> Frame { + Frame { + grant: self.grant, + seq: 0, + ack: self.received, + packet: Packet::Ack, + } + } + /// Apply peer acknowledgement and deliver each ordered packet once. + pub fn receive(&mut self, frame: Frame) -> Result, SdkError> { + if frame.grant != self.grant + || frame.ack > self.sent + || (frame.seq == 0) != matches!(frame.packet, Packet::Ack) + { + return Err(invalid("invalid terminal acknowledgement")); + } + frame.packet.bytes()?; + while self + .pending + .front() + .is_some_and(|pending| pending.frame.seq <= frame.ack) + { + self.pending.pop_front(); + } + if frame.seq == 0 || frame.seq <= self.received { + return Ok(None); + } + if frame.seq != self.received.saturating_add(1) { + return Ok(None); + } + self.received = frame.seq; + Ok(Some(frame.packet)) + } + /// Retry due frames at most five times; callers re-encrypt returned frames. + pub fn retries(&mut self, now_ms: u64) -> Result, SdkError> { + let mut retry = Vec::new(); + for pending in &mut self.pending { + if now_ms.saturating_sub(pending.sent_ms) < RETRY_MS { + continue; + } + if pending.retries >= MAX_RETRIES { + return Err(invalid("terminal peer stopped acknowledging; closing")); + } + pending.retries += 1; + pending.sent_ms = now_ms; + pending.frame.ack = self.received; + retry.push(pending.frame.clone()); + } + Ok(retry) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/sandbox_attach/tests.rs b/crates/buzz-sdk/src/sandbox_attach/tests.rs new file mode 100644 index 00000000000..175ad2a4f58 --- /dev/null +++ b/crates/buzz-sdk/src/sandbox_attach/tests.rs @@ -0,0 +1,292 @@ +use super::*; + +fn authority<'a>( + owner: PublicKey, + relay: PublicKey, + roster: &'a Event, + delegations: &'a [Event], +) -> Authority<'a> { + Authority { + owner, + relay, + roster, + delegations, + profiles: &[], + } +} + +fn scope(agent: &Keys) -> Scope { + Scope { + channel: Uuid::new_v4(), + thread: EventId::all_zeros(), + agent: agent.public_key(), + } +} + +#[test] +fn terminal_grants_fence_scope_owner_gateway_and_expiry() { + let owner = Keys::generate(); + let gateway = Keys::generate(); + let agent = Keys::generate(); + let outsider = Keys::generate(); + let scope = scope(&agent); + let now = Timestamp::now().as_secs(); + let relay = Keys::generate(); + let roster = EventBuilder::new(Kind::from(39002), "") + .tags([ + Tag::parse(["d", scope.channel.to_string().as_str()]).unwrap(), + Tag::parse(["p", owner.public_key().to_hex().as_str(), "", "owner"]).unwrap(), + Tag::parse(["p", agent.public_key().to_hex().as_str(), "", "bot"]).unwrap(), + Tag::parse(["p", gateway.public_key().to_hex().as_str(), "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + let pending_event = request(&owner, scope, Uuid::new_v4(), now + 60).unwrap(); + let pending = Pending::parse(&pending_event, now).unwrap(); + assert!( + Grant::parse(&pending_event, scope, now).is_err(), + "a request is never authority" + ); + let event = approve(&owner, &pending, now + 60).unwrap(); + let grant = Grant::parse(&event, scope, now).unwrap(); + assert_eq!(grant.request_id(), pending.id()); + assert!(!grant.authorized( + &pending, + authority(owner.public_key(), relay.public_key(), &roster, &[]), + grant.expires_at() + )); + let old_request = EventBuilder::new(pending_event.kind, pending_event.content.clone()) + .tags(pending_event.tags.to_vec()) + .custom_created_at(Timestamp::from_secs(pending_event.created_at.as_secs() - 1)) + .sign_with_keys(&owner) + .unwrap(); + let old_request = Pending::parse(&old_request, now).unwrap(); + assert!(!grant.authorized( + &old_request, + authority(owner.public_key(), relay.public_key(), &roster, &[]), + now + )); + let mut bad_signature = event.clone(); + bad_signature.sig = request(&outsider, scope, Uuid::new_v4(), now + 60) + .unwrap() + .sig; + assert!(Grant::parse(&bad_signature, scope, now).is_err()); + assert!(grant.authorized( + &pending, + authority(owner.public_key(), relay.public_key(), &roster, &[]), + now + )); + assert!(Grant::parse(&event, scope, now + 60).is_err()); + assert!(Grant::parse( + &event, + Scope { + channel: Uuid::new_v4(), + ..scope + }, + now + ) + .is_err()); + assert!(request(&owner, scope, Uuid::new_v4(), now + MAX_GRANT_SECS + 60).is_err()); + let delegated_event = request(&gateway, scope, Uuid::new_v4(), now + 60).unwrap(); + let delegated_pending = Pending::parse(&delegated_event, now).unwrap(); + let head = + crate::gateway::build_gateway_grant(&owner, gateway.public_key(), now + 120).unwrap(); + let self_approved = approve(&gateway, &delegated_pending, now + 60).unwrap(); + let self_approved = Grant::parse(&self_approved, scope, now).unwrap(); + assert!( + !self_approved.authorized( + &delegated_pending, + authority( + owner.public_key(), + relay.public_key(), + &roster, + std::slice::from_ref(&head) + ), + now + ), + "live gateways cannot authorize their own shell" + ); + let delegated = approve(&owner, &delegated_pending, now + 60).unwrap(); + let delegated = Grant::parse(&delegated, scope, now).unwrap(); + assert!(!delegated.authorized( + &delegated_pending, + authority(owner.public_key(), relay.public_key(), &roster, &[]), + now + )); + assert!(delegated.authorized( + &delegated_pending, + authority( + owner.public_key(), + relay.public_key(), + &roster, + std::slice::from_ref(&head) + ), + now + )); + assert!(!delegated.authorized( + &delegated_pending, + authority( + outsider.public_key(), + relay.public_key(), + &roster, + std::slice::from_ref(&head) + ), + now + )); + let without_gateway = EventBuilder::new(Kind::from(39002), "") + .tags( + roster + .tags + .iter() + .filter(|tag| tag.as_slice().get(1) != Some(&gateway.public_key().to_hex())) + .cloned(), + ) + .sign_with_keys(&relay) + .unwrap(); + assert!(!delegated.authorized( + &delegated_pending, + authority( + owner.public_key(), + relay.public_key(), + &without_gateway, + std::slice::from_ref(&head) + ), + now + )); + assert!(!delegated.authorized( + &delegated_pending, + authority( + owner.public_key(), + outsider.public_key(), + &roster, + std::slice::from_ref(&head) + ), + now + )); + let mut forged = event.clone(); + forged.content = forged.content.replace("terminal", "snapshot"); + assert!(Grant::parse(&forged, scope, now).is_err()); + let audit = audit(&agent, &grant, "ended", 12, 34).unwrap(); + assert!(Grant::parse(&audit, scope, now).is_err()); + assert!(!audit.content.contains("keystrokes")); +} + +#[test] +fn encrypted_frames_bind_signatures_recipients_scope_grant_and_freshness() { + let client = Keys::generate(); + let agent = Keys::generate(); + let other = Keys::generate(); + let scope = scope(&agent); + let grant = EventId::all_zeros(); + let mut flow = Flow::new(grant); + let frame = flow + .send(Packet::data(b"private terminal bytes").unwrap(), 0) + .unwrap(); + let event = encrypt(&client, agent.public_key(), scope, &frame).unwrap(); + let now = event.created_at.as_secs(); + assert!(!event.content.contains("private terminal bytes")); + assert!(!format!("{frame:?}").contains(&STANDARD.encode(b"private terminal bytes"))); + assert_eq!( + decrypt(&agent, client.public_key(), scope, grant, &event, now).unwrap(), + frame + ); + assert!(decrypt(&other, client.public_key(), scope, grant, &event, now).is_err()); + assert!(decrypt(&agent, other.public_key(), scope, grant, &event, now).is_err()); + assert!(decrypt( + &agent, + client.public_key(), + scope, + EventId::from_hex(&"a".repeat(64)).unwrap(), + &event, + now + ) + .is_err()); + assert!(decrypt( + &agent, + client.public_key(), + Scope { + channel: Uuid::new_v4(), + ..scope + }, + grant, + &event, + now + ) + .is_err()); + assert!(decrypt( + &agent, + client.public_key(), + scope, + grant, + &event, + now + MAX_FRAME_AGE_SECS + 1 + ) + .is_err()); + assert!(Packet::data(&vec![0; MAX_DATA_BYTES + 1]).is_err()); + assert!(Packet::Data { + data: STANDARD.encode(vec![0; MAX_DATA_BYTES + 1]) + } + .bytes() + .is_err()); + let mut forged_signature = event.clone(); + forged_signature.sig = EventBuilder::new(Kind::TextNote, "other event") + .sign_with_keys(&other) + .unwrap() + .sig; + assert!(decrypt( + &agent, + client.public_key(), + scope, + grant, + &forged_signature, + now + ) + .is_err()); + assert!(Packet::Resize { rows: 0, cols: 80 }.bytes().is_err()); + let mut forged = event; + forged.content.push('A'); + assert!(decrypt(&agent, client.public_key(), scope, grant, &forged, now).is_err()); +} + +#[test] +fn transport_delivers_once_detects_gaps_bounds_window_and_ends_retry() { + let grant = EventId::all_zeros(); + let mut sender = Flow::new(grant); + let mut receiver = Flow::new(grant); + let a = sender.send(Packet::Open { rows: 24, cols: 80 }, 0).unwrap(); + let b = sender.send(Packet::data(b"one").unwrap(), 0).unwrap(); + assert!(receiver.receive(b.clone()).unwrap().is_none()); // Gap; sender retries. + assert!(receiver.receive(a.clone()).unwrap().is_some()); + assert!(receiver.receive(a).unwrap().is_none()); // Never repeat a keystroke. + assert!(receiver.receive(b).unwrap().is_some()); + sender.receive(receiver.acknowledgement()).unwrap(); + for _ in 0..MAX_IN_FLIGHT { + sender + .send(Packet::data(&vec![1; MAX_DATA_BYTES]).unwrap(), 0) + .unwrap(); + } + assert!(!sender.writable()); + assert!(sender.send(Packet::Close, 0).is_err()); + for retry in 1..=MAX_RETRIES { + assert_eq!( + sender.retries(u64::from(retry) * RETRY_MS).unwrap().len(), + MAX_IN_FLIGHT + ); + } + assert!(sender + .retries((u64::from(MAX_RETRIES) + 1) * RETRY_MS) + .is_err()); + let mut ack = receiver.acknowledgement(); + ack.ack = u64::MAX; + assert!(sender.receive(ack).is_err()); +} + +#[test] +fn terminal_frame_rate_is_bounded_including_acknowledgements() { + let mut rate = Rate::default(); + for _ in 0..MAX_FRAMES_PER_SECOND { + assert!(rate.allow(0)); + } + assert!(!rate.allow(999)); + assert!(rate.allow(1000)); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ce4661988e1..d9acb9144f0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1265,6 +1265,7 @@ dependencies = [ name = "buzz-sdk" version = "0.1.0" dependencies = [ + "base64 0.22.1", "buzz-core", "nostr 0.44.7", "serde", diff --git a/docs/nips/NIP-TA.md b/docs/nips/NIP-TA.md new file mode 100644 index 00000000000..459ccb54038 --- /dev/null +++ b/docs/nips/NIP-TA.md @@ -0,0 +1,50 @@ +# NIP-TA: Relay terminal attachment + +This protocol grants temporary shell access to one remote-agent sandbox through the existing relay. +The SDK layer defines signed grants, metadata-only audit records, encrypted frames, and bounded reliable delivery. +Runtime and CLI consumers follow in a separate stacked PR; this layer does not open terminals. + +| Kind | Storage | Purpose | +| --- | --- | --- | +| `30624` | Addressable | Pending request, owner approval, acceptance, and end metadata | +| `24203` | Ephemeral, recipient gated | NIP-44 encrypted terminal frames | + +All events carry `h` for the channel, `e` for the immutable thread root, and exactly one recipient `p`. +Requests and owner approvals address the sandbox agent; audit recipients identify the requester. +The `d` tag is `terminal::request`, `:grant`, `:accepted`, or `:ended`. +These events describe an attachment; they are not conversation replies or thread-counter increments. + +A request contains version 1, mode `terminal`, the session UUID, immutable scope, and expiration. +Its NIP-40 `expiration` tag must match the body. +The signature must be valid, creation cannot be in the future, and expiration must fall within one hour of creation. +The harness compares channel, root, and agent to its immutable binding. +It requires an owner-signed, per-session approval naming the requester and pending request ID. +The requester must be the owner or a currently delegated gateway, excluding the agent's own key. +A live global gateway delegation permits requesting approval; it never permits self-authorizing a shell. +The owner, agent, and requester must remain posting members in a current relay-signed kind 39002 roster. +The grant does not confer relay or channel membership. + +Before opening a shell, consumers must strongly read the current owner approval, pending request, membership roster, and gateway delegation through the same community relay. +Missing, replaced, deleted, expired, or malformed records and read failures deny access. +Consumers repeat those checks during attachment and close when authorization ends. +The owner revokes an individual approval through NIP-09 deletion; the requester may delete its pending request. +Durable acceptance makes each approval single-use; a new PTY requires a new request and owner approval. +The acceptance and end records contain the signed metadata-only owner approval and byte counters. +No keystrokes or file bytes belong in stored events. + +Frames contain the signed approval ID, sequence number, cumulative acknowledgement, and packet. +Packets are `open`, `data`, `resize`, `close`, or `ack`. +Data uses canonical base64 and decodes to at most 16 KiB. +Terminal dimensions range from 1 through 1000. +Recipients verify signature, sender, recipient, channel, thread, approval ID, and a maximum frame age of 15 seconds. + +Each direction permits four unacknowledged frames and at most 64 KiB of data. +Sequence gaps are detected; duplicates are acknowledged without repeating input. +Acknowledgement-only frames use sequence zero and are never themselves acknowledged. +Retries re-encrypt after one second, producing fresh Nostr event IDs. +After five retries, the transport reports failure and consumers close the attachment. +A separate admission budget limits every direction to 64 frames per second, including acknowledgements and retries. +Consumers must apply that budget before cryptographic work. + +Shell processes, idle expiry, authorization refresh, audit publication, and local TTY restoration belong to the runtime consumer. +The relay remains the only channel into the sandbox; this protocol adds no broker, exec endpoint, or tunnel. diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index efc144b4b23..f2f0c907840 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -38,6 +38,8 @@ abstract final class EventKind { /// Owner-signed, expiring gateway delegation for remote agents. static const agentGatewayDelegation = 30181; + static const sandboxAttach = 30624; + static const sandboxTerminalFrame = 24203; static const eventReminder = 30300; static const userStatus = 30315; static const dmVisibility = 30622;