feat(core): bind owner-private placement intent to signed event order

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-04 06:54:19 -04:00
parent 46323b4218
commit 125a56de70
5 changed files with 538 additions and 0 deletions

View File

@@ -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,

View File

@@ -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.

View File

@@ -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<Event, Error> {
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<DecodedIntent, Error> {
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;

View File

@@ -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<DecodedIntent, Error> {
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<Tag>) -> 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));
}

View File

@@ -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.