From 125a56de70786d8b0dba724eaf5bc81981ba60bf Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 4 Sep 2026 06:54:19 -0400 Subject: [PATCH] feat(core): bind owner-private placement intent to signed event order Signed-off-by: Logan Johnson --- crates/buzz-core/src/kind.rs | 6 + crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/placement_wire.rs | 193 ++++++++++++++ crates/buzz-core/src/placement_wire/tests.rs | 257 +++++++++++++++++++ docs/multiverse-intent-wire.md | 80 ++++++ 5 files changed, 538 insertions(+) create mode 100644 crates/buzz-core/src/placement_wire.rs create mode 100644 crates/buzz-core/src/placement_wire/tests.rs create mode 100644 docs/multiverse-intent-wire.md diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5e..4ac4daded83 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -633,6 +633,11 @@ pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; /// announcement, never a project. See `docs/nips/NIP-MP.md`. pub const KIND_PROJECT: u32 = 30621; +/// Immutable owner-self-encrypted placement intent (not NIP-33). +/// Reserved codec only: relay ingest remains disabled until private transport +/// authorization and history are implemented. See [`crate::placement_wire`]. +pub const KIND_PLACEMENT_INTENT: u32 = 50003; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -659,6 +664,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, + KIND_PLACEMENT_INTENT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 1f62222c5ea..f721108cae3 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -34,6 +34,8 @@ pub mod observer; pub mod pairing; /// Arrival-independent desired placement, separate from lifecycle execution. pub mod placement; +/// Owner-private authenticated wire binding for placement projection. +pub mod placement_wire; /// Presence status types shared across crates. pub mod presence; /// NIP-PMA owner-encrypted private managed-agent wire codec. diff --git a/crates/buzz-core/src/placement_wire.rs b/crates/buzz-core/src/placement_wire.rs new file mode 100644 index 00000000000..2185a29c551 --- /dev/null +++ b/crates/buzz-core/src/placement_wire.rs @@ -0,0 +1,193 @@ +//! Owner-private immutable placement intent. No transport or execution is enabled. +//! +//! Like NIP-PMA, encryption is owner-to-self: trusted Desktop instances already +//! holding that owner's keys can observe intent for OTHER hosts. Host keys alone +//! cannot decrypt or log in. Never export owner keys to an executor/runtime. +//! The caller supplies current authoritative agent/host bindings; this codec +//! verifies signed scope, not ownership records, revocation or admission. + +use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zeroize::Zeroizing; + +use crate::{ + kind::KIND_PLACEMENT_INTENT, + placement::{EventOrder, PlacementAction, PlacementIntent}, + relay::normalize_relay_url, +}; + +/// Separate from the obsolete exact-run `buzz.host.execution.v1` protocol. +pub const NAMESPACE: &str = "buzz.placement.v1"; +const MAX_PLAINTEXT: usize = 2048; +const MAX_CIPHERTEXT: usize = 4096; + +/// Placement contributions only. Restart is a separate current-host one-shot; +/// Move may issue Start only after ordinary Stop success and validity checks. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Action { + /// Select the destination; not evidence of a running process. + Start, + /// Stop this host without cancelling desired placement on another host. + Stop, +} + +/// All semantic fields are encrypted and bound to ONE signed event identity. +/// No shell, configuration, credentials, run nonce or future Start template. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Payload { + /// Wire version, currently exactly 1. + pub v: u8, + /// Canonical community relay identity, captured from the active session. + pub community: String, + /// Authenticated owner, identical to the event author. + pub owner: PublicKey, + /// Agent identity, not a local display name. + pub agent: PublicKey, + /// Target executor, not necessarily the observing Desktop's executor. + pub host: PublicKey, + /// Non-nil request identity; durable deduplication is a separate boundary. + pub request: Uuid, + /// Agent + host intent, never an implicitly broadened legacy command. + pub action: Action, +} + +/// Fail-closed codec errors; never include decrypted content or keys. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + /// Wrong kind/tags, size, event hash or signature. + #[error("invalid placement envelope")] + Envelope, + /// Malformed/unsupported plaintext, including legacy exact-run payloads. + #[error("invalid placement payload")] + Payload, + /// Owner, community, agent or authorized target differs from captured scope. + #[error("placement scope mismatch")] + Scope, + /// Encryption, decryption or signing failed. + #[error("placement cryptography failed")] + Crypto, +} + +impl Payload { + fn validate(&self) -> Result<(), Error> { + if self.v != 1 + || self.request.is_nil() + || self.community.len() > 512 + || normalize_relay_url(&self.community).ok().as_deref() != Some(&self.community) + { + return Err(Error::Payload); + } + Ok(()) + } +} + +/// Build an inert owner-self-encrypted candidate using the supplied sender +/// seconds (no clock adjustment). Persist and retry this EXACT event; rebuilding +/// randomizes ciphertext and changes order. This function grants no authority +/// and does not publish. The producer must validate current bindings first. +pub fn build_event(owner: &Keys, payload: &Payload, created_at: u64) -> Result { + payload.validate()?; + if payload.owner != owner.public_key() { + return Err(Error::Scope); + } + let plaintext = Zeroizing::new(serde_json::to_string(payload).map_err(|_| Error::Payload)?); + if plaintext.len() > MAX_PLAINTEXT { + return Err(Error::Payload); + } + let content = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + plaintext.as_str(), + nip44::Version::V2, + ) + .map_err(|_| Error::Crypto)?; + EventBuilder::new(Kind::Custom(KIND_PLACEMENT_INTENT as u16), content) + .tags([Tag::parse(["L", NAMESPACE]).map_err(|_| Error::Envelope)?]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(owner) + .map_err(|_| Error::Crypto) +} + +/// Signed, scoped contribution plus separate request identity. Construction is +/// private so callers cannot accidentally substitute fields from another event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedIntent { + payload: Payload, + placement: PlacementIntent, +} + +impl DecodedIntent { + /// Authenticated request metadata; not permission to execute or replay it. + pub fn payload(&self) -> &Payload { + &self.payload + } + + /// Bind M01 order/host/action to the same verified event, never arrival time. + pub fn placement(&self) -> PlacementIntent { + self.placement + } +} + +/// Verify hash AND signature before decrypting, strictly parse, then bind scope. +/// +/// `community`, `agent`, and `authorized_hosts` must come from the captured +/// authenticated owner's authoritative bindings, NOT this event or a profile. +/// Hosts includes every authorized target whose history contributes to this +/// agent, not just the local executor: otherwise X cannot learn Start Y. +/// The caller must recheck authorization at effect boundaries. Historical +/// decoding is read-only projection, never command replay; no expiry/skew gate +/// is imposed on desired state. Errors are not evidence of absent intent. +pub fn decode_event( + event: &Event, + owner: &Keys, + community: &str, + agent: PublicKey, + authorized_hosts: &[PublicKey], +) -> Result { + if event.pubkey != owner.public_key() { + return Err(Error::Scope); + } + if event.kind.as_u16() as u32 != KIND_PLACEMENT_INTENT + || event.tags.len() != 1 + || !event + .tags + .iter() + .any(|tag| tag.as_slice() == ["L", NAMESPACE]) + || event.content.len() > MAX_CIPHERTEXT + || !event.verify_id() + || !event.verify_signature() + { + return Err(Error::Envelope); + } + let plaintext = Zeroizing::new( + nip44::decrypt(owner.secret_key(), &owner.public_key(), &event.content) + .map_err(|_| Error::Crypto)?, + ); + if plaintext.len() > MAX_PLAINTEXT { + return Err(Error::Payload); + } + let payload: Payload = serde_json::from_str(&plaintext).map_err(|_| Error::Payload)?; + payload.validate()?; + if payload.owner != owner.public_key() + || payload.community != community + || payload.agent != agent + || !authorized_hosts.contains(&payload.host) + { + return Err(Error::Scope); + } + let placement = PlacementIntent { + order: EventOrder::from_event(event), + host: payload.host, + action: match payload.action { + Action::Start => PlacementAction::Start, + Action::Stop => PlacementAction::Stop, + }, + }; + Ok(DecodedIntent { payload, placement }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-core/src/placement_wire/tests.rs b/crates/buzz-core/src/placement_wire/tests.rs new file mode 100644 index 00000000000..ed801f36156 --- /dev/null +++ b/crates/buzz-core/src/placement_wire/tests.rs @@ -0,0 +1,257 @@ +use super::*; +use crate::placement::{PlacementProjection, TargetIntent}; +use nostr::JsonUtil; + +fn payload(owner: &Keys) -> Payload { + Payload { + v: 1, + community: "wss://relay.example".into(), + owner: owner.public_key(), + agent: Keys::generate().public_key(), + host: Keys::generate().public_key(), + request: Uuid::new_v4(), + action: Action::Start, + } +} + +fn decode(event: &Event, owner: &Keys, p: &Payload) -> Result { + decode_event(event, owner, &p.community, p.agent, &[p.host]) +} + +// Deliberately bypass the producer's validation to exercise the receive boundary. +fn raw(owner: &Keys, text: &str) -> Event { + let content = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + text, + nip44::Version::V2, + ) + .unwrap(); + resign( + owner, + Kind::Custom(KIND_PLACEMENT_INTENT as u16), + content, + vec![Tag::parse(["L", NAMESPACE]).unwrap()], + ) +} + +fn resign(owner: &Keys, kind: Kind, content: String, tags: Vec) -> Event { + EventBuilder::new(kind, content) + .tags(tags) + .custom_created_at(Timestamp::from(100)) + .sign_with_keys(owner) + .unwrap() +} + +#[test] +fn owner_desktops_observe_the_same_cross_host_identity_without_host_keys() { + let owner = Keys::generate(); + let x = Keys::generate(); + let y = Keys::generate(); + let mut p = payload(&owner); + p.host = x.public_key(); + let start_x = build_event(&owner, &p, 100).unwrap(); + p.host = y.public_key(); + p.request = Uuid::new_v4(); + let start_y = build_event(&owner, &p, 101).unwrap(); + p.host = x.public_key(); + p.request = Uuid::new_v4(); + p.action = Action::Stop; + let stop_x = build_event(&owner, &p, 102).unwrap(); + let hosts = [x.public_key(), y.public_key()]; + let read = |event: &Event| { + decode_event(event, &owner, &p.community, p.agent, &hosts) + .unwrap() + .placement() + }; + let a = [read(&start_x), read(&start_y), read(&stop_x)]; + let b = [read(&stop_x), read(&start_y), read(&start_x)]; + for intents in [&a, &b] { + let projection = PlacementProjection::new(intents); + assert_eq!( + projection.desired(), + Some((y.public_key(), EventOrder::from_event(&start_y))) + ); + assert!(matches!( + projection.target(x.public_key()), + TargetIntent::Stopped(_) + )); + assert!(projection.retains_start(y.public_key(), EventOrder::from_event(&start_y))); + } + // Exact persisted bytes survive retry; no per-recipient re-signing. + let retry = Event::from_json(start_y.as_json()).unwrap(); + assert_eq!(read(&retry), read(&start_y)); + for host in [&x, &y] { + assert_eq!(decode(&start_y, host, &p), Err(Error::Scope)); + assert!(nip44::decrypt(host.secret_key(), &owner.public_key(), &start_y.content).is_err()); + } +} + +#[test] +fn rejects_each_foreign_scope_and_unbound_target() { + let owner = Keys::generate(); + let p = payload(&owner); + let event = build_event(&owner, &p, 100).unwrap(); + let decoded = decode(&event, &owner, &p).unwrap(); + assert_eq!(decoded.payload(), &p); + assert_eq!(decoded.placement().order.event_id(), event.id); + assert_eq!(decoded.placement().host, p.host); + assert_eq!(decoded.placement().action, PlacementAction::Start); + let foreign = Keys::generate(); + assert_eq!(build_event(&foreign, &p, 100), Err(Error::Scope)); + assert_eq!(decode(&event, &foreign, &p), Err(Error::Scope)); + assert_eq!( + decode_event(&event, &owner, "wss://other.example", p.agent, &[p.host]), + Err(Error::Scope) + ); + assert_eq!( + decode_event( + &event, + &owner, + &p.community, + foreign.public_key(), + &[p.host] + ), + Err(Error::Scope) + ); + assert_eq!( + decode_event(&event, &owner, &p.community, p.agent, &[]), + Err(Error::Scope) + ); + assert_eq!( + decode_event( + &event, + &owner, + &p.community, + p.agent, + &[foreign.public_key()] + ), + Err(Error::Scope) + ); + let mut spoof = p.clone(); + spoof.owner = foreign.public_key(); + assert_eq!( + decode( + &raw(&owner, &serde_json::to_string(&spoof).unwrap()), + &owner, + &p + ), + Err(Error::Scope) + ); +} + +#[test] +fn rejects_hash_signature_ciphertext_and_envelope_tampering() { + let owner = Keys::generate(); + let p = payload(&owner); + let event = build_event(&owner, &p, 100).unwrap(); + let mut bad_hash = event.clone(); + bad_hash.created_at = Timestamp::from(101); + assert!(bad_hash.verify_signature()); // signature alone is insufficient + assert_eq!(decode(&bad_hash, &owner, &p), Err(Error::Envelope)); + let mut bad_sig = event.clone(); + bad_sig.sig = build_event(&owner, &p, 102).unwrap().sig; + assert!(bad_sig.verify_id()); + assert_eq!(decode(&bad_sig, &owner, &p), Err(Error::Envelope)); + for (kind, tags) in [ + (Kind::TextNote, event.tags.clone().to_vec()), + (event.kind, vec![]), + ( + event.kind, + vec![Tag::parse(["L", "buzz.host.execution.v1"]).unwrap()], + ), + ( + event.kind, + vec![Tag::parse(["L", NAMESPACE, "extra"]).unwrap()], + ), + (event.kind, vec![Tag::parse(["L", NAMESPACE]).unwrap(); 2]), + ] { + assert_eq!( + decode( + &resign(&owner, kind, event.content.clone(), tags), + &owner, + &p + ), + Err(Error::Envelope) + ); + } + let forged_ciphertext = resign( + &owner, + event.kind, + "not ciphertext".into(), + event.tags.clone().to_vec(), + ); + assert_eq!(decode(&forged_ciphertext, &owner, &p), Err(Error::Crypto)); + let oversized = resign( + &owner, + event.kind, + "a".repeat(MAX_CIPHERTEXT + 1), + event.tags.clone().to_vec(), + ); + assert_eq!(decode(&oversized, &owner, &p), Err(Error::Envelope)); +} + +#[test] +fn rejects_legacy_versions_duplicates_unknown_fields_and_invalid_payloads() { + let owner = Keys::generate(); + let p = payload(&owner); + let json = serde_json::to_string(&p).unwrap(); + let cases = [ + json.replacen("\"v\":1", "\"v\":2", 1), + json.replacen("\"v\":1", "\"v\":1,\"v\":1", 1), + json.replacen("\"v\":1", "\"v\":1,\"run\":\"old-generation\"", 1), + json.replace("\"start\"", "{\"action\":\"stop\",\"run\":\"old\"}"), + json.replace("\"start\"", "\"restart\""), + json.replace("wss://relay.example", "WSS://relay.example/"), + json.replace("wss://relay.example", "wss://user@relay.example"), + json.replace(&p.request.to_string(), &Uuid::nil().to_string()), + json.replace(&p.agent.to_hex(), "invalid-key"), + format!("{json} trailing"), + " ".repeat(MAX_PLAINTEXT + 1), + ]; + for text in cases { + assert_eq!(decode(&raw(&owner, &text), &owner, &p), Err(Error::Payload)); + } + for invalid in [ + Payload { v: 2, ..p.clone() }, + Payload { + community: "https://relay.example".into(), + ..p.clone() + }, + Payload { + community: format!("wss://relay.example/{}", "x".repeat(512)), + ..p.clone() + }, + Payload { + request: Uuid::nil(), + ..p.clone() + }, + ] { + assert_eq!(build_event(&owner, &invalid, 100), Err(Error::Payload)); + } +} + +#[test] +fn signed_seconds_and_lower_id_not_arrival_or_request_id_order() { + let owner = Keys::generate(); + let p = payload(&owner); + let first = build_event(&owner, &p, 100).unwrap(); + let second = build_event(&owner, &p, 100).unwrap(); + assert_ne!(first.id, second.id); // rebuilding is NOT a retry + let a = decode(&first, &owner, &p).unwrap().placement(); + let b = decode(&second, &owner, &p).unwrap().placement(); + assert_eq!(a.order > b.order, first.id < second.id); + let future = build_event(&owner, &p, u64::MAX).unwrap(); + let f = decode(&future, &owner, &p).unwrap().placement(); + assert!(f.order > a.order); // desired state is not expired command admission + let intents = [f, b, a, f]; + assert_eq!( + PlacementProjection::new(&intents).desired(), + Some((p.host, f.order)) + ); + assert!(!crate::kind::is_replaceable(KIND_PLACEMENT_INTENT)); + assert!(!crate::kind::is_parameterized_replaceable( + KIND_PLACEMENT_INTENT + )); + assert!(!crate::kind::is_ephemeral(KIND_PLACEMENT_INTENT)); +} diff --git a/docs/multiverse-intent-wire.md b/docs/multiverse-intent-wire.md new file mode 100644 index 00000000000..a577a52e690 --- /dev/null +++ b/docs/multiverse-intent-wire.md @@ -0,0 +1,80 @@ +# Multiverse M02: authenticated intent, not execution + +Stacks on M01 (`docs/multiverse-placement.md`). This is an inert codec for +`kind:50003`, `L=buzz.placement.v1`, with owner-self NIP-44 v2 ciphertext. +The relay's `required_scope_for_kind` still rejects this kind. No producer, +receiver, new login, key distribution, runtime effect or configuration is enabled. + +## Representation and trust + +Reuse the **audience model**, not the replaceable semantics, of +`crates/buzz-core/src/private_managed_agent.rs::build_event` and +`validate_and_decrypt`: the owner encrypts to itself and signs once. Authorized +Desktop instances already holding that owner's keys can decrypt the same event, +including Start Y observed by X. Executor keys alone cannot read it. Neither a +profile nor possession of a host key grants owner credentials. There is no +blanket group key, per-recipient re-signing, generic signer or runtime key export. +An executor without an existing authenticated owner Desktop context is unsupported; +this work does not invent credentials for it. + +The encrypted payload is exactly version, canonical community, owner, agent, +target host, request UUID and Start/Stop action. Only author, kind, namespace and +signed timestamp are exposed outside ciphertext. Host means stable executor, +not process/run. Unknown fields, duplicate struct fields, invalid/noncanonical +community, nil request, unknown action/version and legacy exact-run objects fail +closed. Restart is not accepted as Start: it remains a separately deduplicated +current-host operation. No Move future-Start template is serialized. + +`decode_event` checks event hash **and** signature before decryption and binds +owner/community/agent/target to caller-supplied authoritative scope. It returns +request metadata separately from an M01 contribution whose host, action and +order come from that single verified event. Caller-supplied bindings are not +proof created by the codec. M01 remains explicitly non-authorizing. + +Newer signed sender seconds wins, lower event ID breaks ties. There is no +expiry or skew check on historical desired state, no relay order, lease or +logical clock. Rebuilding randomizes ciphertext and changes event identity: +persist exact signed bytes before publication, retry those bytes only. A shared +request UUID does not make two differently signed events the same ordering fact; +future admission must reject request collisions, not pick by arrival. + +## Concrete integration path (not implemented here) + +1. Native producer: reuse the captured owner/relay pattern in + `desktop/src-tauri/src/managed_agents/retention.rs::active_retention_scope` + and `persona_events.rs::flush_pending_events_at`. Canonicalize with + `buzz_core::relay::normalize_relay_url`, validate authoritative agent ownership + and executor binding, build once, persist exact bytes. Do **not** reuse persona + NIP-33 upserts or redating: immutable commands are not replaceable definitions. +2. Relay: extend existing author-only filter/result/live/COUNT gates, storage + search exclusion and owner/global ingest validation together, before admitting + 50003. `handlers/req.rs::{author_only_filters_authorized,is_author_only_event}` + already implement the audience shape. Ciphertext is not metadata privacy. + Transport owner authentication and community isolation remain unchanged. +3. Receiver/history: capture the same native owner context, not host-key login. + `relay.rs::query_relay_at_with_keys` is the existing explicit-key query seam; + add bounded no-redirect private queries and honest errors. Query explicit kind + + owner across **all** relevant targets, not destination X only. Agent/host + fields are encrypted: paginate owner history before local scoped decoding, + never treat a page without matching agent rows as exhaustion. + `buzz-db/src/store/event.rs::EventQuery` already has `until` + `before_id` + (`created_at DESC, id ASC`); complete dense-tie paging and overlapping live + intake remain required. No new history database or relay sequencer is needed. +4. Authoritative bindings must distinguish valid historical intent from current + effect authorization. Revocation/compaction must not drop an old stop or + selection and revive a prior host. Preserve authenticated projection fences; + never turn read/binding errors into absence. Current bindings are rechecked + before effects. Scope switches discard decrypted state and fence in-flight work. +5. Durable admission/retention and ordinary Desktop controls precede enablement: + history projects state, never replays Start/Stop/Restart. Reconnect can stop a + superseded copy, not resume interrupted operations. Move waits for ordinary + Stop success; failed/unconfirmed/late results never release automatic Start Y. + +Next executable slice is relay owner-private admission/read coverage plus its +schema search exclusion, retaining disabled runtime consumers. Then combine +native scoped history + binding adapter where the measured diff permits, before +journal/control integration. The earlier M01–M17 outline is not a fixed PR count; +profile/inventory and sender/result splits should be remeasured, not scaffolded +in advance. Preserve the existing `remote-start-preview` convention when native +wiring first appears; keyless lifecycle compatibility and native acceptance are +release gates, not claims of this codec PR.