diff --git a/Cargo.lock b/Cargo.lock index 844e138b05d..02d40134cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2144,6 +2144,8 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "buzz-core", + "chacha20poly1305 0.10.1", + "flate2", "nostr 0.44.7", "serde", "serde_json", diff --git a/apps/buzz-gpui/Cargo.lock b/apps/buzz-gpui/Cargo.lock index bd971bb7141..0b5c04ea963 100644 --- a/apps/buzz-gpui/Cargo.lock +++ b/apps/buzz-gpui/Cargo.lock @@ -1306,6 +1306,8 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "buzz-core", + "chacha20poly1305", + "flate2", "nostr 0.44.8", "serde", "serde_json", diff --git a/crates/buzz-sdk/Cargo.toml b/crates/buzz-sdk/Cargo.toml index 4665da2ef2c..b582d9ab370 100644 --- a/crates/buzz-sdk/Cargo.toml +++ b/crates/buzz-sdk/Cargo.toml @@ -16,3 +16,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } base64 = { workspace = true } + +# AEAD for bounded snapshot blobs; the random key is wrapped by NIP-44. +chacha20poly1305 = "0.10" +flate2 = "1" diff --git a/crates/buzz-sdk/src/sandbox_attach.rs b/crates/buzz-sdk/src/sandbox_attach.rs index d7daeee1dc7..b8447b05354 100644 --- a/crates/buzz-sdk/src/sandbox_attach.rs +++ b/crates/buzz-sdk/src/sandbox_attach.rs @@ -14,6 +14,9 @@ use uuid::Uuid; use crate::{kind, SdkError}; +/// Separately approved, encrypted read-only workspace snapshots. +pub mod snapshot; + /// Longest terminal grant lifetime, in seconds. pub const MAX_GRANT_SECS: u64 = 3600; /// Largest decoded data frame, in bytes. @@ -170,7 +173,7 @@ impl Grant { /// 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 = read_request(event, Some(scope), "grant", "terminal", now)?; let request_id = request .request_id .ok_or_else(|| invalid("terminal approval has no request"))?; @@ -232,7 +235,7 @@ pub struct Pending { 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)?; + let body = read_request(event, None, "request", "terminal", now)?; if body.requester != event.pubkey || body.request_id.is_some() { return Err(invalid("invalid pending terminal requester")); } @@ -267,6 +270,7 @@ fn read_request( event: &Event, scope: Option, phase: &str, + mode: &str, now: u64, ) -> Result { if event.kind.as_u16() as u32 != kind::KIND_SANDBOX_ATTACH @@ -278,7 +282,7 @@ fn read_request( let request: Request = serde_json::from_str(&event.content).map_err(|_| invalid("invalid terminal request"))?; if request.version != 1 - || request.mode != "terminal" + || request.mode != mode || scope.is_some_and(|scope| scope != request.scope) || event.created_at.as_secs() > now || request.expires_at <= now @@ -288,7 +292,7 @@ fn read_request( > 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()) + || tag(event, "d") != Some(format!("{mode}:{}:{phase}", request.session).as_str()) || !request.scope.matches(event, request.scope.agent) { return Err(invalid("expired or incorrectly scoped terminal request")); @@ -304,7 +308,7 @@ fn signed_request(keys: &Keys, body: Request, phase: &str) -> Result Result { + let body = read_request(event, None, "request", "snapshot", now)?; + if body.requester != event.pubkey || body.request_id.is_some() { + return Err(invalid("invalid snapshot requester")); + } + Ok(Self(super::Pending { + event: event.clone(), + body, + })) + } + /// Signed pending request ID. + pub fn id(&self) -> EventId { + self.0.id() + } + /// Immutable sandbox scope. + pub fn scope(&self) -> Scope { + self.0.scope() + } + /// Per-session identifier. + pub fn session(&self) -> Uuid { + self.0.session() + } + /// Identity that will receive the encrypted snapshot. + pub fn requester(&self) -> PublicKey { + self.0.requester() + } + /// Latest approval expiry in Unix seconds. + pub fn expires_at(&self) -> u64 { + self.0.expires_at() + } +} +/// An owner-signed snapshot capability, which cannot be passed to terminal APIs. +#[derive(Clone, Debug)] +pub struct Grant(super::Grant); +impl Grant { + /// Verify the snapshot mode, signature, scope and bounded expiry. + pub fn parse(event: &Event, scope: Scope, now: u64) -> Result { + let body = read_request(event, Some(scope), "grant", "snapshot", now)?; + Ok(Self(super::Grant { + event: event.clone(), + session: body.session, + scope, + requester: body.requester, + expires_at: body.expires_at, + request_id: body + .request_id + .ok_or_else(|| invalid("snapshot approval has no request"))?, + })) + } + /// Owner-signed approval event ID. + pub fn id(&self) -> EventId { + self.0.id() + } + /// Original metadata-only approval for durable audit. + pub fn event(&self) -> &Event { + self.0.event() + } + /// Immutable sandbox scope. + pub fn scope(&self) -> Scope { + self.0.scope() + } + /// Per-session identifier. + pub fn session(&self) -> Uuid { + self.0.session() + } + /// Approved recipient of snapshot bytes. + pub fn requester(&self) -> PublicKey { + self.0.requester() + } + /// Approval expiry in Unix seconds. + pub fn expires_at(&self) -> u64 { + self.0.expires_at() + } + /// Pending request this owner approved. + pub fn request_id(&self) -> EventId { + self.0.request_id() + } + /// Require current owner approval, live gateway authority and posting membership. + pub fn authorized(&self, pending: &Pending, authority: Authority<'_>, now: u64) -> bool { + self.0.authorized(&pending.0, authority, now) + } +} +/// Build a pending snapshot request; global delegation alone never approves it. +pub fn request( + keys: &Keys, + scope: Scope, + session: Uuid, + expires_at: u64, +) -> Result { + signed_request( + keys, + Request { + version: 1, + mode: "snapshot".into(), + session, + scope, + expires_at, + requester: keys.public_key(), + request_id: None, + }, + "request", + ) +} +/// Approve only this snapshot request, never extending its expiry. +pub fn approve(owner: &Keys, pending: &Pending, expires_at: u64) -> Result { + super::approve(owner, &pending.0, expires_at) +} +/// A public failure category; never contains scanner output, file names or credentials. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Failure { + /// Secret scan refused the exact artifact. + Scan, + /// Byte, file, object, time or capacity budget was exceeded. + Budget, + /// Archive creation or integrity validation failed. + Archive, + /// Encrypted blob upload failed. + Upload, + /// Session authority ended during creation. + Authority, +} +/// Durable snapshot acceptance/end metadata; no file content or encryption key. +/// Persist one accepted event per attempt and reuse that exact event on retries. +pub fn audit( + keys: &Keys, + grant: &Grant, + status: &str, + bytes: u64, + reason: Option, +) -> Result { + if keys.public_key() != grant.scope().agent + || !matches!(status, "accepted" | "ended") + || (status == "accepted" && reason.is_some()) + { + return Err(invalid("invalid snapshot audit")); + } + let mut values = grant.scope().tags(grant.requester()); + if status == "accepted" { + values.push(vec!["buzz:attach-claim".into(), Uuid::new_v4().to_string()]); + } + values.push(vec![ + "d".into(), + format!("snapshot:{}:{status}", grant.session()), + ]); + EventBuilder::new(Kind::from(kind::KIND_SANDBOX_ATTACH as u16),serde_json::json!({"version":1,"mode":"snapshot","session":grant.session(),"status":status,"grant":grant.event(),"bytes":bytes,"reason":reason}).to_string()).tags(tags(values)?).sign_with_keys(keys).map_err(|_|invalid("could not sign snapshot audit")) +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Metadata { + version: u8, + grant: EventId, + expires_at: u64, + sha256: String, + size: u64, + key: [u8; 32], + nonce: [u8; 24], +} +/// Ciphertext and private key material. Deliberately has no Debug or Serialize implementation. +pub struct Sealed { + ciphertext: Vec, + metadata: Metadata, +} +impl Sealed { + /// Exact bytes uploaded to Blossom; only ciphertext crosses this boundary. + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + /// SHA-256 of those ciphertext bytes. + pub fn hash(&self) -> &str { + &self.metadata.sha256 + } +} +fn aad(grant: &Grant) -> Result, SdkError> { + serde_json::to_vec(&(grant.id(), grant.scope(), grant.requester())) + .map_err(|_| invalid("invalid snapshot scope")) +} +fn digest(bytes: &[u8]) -> String { + sha256::Hash::hash(bytes).to_string() +} +/// Encrypt a bounded archive using a fresh random XChaCha20-Poly1305 key. +/// The key is available only through the recipient-encrypted result event. +pub fn seal(grant: &Grant, archive: &[u8]) -> Result { + if archive.len() > MAX_BLOB_BYTES - MAGIC.len() - 16 { + return Err(invalid("snapshot exceeds 100 MiB blob budget")); + } + let key = Keys::generate().secret_key().secret_bytes(); + let mut nonce = [0u8; 24]; + nonce[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + nonce[16..].copy_from_slice(&Uuid::new_v4().as_bytes()[..8]); + let cipher = + XChaCha20Poly1305::new_from_slice(&key).map_err(|_| invalid("invalid snapshot key"))?; + let encrypted = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: archive, + aad: &aad(grant)?, + }, + ) + .map_err(|_| invalid("could not encrypt snapshot"))?; + let mut ciphertext = MAGIC.to_vec(); + ciphertext.extend(encrypted); + let metadata = Metadata { + version: 1, + grant: grant.id(), + expires_at: grant.expires_at(), + sha256: digest(&ciphertext), + size: ciphertext.len() as u64, + key, + nonce, + }; + Ok(Sealed { + ciphertext, + metadata, + }) +} +/// Build the agent's recipient-encrypted result after upload confirmation. +/// The event stores no plaintext key or file bytes and carries NIP-40 expiry. +pub fn result(keys: &Keys, grant: &Grant, sealed: &Sealed) -> Result { + if keys.public_key() != grant.scope().agent || sealed.metadata.grant != grant.id() { + return Err(invalid("invalid snapshot result signer or grant")); + } + let content = encrypt_observer_payload(keys, &grant.requester(), &sealed.metadata) + .map_err(|_| invalid("could not encrypt snapshot receipt"))?; + let mut values = grant.scope().tags(grant.requester()); + values.push(vec![ + "d".into(), + format!("snapshot:{}:ready", grant.session()), + ]); + values.push(vec!["expiration".into(), grant.expires_at().to_string()]); + EventBuilder::new(Kind::from(kind::KIND_SANDBOX_ATTACH as u16), content) + .tags(tags(values)?) + .sign_with_keys(keys) + .map_err(|_| invalid("could not sign snapshot result")) +} +/// Verified private receipt. Callers must still refresh current grant authority before download. +pub struct Receipt { + metadata: Metadata, + aad: Vec, +} +impl Receipt { + /// Ciphertext hash used to download only from the same community Blossom server. + pub fn hash(&self) -> &str { + &self.metadata.sha256 + } + /// Exact ciphertext byte count; enforce this while streaming the download. + pub fn size(&self) -> u64 { + self.metadata.size + } + /// Verify ciphertext hash/length, then authenticate and decrypt the archive. + pub fn open(&self, ciphertext: &[u8], now: u64) -> Result, SdkError> { + if now >= self.metadata.expires_at + || ciphertext.len() as u64 != self.metadata.size + || ciphertext.len() > MAX_BLOB_BYTES + || !ciphertext.starts_with(MAGIC) + || digest(ciphertext) != self.metadata.sha256 + { + return Err(invalid("expired or mismatched snapshot blob")); + } + let cipher = XChaCha20Poly1305::new_from_slice(&self.metadata.key) + .map_err(|_| invalid("invalid snapshot key"))?; + cipher + .decrypt( + XNonce::from_slice(&self.metadata.nonce), + Payload { + msg: &ciphertext[MAGIC.len()..], + aad: &self.aad, + }, + ) + .map_err(|_| invalid("snapshot authentication failed")) + } +} +/// Verify the signed agent result, immutable scope, approval, recipient and expiry. +pub fn receipt(keys: &Keys, grant: &Grant, event: &Event, now: u64) -> Result { + if keys.public_key() != grant.requester() + || event.pubkey != grant.scope().agent + || event.kind.as_u16() as u32 != kind::KIND_SANDBOX_ATTACH + || event.verify().is_err() + || event.content.len() > 4096 + || event.created_at.as_secs() > now + || now >= grant.expires_at() + || !grant.scope().matches(event, grant.requester()) + || tag(event, "d") != Some(format!("snapshot:{}:ready", grant.session()).as_str()) + || tag(event, "expiration").and_then(|s| s.parse::().ok()) != Some(grant.expires_at()) + { + return Err(invalid("invalid snapshot receipt envelope")); + } + let metadata: Metadata = decrypt_observer_payload(keys, event) + .map_err(|_| invalid("invalid encrypted snapshot receipt"))?; + if metadata.version != 1 + || metadata.grant != grant.id() + || metadata.expires_at != grant.expires_at() + || metadata.size > MAX_BLOB_BYTES as u64 + || metadata.size < (MAGIC.len() + 16) as u64 + || metadata.sha256.len() != 64 + || !metadata + .sha256 + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(invalid("invalid snapshot receipt metadata")); + } + Ok(Receipt { + metadata, + aad: aad(grant)?, + }) +} +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/sandbox_attach/snapshot/archive.rs b/crates/buzz-sdk/src/sandbox_attach/snapshot/archive.rs new file mode 100644 index 00000000000..1cfca3d8bc7 --- /dev/null +++ b/crates/buzz-sdk/src/sandbox_attach/snapshot/archive.rs @@ -0,0 +1,277 @@ +//! Preflight from the official Git bundle/pack specifications. Git must still +//! verify integrity and inventory imported objects before any checkout. +use super::*; +use flate2::{Decompress, FlushDecompress, Status}; +use std::time::{Duration, Instant}; + +/// Largest number of historical objects accepted in one snapshot. +pub const MAX_OBJECTS: u32 = 200000; +/// Bounded expansion inventory, calculated before Git imports or checks out bytes. +pub struct Inventory { + /// Total number of packed objects. + pub objects: u32, + /// Sum of reconstructed object sizes, including delta targets. + pub expanded_bytes: u64, + /// Bundle references, as object-ID/ref-name pairs. Git validates ref syntax later. + pub refs: Vec<(String, String)>, + /// Object ID length, in bytes (20 for SHA-1, 32 for SHA-256). + pub hash_bytes: usize, +} +fn take<'a>(bytes: &'a [u8], position: &mut usize, count: usize) -> Result<&'a [u8], SdkError> { + let end = position + .checked_add(count) + .ok_or_else(|| invalid("snapshot bundle offset overflow"))?; + let value = bytes + .get(*position..end) + .ok_or_else(|| invalid("truncated snapshot bundle"))?; + *position = end; + Ok(value) +} +fn byte(bytes: &[u8], position: &mut usize) -> Result { + Ok(take(bytes, position, 1)?[0]) +} +fn varint(bytes: &[u8], position: &mut usize) -> Result { + let mut value = 0u64; + for shift in (0..=63).step_by(7) { + let part = byte(bytes, position)?; + value = value + .checked_add( + u64::from(part & 127) + .checked_mul(1u64 << shift) + .ok_or_else(|| invalid("snapshot integer overflow"))?, + ) + .ok_or_else(|| invalid("snapshot integer overflow"))?; + if part & 128 == 0 { + return Ok(value); + } + } + Err(invalid("snapshot integer overflow")) +} +fn size(bytes: &[u8], position: &mut usize) -> Result<(u8, u64), SdkError> { + let mut part = byte(bytes, position)?; + let kind = (part >> 4) & 7; + let mut value = u64::from(part & 15); + for shift in (4..=60).step_by(7) { + if part & 128 == 0 { + return Ok((kind, value)); + } + part = byte(bytes, position)?; + value = value + .checked_add( + u64::from(part & 127) + .checked_mul(1u64 << shift) + .ok_or_else(|| invalid("snapshot object size overflow"))?, + ) + .ok_or_else(|| invalid("snapshot object size overflow"))?; + } + if part & 128 != 0 { + return Err(invalid("snapshot object size overflow")); + } + Ok((kind, value)) +} +fn bounded(value: u64, budget: u64) -> Result<(), SdkError> { + if value > budget { + return Err(invalid("snapshot Git expansion exceeds budget")); + } + Ok(()) +} +struct Header { + start: usize, + hash_bytes: usize, + refs: Vec<(String, String)>, +} +fn header(bytes: &[u8]) -> Result { + let end = bytes[..bytes.len().min(8 * 1024 * 1024)] + .windows(2) + .position(|pair| pair == b"\n\n") + .ok_or_else(|| invalid("invalid snapshot bundle header"))? + + 2; + let text = std::str::from_utf8(&bytes[..end]) + .map_err(|_| invalid("invalid snapshot bundle header"))?; + let mut lines = text.lines(); + let signature = lines + .next() + .ok_or_else(|| invalid("missing snapshot bundle signature"))?; + if !matches!(signature, "# v2 git bundle" | "# v3 git bundle") { + return Err(invalid("unsupported snapshot bundle version")); + } + let mut hash_bytes = 20; + let mut capability = false; + let mut refs = Vec::new(); + for line in lines.filter(|line| !line.is_empty()) { + if line.starts_with('-') { + return Err(invalid("snapshot bundle requires external history")); + } + if line.starts_with('@') { + if signature != "# v3 git bundle" || capability || !refs.is_empty() { + return Err(invalid("invalid snapshot bundle capabilities")); + } + hash_bytes = match line { + "@object-format=sha1" => 20, + "@object-format=sha256" => 32, + _ => return Err(invalid("unsupported snapshot bundle capability")), + }; + capability = true; + continue; + } + let (oid, name) = line + .split_once(' ') + .ok_or_else(|| invalid("invalid snapshot bundle reference"))?; + if oid.len() != hash_bytes * 2 + || !oid.bytes().all(|c| c.is_ascii_hexdigit()) + || name.len() > 1024 + || name.chars().any(|c| c.is_whitespace() || c.is_control()) + || !(name == "HEAD" || name.starts_with("refs/")) + || name.starts_with("refs/replace/") + || refs.len() >= MAX_FILES + { + return Err(invalid("invalid snapshot bundle reference")); + } + refs.push((oid.to_owned(), name.to_owned())); + } + if refs.is_empty() { + return Err(invalid("snapshot bundle has no references")); + } + Ok(Header { + start: end, + hash_bytes, + refs, + }) +} +/// Refuse declared object/delta expansion, aggregate expansion, count overflow, +/// incomplete packs and external prerequisites without allocating expanded objects. +/// `budget` may tighten, never raise, the 256 MiB source limit. +/// See https://git-scm.com/docs/gitformat-pack and gitformat-bundle. +pub fn inspect_bundle(bytes: &[u8], budget: u64) -> Result { + if bytes.len() > MAX_BLOB_BYTES || budget == 0 || budget > MAX_SOURCE_BYTES { + return Err(invalid("invalid snapshot bundle budget")); + } + let Header { + start, + hash_bytes, + refs, + } = header(bytes)?; + let pack = &bytes[start..]; + let mut position = 0; + if take(pack, &mut position, 4)? != b"PACK" { + return Err(invalid("snapshot bundle has no pack")); + } + let version = u32::from_be_bytes( + take(pack, &mut position, 4)? + .try_into() + .map_err(|_| invalid("invalid pack version"))?, + ); + let objects = u32::from_be_bytes( + take(pack, &mut position, 4)? + .try_into() + .map_err(|_| invalid("invalid pack count"))?, + ); + if !matches!(version, 2 | 3) || objects == 0 || objects > MAX_OBJECTS { + return Err(invalid("snapshot Git object count exceeds budget")); + } + let started = Instant::now(); + let mut expanded_bytes = 0u64; + let mut inflated_bytes = 0u64; + let mut output = [0u8; 16384]; + for _ in 0..objects { + let (kind, declared) = size(pack, &mut position)?; + bounded(declared, budget)?; + inflated_bytes = inflated_bytes + .checked_add(declared) + .ok_or_else(|| invalid("snapshot expansion overflow"))?; + bounded(inflated_bytes, budget)?; + match kind { + 1..=4 => {} + 6 => { + let mut done = false; + for _ in 0..10 { + if byte(pack, &mut position)? & 128 == 0 { + done = true; + break; + } + } + if !done { + return Err(invalid("snapshot delta offset overflow")); + } + } + 7 => { + take(pack, &mut position, hash_bytes)?; + } + _ => return Err(invalid("unsupported snapshot Git object type")), + } + let mut inflater = Decompress::new(true); + let mut prefix = Vec::with_capacity(32); + let mut expanded = if kind <= 4 { Some(declared) } else { None }; + if let Some(value) = expanded { + bounded( + expanded_bytes + .checked_add(value) + .ok_or_else(|| invalid("snapshot expansion overflow"))?, + budget, + )?; + } + loop { + if started.elapsed() > Duration::from_secs(30) { + return Err(invalid("snapshot Git preflight timed out")); + } + let before_in = inflater.total_in(); + let before_out = inflater.total_out(); + let input = pack + .get(position..position.saturating_add(65536).min(pack.len())) + .ok_or_else(|| invalid("truncated snapshot Git object"))?; + let status = inflater + .decompress(input, &mut output, FlushDecompress::None) + .map_err(|_| invalid("invalid compressed snapshot Git object"))?; + let consumed = (inflater.total_in() - before_in) as usize; + let produced = (inflater.total_out() - before_out) as usize; + position = position + .checked_add(consumed) + .ok_or_else(|| invalid("snapshot bundle offset overflow"))?; + if inflater.total_out() > declared { + return Err(invalid("snapshot Git object exceeds declared size")); + } + if expanded.is_none() { + let count = produced.min(32 - prefix.len()); + prefix.extend_from_slice(&output[..count]); + if prefix.len() >= 20 || status == Status::StreamEnd { + let mut offset = 0; + let base = varint(&prefix, &mut offset)?; + bounded(base, budget)?; + let target = varint(&prefix, &mut offset)?; + bounded(target, budget)?; + bounded( + expanded_bytes + .checked_add(target) + .ok_or_else(|| invalid("snapshot expansion overflow"))?, + budget, + )?; + expanded = Some(target); + } + } + if status == Status::StreamEnd { + if inflater.total_out() != declared { + return Err(invalid("snapshot Git object size mismatch")); + } + break; + } + if consumed == 0 && produced == 0 { + return Err(invalid("truncated snapshot Git object")); + } + } + expanded_bytes = expanded_bytes + .checked_add(expanded.ok_or_else(|| invalid("invalid snapshot delta"))?) + .ok_or_else(|| invalid("snapshot expansion overflow"))?; + bounded(expanded_bytes, budget)?; + } + if pack.len().saturating_sub(position) != hash_bytes { + return Err(invalid("snapshot pack trailer is incomplete")); + } + Ok(Inventory { + objects, + expanded_bytes, + refs, + hash_bytes, + }) +} +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/sandbox_attach/snapshot/archive/tests.rs b/crates/buzz-sdk/src/sandbox_attach/snapshot/archive/tests.rs new file mode 100644 index 00000000000..bab47823ae8 --- /dev/null +++ b/crates/buzz-sdk/src/sandbox_attach/snapshot/archive/tests.rs @@ -0,0 +1,146 @@ +use super::*; +use flate2::{write::ZlibEncoder, Compression}; +use std::io::Write; +fn object(kind: u8, body: &[u8], declared: u64) -> Vec { + let mut size = declared; + let mut head = ((kind & 7) << 4) | (size as u8 & 15); + size >>= 4; + if size > 0 { + head |= 128; + } + let mut bytes = vec![head]; + while size > 0 { + let mut byte = size as u8 & 127; + size >>= 7; + if size > 0 { + byte |= 128; + } + bytes.push(byte); + } + if kind == 7 { + bytes.extend([0u8; 20]); + } + let mut compressed = ZlibEncoder::new(Vec::new(), Compression::best()); + compressed.write_all(body).unwrap(); + bytes.extend(compressed.finish().unwrap()); + bytes +} +fn bundle(objects: Vec>) -> Vec { + let mut bytes = format!("# v2 git bundle\n{} refs/heads/main\n\n", "1".repeat(40)).into_bytes(); + bytes.extend(b"PACK"); + bytes.extend(2u32.to_be_bytes()); + bytes.extend((objects.len() as u32).to_be_bytes()); + for object in objects { + bytes.extend(object); + } + bytes.extend([0u8; 20]); + bytes +} +#[test] +fn pack_preflight_refuses_compressed_expansion_before_git_import() { + let valid = bundle(vec![object(3, b"hello", 5)]); + let stats = inspect_bundle(&valid, 100).unwrap(); + assert_eq!(stats.expanded_bytes, 5); + assert_eq!(stats.objects, 1); + assert!( + inspect_bundle(&bundle(vec![object(3, &[0; 101], 101)]), 100).is_err(), + "declared object size must refuse before decompression" + ); + assert!( + inspect_bundle( + &bundle(vec![object(3, &[0; 60], 60), object(3, &[0; 60], 60)]), + 100 + ) + .is_err(), + "aggregate expansion must be bounded" + ); + // Delta stream is tiny, but its declared reconstructed target is 16384 bytes. + assert!( + inspect_bundle(&bundle(vec![object(7, &[1, 128, 128, 1], 4)]), 100).is_err(), + "delta result must be bounded independently of instruction size" + ); + assert!(inspect_bundle(&bundle(vec![object(3, b"longer than declared", 1)]), 100).is_err()); + assert!(inspect_bundle(&valid[..valid.len() - 1], 100).is_err()); + let count = bundle(vec![object(3, &[], 0); MAX_OBJECTS as usize + 1]); + assert!(inspect_bundle(&count, 100).is_err()); + let mut thin = valid.clone(); + let header = thin.windows(2).position(|s| s == b"\n\n").unwrap(); + thin.splice( + header + 1..header + 1, + format!("-{} missing history\n", "2".repeat(40)).bytes(), + ); + assert!(inspect_bundle(&thin, 100).is_err()); +} +#[test] +fn actual_git_bundle_all_is_accepted_and_object_inventory_matches() { + use std::process::Command; + let root = std::env::temp_dir().join(format!("buzz-snapshot-pack-{}", Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let run = |args: &[&str]| { + let result = Command::new("git") + .args([ + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-c", + "user.name=test", + "-c", + "user.email=test@example.com", + "-C", + ]) + .arg(&root) + .args(args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + result.stdout + }; + run(&["init"]); + std::fs::write(root.join("hello"), "first version\n").unwrap(); + run(&["add", "hello"]); + run(&["commit", "-m", "first"]); + std::fs::write(root.join("hello"), "second version\n").unwrap(); + run(&["commit", "-am", "second"]); + run(&["branch", "local-only", "HEAD~1"]); + run(&["bundle", "create", "snapshot.bundle", "--all", "HEAD"]); + let bytes = std::fs::read(root.join("snapshot.bundle")).unwrap(); + let inventory = inspect_bundle(&bytes, MAX_SOURCE_BYTES).unwrap(); + let actual = run(&[ + "cat-file", + "--batch-all-objects", + "--batch-check=%(objectsize)", + ]); + let total: u64 = String::from_utf8(actual) + .unwrap() + .lines() + .map(|line| line.parse::().unwrap()) + .sum(); + assert_eq!(inventory.expanded_bytes, total); + assert_eq!(inventory.hash_bytes, 20); + assert!(inventory + .refs + .iter() + .any(|(_, name)| name == "refs/heads/local-only")); + let origin = nostr::Url::from_file_path(&root).unwrap().to_string(); + run(&["clone", "--depth=1", &origin, "shallow"]); + let shallow = root.join("shallow"); + assert!(!std::fs::read(shallow.join(".git/shallow")) + .unwrap() + .is_empty()); + let result = Command::new("git") + .arg("-C") + .arg(&shallow) + .args(["bundle", "create", "shallow.bundle", "--all", "HEAD"]) + .output() + .unwrap(); + assert!(result.status.success()); + let bytes = std::fs::read(shallow.join("shallow.bundle")).unwrap(); + let shallow_inventory = inspect_bundle(&bytes, MAX_SOURCE_BYTES).unwrap(); + assert!(shallow_inventory.objects < inventory.objects); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/buzz-sdk/src/sandbox_attach/snapshot/tests.rs b/crates/buzz-sdk/src/sandbox_attach/snapshot/tests.rs new file mode 100644 index 00000000000..2405e80a735 --- /dev/null +++ b/crates/buzz-sdk/src/sandbox_attach/snapshot/tests.rs @@ -0,0 +1,153 @@ +use super::*; +fn fixture() -> (Keys, Keys, Keys, Pending, Grant) { + let owner = Keys::generate(); + let agent = Keys::generate(); + let relay = Keys::generate(); + let scope = Scope { + channel: Uuid::new_v4(), + thread: EventId::all_zeros(), + agent: agent.public_key(), + }; + let event = request( + &owner, + scope, + Uuid::new_v4(), + Timestamp::now().as_secs() + 600, + ) + .unwrap(); + let pending = Pending::parse(&event, Timestamp::now().as_secs()).unwrap(); + let approval = approve(&owner, &pending, pending.expires_at()).unwrap(); + let grant = Grant::parse(&approval, scope, Timestamp::now().as_secs()).unwrap(); + (owner, agent, relay, pending, grant) +} +#[test] +fn snapshot_approvals_are_separate_capabilities_bound_to_current_authority() { + let (owner, agent, relay, pending, grant) = fixture(); + let now = Timestamp::now().as_secs(); + assert!(super::super::Pending::parse(&pending.0.event, now).is_err()); + assert!(super::super::Grant::parse(grant.event(), grant.scope(), now).is_err()); + let terminal = super::super::request(&owner, grant.scope(), Uuid::new_v4(), now + 600).unwrap(); + assert!(Pending::parse(&terminal, now).is_err()); + let roster = EventBuilder::new(Kind::from(39002), "") + .tags([ + Tag::parse(["d", &grant.scope().channel.to_string()]).unwrap(), + Tag::parse(["p", &owner.public_key().to_hex(), "", "member"]).unwrap(), + Tag::parse(["p", &agent.public_key().to_hex(), "", "member"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + assert!(grant.authorized( + &pending, + Authority { + owner: owner.public_key(), + relay: relay.public_key(), + roster: &roster, + delegations: &[], + profiles: &[] + }, + now + )); + assert!(!grant.authorized( + &pending, + Authority { + owner: owner.public_key(), + relay: relay.public_key(), + roster: &roster, + delegations: &[], + profiles: &[] + }, + grant.expires_at() + )); + let mut forged = grant.event().clone(); + forged.pubkey = agent.public_key(); + assert!(Grant::parse(&forged, grant.scope(), now).is_err()); + let self_approved = EventBuilder::new(grant.event().kind, grant.event().content.clone()) + .tags(grant.event().tags.iter().cloned()) + .allow_self_tagging() + .sign_with_keys(&agent) + .unwrap(); + let bad = Grant::parse(&self_approved, grant.scope(), Timestamp::now().as_secs()).unwrap(); + assert!(!bad.authorized( + &pending, + Authority { + owner: owner.public_key(), + relay: relay.public_key(), + roster: &roster, + delegations: &[], + profiles: &[] + }, + now + )); +} +#[test] +fn encrypted_snapshot_result_binds_hash_recipient_scope_signature_approval_and_expiry() { + let (owner, agent, _, _, grant) = fixture(); + let bytes = b"archive with confidential workspace bytes"; + let sealed = seal(&grant, bytes).unwrap(); + assert_ne!(sealed.ciphertext(), bytes); + let result = result(&agent, &grant, &sealed).unwrap(); + assert!(!result.content.contains("confidential")); + let now = Timestamp::now().as_secs(); + let received = receipt(&owner, &grant, &result, now).unwrap(); + assert_eq!(received.hash(), sealed.hash()); + assert_eq!(received.open(sealed.ciphertext(), now).unwrap(), bytes); + let mut wrong_hash = seal(&grant, bytes).unwrap(); + wrong_hash.metadata.sha256 = "0".repeat(64); + let bad_result = super::result(&agent, &grant, &wrong_hash).unwrap(); + let bad_receipt = receipt(&owner, &grant, &bad_result, Timestamp::now().as_secs()).unwrap(); + assert!( + bad_receipt.open(wrong_hash.ciphertext(), now).is_err(), + "valid AEAD bytes with mismatched signed hash must be refused" + ); + let mut wrong_size = seal(&grant, bytes).unwrap(); + wrong_size.metadata.size += 1; + let bad_result = super::result(&agent, &grant, &wrong_size).unwrap(); + let bad_receipt = receipt(&owner, &grant, &bad_result, Timestamp::now().as_secs()).unwrap(); + assert!(bad_receipt.open(wrong_size.ciphertext(), now).is_err()); + let mut oversized = seal(&grant, bytes).unwrap(); + oversized.metadata.size = MAX_BLOB_BYTES as u64 + 1; + let bad_result = super::result(&agent, &grant, &oversized).unwrap(); + assert!(receipt(&owner, &grant, &bad_result, Timestamp::now().as_secs()).is_err()); + let mut corrupted = sealed.ciphertext().to_vec(); + corrupted[20] ^= 1; + assert!(received.open(&corrupted, now).is_err()); + assert!(received + .open(&corrupted[..corrupted.len() - 1], now) + .is_err()); + assert!(received + .open(sealed.ciphertext(), grant.expires_at()) + .is_err()); + assert!(receipt(&Keys::generate(), &grant, &result, now).is_err()); + let mut forged = result.clone(); + forged.sig = request(&Keys::generate(), grant.scope(), Uuid::new_v4(), now + 60) + .unwrap() + .sig; + assert!(receipt(&owner, &grant, &forged, now).is_err()); + let (_, _, _, _, other) = fixture(); + assert!(receipt(&owner, &other, &result, now).is_err()); + let mut corrupt_scope = result.clone(); + corrupt_scope + .tags + .push(Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()); + assert!(receipt(&owner, &grant, &corrupt_scope, now).is_err()); + let first_claim = audit(&agent, &grant, "accepted", 0, None).unwrap(); + let second_claim = audit(&agent, &grant, "accepted", 0, None).unwrap(); + assert_ne!( + tag(&first_claim, "buzz:attach-claim"), + tag(&second_claim, "buzz:attach-claim") + ); + assert!(tag(&first_claim, "expiration").is_none()); + let audit = audit( + &agent, + &grant, + "ended", + sealed.ciphertext().len() as u64, + None, + ) + .unwrap(); + assert!( + !audit.content.contains("confidential") + && !audit.content.contains("nonce") + && !audit.content.contains("\"key\":") + ); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d9acb9144f0..e3f01ece238 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1267,6 +1267,8 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "buzz-core", + "chacha20poly1305 0.10.1", + "flate2", "nostr 0.44.7", "serde", "serde_json", diff --git a/docs/nips/NIP-SA.md b/docs/nips/NIP-SA.md new file mode 100644 index 00000000000..5e6c19bee9b --- /dev/null +++ b/docs/nips/NIP-SA.md @@ -0,0 +1,52 @@ +# NIP-SA: Owner-approved workspace snapshots + +This SDK layer defines a snapshot capability, encrypted result receipt, and bounded Git-bundle preflight. +Archive packaging, extraction, ACP execution, and CLI commands follow in dependent PRs. +It does not export workspaces by itself. + +Snapshots use kind 30624 with `h`, `e`, and one `p`, as NIP-TA does. +Their `d` coordinates are `snapshot::request`, `:grant`, `:accepted`, `:ready`, and `:ended`. +The body mode is `snapshot`; terminal parsers refuse it. +Separate SDK types prevent passing a snapshot approval into a terminal transport API. + +The requester publishes a signed pending request naming one channel, immutable thread root, agent, and expiration. +The owner must sign a separate per-session approval naming that requester and pending request ID. +A live gateway delegation permits requesting approval, never self-approval. +The maximum grant lifetime is one hour; approvals cannot extend the pending request. +Owner, agent, and requester must remain posting members in a strongly fetched relay-signed roster. +A gateway requester must remain a live delegate throughout creation and retrieval. +Consumers fail closed on failed reads, expiry, deletion, replacement, or lost membership. +The same atomic acceptance burn as terminal attachment makes each approval single-use. + +Only metadata is stored in plaintext: the approval, acceptance/end status, byte count, and optional failure category. +Failure categories are `scan`, `budget`, `archive`, `upload`, or `authority`. +Scanner output, file names, credentials, and file contents must never enter audit failures. +The agent signs an encrypted `:ready` result after confirming the ciphertext upload. +Its NIP-44 content contains version 1, approval ID, expiry, ciphertext SHA-256, exact ciphertext size, random blob key, and nonce. +Its expiration tag matches the approval expiry. +The requester verifies the agent signature, recipient, scope, approval ID, expiry, size, and ciphertext hash before decrypting. + +Blob bytes start with `BUZZSNAP1` followed by a NUL, then XChaCha20-Poly1305 ciphertext and authentication tag. +Every blob uses a fresh random 32-byte key and 24-byte nonce. +Associated data is the canonical JSON tuple of approval ID, scope, and requester. +The key and nonce appear only inside the recipient-encrypted receipt. +The ciphertext hash names the blob on the same community's Blossom server; a receipt never supplies an arbitrary download URL. +The ciphertext cap is 100 MiB, including its prefix and tag, matching the default generic-file upload limit. +Grant expiry prevents new retrieval through this protocol; it cannot revoke bytes already copied by an authorized requester. + +The archive consumer must preserve self-contained `git bundle --all HEAD` history plus the working-tree copy. +The user explicitly chose full locally available scanned history: exclusions apply to copied working-tree files, while historical versioned content remains in the bundle and must be fully scanned. +Never route snapshot creation through checkpoint quarantine or an unscanned artifact. +Scan the exact staged bundle objects and copied files, with immutable scanner rules and no repository/global ignore or replacement-ref bypass. +No forge credentials or network clone may be required to restore the bundle. +Shallow repositories carry the checkpoint format's explicit boundary metadata; consumers must retain it and validate missing parents against those boundaries, without implicit unshallow or fetch. +Unborn HEAD snapshots must be explicitly refused until a separate supported format exists. + +The SDK preflight follows [Git's pack specification](https://git-scm.com/docs/gitformat-pack) and [bundle specification](https://git-scm.com/docs/gitformat-bundle). +It refuses external prerequisites, unknown capabilities, replacement refs, malformed compression, size overflow, and incomplete packs. +It bounds each declared object, delta base/result size, aggregate reconstructed size, aggregate inflated instruction bytes, object count, input bytes, and preflight time. +The limits are 256 MiB expanded/source bytes, 200,000 Git objects, 50,000 source files/references, and 30 seconds of preflight work. +Consumers may tighten the expanded-byte budget. +Preflight does not replace Git integrity checks: consumers must validate the imported object graph and inventory uncompressed sizes before checkout. +Extraction must reject unsafe paths/types, bound tar expansion and Git subprocesses, check actual free capacity for all staging/import/copy work, and publish a fresh read-only destination atomically. +Existing destinations must never be overwritten. diff --git a/docs/nips/NIP-TA.md b/docs/nips/NIP-TA.md index 459ccb54038..73670014bfa 100644 --- a/docs/nips/NIP-TA.md +++ b/docs/nips/NIP-TA.md @@ -48,3 +48,5 @@ 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. + +Read-only workspace snapshots use the distinct [NIP-SA](NIP-SA.md) capability; terminal approvals never authorize snapshot export.