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
123 changes: 123 additions & 0 deletions crates/buzz-core/src/desktop_observation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! Owner-private, advisory Desktop observations; never agent readiness or placement.
use nostr::{nips::nip44, Event, EventBuilder, Keys, Kind, Tag};
use serde::{Deserialize, Serialize};

use crate::{desktop_profile::DesktopProfile, kind::KIND_DESKTOP_OBSERVATION};

/// A pulse for one local profile. The signed event timestamp is the observed time.
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DesktopObservation {
/// Format version.
pub v: u8,
/// Canonical community, encrypted along with the coordinate.
pub community: String,
/// Stable Desktop profile coordinate, not an execution credential.
pub id: String,
}

/// Validate the bounded public envelope without decrypting it.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
crate::desktop_profile::validate_private_desktop_envelope(event, KIND_DESKTOP_OBSERVATION)
}

impl DesktopObservation {
/// Observe a profile belonging to this local Desktop.
pub fn new(profile: DesktopProfile) -> Self {
Self {
v: 1,
community: profile.community,
id: profile.id,
}
}

/// Encrypt and sign a fresh observation, without rewriting the durable profile.
pub fn sign(&self, keys: &Keys) -> Result<Event, String> {
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
serde_json::to_string(self).map_err(|e| e.to_string())?,
nip44::Version::V2,
)
.map_err(|e| e.to_string())?;
EventBuilder::new(Kind::Custom(KIND_DESKTOP_OBSERVATION as u16), content)
.tag(Tag::identifier(&self.id))
.sign_with_keys(keys)
.map_err(|e| e.to_string())
}

/// Authenticate and decrypt an observation before displaying its timestamp.
pub fn read(event: &Event, keys: &Keys, community: &str) -> Result<Self, String> {
validate_envelope(event)?;
event
.verify()
.map_err(|_| "invalid Desktop observation signature")?;
if event.pubkey != keys.public_key() {
return Err("foreign Desktop observation".into());
}
let plaintext = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(|_| "Desktop observation decryption failed")?;
let observation: Self =
serde_json::from_str(&plaintext).map_err(|_| "invalid Desktop observation")?;
let expected = Self::new(DesktopProfile::new(
community.to_owned(),
event.tags.identifier().unwrap_or_default().to_owned(),
)?);
if observation != expected {
return Err("Desktop observation scope mismatch".into());
}
Ok(observation)
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn observation_is_private_scoped_and_distinct_from_profile() {
let keys = Keys::generate();
let profile = DesktopProfile::new("wss://one.example".into(), "a".repeat(32)).unwrap();
let saved = profile.sign(&keys).unwrap();
let observation = DesktopObservation::new(profile);
let event = observation.sign(&keys).unwrap();
assert_eq!(
DesktopObservation::read(&event, &keys, &observation.community).unwrap(),
observation
);
assert!(DesktopObservation::read(&event, &keys, "wss://two.example").is_err());
assert!(
DesktopObservation::read(&event, &Keys::generate(), &observation.community).is_err()
);
assert!(DesktopObservation::read(&saved, &keys, &observation.community).is_err());
assert!(DesktopProfile::read(&event, &keys, &observation.community).is_err());
let forged_author = EventBuilder::new(event.kind, &event.content)
.tags(event.tags.clone())
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(DesktopObservation::read(&forged_author, &keys, &observation.community).is_err());
let mut tampered = event.clone();
tampered.created_at = nostr::Timestamp::from(1);
assert!(DesktopObservation::read(&tampered, &keys, &observation.community).is_err());
assert!(crate::kind::AUTHOR_ONLY_KINDS.contains(&KIND_DESKTOP_OBSERVATION));
for field in ["v", "community", "id", "extra"] {
let mut payload = serde_json::to_value(&observation).unwrap();
payload[field] = serde_json::json!("invalid");
let ciphertext = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
payload.to_string(),
nip44::Version::V2,
)
.unwrap();
let invalid = EventBuilder::new(event.kind, ciphertext)
.tags(event.tags.clone())
.sign_with_keys(&keys)
.unwrap();
assert!(
DesktopObservation::read(&invalid, &keys, &observation.community).is_err(),
"{field}"
);
}
}
}
9 changes: 8 additions & 1 deletion crates/buzz-core/src/desktop_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ pub struct DesktopProfile {

/// Validate the public envelope without decrypting private content.
pub fn validate_envelope(event: &Event) -> Result<(), &'static str> {
validate_private_desktop_envelope(event, KIND_DESKTOP_PROFILE)
}

pub(crate) fn validate_private_desktop_envelope(
event: &Event,
kind: u32,
) -> Result<(), &'static str> {
let tags: Vec<_> = event.tags.iter().map(|tag| tag.as_slice()).collect();
if event.kind.as_u16() as u32 != KIND_DESKTOP_PROFILE
if event.kind.as_u16() as u32 != kind
|| event.created_at.as_secs() > 253_402_300_799
|| !(132..=2048).contains(&event.content.len())
|| tags.len() != 1
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179;
/// Owner-private encrypted Desktop identity/name, keyed by installation coordinate.
pub const KIND_DESKTOP_PROFILE: u32 = 30180;

/// Owner-private, per-Desktop last-heard observation; not online or readiness.
pub const KIND_DESKTOP_OBSERVATION: u32 = 30181;

/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
Expand All @@ -134,6 +137,7 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[
KIND_PUSH_LEASE,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
];

/// Kinds that require a result-level read gate beyond the filter-layer
Expand Down Expand Up @@ -664,6 +668,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_TEAM_CATALOG,
KIND_PRIVATE_MANAGED_AGENT,
KIND_DESKTOP_PROFILE,
KIND_DESKTOP_OBSERVATION,
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 @@ -9,6 +9,7 @@
pub mod agent_turn_metric;
/// Channel and membership enums shared across crates.
pub mod channel;
pub mod desktop_observation;
/// Owner-private Desktop display profiles.
pub mod desktop_profile;
/// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation,
Expand Down
30 changes: 26 additions & 4 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(), 45);
assert_eq!(migrations.len(), 46);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
Expand Down Expand Up @@ -910,8 +910,9 @@ mod postgres_tests {
assert!(migrations[32].sql.as_str().contains("kind = 30179"));
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, 30300, 30350, 30622, 44100, 44101, 44200)"));
assert!(include_str!("../../../../schema/schema.sql").contains(
"kind IN (1059, 30179, 30180, 30181, 30300, 30350, 30622, 44100, 44101, 44200)"
));

// Public push-gateway authority is intentionally deployment-global and
// durable: immediate revocation and hostile-relay admission cannot be
Expand Down Expand Up @@ -2391,6 +2392,7 @@ mod postgres_tests {
(2_u8, 30_350_i32),
(3_u8, 30_179_i32),
(4_u8, 30_180_i32),
(5_u8, 30_181_i32),
] {
sqlx::query(
"INSERT INTO events \
Expand Down Expand Up @@ -2420,7 +2422,13 @@ mod postgres_tests {
.expect("read pre-push search behavior");
assert_eq!(
before,
vec![(1, true), (30_179, true), (30_180, true), (30_350, true)]
vec![
(1, true),
(30_179, true),
(30_180, true),
(30_181, true),
(30_350, true)
]
);

// 0014 fixes 30350 only. A brownfield database that stopped here still
Expand All @@ -2442,6 +2450,7 @@ mod postgres_tests {
(1, Some(true)),
(30_179, Some(true)),
(30_180, Some(true)),
(30_181, Some(true)),
(30_350, None)
]
);
Expand All @@ -2457,6 +2466,18 @@ mod postgres_tests {
.await
.expect("read pre-0045 Desktop search behavior");
assert!(desktop_indexed, "upgrade fixture must exercise legacy FTS");
run_migrations_through(&pool, 45)
.await
.expect("apply through 45");
let observation_indexed: bool =
sqlx::query_scalar("SELECT search_tsv IS NOT NULL FROM events WHERE kind = 30181")
.fetch_one(&pool)
.await
.unwrap();
assert!(
observation_indexed,
"0046 must change brownfield observation FTS"
);

run_migrations(&pool)
.await
Expand All @@ -2474,6 +2495,7 @@ mod postgres_tests {
(1, Some(true)),
(30_179, None),
(30_180, None),
(30_181, None),
(30_350, None)
]
);
Expand Down
28 changes: 23 additions & 5 deletions crates/buzz-relay/src/api/desktop_profile_postgres_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
use super::postgres_tests::bridge_handler_test_state;
use super::*;
use axum::{body::Body, http::Request};
use buzz_core::kind::KIND_DESKTOP_PROFILE;
use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use serde_json::json;
use tower::ServiceExt;
Expand Down Expand Up @@ -67,6 +67,16 @@ fn drain(rx: &mut tokio::sync::mpsc::Receiver<axum::extract::ws::Message>) -> Ve
#[tokio::test]
#[ignore = "requires Postgres"]
async fn desktop_profile_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_PROFILE).await;
}

#[tokio::test]
#[ignore = "requires Postgres"]
async fn desktop_observation_authenticated_owner_query_and_private_storage() {
assert_private_desktop(KIND_DESKTOP_OBSERVATION).await;
}

async fn assert_private_desktop(kind: u32) {
let mut state = bridge_handler_test_state()
.await
.expect("test infrastructure");
Expand All @@ -86,13 +96,21 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
uuid::Uuid::new_v4().simple().to_string(),
)
.unwrap();
let event = profile.sign(&owner).unwrap();
let id = profile.id.clone();
let event = if kind == KIND_DESKTOP_PROFILE {
profile.sign(&owner).unwrap()
} else {
buzz_core::desktop_observation::DesktopObservation::new(profile)
.sign(&owner)
.unwrap()
};
let (status, result) = post(&state, &host, "/events", &owner, json!(event), true).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["accepted"], true, "{result}");
// Match Desktop's actual bounded owner+kind inventory and exact-coordinate probe.
let own = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "limit":100}]);
let exact = json!([{"kinds":[KIND_DESKTOP_PROFILE], "authors":[owner.public_key().to_hex()], "#d":[profile.id], "limit":1}]);
let own = json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "limit":100}]);
let exact =
json!([{"kinds":[kind], "authors":[owner.public_key().to_hex()], "#d":[id], "limit":1}]);
for filters in [&own, &exact] {
let (status, rows) = post(&state, &host, "/query", &owner, filters.clone(), true).await;
assert_eq!(status, StatusCode::OK, "{rows}");
Expand All @@ -105,7 +123,7 @@ async fn desktop_profile_authenticated_owner_query_and_private_storage() {
assert_eq!(status, StatusCode::UNAUTHORIZED, "{result}");
}
// Known IDs cannot grant an authenticated outsider read access either.
let known = json!([{"ids":[event.id.to_hex()], "kinds":[KIND_DESKTOP_PROFILE,1]}]);
let known = json!([{"ids":[event.id.to_hex()], "kinds":[kind,1]}]);
let (status, rows) = post(&state, &host, "/query", &outsider, known, true).await;
assert_eq!(status, StatusCode::OK, "{rows}");
assert_eq!(rows, json!([]));
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,11 @@ mod tests {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_PROFILE).await;
}

#[tokio::test]
async fn desktop_observation_delivers_to_author_only() {
assert_author_only_fanout(buzz_core::kind::KIND_DESKTOP_OBSERVATION).await;
}

async fn assert_author_only_fanout(kind: u32) {
let state = test_state().await;

Expand Down
10 changes: 8 additions & 2 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ use tracing::{debug, error, info, warn};
use uuid::Uuid;

use buzz_auth::Scope;
use buzz_core::kind::KIND_DESKTOP_PROFILE;
use buzz_core::kind::{
event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable,
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
Expand All @@ -37,6 +36,7 @@ use buzz_core::kind::{
RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER,
RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::kind::{KIND_DESKTOP_OBSERVATION, KIND_DESKTOP_PROFILE};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
use buzz_core::CommunityId;
Expand Down Expand Up @@ -437,7 +437,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError {
/// Returns `Err` for unknown kinds — the relay rejects them.
fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static str> {
match kind {
KIND_PROFILE | KIND_DESKTOP_PROFILE => Ok(Scope::UsersWrite),
KIND_PROFILE | KIND_DESKTOP_PROFILE | KIND_DESKTOP_OBSERVATION => Ok(Scope::UsersWrite),
KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
Expand Down Expand Up @@ -659,6 +659,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_MANAGED_AGENT
| KIND_PRIVATE_MANAGED_AGENT
| KIND_DESKTOP_PROFILE
| KIND_DESKTOP_OBSERVATION
| KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
Expand Down Expand Up @@ -2792,6 +2793,11 @@ async fn ingest_event_inner(
}
}

if kind_u32 == KIND_DESKTOP_OBSERVATION {
buzz_core::desktop_observation::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}

if kind_u32 == KIND_DESKTOP_PROFILE {
buzz_core::desktop_profile::validate_envelope(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
Expand Down
Loading