Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions crates/buzz-core/src/desktop_stop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//! Immutable, owner-to-self Desktop Stop messages. Profiles are not authority.
use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, PublicKey, Tag};
use serde::{Deserialize, Serialize};

use crate::kind::{KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT};

/// One agent on one Desktop in one community; never a caller-selected process.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct StopTarget {
/// Schema version. Old exact-run commands are not accepted here.
pub v: u8,
/// Canonical community WebSocket URL.
pub community: String,
/// Installation coordinate from the private Desktop inventory.
pub desktop: String,
/// Agent public key. The receiver independently verifies local ownership.
pub agent: String,
}

/// Ordinary Desktop outcome, not a stronger process-termination certificate.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum StopOutcome {
/// Ordinary Stop returned success.
Stopped,
/// Ordinary Stop returned an error. No automatic retry of the effect.
Failed,
/// Interrupted, stale or evicted request; never inferred success.
Unknown,
}

/// Correlates exactly one immutable request with its Desktop's result.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct StopResult {
/// Original target, not mutable current routing.
pub target: StopTarget,
/// Signed request event ID.
pub request: String,
/// No diagnostic paths, credentials or process details on the wire.
pub outcome: StopOutcome,
}

fn hex(value: &str, len: usize) -> bool {
value.len() == len
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}

/// Check public shape before storage, without decrypting content.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
let kind = event.kind.as_u16() as u32;
let tags: Vec<_> = event.tags.iter().map(|t| t.as_slice()).collect();
let result = kind == KIND_DESKTOP_STOP_RESULT;
if !matches!(kind, KIND_DESKTOP_STOP | KIND_DESKTOP_STOP_RESULT)
|| !(132..=4096).contains(&event.content.len())
|| tags.len() != if result { 2 } else { 1 }
|| tags[0].len() != 2
|| tags[0][0] != "d"
|| !hex(&tags[0][1], 32)
|| (result && (tags[1].len() != 2 || tags[1][0] != "e" || !hex(&tags[1][1], 64)))
{
return Err("invalid Desktop Stop envelope");
}
Ok(())
}

fn sign<T: Serialize>(value: &T, keys: &Keys, kind: u32, tags: Vec<Tag>) -> Result<Event, String> {
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
serde_json::to_string(value).map_err(|e| e.to_string())?,
nip44::Version::V2,
)
.map_err(|e| e.to_string())?;
EventBuilder::new(Kind::Custom(kind as u16), ciphertext)
.tags(tags)
.sign_with_keys(keys)
.map_err(|e| e.to_string())
}

fn read<T: serde::de::DeserializeOwned>(
event: &Event,
keys: &Keys,
kind: u32,
) -> Result<T, String> {
validate_envelope(event)?;
event
.verify()
.map_err(|_| "invalid Desktop Stop signature")?;
if event.pubkey != keys.public_key() || event.kind.as_u16() as u32 != kind {
return Err("foreign Desktop Stop message".into());
}
let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(|_| "Desktop Stop decryption failed")?;
serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop Stop payload".into())
}

impl StopTarget {
/// Validate the decrypted target against the captured community.
pub fn validate(&self, community: &str) -> Result<(), &'static str> {
if self.v != 1
|| self.community != community
|| community.is_empty()
|| community.len() > 512
|| !hex(&self.desktop, 32)
|| !hex(&self.agent, 64)
|| PublicKey::from_hex(&self.agent).is_err()
{
return Err("invalid Desktop Stop target");
}
Ok(())
}

/// Produce a new immutable Stop. Transport retries must reuse this event.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
self.validate(&self.community)?;
sign(
self,
keys,
KIND_DESKTOP_STOP,
vec![Tag::identifier(&self.desktop)],
)
}

/// Authenticate, decrypt and bind a Stop to its signed host coordinate.
pub fn read(event: &Event, keys: &Keys, community: &str) -> Result<Self, String> {
let target: Self = read(event, keys, KIND_DESKTOP_STOP)?;
target.validate(community)?;
if event.tags.identifier() != Some(target.desktop.as_str()) {
return Err("Desktop Stop routing mismatch".into());
}
Ok(target)
}
}

impl StopResult {
/// Sign the saved ordinary Stop result without exposing local diagnostics.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
self.target.validate(&self.target.community)?;
if !hex(&self.request, 64) {
return Err("invalid Stop request ID".into());
}
sign(
self,
keys,
KIND_DESKTOP_STOP_RESULT,
vec![
Tag::identifier(&self.target.desktop),
Tag::parse(["e", &self.request]).map_err(|e| e.to_string())?,
],
)
}

/// Check all correlation fields against the original authenticated request.
pub fn read(
event: &Event,
keys: &Keys,
request: &Event,
community: &str,
) -> Result<Self, String> {
let target = StopTarget::read(request, keys, community)?;
let result: Self = read(event, keys, KIND_DESKTOP_STOP_RESULT)?;
if result.target != target
|| result.request != request.id.to_hex()
|| event.tags.identifier() != Some(target.desktop.as_str())
|| event.tags.iter().nth(1).and_then(|t| t.content()) != Some(result.request.as_str())
{
return Err("Desktop Stop result correlation mismatch".into());
}
Ok(result)
}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_immutable_stop_and_exact_result_correlation() {
let keys = Keys::generate();
let target = StopTarget {
v: 1,
community: "wss://one.example".into(),
desktop: "a".repeat(32),
agent: Keys::generate().public_key().to_hex(),
};
let request = target.sign(&keys).unwrap();
assert_eq!(
StopTarget::read(&request, &keys, &target.community).unwrap(),
target
);
assert!(!request.content.contains(&target.agent));
assert!(StopTarget::read(&request, &Keys::generate(), &target.community).is_err());
assert!(StopTarget::read(&request, &keys, "wss://other.example").is_err());
let result = StopResult {
target: target.clone(),
request: request.id.to_hex(),
outcome: StopOutcome::Stopped,
}
.sign(&keys)
.unwrap();
assert_eq!(
StopResult::read(&result, &keys, &request, &target.community)
.unwrap()
.outcome,
StopOutcome::Stopped
);
let another = target.sign(&keys).unwrap();
assert!(StopResult::read(&result, &keys, &another, &target.community).is_err());
let mut tampered = request.clone();
tampered.content.push('x');
assert!(StopTarget::read(&tampered, &keys, &target.community).is_err());
for kind in [KIND_DESKTOP_STOP, KIND_DESKTOP_STOP_RESULT] {
assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&kind));
assert!(!crate::kind::is_parameterized_replaceable(kind));
}
}
}
9 changes: 9 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;
/// Owner-private built-in runtime facts per Desktop, not agent readiness.
pub const KIND_DESKTOP_CAPABILITIES: u32 = 30182;

/// Immutable owner-private Desktop Stop request (not a replaceable profile).
pub const KIND_DESKTOP_STOP: u32 = 50180;
/// Owner-private ordinary Desktop Stop outcome, correlated by request event ID.
pub const KIND_DESKTOP_STOP_RESULT: u32 = 50181;

/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
Expand All @@ -141,6 +146,8 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
];

/// Kinds that require a result-level read gate beyond the filter-layer
Expand Down Expand Up @@ -673,6 +680,8 @@ pub const ALL_KINDS: &[u32] = &[
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
KIND_DESKTOP_CAPABILITIES,
KIND_DESKTOP_STOP,
KIND_DESKTOP_STOP_RESULT,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod desktop_capabilities;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
pub mod desktop_stop;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
/// body parse/serialize, envelope build/validate, head selection.
pub mod engram;
Expand Down
27 changes: 22 additions & 5 deletions crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 47);
assert_eq!(migrations.len(), 48);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -911,7 +911,7 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("search_tsv"));
assert!(!migrations[0].sql.as_str().contains("30179"));
assert!(include_str!("../../../../schema/schema.sql").contains(
"kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200)"
"kind IN (1059, 30179, 30180, 30181, 30182, 30300, 30350, 30622, 44100, 44101, 44200, 50180, 50181)"
));

// Public push-gateway authority is intentionally deployment-global and
Expand Down Expand Up @@ -2394,6 +2394,8 @@ mod postgres_tests {
(4_u8, 30_180_i32),
(5_u8, 30_181_i32),
(6_u8, 30_182_i32),
(7_u8, 50_180_i32),
(8_u8, 50_181_i32),
] {
sqlx::query(
"INSERT INTO events \
Expand Down Expand Up @@ -2429,7 +2431,9 @@ mod postgres_tests {
(30_180, true),
(30_181, true),
(30_182, true),
(30_350, true)
(30_350, true),
(50_180, true),
(50_181, true)
]
);

Expand All @@ -2454,7 +2458,9 @@ mod postgres_tests {
(30_180, Some(true)),
(30_181, Some(true)),
(30_182, Some(true)),
(30_350, None)
(30_350, None),
(50_180, Some(true)),
(50_181, Some(true))
]
);

Expand Down Expand Up @@ -2493,6 +2499,15 @@ mod postgres_tests {
"0047 must change brownfield capability FTS"
);

run_migrations_through(&pool, 47).await.unwrap();
let stop_indexed: i64 = sqlx::query_scalar(
"SELECT count(*) FROM events WHERE kind IN (50180, 50181) AND search_tsv IS NOT NULL",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stop_indexed, 2, "0048 must change brownfield Stop FTS");

run_migrations(&pool)
.await
.expect("apply remaining migrations to populated database");
Expand All @@ -2511,7 +2526,9 @@ mod postgres_tests {
(30_180, None),
(30_181, None),
(30_182, None),
(30_350, None)
(30_350, None),
(50_180, None),
(50_181, None)
]
);
let gin_exists: bool = sqlx::query_scalar(
Expand Down
Loading
Loading