diff --git a/crates/buzz-core/src/client_binding_bootstrap.rs b/crates/buzz-core/src/client_binding_bootstrap.rs new file mode 100644 index 0000000000..5720d94e3e --- /dev/null +++ b/crates/buzz-core/src/client_binding_bootstrap.rs @@ -0,0 +1,640 @@ +//! Relay-authenticated connection bootstrap for client binding status. +//! +//! Kind `24245` is delivered only on the WebSocket connection whose native +//! client supplied the echoed epoch. It binds the relay's NIP-11 signing key, +//! the server-resolved authorization domain, and the authenticated event +//! author before kind `24244` status can be consumed. + +use std::fmt; + +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Timestamp}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + client_binding_status::MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS, + kind::KIND_CLIENT_BINDING_BOOTSTRAP, verify_event, CommunityId, +}; + +/// Integrity-protected NIP-42 tag carrying the native connection scope. +pub const CLIENT_BINDING_SCOPE_TAG: &str = "buzz_client_binding_scope"; +/// Reserved exact-connection subscription id for bootstrap delivery. +pub const CLIENT_BINDING_BOOTSTRAP_SUB_ID: &str = "__buzz_client_binding_bootstrap_v1__"; +/// Reserved exact-connection subscription id for status delivery. +pub const CLIENT_BINDING_STATUS_SUB_ID: &str = "__buzz_client_binding_status_v1__"; +/// Bootstrap wire version accepted by this module. +pub const CLIENT_BINDING_BOOTSTRAP_VERSION: u64 = 1; +/// Maximum encoded bootstrap payload length. +pub const MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES: usize = 1024; + +/// Opaque, native-generated canonical lowercase UUIDv4 connection epoch. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ClientBindingEpoch(String); + +impl ClientBindingEpoch { + /// Generate a fresh connection epoch from the operating system CSPRNG. + pub fn new_v4() -> Self { + Self(Uuid::new_v4().to_string()) + } + + /// Parse the canonical lowercase hyphenated UUIDv4 wire form. + pub fn parse(value: &str) -> Result { + let parsed = Uuid::parse_str(value) + .map_err(|_| ClientBindingBootstrapError::InvalidConnectionEpoch)?; + let bytes = parsed.as_bytes(); + if parsed.to_string() != value || (bytes[6] >> 4) != 4 || (bytes[8] >> 6) != 2 { + return Err(ClientBindingBootstrapError::InvalidConnectionEpoch); + } + Ok(Self(value.to_owned())) + } + + /// Canonical payload and signed-tag representation. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Verified native connection scope carried by one signed NIP-42 AUTH event. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingScopeV1 { + connection_epoch: ClientBindingEpoch, + relay_signer: PublicKey, +} + +impl ClientBindingScopeV1 { + /// Parse exactly one canonical v1 scope tag from a verified AUTH event. + /// + /// This parser does not authenticate the event. Callers must invoke it only + /// after the ordinary NIP-42 signature, challenge, and relay checks pass. + pub fn from_verified_auth_event(event: &Event) -> Result { + let mut matching = event.tags.iter().filter(|tag| { + tag.as_slice().first().map(String::as_str) == Some(CLIENT_BINDING_SCOPE_TAG) + }); + let tag = matching + .next() + .ok_or(ClientBindingBootstrapError::MissingScopeTag)?; + if matching.next().is_some() { + return Err(ClientBindingBootstrapError::DuplicateScopeTag); + } + let values = tag.as_slice(); + if values.len() != 4 || values[1] != "1" { + return Err(ClientBindingBootstrapError::InvalidScopeTag); + } + let connection_epoch = ClientBindingEpoch::parse(&values[2])?; + let relay_signer = parse_canonical_pubkey(&values[3]) + .map_err(|_| ClientBindingBootstrapError::InvalidScopeTag)?; + Ok(Self { + connection_epoch, + relay_signer, + }) + } + + /// Native-generated epoch authenticated by the NIP-42 event signature. + pub fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// NIP-11 relay signer pinned by native before the socket was opened. + pub const fn relay_signer(&self) -> PublicKey { + self.relay_signer + } +} + +impl fmt::Debug for ClientBindingScopeV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingScopeV1") + .field("connection_epoch", &"[redacted]") + .field("relay_signer", &"[redacted]") + .finish() + } +} + +impl fmt::Debug for ClientBindingEpoch { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ClientBindingEpoch") + .field(&"[redacted]") + .finish() + } +} + +/// Validated relay-authenticated bootstrap. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientBindingBootstrapV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, +} + +impl ClientBindingBootstrapV1 { + /// Server-resolved authorization domain pinned by this connection. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Authenticated event author pinned by this connection. + pub const fn event_author_pubkey(&self) -> PublicKey { + self.event_author_pubkey + } + + /// Echoed native connection epoch. + pub fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// Relay issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } +} + +impl fmt::Debug for ClientBindingBootstrapV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingBootstrapV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field("issued_at", &"[redacted]") + .finish() + } +} + +/// Validated server-side signing input for one connection bootstrap. +pub struct ClientBindingBootstrapInputV1 { + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, +} + +impl ClientBindingBootstrapInputV1 { + /// Bind server-resolved connection authority to a native epoch. + pub fn new( + authorization_domain: CommunityId, + event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + issued_at: u64, + ) -> Result { + if authorization_domain.as_uuid().is_nil() { + return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain); + } + if issued_at == 0 { + return Err(ClientBindingBootstrapError::InvalidIssueTime); + } + Ok(Self { + authorization_domain, + event_author_pubkey, + connection_epoch, + issued_at, + }) + } + + /// Sign the bootstrap with the relay key advertised by NIP-11 `self`. + pub fn sign_with_relay_keys( + self, + relay_keys: &Keys, + ) -> Result { + let wire = WireClientBindingBootstrapV1 { + version: CLIENT_BINDING_BOOTSTRAP_VERSION, + authorization_domain: self.authorization_domain.as_uuid().to_string(), + event_author_pubkey: self.event_author_pubkey.to_hex(), + connection_epoch: self.connection_epoch.0, + issued_at: self.issued_at, + }; + let content = serde_json::to_string(&wire) + .map_err(|_| ClientBindingBootstrapBuildError::Serialization)?; + if content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES { + return Err(ClientBindingBootstrapBuildError::PayloadTooLarge); + } + EventBuilder::new(Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16), content) + .tags([]) + .custom_created_at(Timestamp::from(wire.issued_at)) + .sign_with_keys(relay_keys) + .map_err(|_| ClientBindingBootstrapBuildError::Signing) + } +} + +impl fmt::Debug for ClientBindingBootstrapInputV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingBootstrapInputV1") + .field("authorization_domain", &"[redacted]") + .field("event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field("issued_at", &"[redacted]") + .finish() + } +} + +#[derive(Deserialize)] +struct VersionHeader { + version: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WireClientBindingBootstrapV1 { + version: u64, + authorization_domain: String, + event_author_pubkey: String, + connection_epoch: String, + issued_at: u64, +} + +/// Authenticate and validate a connection bootstrap against native authority. +pub fn validate_client_binding_bootstrap_event( + event: &Event, + trusted_relay_pubkey: &PublicKey, + expected_connection_epoch: &ClientBindingEpoch, + expected_event_author_pubkey: &PublicKey, + now: u64, +) -> Result { + if event.kind.as_u16() as u32 != KIND_CLIENT_BINDING_BOOTSTRAP { + return Err(ClientBindingBootstrapError::WrongKind); + } + if event.content.len() > MAX_CLIENT_BINDING_BOOTSTRAP_PAYLOAD_BYTES { + return Err(ClientBindingBootstrapError::PayloadTooLarge); + } + verify_event(event).map_err(|_| ClientBindingBootstrapError::UnauthenticatedEvent)?; + if event.pubkey != *trusted_relay_pubkey { + return Err(ClientBindingBootstrapError::UnexpectedRelay); + } + if !event.tags.is_empty() { + return Err(ClientBindingBootstrapError::UnexpectedTags); + } + let header: VersionHeader = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingBootstrapError::MalformedPayload)?; + if header.version != CLIENT_BINDING_BOOTSTRAP_VERSION { + return Err(ClientBindingBootstrapError::UnsupportedVersion); + } + let wire: WireClientBindingBootstrapV1 = serde_json::from_str(&event.content) + .map_err(|_| ClientBindingBootstrapError::MalformedPayload)?; + let authorization_domain = Uuid::parse_str(&wire.authorization_domain) + .map_err(|_| ClientBindingBootstrapError::InvalidAuthorizationDomain)?; + if authorization_domain.is_nil() + || authorization_domain.to_string() != wire.authorization_domain + { + return Err(ClientBindingBootstrapError::InvalidAuthorizationDomain); + } + let event_author_pubkey = parse_canonical_pubkey(&wire.event_author_pubkey)?; + if event_author_pubkey != *expected_event_author_pubkey { + return Err(ClientBindingBootstrapError::EventAuthorMismatch); + } + let connection_epoch = ClientBindingEpoch::parse(&wire.connection_epoch)?; + if connection_epoch != *expected_connection_epoch { + return Err(ClientBindingBootstrapError::ConnectionEpochMismatch); + } + if wire.issued_at == 0 { + return Err(ClientBindingBootstrapError::InvalidIssueTime); + } + if event.created_at.as_secs() != wire.issued_at { + return Err(ClientBindingBootstrapError::EventTimeMismatch); + } + if wire.issued_at > now { + return Err(ClientBindingBootstrapError::NotYetValid); + } + if now - wire.issued_at > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS { + return Err(ClientBindingBootstrapError::Expired); + } + Ok(ClientBindingBootstrapV1 { + authorization_domain: CommunityId::from_uuid(authorization_domain), + event_author_pubkey, + connection_epoch, + issued_at: wire.issued_at, + }) +} + +fn parse_canonical_pubkey(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ClientBindingBootstrapError::InvalidEventAuthorPubkey); + } + PublicKey::from_hex(value).map_err(|_| ClientBindingBootstrapError::InvalidEventAuthorPubkey) +} + +/// Fail-closed bootstrap validation failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ClientBindingBootstrapError { + /// The verified AUTH event did not opt into native status projection. + #[error("client binding scope tag is missing")] + MissingScopeTag, + /// More than one connection scope tag was present. + #[error("client binding scope tag is duplicated")] + DuplicateScopeTag, + /// The signed connection scope tag was not the exact canonical v1 shape. + #[error("client binding scope tag is invalid")] + InvalidScopeTag, + /// Event kind was not the dedicated bootstrap kind. + #[error("client binding bootstrap event has the wrong kind")] + WrongKind, + /// Payload exceeded the public bound. + #[error("client binding bootstrap payload is too large")] + PayloadTooLarge, + /// Event signature or identifier was invalid. + #[error("client binding bootstrap event is not authenticated")] + UnauthenticatedEvent, + /// Signer did not match NIP-11 `self`. + #[error("client binding bootstrap signer is not the trusted relay")] + UnexpectedRelay, + /// Bootstrap events must have no tags. + #[error("client binding bootstrap contains unexpected tags")] + UnexpectedTags, + /// Payload was not the bounded v1 shape. + #[error("client binding bootstrap payload is malformed")] + MalformedPayload, + /// Wire version is unsupported. + #[error("client binding bootstrap version is unsupported")] + UnsupportedVersion, + /// Authorization domain was nil or noncanonical. + #[error("client binding bootstrap authorization domain is invalid")] + InvalidAuthorizationDomain, + /// Event-author key was noncanonical. + #[error("client binding bootstrap event author is invalid")] + InvalidEventAuthorPubkey, + /// Authenticated author did not match native signing state. + #[error("client binding bootstrap event author does not match")] + EventAuthorMismatch, + /// Connection epoch was noncanonical. + #[error("client binding bootstrap connection epoch is invalid")] + InvalidConnectionEpoch, + /// Echoed epoch did not match the native signed NIP-42 scope. + #[error("client binding bootstrap connection epoch does not match")] + ConnectionEpochMismatch, + /// Issue time was zero. + #[error("client binding bootstrap issue time is invalid")] + InvalidIssueTime, + /// Signed timestamp did not equal the payload timestamp. + #[error("client binding bootstrap event time does not match")] + EventTimeMismatch, + /// Bootstrap claims a future issue time. + #[error("client binding bootstrap is not yet valid")] + NotYetValid, + /// Bootstrap exceeded the client-status maximum lifetime. + #[error("client binding bootstrap has expired")] + Expired, +} + +/// Bootstrap serialization or signing failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ClientBindingBootstrapBuildError { + /// JSON serialization failed. + #[error("client binding bootstrap serialization failed")] + Serialization, + /// Serialized payload exceeded its public bound. + #[error("client binding bootstrap payload is too large")] + PayloadTooLarge, + /// Relay signing failed. + #[error("client binding bootstrap signing failed")] + Signing, +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{JsonUtil, Tag}; + use serde_json::{json, Value}; + + const DOMAIN: &str = "abcdefab-cdef-4abc-8def-abcdefabcdef"; + const ISSUED_AT: u64 = 1_800_000_000; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::parse_str(DOMAIN).expect("synthetic domain is valid")) + } + + fn epoch(byte: u8) -> ClientBindingEpoch { + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{byte:012x}")) + .expect("synthetic epoch is canonical UUIDv4") + } + + fn signed_bootstrap(relay: &Keys, author: PublicKey) -> Event { + ClientBindingBootstrapInputV1::new(domain(), author, epoch(0x11), ISSUED_AT) + .expect("synthetic bootstrap input is valid") + .sign_with_relay_keys(relay) + .expect("synthetic bootstrap signs") + } + + fn validate( + event: &Event, + relay: &Keys, + author: &Keys, + expected_epoch: &ClientBindingEpoch, + now: u64, + ) -> Result { + validate_client_binding_bootstrap_event( + event, + &relay.public_key(), + expected_epoch, + &author.public_key(), + now, + ) + } + + fn resign_payload(relay: &Keys, payload: Value) -> Event { + EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_BOOTSTRAP as u16), + payload.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(relay) + .expect("synthetic bootstrap variant signs") + } + + #[test] + fn relay_authenticated_bootstrap_roundtrips_exact_authority() { + let relay = Keys::generate(); + let author = Keys::generate(); + let expected_epoch = epoch(0x11); + let event = signed_bootstrap(&relay, author.public_key()); + + let bootstrap = validate(&event, &relay, &author, &expected_epoch, ISSUED_AT) + .expect("synthetic bootstrap validates"); + + assert_eq!(event.kind.as_u16() as u32, KIND_CLIENT_BINDING_BOOTSTRAP); + assert!(event.tags.is_empty()); + assert_eq!(bootstrap.authorization_domain(), domain()); + assert_eq!(bootstrap.event_author_pubkey(), author.public_key()); + assert_eq!(bootstrap.connection_epoch(), &expected_epoch); + assert_eq!(bootstrap.issued_at(), ISSUED_AT); + let payload: Value = serde_json::from_str(&event.content).expect("payload parses"); + assert_eq!( + payload + .as_object() + .expect("payload is an object") + .keys() + .map(String::as_str) + .collect::>(), + std::collections::BTreeSet::from([ + "authorization_domain", + "connection_epoch", + "event_author_pubkey", + "issued_at", + "version", + ]) + ); + let debug = format!("{bootstrap:?}"); + assert!(!debug.contains(DOMAIN)); + assert!(!debug.contains(&author.public_key().to_hex())); + assert!(!debug.contains(expected_epoch.as_str())); + } + + #[test] + fn bootstrap_rejects_noncanonical_epoch_and_invalid_input_bounds() { + assert_eq!( + ClientBindingEpoch::parse("11111111-1111-4111-8111-AAAAAAAAAAAA"), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingEpoch::parse("11111111-1111-5111-8111-111111111111"), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingEpoch::parse("11111111-1111-4111-7111-111111111111"), + Err(ClientBindingBootstrapError::InvalidConnectionEpoch) + ); + assert_eq!( + ClientBindingBootstrapInputV1::new( + CommunityId::from_uuid(Uuid::nil()), + Keys::generate().public_key(), + epoch(1), + ISSUED_AT, + ) + .expect_err("nil domains are invalid"), + ClientBindingBootstrapError::InvalidAuthorizationDomain + ); + assert_eq!( + ClientBindingBootstrapInputV1::new( + domain(), + Keys::generate().public_key(), + epoch(1), + 0, + ) + .expect_err("zero issue time is invalid"), + ClientBindingBootstrapError::InvalidIssueTime + ); + } + + #[test] + fn bootstrap_rejects_wrong_scope_tampering_and_unknown_shape() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let expected_epoch = epoch(0x11); + let event = signed_bootstrap(&relay, author.public_key()); + + assert_eq!( + validate(&event, &wrong_relay, &author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::UnexpectedRelay) + ); + assert_eq!( + validate(&event, &relay, &other_author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::EventAuthorMismatch) + ); + assert_eq!( + validate(&event, &relay, &author, &epoch(0x22), ISSUED_AT), + Err(ClientBindingBootstrapError::ConnectionEpochMismatch) + ); + assert_eq!( + validate(&event, &relay, &author, &expected_epoch, ISSUED_AT - 1), + Err(ClientBindingBootstrapError::NotYetValid) + ); + assert_eq!( + validate( + &event, + &relay, + &author, + &expected_epoch, + ISSUED_AT + MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS + 1, + ), + Err(ClientBindingBootstrapError::Expired) + ); + + let mut tampered_json: Value = + serde_json::from_str(&event.as_json()).expect("event parses"); + tampered_json["content"] = Value::String("{}".to_string()); + let tampered = + Event::from_json(tampered_json.to_string()).expect("tampered event still parses"); + assert_eq!( + validate(&tampered, &relay, &author, &expected_epoch, ISSUED_AT), + Err(ClientBindingBootstrapError::UnauthenticatedEvent) + ); + + let mut payload: Value = + serde_json::from_str(&event.content).expect("bootstrap content parses"); + payload["synthetic_extension"] = json!(true); + assert_eq!( + validate( + &resign_payload(&relay, payload), + &relay, + &author, + &expected_epoch, + ISSUED_AT, + ), + Err(ClientBindingBootstrapError::MalformedPayload) + ); + + let mut payload: Value = + serde_json::from_str(&event.content).expect("bootstrap content parses"); + payload["authorization_domain"] = json!(DOMAIN.to_uppercase()); + assert_eq!( + validate( + &resign_payload(&relay, payload), + &relay, + &author, + &expected_epoch, + ISSUED_AT, + ), + Err(ClientBindingBootstrapError::InvalidAuthorizationDomain) + ); + } + + #[test] + fn verified_auth_scope_requires_one_exact_signed_tag() { + let author = Keys::generate(); + let relay = Keys::generate(); + let epoch = epoch(0x11); + let scope = vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + epoch.as_str().to_string(), + relay.public_key().to_hex(), + ]; + let auth = EventBuilder::new(Kind::Custom(22242), "") + .tags([Tag::parse(scope.clone()).expect("synthetic scope tag")]) + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + let parsed = ClientBindingScopeV1::from_verified_auth_event(&auth) + .expect("exact signed scope parses"); + assert_eq!(parsed.connection_epoch(), &epoch); + assert_eq!(parsed.relay_signer(), relay.public_key()); + + let missing = EventBuilder::new(Kind::Custom(22242), "") + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + assert_eq!( + ClientBindingScopeV1::from_verified_auth_event(&missing), + Err(ClientBindingBootstrapError::MissingScopeTag) + ); + + let duplicate = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(scope.clone()).expect("synthetic scope tag"), + Tag::parse(scope).expect("synthetic scope tag"), + ]) + .sign_with_keys(&author) + .expect("synthetic AUTH signs"); + assert_eq!( + ClientBindingScopeV1::from_verified_auth_event(&duplicate), + Err(ClientBindingBootstrapError::DuplicateScopeTag) + ); + } +} diff --git a/crates/buzz-core/src/client_binding_status.rs b/crates/buzz-core/src/client_binding_status.rs index 6428938d23..038992af4d 100644 --- a/crates/buzz-core/src/client_binding_status.rs +++ b/crates/buzz-core/src/client_binding_status.rs @@ -602,6 +602,41 @@ impl ClientBindingStatusTracker { self.status = None; } + /// Clear presentation and retain a parseable trusted-invalid revision. + /// + /// The event is independently authenticated against this tracker's relay + /// before its bounded revision is considered. Retaining the revision + /// prevents replaying the same malformed envelope as a later syntactically + /// valid value from restoring presentation. + pub fn retain_trusted_invalid_high_water(&mut self, event: &Event) { + #[derive(Deserialize)] + struct RevisionOnly { + status_revision: u64, + } + + self.status = None; + if event.content.len() > MAX_CLIENT_BINDING_STATUS_PAYLOAD_BYTES + || verify_event(event).is_err() + || event.pubkey != self.trusted_relay_pubkey + { + return; + } + let Ok(candidate) = serde_json::from_str::(&event.content) else { + return; + }; + if candidate.status_revision == 0 + || self + .high_water + .is_some_and(|high_water| candidate.status_revision <= high_water.revision) + { + return; + } + self.high_water = Some(StatusHighWater { + revision: candidate.status_revision, + event_id: event.id, + }); + } + /// Replace the trusted scope and clear both presentation and revision state. /// /// Call this on relay-identity, authorization-domain, or event-author diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 495fe84654..3a64ecc5ee 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -91,6 +91,8 @@ pub const KIND_NOSTR_IDENTITY_BINDING: u32 = 24243; /// is intentionally absent from relay ingest and storage allowlists until the /// binding lifecycle and client-presentation joins are complete. pub const KIND_CLIENT_BINDING_STATUS: u32 = 24244; +/// Buzz relay-authenticated connection binding bootstrap (ephemeral, not stored). +pub const KIND_CLIENT_BINDING_BOOTSTRAP: u32 = 24245; /// NIP-98: HTTP auth event (used in nip98.rs, not stored). pub const KIND_HTTP_AUTH: u32 = 27235; @@ -694,6 +696,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TYPING_INDICATOR, KIND_HUDDLE_REACTION, KIND_BLOSSOM_AUTH, + KIND_CLIENT_BINDING_BOOTSTRAP, KIND_PAIRING, KIND_AGENT_OBSERVER_FRAME, KIND_HTTP_AUTH, @@ -829,6 +832,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { matches!( kind, KIND_NIP43_MEMBERSHIP_LIST + | KIND_CLIENT_BINDING_BOOTSTRAP | KIND_CLIENT_BINDING_STATUS | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT @@ -912,7 +916,9 @@ mod tests { } #[test] - fn client_binding_status_is_relay_only() { + fn client_binding_connection_events_are_relay_only_and_ephemeral() { + assert!(is_relay_only_kind(KIND_CLIENT_BINDING_BOOTSTRAP)); + assert!(is_ephemeral(KIND_CLIENT_BINDING_BOOTSTRAP)); assert!(is_relay_only_kind(KIND_CLIENT_BINDING_STATUS)); assert!(is_ephemeral(KIND_CLIENT_BINDING_STATUS)); } diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 6be3e97c40..c80eea91c1 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -9,6 +9,8 @@ pub mod agent_turn_metric; /// Channel and membership enums shared across crates. pub mod channel; +/// Relay-authenticated, connection-scoped client binding bootstrap contract. +pub mod client_binding_bootstrap; /// Relay-authenticated, display-only client binding status contract. pub mod client_binding_status; /// NIP-AE Agent Engrams — slug grammar, conversation key, d-tag derivation, diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs index a3e702aeb3..a2ccb9a130 100644 --- a/crates/buzz-relay/src/authorization_runtime/status.rs +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -18,6 +18,7 @@ use buzz_auth::{ AuthorizationProfileId, BindingVersion, PolicyVersion, VerificationOnlyDisposition, }; use buzz_core::{ + client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID, client_binding_status::{ ClientBindingStatusBuildError, ClientBindingStatusError, ClientBindingStatusInputV1, MAX_CLIENT_BINDING_STATUS_LABEL_BYTES, @@ -760,10 +761,8 @@ impl DedicatedClientStatusTransport for ConnectionManagerClientStatusTransport { { return Err(DedicatedClientStatusTransportError::Unavailable); } - let frame = crate::protocol::RelayMessage::event( - "__buzz_client_binding_status_v1__", - delivery.event(), - ); + let frame = + crate::protocol::RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, delivery.event()); self.connections .send_to(delivery.connection_id(), frame) .then_some(()) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index f9e3081605..2dca8365d2 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -987,6 +987,10 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { } } +#[cfg(test)] +#[path = "connection/j3c_current_binding_wire.rs"] +mod j3c_current_binding_wire; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs new file mode 100644 index 0000000000..d0df65e58e --- /dev/null +++ b/crates/buzz-relay/src/connection/j3c_current_binding_wire.rs @@ -0,0 +1,271 @@ +//! Exact-byte loopback proof for the test-only J3C client-status composition. + +#[path = "../../../../desktop/src-tauri/src/client_binding_status_session.rs"] +mod client_binding_status_session; + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::AtomicU8; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use axum::extract::ws::Message as AxumMessage; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_STATUS_SUB_ID, +}; +use buzz_core::client_binding_status::ClientBindingStatusInputV1; +use buzz_core::CommunityId; +use futures_util::{Sink, StreamExt}; +use nostr::{Keys, Timestamp}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, Mutex}; +use tokio::time::{timeout, Duration}; +use tokio_tungstenite::tungstenite::{ + protocol::{frame::coding::CloseCode, CloseFrame}, + Error as TungsteniteError, Message as TungsteniteMessage, +}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{send_loop_inner, OutboundData}; +use crate::protocol::RelayMessage; +use crate::state::ConnectionManager; +use client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; + +struct TungsteniteSink(S); + +impl Sink for TungsteniteSink +where + S: Sink + Unpin, +{ + type Error = TungsteniteError; + + fn poll_ready( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_ready(context) + } + + fn start_send(mut self: Pin<&mut Self>, item: AxumMessage) -> Result<(), Self::Error> { + let item = match item { + AxumMessage::Text(text) => TungsteniteMessage::Text(text.to_string().into()), + AxumMessage::Binary(bytes) => TungsteniteMessage::Binary(bytes), + AxumMessage::Ping(bytes) => TungsteniteMessage::Ping(bytes), + AxumMessage::Pong(bytes) => TungsteniteMessage::Pong(bytes), + AxumMessage::Close(frame) => TungsteniteMessage::Close(frame.map(|frame| CloseFrame { + code: CloseCode::from(frame.code), + reason: frame.reason.to_string().into(), + })), + }; + Pin::new(&mut self.0).start_send(item) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_flush(context) + } + + fn poll_close( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_close(context) + } +} + +async fn receive_exact( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + expected: &str, +) -> String { + let message = timeout(Duration::from_secs(2), socket.next()) + .await + .expect("production socket writer must not time out") + .expect("loopback socket remains connected") + .expect("production socket writer emits a valid frame"); + let TungsteniteMessage::Text(text) = message else { + panic!("client-status transport must emit text"); + }; + assert_eq!(text.as_str().as_bytes(), expected.as_bytes()); + text.to_string() +} + +fn assert_current( + update: Option, + author: &Keys, + epoch: &ClientBindingEpoch, + fresh_until: u64, +) { + let Some(ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey, + fresh_until: projected_fresh_until, + connection_epoch, + })) = update + else { + panic!("exact production bytes must project current status"); + }; + assert_eq!(event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected_fresh_until, fresh_until); + assert_eq!(connection_epoch, epoch.as_str()); +} + +#[tokio::test] +async fn production_outbound_bytes_cross_loopback_into_native_status_session() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral loopback listener binds"); + let address = listener.local_addr().expect("loopback address resolves"); + assert_ne!(address.port(), 0); + + let relay = Keys::generate(); + let author = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let epoch = ClientBindingEpoch::new_v4(); + let connection_id = Uuid::new_v4(); + let now = Timestamp::now().as_secs(); + let fresh_until = now + 120; + + let connections = Arc::new(ConnectionManager::new()); + let (data_tx, data_rx) = mpsc::channel::(8); + let (ctrl_tx, ctrl_rx) = mpsc::channel(2); + let cancel = CancellationToken::new(); + connections.register( + connection_id, + data_tx, + ctrl_tx, + cancel.clone(), + domain, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + connections.set_authenticated_pubkey(connection_id, author.public_key().to_bytes().to_vec()); + + let writer_cancel = cancel.clone(); + let server = tokio::spawn(async move { + let (tcp, peer) = listener.accept().await.expect("loopback client connects"); + assert!(peer.ip().is_loopback()); + let socket = tokio_tungstenite::accept_async(tcp) + .await + .expect("loopback WebSocket upgrades"); + let (sink, _stream) = socket.split(); + send_loop_inner(TungsteniteSink(sink), data_rx, ctrl_rx, writer_cancel).await; + }); + + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}")) + .await + .expect("loopback WebSocket client connects"); + let mut session = + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + assert_eq!(session.connection_epoch(), &epoch); + + let bootstrap = + ClientBindingBootstrapInputV1::new(domain, author.public_key(), epoch.clone(), now) + .expect("connection bootstrap input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs bootstrap"); + let bootstrap_frame = RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap); + assert!(connections.send_to(connection_id, bootstrap_frame.clone())); + let bootstrap_text = receive_exact(&mut socket, &bootstrap_frame).await; + assert!(matches!( + session.consume_text(&bootstrap_text, now), + Some(ProjectionUpdate::Unchanged) + )); + + let current = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 7, + "opaque-current", + 10, + now, + fresh_until, + None, + ) + .expect("current status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs current status"); + let current_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, ¤t); + assert!(connections.send_to(connection_id, current_frame.clone())); + let current_text = receive_exact(&mut socket, ¤t_frame).await; + assert_current( + session.consume_text(¤t_text, now), + &author, + &epoch, + fresh_until, + ); + + let trusted_invalid = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-trusted-invalid", + 11, + now, + fresh_until, + None, + ) + .expect("trusted-invalid status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs trusted-invalid status"); + let malformed_outer = serde_json::json!([ + "EVENT", + CLIENT_BINDING_STATUS_SUB_ID, + trusted_invalid, + "unexpected" + ]) + .to_string(); + assert!(connections.send_to(connection_id, malformed_outer.clone())); + let malformed_text = receive_exact(&mut socket, &malformed_outer).await; + assert!(matches!( + session.consume_text(&malformed_text, now), + Some(ProjectionUpdate::Clear) + )); + + let replay_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, &trusted_invalid); + assert!(connections.send_to(connection_id, replay_frame.clone())); + let replay_text = receive_exact(&mut socket, &replay_frame).await; + assert!(matches!( + session.consume_text(&replay_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + let newer = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 9, + "opaque-newer-restoration", + 12, + now, + fresh_until, + None, + ) + .expect("newer status input is valid") + .sign_with_relay_keys(&relay) + .expect("ephemeral relay signs newer status"); + let newer_frame = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, &newer); + assert!(connections.send_to(connection_id, newer_frame.clone())); + let newer_text = receive_exact(&mut socket, &newer_frame).await; + assert_current( + session.consume_text(&newer_text, now), + &author, + &epoch, + fresh_until, + ); + assert!(matches!(session.disconnect(), ProjectionUpdate::Clear)); + assert_eq!(session.projected_fresh_until(), None); + + cancel.cancel(); + timeout(Duration::from_secs(2), server) + .await + .expect("production socket writer stops after cancellation") + .expect("production socket writer task does not panic"); +} diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 9bc15e9e09..9a46ff9af9 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -15,6 +15,9 @@ use axum::extract::ws::Message as WsMessage; use buzz_auth::{ AuthTransport, VerifiedDelegationOutput, VerifiedEvidenceAdapter, VerifiedNostrProof, }; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingScopeV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, +}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -441,25 +444,46 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } }; + let client_binding_scope = + ClientBindingScopeV1::from_verified_auth_event(&verified_event) + .ok() + .filter(|scope| scope.relay_signer() == state.relay_keypair.public_key()); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state.conn_manager.set_authenticated_authority( conn_id, Arc::clone(&verified_proof), verified_assertion.clone(), ); - if let (Some(runtime), Some(assertion)) = - (state.client_status_runtime().cloned(), verified_assertion) - { - if let Err(error) = runtime - .present_after_auth( - Arc::clone(&state), - verified_proof, - assertion, - conn_id, - conn.cancel.clone(), - ) - .await - { + if let (Some(runtime), Some(assertion), Some(scope)) = ( + state.client_status_runtime().cloned(), + verified_assertion, + client_binding_scope, + ) { + let bootstrap_queued = ClientBindingBootstrapInputV1::new( + conn.tenant.community(), + pubkey, + scope.connection_epoch().clone(), + nostr::Timestamp::now().as_secs(), + ) + .ok() + .and_then(|input| input.sign_with_relay_keys(&state.relay_keypair).ok()) + .is_some_and(|event| { + conn.send(RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &event)) + }); + let presentation = if bootstrap_queued { + runtime + .present_after_auth( + Arc::clone(&state), + verified_proof, + assertion, + conn_id, + conn.cancel.clone(), + ) + .await + } else { + Err(crate::authorization_runtime::status::ClientStatusRuntimeError::DeliveryUnavailable) + }; + if let Err(error) = presentation { // Presentation failure never widens or narrows access. The // client receives no current indicator and clears any old // status on its existing freshness/disconnect boundary. diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314c8ac9b0..fc8f230794 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -2,6 +2,9 @@ #![warn(missing_docs)] //! NIP-01 WebSocket relay for Buzz private team communication. +#[cfg(test)] +extern crate buzz_core as buzz_core_pkg; + mod admission; /// Provider-neutral runtime authorization and bounded finalization. diff --git a/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs new file mode 100644 index 0000000000..75f2266131 --- /dev/null +++ b/crates/buzz-relay/tests/j3c_current_binding_relay_harness.rs @@ -0,0 +1,1194 @@ +//! Test-only J3C relay-authenticated client-binding status composition. +//! +//! This harness deliberately composes only public production contracts. It +//! binds a real loopback WebSocket, carries a real NIP-42 `AUTH` frame through +//! the Buzz parser and verifier, creates verification-only evidence through the +//! authorization finalizer, and asks the production issuer and exact-connection +//! transport to deliver. The production J1 native session source consumes the +//! resulting bootstrap/status frames. + +extern crate buzz_core as buzz_core_pkg; + +#[path = "../../../desktop/src-tauri/src/client_binding_status_session.rs"] +mod client_binding_status_session; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::atomic::{AtomicU64, AtomicU8, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::context::BindingExpiry; +use buzz_auth::evidence_adapter::{ActiveBindingResolution, VerifiedEvidenceAdapter}; +use buzz_auth::{ + resolve_authorization, resolve_current_federated_policy, ApplicationLeaseLimit, + AssertionTransport, AuthContextInput, AuthTransport, AuthorityAdapterError, + AuthorityAdapterFuture, AuthorizationCapability, AuthorizationClock, AuthorizationClockError, + AuthorizationClockSkew, AuthorizationFinalizer, AuthorizationOutcome, AuthorizationProfileId, + AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationTime, + AuthorizedCommunityAccess, BindingLeaseBound, BindingResolutionRequest, BindingSource, + BindingVersion, CapabilitySet, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, + FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, PolicyVersion, + ProviderAllow, ProviderAuthorizationClock, ProviderDecision, ProviderTimeout, Scope, + VerificationOnlyDisposition, VerificationStatusPolicy, VerifiedNostrProof, +}; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, ClientBindingScopeV1, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_SCOPE_TAG, CLIENT_BINDING_STATUS_SUB_ID, +}; +use buzz_core::client_binding_status::{ + ClientBindingStatusError, ClientBindingStatusFoldError, ClientBindingStatusInputV1, + ClientBindingStatusTracker, ClientBindingStatusUpdate, +}; +use buzz_core::kind::{KIND_CLIENT_BINDING_STATUS, KIND_USER_TRUSTED_ASSERTION}; +use buzz_core::CommunityId; +use buzz_relay::authorization_runtime::status::{ + AuthoritativeClientStatusEvidence, ClientStatusPresentationGateError, + ClientStatusPresentationPermit, ClientStatusPrivacyKey, ClientStatusRevisionScope, + CompleteClientStatusPresentationApproval, ConnectionManagerClientStatusTransport, + DurableClientStatusRevision, DurableClientStatusRevisionSource, ProviderNeutralPolicyRevision, + RelayClientBindingStatusIssuer, +}; +use buzz_relay::connection::OutboundData; +use buzz_relay::protocol::{ClientMessage, RelayMessage}; +use buzz_relay::state::ConnectionManager; +use client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; +use futures::{SinkExt, StreamExt}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex}; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +#[derive(Clone)] +struct FixedClock(u64); + +impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(self.0)) + } +} + +impl ProviderAuthorizationClock for FixedClock { + fn now_unix_seconds(&self) -> Option { + Some(self.0) + } +} + +struct SyntheticAuthority { + policy_id: Uuid, + binding_id: Uuid, + binding_version: BindingVersion, + valid_until: u64, +} + +impl SyntheticAuthority { + fn resolve_binding( + &self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> Result> + { + Ok(sink.existing_active( + request.authorization_domain(), + self.binding_id, + request.principal().clone(), + request.bound_pubkey(), + self.binding_version, + Some(BindingExpiry::new(self.valid_until)?), + BindingSource::AttestedKey, + )?) + } +} + +impl FederatedAuthorityAdapter for SyntheticAuthority { + type Error = Infallible; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + Ok(sink.resolved( + request.authorization_domain(), + self.policy_id, + 1, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + request.observed_at().saturating_sub(1), + self.valid_until, + )?) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result< + buzz_auth::context::AuthoritativeBindingResolution, + AuthorityAdapterError, + >, + > { + Box::pin(async move { self.resolve_binding(request, sink) }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result< + buzz_auth::context::AuthoritativeBindingResolution, + AuthorityAdapterError, + >, + > { + Box::pin(async move { + Ok(sink.existing_active( + request.authorization_domain(), + self.binding_id, + request.principal().clone(), + request.bound_pubkey(), + self.binding_version, + Some(BindingExpiry::new(self.valid_until)?), + BindingSource::AttestedKey, + )?) + }) + } +} + +struct SyntheticProvider { + profile: AuthorizationProfileId, + policy: PolicyVersion, + issued_at: u64, + fresh_until: u64, +} + +impl AuthorizationProvider for SyntheticProvider { + fn profile_id(&self) -> AuthorizationProfileId { + self.profile.clone() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + let decision = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + self.profile.clone(), + request.requested_capabilities().clone(), + self.policy.clone(), + self.issued_at, + self.fresh_until, + ) + .expect("synthetic provider output must satisfy the production contract"); + Box::pin(std::future::ready(ProviderDecision::Allow(decision))) + } +} + +struct CompleteSyntheticApproval { + reviewed_revision: String, +} + +impl CompleteClientStatusPresentationApproval for CompleteSyntheticApproval { + fn reviewed_implementation_revision(&self) -> &str { + &self.reviewed_revision + } + + fn presentation_gate_passed(&self) -> bool { + true + } + + fn dedicated_client_contract_passed(&self) -> bool { + true + } +} + +struct SyntheticRevisions { + revision: AtomicU64, + current_reads: AtomicUsize, + withdrawal_reads: AtomicUsize, + scopes: Mutex>, +} + +impl SyntheticRevisions { + fn new(revision: u64) -> Self { + Self { + revision: AtomicU64::new(revision), + current_reads: AtomicUsize::new(0), + withdrawal_reads: AtomicUsize::new(0), + scopes: Mutex::new(Vec::new()), + } + } + + fn set(&self, revision: u64) { + self.revision.store(revision, Ordering::SeqCst); + } + + fn durable(&self) -> Option { + let revision = self.revision.load(Ordering::SeqCst); + DurableClientStatusRevision::from_durable_state(revision, revision).ok() + } +} + +#[async_trait] +impl DurableClientStatusRevisionSource for SyntheticRevisions { + async fn current_revision_for( + &self, + requirement: &buzz_relay::authorization_runtime::status::ClientStatusCurrentRequirement<'_>, + _issuance_fingerprint: [u8; 32], + ) -> Option { + self.current_reads.fetch_add(1, Ordering::SeqCst); + self.scopes + .lock() + .expect("synthetic revision scope lock") + .push(requirement.scope()); + self.durable() + } + + async fn withdrawal_revision_for( + &self, + receipt: &buzz_relay::authorization_runtime::status::ClientStatusIssuanceReceipt, + _withdrawal_fingerprint: [u8; 32], + ) -> Option { + self.withdrawal_reads.fetch_add(1, Ordering::SeqCst); + assert!(!receipt.connection_id().is_nil()); + self.durable() + } +} + +async fn verification_only_disposition( + domain: CommunityId, + author: &Keys, + now: u64, + proof: VerifiedNostrProof, +) -> (VerificationOnlyDisposition, ClientStatusPrivacyKey) { + let adapter = VerifiedEvidenceAdapter::new(); + let issuer = format!("https://{}.invalid", Uuid::new_v4()); + let subject = Uuid::new_v4().to_string(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain, + AuthTransport::RelayWebSocket, + &issuer, + &subject, + Some(author.public_key()), + AssertionTransport::TrustedProxy, + Some(now.saturating_sub(1)), + now + 240, + now, + ) + .expect("synthetic validated claims seal exact assertion evidence"); + let correlation_id = Uuid::new_v4(); + let authority = SyntheticAuthority { + policy_id: Uuid::new_v4(), + binding_id: Uuid::new_v4(), + binding_version: BindingVersion::new(7).expect("positive synthetic binding version"), + valid_until: now + 240, + }; + let policy = resolve_current_federated_policy(&authority, domain, correlation_id, now) + .await + .expect("test-only authoritative policy resolves"); + let profile = AuthorizationProfileId::from_server_configuration(format!( + "synthetic-profile-{}", + Uuid::new_v4() + )) + .expect("synthetic profile is valid"); + let provider_policy = PolicyVersion::new(format!("private-policy-{}", Uuid::new_v4())) + .expect("synthetic provider policy is valid"); + let capabilities = CapabilitySet::single(AuthorizationCapability::CommunityRead); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities, + correlation_id, + now, + ) + .expect("exact direct provider request is valid"); + let provider = SyntheticProvider { + profile: profile.clone(), + policy: provider_policy, + issued_at: now, + fresh_until: now + 180, + }; + let snapshot = match resolve_authorization( + &provider, + &request, + &FixedClock(now), + ProviderTimeout::new(Duration::from_secs(1)).expect("bounded provider timeout"), + Uuid::new_v4(), + ) + .await + { + AuthorizationOutcome::Allow(snapshot) => snapshot, + other => panic!("synthetic exact provider request must allow: {other:?}"), + }; + let binding = adapter + .active_binding_from_store( + domain, + domain, + authority.binding_id, + &issuer, + &subject, + author.public_key(), + authority.binding_version.get(), + Some(authority.valid_until), + BindingSource::AttestedKey, + ActiveBindingResolution::Existing, + Some(&assertion), + ) + .expect("typed current binding store output seals"); + let binding_bound = BindingLeaseBound::new(&binding, authority.valid_until) + .expect("synthetic binding bound is current"); + let tenant = + buzz_core::tenant::TenantContext::resolved(domain, format!("{}.invalid", Uuid::new_v4())); + let admission: AuthorizedCommunityAccess = adapter + .community_access_from_policy(&tenant, domain, vec![Scope::MessagesRead], None) + .expect("server-resolved community admission seals"); + let input = AuthContextInput::new(tenant, correlation_id, proof, admission); + let policy = resolve_current_federated_policy(&authority, domain, correlation_id, now) + .await + .expect("same current policy resolves at finalization"); + let finalizer = AuthorizationFinalizer::new(Arc::new(FixedClock(now))); + let disposition = finalizer + .finalize_verification_only( + input, + policy, + FederatedAuthorization::Direct { binding, assertion }, + snapshot, + &profile, + binding_bound, + VerificationStatusPolicy::new( + ApplicationLeaseLimit::from_seconds(120).expect("short display lifetime is valid"), + AuthorizationClockSkew::from_seconds(0).expect("zero skew is valid"), + ), + ) + .expect("production finalizer yields display-only evidence"); + let privacy_key = ClientStatusPrivacyKey::from_secret(rand::random()); + (disposition, privacy_key) +} + +fn register_connection( + connections: &ConnectionManager, + connection_id: Uuid, + domain: CommunityId, +) -> ( + mpsc::Receiver, + mpsc::Receiver, +) { + let (tx, rx) = mpsc::channel(16); + let (ctrl_tx, ctrl_rx) = mpsc::channel(4); + connections.register( + connection_id, + tx, + ctrl_tx, + CancellationToken::new(), + domain, + Arc::new(AtomicU8::new(0)), + Arc::new(AsyncMutex::new(HashMap::new())), + 3, + ); + (rx, ctrl_rx) +} + +fn authoritative_evidence( + disposition: &VerificationOnlyDisposition, + privacy_key: &ClientStatusPrivacyKey, +) -> AuthoritativeClientStatusEvidence { + let policy_revision = ProviderNeutralPolicyRevision::derive( + privacy_key, + disposition.profile_id(), + disposition.policy_version(), + ) + .expect("ephemeral privacy key derives provider-neutral policy revision"); + AuthoritativeClientStatusEvidence::from_authoritative_runtime( + disposition.authorization_domain(), + disposition.actor_pubkey(), + disposition.binding_id(), + disposition.binding_version(), + disposition.profile_id().clone(), + disposition.policy_version().clone(), + policy_revision, + disposition.correlation_id(), + 1, + disposition.issued_at(), + disposition.expires_at(), + ) +} + +fn raw_signed_event(keys: &Keys, kind: u32, content: String, issued_at: u64) -> Event { + EventBuilder::new(Kind::Custom(kind as u16), content) + .custom_created_at(Timestamp::from(issued_at)) + .sign_with_keys(keys) + .expect("ephemeral synthetic event signs") +} + +async fn receive_status( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> (String, Event) { + let message = timeout(Duration::from_secs(2), socket.next()) + .await + .expect("loopback status frame must not time out") + .expect("loopback relay remains connected") + .expect("loopback WebSocket frame is valid"); + let Message::Text(text) = message else { + panic!("client status must use a text WebSocket frame"); + }; + let envelope: Value = serde_json::from_str(&text).expect("relay frame is JSON"); + assert_eq!(envelope[0], "EVENT"); + let event = + Event::from_json(envelope[2].to_string()).expect("relay frame carries a Nostr event"); + (text.to_string(), event) +} + +async fn receive_transport_event( + expected_frames: &mpsc::UnboundedSender, + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + subscription: &str, + event: &Event, +) -> (String, Event) { + expected_frames + .send(RelayMessage::event(subscription, event)) + .expect("queue-drain adapter remains live"); + let (text, received) = receive_status(socket).await; + assert_eq!(received.id, event.id, "wire event must be issuer-produced"); + (text, received) +} + +async fn enqueue_and_receive_event( + connections: &ConnectionManager, + connection_id: Uuid, + expected_frames: &mpsc::UnboundedSender, + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + subscription: &str, + event: &Event, +) -> (String, Event) { + let frame = RelayMessage::event(subscription, event); + expected_frames + .send(frame.clone()) + .expect("queue-drain adapter remains live"); + assert!( + connections.send_to(connection_id, frame), + "production ConnectionManager must enqueue the exact-connection frame" + ); + let (text, received) = receive_status(socket).await; + assert_eq!(received.id, event.id, "wire event must be sender-produced"); + (text, received) +} + +fn assert_current_projection( + update: Option, + author: &Keys, + epoch: &ClientBindingEpoch, + fresh_until: u64, +) { + let Some(ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey, + fresh_until: projected_fresh_until, + connection_epoch, + })) = update + else { + panic!("production J1 session must project current status"); + }; + assert_eq!(event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected_fresh_until, fresh_until); + assert_eq!(connection_epoch, epoch.as_str()); +} + +fn assert_clear(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Clear))); +} + +#[tokio::test] +async fn relay_authenticated_status_uses_real_loopback_and_exact_connection_scope() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("ephemeral loopback listener binds"); + let address = listener + .local_addr() + .expect("loopback listener has an address"); + assert_ne!(address.port(), 0, "OS must allocate a real ephemeral port"); + let relay_url = format!("ws://{address}"); + let now = Timestamp::now().as_secs(); + let relay = Keys::generate(); + let author = Keys::generate(); + let spoof = Keys::generate(); + let wrong_relay = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let wrong_domain = CommunityId::from_uuid(Uuid::new_v4()); + let connection_id = Uuid::new_v4(); + let epoch = ClientBindingEpoch::new_v4(); + let challenge = Uuid::new_v4().to_string(); + let auth_event = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(vec!["relay", relay_url.as_str()]).expect("loopback relay tag is valid"), + Tag::parse(vec!["challenge", challenge.as_str()]) + .expect("ephemeral challenge tag is valid"), + Tag::parse(vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + epoch.as_str().to_string(), + relay.public_key().to_hex(), + ]) + .expect("native status scope tag is valid"), + ]) + .sign_with_keys(&author) + .expect("ephemeral author signs scoped NIP-42 proof"); + + let connections = Arc::new(ConnectionManager::new()); + let (mut outbound_rx, _ctrl_rx) = register_connection(&connections, connection_id, domain); + let (expected_frame_tx, mut expected_frame_rx) = mpsc::unbounded_channel::(); + let (auth_proof_tx, auth_proof_rx) = + oneshot::channel::<(VerifiedNostrProof, ClientBindingScopeV1)>(); + let server_relay_url = relay_url.clone(); + let server_challenge = challenge.clone(); + let expected_relay_signer = relay.public_key(); + let server = tokio::spawn(async move { + let (tcp, peer) = listener.accept().await.expect("loopback client connects"); + assert!(peer.ip().is_loopback(), "harness must remain loopback-only"); + let mut websocket = tokio_tungstenite::accept_async(tcp) + .await + .expect("loopback WebSocket upgrades"); + + let auth_text = match websocket.next().await { + Some(Ok(Message::Text(text))) => text, + _ => panic!("first loopback client frame must be text AUTH"), + }; + let ClientMessage::Auth(event) = + ClientMessage::parse(&auth_text).expect("Buzz parser accepts real AUTH frame") + else { + panic!("first loopback client frame must parse as AUTH"); + }; + let proof = VerifiedEvidenceAdapter::new() + .verify_nip42( + domain, + AuthTransport::RelayWebSocket, + &event, + &server_challenge, + &server_relay_url, + None, + ) + .expect("Buzz verifier seals AUTH received over loopback"); + let status_scope = ClientBindingScopeV1::from_verified_auth_event(&event) + .expect("verified AUTH carries one exact signed status scope"); + assert_eq!(status_scope.relay_signer(), expected_relay_signer); + auth_proof_tx + .send((proof, status_scope)) + .expect("test driver awaits verified AUTH evidence"); + + let mut sent = 0usize; + while let Some(queued) = outbound_rx.recv().await { + let frame = expected_frame_rx + .recv() + .await + .expect("each production queue item has one test-visible oracle frame"); + // `OutboundData::release` is intentionally crate-private. Receiving + // and consuming this value proves the production manager/transport + // emitted before the test-only adapter sends its matching oracle. + drop(queued); + websocket + .send(Message::Text(frame.into())) + .await + .expect("queue-gated loopback frame sends"); + sent += 1; + } + let _ = websocket.close(None).await; + sent + }); + + let (mut socket, _) = tokio_tungstenite::connect_async(&relay_url) + .await + .expect("real loopback WebSocket client connects"); + socket + .send(Message::Text( + json!([ + "AUTH", + serde_json::to_value(&auth_event).expect("AUTH serializes") + ]) + .to_string() + .into(), + )) + .await + .expect("real AUTH frame crosses loopback WebSocket"); + let (proof, status_scope) = auth_proof_rx + .await + .expect("server returns exact verified AUTH evidence"); + assert_eq!(proof.actor_pubkey(), author.public_key()); + assert_eq!(status_scope.connection_epoch(), &epoch); + assert_eq!(status_scope.relay_signer(), relay.public_key()); + connections.set_authenticated_pubkey(connection_id, proof.actor_pubkey().to_bytes().to_vec()); + + let (disposition, privacy_key) = + verification_only_disposition(domain, &author, now, proof).await; + let evidence = authoritative_evidence(&disposition, &privacy_key); + let revisions = SyntheticRevisions::new(10); + let issuer = RelayClientBindingStatusIssuer::new(&relay, &revisions, &privacy_key); + let permit = ClientStatusPresentationPermit::from_complete_stack(&CompleteSyntheticApproval { + reviewed_revision: "a".repeat(40), + }) + .expect("test-only complete approval constructs the production gate"); + + let transport = ConnectionManagerClientStatusTransport::new(Arc::clone(&connections)); + + let authenticated_epoch = status_scope.connection_epoch().clone(); + let bootstrap = ClientBindingBootstrapInputV1::new( + domain, + author.public_key(), + authenticated_epoch.clone(), + now, + ) + .expect("authenticated connection scope creates bootstrap input") + .sign_with_relay_keys(&relay) + .expect("relay signs exact connection bootstrap"); + let mut session = ClientBindingStatusSession::new( + relay.public_key(), + author.public_key(), + authenticated_epoch, + ); + let (bootstrap_text, received_bootstrap) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, + &bootstrap, + ) + .await; + assert_eq!(received_bootstrap.id, bootstrap.id); + assert!(matches!( + session.consume_text(&bootstrap_text, now), + Some(ProjectionUpdate::Unchanged) + )); + + let current_attempt = issuer + .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) + .await + .expect("production issuer creates current status"); + assert_eq!(current_attempt.delivery_error(), None); + assert_eq!(current_attempt.receipt().connection_id(), connection_id); + assert_eq!(current_attempt.receipt().revision(), 10); + assert_eq!(current_attempt.event().pubkey, relay.public_key()); + assert!(current_attempt.event().verify().is_ok()); + assert!(!current_attempt + .event() + .content + .contains(disposition.profile_id().as_str())); + assert!(!current_attempt + .event() + .content + .contains(disposition.policy_version().as_str())); + + // The same issuer cannot target a connection authenticated as another key + // or resolved for another authorization domain. + let wrong_author_connection = Uuid::new_v4(); + let (_wrong_author_rx, _wrong_author_ctrl_rx) = + register_connection(&connections, wrong_author_connection, domain); + connections.set_authenticated_pubkey( + wrong_author_connection, + spoof.public_key().to_bytes().to_vec(), + ); + let wrong_author_attempt = issuer + .deliver_verification_only( + &permit, + &disposition, + 1, + None, + wrong_author_connection, + &transport, + ) + .await + .expect("issuance succeeds independently of exact delivery"); + assert!(wrong_author_attempt.delivery_error().is_some()); + + let wrong_domain_connection = Uuid::new_v4(); + let (_wrong_domain_rx, _wrong_domain_ctrl_rx) = + register_connection(&connections, wrong_domain_connection, wrong_domain); + connections.set_authenticated_pubkey( + wrong_domain_connection, + author.public_key().to_bytes().to_vec(), + ); + let wrong_domain_attempt = issuer + .deliver_verification_only( + &permit, + &disposition, + 1, + None, + wrong_domain_connection, + &transport, + ) + .await + .expect("issuance succeeds independently of exact delivery"); + assert!(wrong_domain_attempt.delivery_error().is_some()); + + revisions.set(10); + let (current_text, current) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + current_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(¤t_text, now), + &author, + &epoch, + disposition.expires_at(), + ); + let mut tracker = + ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); + assert_eq!( + tracker.accept(¤t, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + assert_eq!(tracker.high_water_revision(), Some(10)); + + let malformed = raw_signed_event(&relay, KIND_CLIENT_BINDING_STATUS, "{".to_string(), now); + let (malformed_text, malformed) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &malformed, + ) + .await; + assert_clear(session.consume_text(&malformed_text, now)); + assert_eq!( + tracker.accept(&malformed, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::MalformedPayload + )) + ); + + let mut unsupported_content: Value = + serde_json::from_str(¤t.content).expect("current status content is JSON"); + unsupported_content["version"] = json!(2); + let unsupported = raw_signed_event( + &relay, + KIND_CLIENT_BINDING_STATUS, + unsupported_content.to_string(), + now, + ); + let (unsupported_text, unsupported) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &unsupported, + ) + .await; + assert_clear(session.consume_text(&unsupported_text, now)); + assert_eq!( + tracker.accept(&unsupported, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::UnsupportedVersion + )) + ); + + let wrong_signer = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 7, + "opaque-wrong-relay", + 11, + now, + now + 120, + None, + ) + .expect("bounded wrong-relay status input") + .sign_with_relay_keys(&wrong_relay) + .expect("wrong relay still produces an authenticated Nostr event"); + let (wrong_signer_text, wrong_signer) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &wrong_signer, + ) + .await; + assert!(matches!( + session.consume_text(&wrong_signer_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!( + tracker.accept(&wrong_signer, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::UnexpectedRelay + )) + ); + + let author_mismatch = ClientBindingStatusInputV1::current( + domain, + spoof.public_key(), + 7, + "opaque-author-spoof", + 11, + now, + now + 120, + None, + ) + .expect("bounded mismatched-author status input") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit mismatched-author test event"); + let (author_mismatch_text, author_mismatch) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &author_mismatch, + ) + .await; + assert_clear(session.consume_text(&author_mismatch_text, now)); + assert_eq!( + tracker.accept(&author_mismatch, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::EventAuthorMismatch + )) + ); + + let domain_mismatch = ClientBindingStatusInputV1::current( + wrong_domain, + author.public_key(), + 7, + "opaque-domain-spoof", + 11, + now, + now + 120, + None, + ) + .expect("bounded mismatched-domain status input") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit mismatched-domain test event"); + let (domain_mismatch_text, domain_mismatch) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &domain_mismatch, + ) + .await; + assert_clear(session.consume_text(&domain_mismatch_text, now)); + assert_eq!( + tracker.accept(&domain_mismatch, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::AuthorizationDomainMismatch + )) + ); + + // Neither mutable profile metadata nor a legacy NIP-85 assertion can + // restore or rename the relay-authenticated status presentation. + for (legacy, expected_clear) in [ + ( + raw_signed_event( + &spoof, + Kind::Metadata.as_u16() as u32, + json!({"name": format!("spoof-{}", Uuid::new_v4())}).to_string(), + now, + ), + false, + ), + ( + raw_signed_event( + &relay, + KIND_USER_TRUSTED_ASSERTION, + json!({"active": true, "label": format!("legacy-{}", Uuid::new_v4())}).to_string(), + now, + ), + true, + ), + ] { + let (legacy_text, legacy) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &legacy, + ) + .await; + let update = session.consume_text(&legacy_text, now); + if expected_clear { + assert_clear(update); + } else { + assert!(matches!(update, Some(ProjectionUpdate::Unchanged))); + } + assert_eq!( + tracker.accept(&legacy, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::WrongKind + )) + ); + assert_eq!(tracker.high_water_revision(), Some(10)); + assert_eq!( + tracker + .current_presentation(now) + .and_then(|status| status.display_label()), + None + ); + } + + revisions.set(11); + let withdrawal = issuer + .deliver_withdrawn_after_invalidation( + &permit, + &evidence, + current_attempt.receipt(), + &transport, + ) + .await + .expect("production issuer delivers a strictly newer withdrawal"); + let (withdrawal_text, withdrawal) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &withdrawal, + ) + .await; + assert_clear(session.consume_text(&withdrawal_text, now)); + assert_eq!( + tracker.accept(&withdrawal, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!( + tracker.accept(&withdrawal, now), + Ok(ClientBindingStatusUpdate::Duplicate) + ); + assert_eq!( + tracker.accept(¤t, now), + Err(ClientBindingStatusFoldError::LowerRevisionReplay) + ); + + let equal_conflict = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-equal-conflict", + 11, + now, + now + 120, + None, + ) + .expect("equal-revision conflict input is structurally valid") + .sign_with_relay_keys(&relay) + .expect("relay signs explicit conflict event"); + let (equal_conflict_text, equal_conflict) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &equal_conflict, + ) + .await; + assert_clear(session.consume_text(&equal_conflict_text, now)); + assert_eq!( + tracker.accept(&equal_conflict, now), + Err(ClientBindingStatusFoldError::ConflictingEqualRevision) + ); + + // A trusted relay event in a malformed reserved outer frame must clear and + // consume its revision. Replaying the same event in an exact frame cannot + // restore; only a strictly newer issuer event may do so (J1 fail-closed + // high-water latch). + let trusted_invalid_current = ClientBindingStatusInputV1::current( + domain, + author.public_key(), + 8, + "opaque-trusted-invalid", + 12, + now, + disposition.expires_at(), + None, + ) + .expect("trusted-invalid inner status is structurally valid") + .sign_with_relay_keys(&relay) + .expect("trusted relay signs inner status"); + let trusted_invalid_frame = json!([ + "EVENT", + CLIENT_BINDING_STATUS_SUB_ID, + serde_json::to_value(&trusted_invalid_current).expect("status serializes"), + {"unexpected": true} + ]) + .to_string(); + expected_frame_tx + .send(trusted_invalid_frame.clone()) + .expect("queue-drain adapter remains live"); + assert!(connections.send_to(connection_id, trusted_invalid_frame)); + let (trusted_invalid_text, received_trusted_invalid) = receive_status(&mut socket).await; + assert_eq!(received_trusted_invalid.id, trusted_invalid_current.id); + assert_clear(session.consume_text(&trusted_invalid_text, now)); + assert_eq!(session.projected_fresh_until(), None); + + let (equal_replay_text, equal_replay) = enqueue_and_receive_event( + &connections, + connection_id, + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + &trusted_invalid_current, + ) + .await; + assert_eq!(equal_replay.id, trusted_invalid_current.id); + assert!(matches!( + session.consume_text(&equal_replay_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + revisions.set(13); + let restored_attempt = issuer + .deliver_verification_only(&permit, &disposition, 1, None, connection_id, &transport) + .await + .expect("production issuer creates strictly newer restoration"); + assert_eq!(restored_attempt.delivery_error(), None); + let (restored_text, restored) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + restored_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(&restored_text, now), + &author, + &epoch, + disposition.expires_at(), + ); + assert_eq!( + tracker.accept(&restored, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + + tracker.on_disconnect(); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!(tracker.high_water_revision(), Some(13)); + assert_eq!( + tracker.accept(&restored, now), + Ok(ClientBindingStatusUpdate::Duplicate), + "reconnect must not restore presentation from a duplicate" + ); + assert!(tracker.current_presentation(now).is_none()); + + assert_clear(Some(session.disconnect())); + assert_eq!(session.projected_fresh_until(), None); + assert!(matches!( + session.consume_text(&restored_text, now), + Some(ProjectionUpdate::Unchanged) + )); + assert_eq!(session.projected_fresh_until(), None); + + revisions.set(14); + let reconnect_attempt = issuer + .deliver_verification_only(&permit, &disposition, 2, None, connection_id, &transport) + .await + .expect("reconnect obtains a newer production issuance"); + let (reconnect_text, reconnect) = receive_transport_event( + &expected_frame_tx, + &mut socket, + CLIENT_BINDING_STATUS_SUB_ID, + reconnect_attempt.event(), + ) + .await; + assert_current_projection( + session.consume_text(&reconnect_text, now), + &author, + &epoch, + disposition.expires_at(), + ); + assert_eq!( + tracker.accept(&reconnect, now), + Ok(ClientBindingStatusUpdate::Accepted) + ); + assert!(tracker.current_presentation(now).is_some()); + assert!(tracker + .current_presentation(disposition.expires_at()) + .is_none()); + assert_eq!(tracker.high_water_revision(), Some(14)); + assert_clear(Some(session.expire(disposition.expires_at()))); + assert_eq!(session.projected_fresh_until(), None); + + tracker.change_scope(relay.public_key(), wrong_domain, author.public_key()); + assert_eq!(tracker.high_water_revision(), None); + assert!(tracker.current_presentation(now).is_none()); + assert_eq!( + tracker.accept(&reconnect, now), + Err(ClientBindingStatusFoldError::InvalidStatus( + ClientBindingStatusError::AuthorizationDomainMismatch + )) + ); + + // Logout/restart starts with no projection. It does not synthesize a + // profile-derived or NIP-85-derived fallback while awaiting a new status. + let mut restarted = + ClientBindingStatusTracker::new(relay.public_key(), domain, author.public_key()); + assert!(restarted.current_presentation(now).is_none()); + assert_eq!(restarted.high_water_revision(), None); + let restarted_session = + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch.clone()); + assert_eq!(restarted_session.connection_epoch(), &epoch); + assert_eq!(restarted_session.projected_fresh_until(), None); + + assert_eq!(revisions.current_reads.load(Ordering::SeqCst), 5); + assert_eq!(revisions.withdrawal_reads.load(Ordering::SeqCst), 1); + { + let observed_scopes = revisions + .scopes + .lock() + .expect("synthetic revision scope lock"); + assert!( + !observed_scopes.is_empty(), + "issuer must read durable scope" + ); + assert!(observed_scopes.iter().all(|scope| { + scope.authorization_domain() == domain + && scope.event_author_pubkey() == author.public_key() + })); + } + + connections.deregister(connection_id); + drop(expected_frame_tx); + let sent = server.await.expect("loopback relay task exits cleanly"); + assert!( + sent >= 15, + "non-vacuity: AUTH/bootstrap/status cases crossed queue-gated WebSocket" + ); +} + +#[test] +fn presentation_gate_rejects_incomplete_review_evidence() { + let incomplete = CompleteSyntheticApproval { + reviewed_revision: "not-a-revision".to_string(), + }; + assert!(matches!( + ClientStatusPresentationPermit::from_complete_stack(&incomplete), + Err(ClientStatusPresentationGateError::Incomplete) + )); +} diff --git a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs index 95b0d110f5..ce77d98c08 100644 --- a/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs +++ b/crates/buzz-relay/tests/nip_fi_runtime_conformance.rs @@ -13,6 +13,17 @@ const PRODUCTION_RUNTIME: &str = include_str!("../src/authorization_runtime/prod const AUTH_HANDLER: &str = include_str!("../src/handlers/auth.rs"); const KIND_REGISTRY: &str = include_str!("../../buzz-core/src/kind.rs"); const INGEST_HANDLER: &str = include_str!("../src/handlers/ingest.rs"); +const READ_ONLY_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/readOnlyRelayClient.ts"); +const PRIMARY_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/relayClientSession.ts"); +const STATUS_RELAY_CLIENT: &str = + include_str!("../../../desktop/src/shared/api/relayClientStatusConnection.ts"); +const NATIVE_WEBSOCKET: &str = include_str!("../../../desktop/src-tauri/src/native_websocket.rs"); +const DESKTOP_BUILD: &str = include_str!("../../../desktop/src-tauri/build.rs"); +const WORKSPACE_COMMAND: &str = + include_str!("../../../desktop/src-tauri/src/commands/workspace.rs"); +const IDENTITY_COMMAND: &str = include_str!("../../../desktop/src-tauri/src/commands/identity.rs"); #[test] fn mandatory_o4_security_contracts_are_present() { @@ -318,6 +329,31 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { continue; } let source = fs::read_to_string(&file).expect("source file is readable"); + let native_socket_tests = + file == repo.join("desktop/src-tauri/src/native_websocket_tests.rs"); + if native_socket_tests { + assert!(NATIVE_WEBSOCKET + .contains("#[cfg(test)]\n#[path = \"native_websocket_tests.rs\"]\nmod tests;")); + continue; + } + let native_session = + file == repo.join("desktop/src-tauri/src/client_binding_status_session.rs"); + let native_socket = file == repo.join("desktop/src-tauri/src/native_websocket.rs"); + let native_socket_status = + file == repo.join("desktop/src-tauri/src/native_websocket_status.rs"); + let native_module_declaration = file == repo.join("desktop/src-tauri/src/lib.rs"); + if native_module_declaration { + assert_eq!( + source.matches("mod client_binding_status_session;").count(), + 1, + "native status module must have one private declaration" + ); + } + let source_to_scan = if native_module_declaration { + source.replacen("mod client_binding_status_session;", "", 1) + } else { + source.clone() + }; for forbidden in [ "KIND_CLIENT_BINDING_STATUS", "ClientBindingStatus", @@ -325,8 +361,32 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { "24244", "deliver_verification_only", ] { + let expected_native_count = match ( + native_session, + native_socket, + native_socket_status, + forbidden, + ) { + (true, false, false, "KIND_CLIENT_BINDING_STATUS") => Some(1), + (true, false, false, "ClientBindingStatus") => Some(33), + (true, false, false, "client_binding_status") => Some(2), + (false, true, false, "ClientBindingStatus") => Some(2), + (false, true, false, "client_binding_status") => Some(1), + (false, false, true, "ClientBindingStatus") => Some(3), + (false, false, true, "client_binding_status") => Some(1), + _ => None, + }; + if let Some(expected) = expected_native_count { + assert_eq!( + source_to_scan.matches(forbidden).count(), + expected, + "{} changed the narrow native status allowance for {forbidden}", + file.display() + ); + continue; + } assert!( - !source.contains(forbidden), + !source_to_scan.contains(forbidden), "{} exposes status through an ordinary route {forbidden}", file.display() ); @@ -355,6 +415,100 @@ fn status_uses_only_the_dedicated_authenticated_production_path() { assert!(!status.contains("std::env")); } +#[test] +fn scope_mutations_finalize_the_status_fence_before_propagating_failure() { + let workspace = WORKSPACE_COMMAND + .split("pub async fn apply_workspace") + .nth(1) + .and_then(|suffix| suffix.split("#[tauri::command]").next()) + .expect("workspace command body exists"); + let identity = IDENTITY_COMMAND + .split("pub async fn import_identity") + .nth(1) + .and_then(|suffix| suffix.split("/// Commit an imported identity").next()) + .expect("identity command body exists"); + + for (name, command, result_propagation) in [ + ("workspace", workspace, "mutation_result?"), + ("identity", identity, "let identity = identity_result?"), + ] { + let begin = command + .find(".begin_scope_mutation()") + .unwrap_or_else(|| panic!("{name} must enter the status mutation fence")); + let blocking = command + .find("spawn_blocking") + .unwrap_or_else(|| panic!("{name} blocking mutation exists")); + let finish = command + .find(".finish_scope_mutation()") + .unwrap_or_else(|| panic!("{name} must exit the status mutation fence")); + let propagate = command + .find(result_propagation) + .unwrap_or_else(|| panic!("{name} must propagate its stored result")); + assert!( + begin < blocking && blocking < finish && finish < propagate, + "{name} must finalize the status fence on success, error, or join failure" + ); + assert!( + !command.contains(".invalidate_projection()"), + "{name} must not leave a reconnect gap between independent invalidations" + ); + } +} + +#[test] +fn auth_bootstrap_precedes_status_and_success_ack() { + let bootstrap = AUTH_HANDLER + .find("conn.send(RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &event))") + .expect("AUTH queues the exact-connection bootstrap"); + let presentation = AUTH_HANDLER + .find(".present_after_auth(") + .expect("AUTH invokes O4 presentation"); + let success = AUTH_HANDLER + .find("conn.send(RelayMessage::ok(&event_id_hex, true, \"\"))") + .expect("AUTH queues the successful NIP-42 acknowledgement"); + + assert!( + bootstrap < presentation && presentation < success, + "bootstrap must be queued before O4 status and the NIP-42 OK" + ); + assert!(AUTH_HANDLER.contains("ClientBindingScopeV1::from_verified_auth_event")); + assert!(AUTH_HANDLER.contains("scope.relay_signer() == state.relay_keypair.public_key()")); + assert!(AUTH_HANDLER.contains("if let (Some(runtime), Some(assertion), Some(scope)) =")); + assert!(AUTH_HANDLER.contains("let presentation = if bootstrap_queued")); + assert!(!include_str!("../src/router.rs").contains("CLIENT_BINDING_EPOCH_HEADER")); + assert!(!READ_ONLY_RELAY_CLIENT.contains("onProjection")); + assert!(!READ_ONLY_RELAY_CLIENT.contains("nativeWebsocketId")); +} + +#[test] +fn native_status_connect_is_dedicated_and_primary_composition_is_bound() { + let ordinary = NATIVE_WEBSOCKET + .split("async fn connect(") + .nth(1) + .and_then(|suffix| suffix.split("#[tauri::command]").next()) + .expect("ordinary native connect command exists"); + let status = NATIVE_WEBSOCKET + .split("async fn connect_with_status(") + .nth(1) + .and_then(|suffix| suffix.split("async fn connect_internal(").next()) + .expect("dedicated status connect command exists"); + + assert!(!ordinary.contains("on_projection")); + assert!(status.contains("on_projection: Channel")); + assert!(!status.contains("Option>")); + assert!(NATIVE_WEBSOCKET.contains("connect_with_status,")); + assert!(DESKTOP_BUILD.contains("\"connect_with_status\"")); + + assert!(PRIMARY_RELAY_CLIENT.contains("RelayClientStatusConnection")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.connect(")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.bind(wsId, connectionRelayUrl);")); + assert!(PRIMARY_RELAY_CLIENT.contains("statusConnection.handleAuthChallenge(rest[0])")); + assert!(STATUS_RELAY_CLIENT.contains("\"plugin:websocket|connect_with_status\"")); + assert!(!STATUS_RELAY_CLIENT.contains("\"plugin:websocket|connect\"")); + assert!(STATUS_RELAY_CLIENT.contains("onProjection: this.projectionChannel")); + assert!(STATUS_RELAY_CLIENT.contains("nativeWebsocketId: binding.id")); +} + #[test] fn neutral_verified_evidence_is_exactly_one_and_reachable_in_production() { assert!(TRANSPORT_RUNTIME.contains("trait VerifiedProviderEvidenceResolver")); diff --git a/desktop/playwright.j3c.config.ts b/desktop/playwright.j3c.config.ts new file mode 100644 index 0000000000..66f4713c42 --- /dev/null +++ b/desktop/playwright.j3c.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e/j3c", + testMatch: "current-binding-status-native-trace.spec.ts", + timeout: 30_000, + retries: 0, + workers: 1, + outputDir: "test-results/j3c-current-binding-status", + reporter: [["list"]], + use: { + ...devices["Desktop Chrome"], + baseURL: "http://127.0.0.1:4175", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "current-binding-status-native-trace", + }, + ], + webServer: { + command: "python3 -m http.server 4175 -d dist", + cwd: ".", + reuseExistingServer: false, + url: "http://127.0.0.1:4175", + }, +}); diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..543f799bb0 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -112,7 +112,13 @@ fn main() { tauri_build::Attributes::new().plugin( "websocket", tauri_build::InlinedPlugin::new() - .commands(&["connect", "send", "disconnect", "disconnect_all"]) + .commands(&[ + "connect", + "connect_with_status", + "send", + "disconnect", + "disconnect_all", + ]) .default_permission(tauri_build::DefaultPermissionRule::AllowAllCommands), ), ) diff --git a/desktop/src-tauri/src/client_binding_status_session.rs b/desktop/src-tauri/src/client_binding_status_session.rs new file mode 100644 index 0000000000..ba36b734cc --- /dev/null +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -0,0 +1,669 @@ +//! Native-only relay binding status validation and projection. + +use std::fmt; + +use buzz_core_pkg::{ + client_binding_bootstrap::{ + validate_client_binding_bootstrap_event, ClientBindingEpoch, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::{ClientBindingStatusTracker, ClientBindingStatusUpdate}, + verify_event, +}; +use nostr::{Event, EventId, PublicKey}; +use serde::Serialize; +use serde_json::Value; + +/// Current-only data permitted to cross the native IPC boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CurrentProjection { + pub(crate) event_author_pubkey: String, + pub(crate) fresh_until: u64, + pub(crate) connection_epoch: String, +} + +/// One change produced by the serialized native fold. +pub(crate) enum ProjectionUpdate { + Unchanged, + Clear, + Current(CurrentProjection), +} + +struct ReservedEvent { + event: Result, + exact_outer_shape: bool, +} + +enum ReservedFrame { + Bootstrap(ReservedEvent), + Status(ReservedEvent), +} + +/// Connection-scoped wrapper around the shared authenticated status tracker. +pub(crate) struct ClientBindingStatusSession { + trusted_relay_pubkey: PublicKey, + expected_event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + bootstrap_event_id: Option, + bootstrap_latched_invalid: bool, + tracker: Option, + projected_fresh_until: Option, +} + +impl fmt::Debug for ClientBindingStatusSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBindingStatusSession") + .field("trusted_relay_pubkey", &"[redacted]") + .field("expected_event_author_pubkey", &"[redacted]") + .field("connection_epoch", &"[redacted]") + .field( + "bootstrap_event_id", + &self.bootstrap_event_id.map(|_| "[redacted]"), + ) + .field("bootstrap_latched_invalid", &self.bootstrap_latched_invalid) + .field("tracker", &self.tracker.as_ref().map(|_| "[redacted]")) + .field("projected_fresh_until", &"[redacted]") + .finish() + } +} + +impl ClientBindingStatusSession { + pub(crate) fn new( + trusted_relay_pubkey: PublicKey, + expected_event_author_pubkey: PublicKey, + connection_epoch: ClientBindingEpoch, + ) -> Self { + Self { + trusted_relay_pubkey, + expected_event_author_pubkey, + connection_epoch, + bootstrap_event_id: None, + bootstrap_latched_invalid: false, + tracker: None, + projected_fresh_until: None, + } + } + + pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.connection_epoch + } + + /// Swallow and fold an exact reserved EVENT frame. All other frames return + /// `None` and must be delivered to the webview unchanged. + pub(crate) fn consume_text(&mut self, text: &str, now: u64) -> Option { + let frame = reserved_frame(text.as_bytes())?; + Some(match frame { + ReservedFrame::Bootstrap(event) => self.accept_bootstrap(event, now), + ReservedFrame::Status(event) => self.accept_status(event, now), + }) + } + + #[cfg(test)] + pub(crate) fn projected_fresh_until(&self) -> Option { + self.projected_fresh_until + } + + pub(crate) fn expire(&mut self, now: u64) -> ProjectionUpdate { + let expired = self + .projected_fresh_until + .is_some_and(|fresh_until| now >= fresh_until); + if !expired { + return ProjectionUpdate::Unchanged; + } + if let Some(tracker) = self.tracker.as_mut() { + let _ = tracker.current_presentation(now); + } + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } + + pub(crate) fn disconnect(&mut self) -> ProjectionUpdate { + if let Some(tracker) = self.tracker.as_mut() { + tracker.on_disconnect(); + } + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } + + fn accept_bootstrap(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate { + let Ok(event) = reserved.event else { + return ProjectionUpdate::Unchanged; + }; + if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey { + return ProjectionUpdate::Unchanged; + } + if !reserved.exact_outer_shape || self.bootstrap_latched_invalid { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + } + let bootstrap = match validate_client_binding_bootstrap_event( + &event, + &self.trusted_relay_pubkey, + &self.connection_epoch, + &self.expected_event_author_pubkey, + now, + ) { + Ok(bootstrap) => bootstrap, + Err(_) => { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + } + }; + if let Some(event_id) = self.bootstrap_event_id { + return if event_id == event.id { + ProjectionUpdate::Unchanged + } else { + self.bootstrap_latched_invalid = true; + self.clear_trusted_invalid() + }; + } + self.bootstrap_event_id = Some(event.id); + self.tracker = Some(ClientBindingStatusTracker::new( + self.trusted_relay_pubkey, + bootstrap.authorization_domain(), + self.expected_event_author_pubkey, + )); + ProjectionUpdate::Unchanged + } + + fn accept_status(&mut self, reserved: ReservedEvent, now: u64) -> ProjectionUpdate { + let Ok(event) = reserved.event else { + return ProjectionUpdate::Unchanged; + }; + if verify_event(&event).is_err() || event.pubkey != self.trusted_relay_pubkey { + return ProjectionUpdate::Unchanged; + } + if self.bootstrap_latched_invalid { + return self.clear_trusted_invalid(); + } + if !reserved.exact_outer_shape { + if let Some(tracker) = self.tracker.as_mut() { + // The outer frame is trusted-invalid, but a valid inner status + // must still consume its revision so replaying the identical + // event later in an exact array cannot restore presentation. + if tracker.accept(&event, now).is_err() { + tracker.retain_trusted_invalid_high_water(&event); + } + tracker.on_disconnect(); + } else { + self.bootstrap_latched_invalid = true; + } + return self.clear_trusted_invalid(); + } + let Some(tracker) = self.tracker.as_mut() else { + self.bootstrap_latched_invalid = true; + return self.clear_trusted_invalid(); + }; + match tracker.accept(&event, now) { + Ok(ClientBindingStatusUpdate::Duplicate) => ProjectionUpdate::Unchanged, + Ok(ClientBindingStatusUpdate::Accepted) => { + let Some(status) = tracker.current_presentation(now) else { + self.projected_fresh_until = None; + return ProjectionUpdate::Clear; + }; + self.projected_fresh_until = Some(status.fresh_until()); + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: self.expected_event_author_pubkey.to_hex(), + fresh_until: status.fresh_until(), + connection_epoch: self.connection_epoch.as_str().to_owned(), + }) + } + Err(_) => { + tracker.retain_trusted_invalid_high_water(&event); + self.clear_trusted_invalid() + } + } + } + + fn clear_trusted_invalid(&mut self) -> ProjectionUpdate { + self.projected_fresh_until = None; + ProjectionUpdate::Clear + } +} + +fn reserved_frame(bytes: &[u8]) -> Option { + let value: Value = serde_json::from_slice(bytes).ok()?; + let values = value.as_array()?; + if values.first().and_then(Value::as_str) != Some("EVENT") { + return None; + } + let reserved = match values.get(1).and_then(Value::as_str) { + Some(CLIENT_BINDING_BOOTSTRAP_SUB_ID) => ReservedFrame::Bootstrap, + Some(CLIENT_BINDING_STATUS_SUB_ID) => ReservedFrame::Status, + _ => return None, + }; + let event = values + .get(2) + .cloned() + .ok_or(()) + .and_then(|value| serde_json::from_value(value).map_err(|_| ())); + Some(reserved(ReservedEvent { + event, + exact_outer_shape: values.len() == 3, + })) +} + +/// Classify the reserved exact-connection channels without requiring an +/// eligible presentation session. Every native socket uses this to prevent +/// bootstrap and status frames from reaching raw browser delivery. +pub(crate) fn is_reserved_text(text: &str) -> bool { + reserved_frame(text.as_bytes()).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core_pkg::{ + client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, + CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::{ClientBindingStatusDisposition, ClientBindingStatusInputV1}, + CommunityId, + }; + use nostr::{EventBuilder, JsonUtil, Keys, Kind, Timestamp}; + use serde_json::json; + use uuid::Uuid; + + const ISSUED_AT: u64 = 1_800_000_000; + const FRESH_UNTIL: u64 = ISSUED_AT + 120; + + fn domain() -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(0x1234)) + } + + fn epoch() -> ClientBindingEpoch { + ClientBindingEpoch::parse("11111111-1111-4111-8111-111111111111").expect("synthetic epoch") + } + + fn session(relay: &Keys, author: &Keys) -> ClientBindingStatusSession { + ClientBindingStatusSession::new(relay.public_key(), author.public_key(), epoch()) + } + + fn bootstrap(relay: &Keys, author: &Keys) -> Event { + ClientBindingBootstrapInputV1::new(domain(), author.public_key(), epoch(), ISSUED_AT) + .expect("synthetic bootstrap input") + .sign_with_relay_keys(relay) + .expect("synthetic bootstrap signs") + } + + fn status( + relay: &Keys, + author: &Keys, + revision: u64, + disposition: ClientBindingStatusDisposition, + policy: &str, + ) -> Event { + let input = match disposition { + ClientBindingStatusDisposition::DisplayCurrent => ClientBindingStatusInputV1::current( + domain(), + author.public_key(), + 7, + policy, + revision, + ISSUED_AT, + FRESH_UNTIL, + None, + ), + ClientBindingStatusDisposition::Withdrawn => ClientBindingStatusInputV1::withdrawn( + domain(), + author.public_key(), + revision, + ISSUED_AT, + FRESH_UNTIL, + ), + } + .expect("synthetic status input"); + input + .sign_with_relay_keys(relay) + .expect("synthetic status signs") + } + + fn frame(sub_id: &str, event: &Event) -> String { + json!(["EVENT", sub_id, event]).to_string() + } + + fn extra_outer_value_frame(sub_id: &str, event: &Event) -> String { + json!(["EVENT", sub_id, event, "unexpected"]).to_string() + } + + fn assert_unchanged(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Unchanged))); + } + + fn assert_clear(update: Option) { + assert!(matches!(update, Some(ProjectionUpdate::Clear))); + } + + fn assert_current(update: Option, author: &Keys, revision: u64) { + let Some(ProjectionUpdate::Current(current)) = update else { + panic!("expected a current projection"); + }; + assert_eq!(current.event_author_pubkey, author.public_key().to_hex()); + assert_eq!(current.fresh_until, FRESH_UNTIL); + assert_eq!(current.connection_epoch, epoch().as_str()); + assert!(revision > 0, "call site documents the accepted revision"); + } + + #[test] + fn reserved_channels_are_classified_before_event_decoding() { + assert!(is_reserved_text( + &json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]).to_string() + )); + assert!(is_reserved_text( + &json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "not-an-event"]).to_string() + )); + assert!(!is_reserved_text( + &json!(["EVENT", "ordinary-subscription", {}]).to_string() + )); + assert!(!is_reserved_text("not-json")); + } + + #[test] + fn status_before_bootstrap_fails_closed_and_latches_the_session() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + assert!(session.projected_fresh_until().is_none()); + } + + #[test] + fn unauthenticated_or_wrong_signer_noise_has_no_effect() { + let relay = Keys::generate(); + let wrong_relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let wrong_signer = status( + &wrong_relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &wrong_signer), + ISSUED_AT, + )); + + let signed = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + let mut tampered_json: Value = + serde_json::from_str(&signed.as_json()).expect("status event parses"); + tampered_json["content"] = Value::String("{}".to_string()); + let tampered = Event::from_json(tampered_json.to_string()).expect("tampered event parses"); + assert_unchanged( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &tampered), ISSUED_AT), + ); + + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &signed), ISSUED_AT), + &author, + 1, + ); + } + + #[test] + fn trusted_invalid_status_clears_duplicate_cannot_restore_and_newer_can() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let bootstrap = bootstrap(&relay, &author); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + let conflicting_equal = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "different-policy", + ); + let newer = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v2", + ); + + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap), + ISSUED_AT, + )); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + &author, + 1, + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &conflicting_equal), + ISSUED_AT, + )); + assert_unchanged( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + ); + assert!(session.projected_fresh_until().is_none()); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &newer), ISSUED_AT), + &author, + 2, + ); + } + + #[test] + fn malformed_reserved_outer_shape_consumes_high_water_before_clearing() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let revision_two = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v2", + ); + let revision_three = status( + &relay, + &author, + 3, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v3", + ); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + + assert_clear(session.consume_text( + &extra_outer_value_frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two), + ISSUED_AT, + )); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_two), + ISSUED_AT, + )); + assert!(session.projected_fresh_until().is_none()); + assert_current( + session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_three), + ISSUED_AT, + ), + &author, + 3, + ); + } + + #[test] + fn trusted_invalid_parseable_revision_advances_hidden_high_water() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + let first = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &first), ISSUED_AT), + &author, + 1, + ); + + let revision_four = status( + &relay, + &author, + 4, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v4", + ); + let mut invalid_payload: Value = + serde_json::from_str(&revision_four.content).expect("synthetic status payload"); + invalid_payload["authorization_domain"] = json!("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let trusted_invalid = EventBuilder::new( + Kind::Custom(buzz_core_pkg::kind::KIND_CLIENT_BINDING_STATUS as u16), + invalid_payload.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("trusted-invalid status signs"); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &trusted_invalid), + ISSUED_AT, + )); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_four), + ISSUED_AT, + )); + + let revision_five = status( + &relay, + &author, + 5, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v5", + ); + assert_current( + session.consume_text( + &frame(CLIENT_BINDING_STATUS_SUB_ID, &revision_five), + ISSUED_AT, + ), + &author, + 5, + ); + } + + #[test] + fn withdrawal_and_passive_expiry_clear_current_projection() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + assert_unchanged(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + let current = status( + &relay, + &author, + 1, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v1", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, ¤t), ISSUED_AT), + &author, + 1, + ); + let withdrawn = status( + &relay, + &author, + 2, + ClientBindingStatusDisposition::Withdrawn, + "unused", + ); + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &withdrawn), ISSUED_AT), + ); + + let newer = status( + &relay, + &author, + 3, + ClientBindingStatusDisposition::DisplayCurrent, + "policy-v3", + ); + assert_current( + session.consume_text(&frame(CLIENT_BINDING_STATUS_SUB_ID, &newer), ISSUED_AT), + &author, + 3, + ); + assert!(matches!( + session.expire(FRESH_UNTIL - 1), + ProjectionUpdate::Unchanged + )); + assert!(matches!( + session.expire(FRESH_UNTIL), + ProjectionUpdate::Clear + )); + assert!(session.projected_fresh_until().is_none()); + } + + #[test] + fn expected_signer_invalid_bootstrap_latches_exact_origin() { + let relay = Keys::generate(); + let author = Keys::generate(); + let mut session = session(&relay, &author); + let invalid = EventBuilder::new( + Kind::Custom(buzz_core_pkg::kind::KIND_CLIENT_BINDING_BOOTSTRAP as u16), + "{}", + ) + .tags([]) + .custom_created_at(Timestamp::from(ISSUED_AT)) + .sign_with_keys(&relay) + .expect("trusted-invalid bootstrap signs"); + + assert_clear( + session.consume_text(&frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &invalid), ISSUED_AT), + ); + assert_clear(session.consume_text( + &frame(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap(&relay, &author)), + ISSUED_AT, + )); + } +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..6a025c0bf9 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -10,6 +10,7 @@ use crate::{ nostr_bind, relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; +use buzz_core_pkg::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG; /// Encode `pubkey` as npub bech32 and truncate it for display: first 10 chars /// + "…" + last 4 chars. Returns the full bech32 when it is 16 chars or fewer. @@ -339,7 +340,12 @@ pub async fn import_identity( password: Option, app_handle: tauri::AppHandle, ) -> Result { - tokio::task::spawn_blocking(move || { + let projection_app = app_handle.clone(); + projection_app + .state::() + .begin_scope_mutation() + .await; + let identity_result = tokio::task::spawn_blocking(move || { // NIP-49 backups require a passphrase and decrypt entirely in Rust. // Raw nsec/hex input follows the existing parser path unchanged. let password = password.map(zeroize::Zeroizing::new); @@ -385,7 +391,14 @@ pub async fn import_identity( }) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}")) + .and_then(|result| result); + projection_app + .state::() + .finish_scope_mutation() + .await; + let identity = identity_result?; + Ok(identity) } /// Commit an imported identity: durably persist, swap in-memory keys, clear @@ -542,6 +555,10 @@ pub async fn sign_out(app: tauri::AppHandle) -> Result<(), String> { ); } + app.state::() + .suspend_projection() + .await; + // Stop all managed agents before restart so they don't race the wipe. if let Err(e) = crate::shutdown::shutdown_managed_agents(&app) { eprintln!("buzz-desktop sign-out: agent shutdown: {e}"); @@ -643,26 +660,75 @@ pub async fn create_auth_event( challenge: String, relay_url: String, state: State<'_, AppState>, + websocket_manager: State<'_, crate::native_websocket::WebSocketManager>, + native_websocket_id: Option, ) -> Result { let keys = state.signing_keys()?; + let status_proof = match native_websocket_id { + Some(id) if relay_url == relay_ws_url_with_override(&state) => websocket_manager + .status_auth_proof(id, &challenge, &relay_url, keys.public_key()) + .await + .ok(), + None => None, + Some(_) => None, + }; + let scope_tag = status_proof.as_ref().map(|proof| { + vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + proof.connection_epoch().as_str().to_string(), + proof.relay_signer().to_hex(), + ] + }); + let (ordinary_event_json, scoped_event_json) = + tauri::async_runtime::spawn_blocking(move || { + let ordinary = build_auth_event_json(&keys, &challenge, &relay_url, None)?; + let scoped = scope_tag.and_then(|scope_tag| { + build_auth_event_json(&keys, &challenge, &relay_url, Some(scope_tag)).ok() + }); + Ok::<_, String>((ordinary, scoped)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + if let (Some(id), Some(proof), Some(scoped)) = ( + native_websocket_id, + status_proof.as_ref(), + scoped_event_json, + ) { + if websocket_manager + .complete_status_auth(id, proof) + .await + .is_ok() + { + return Ok(scoped); + } + } + Ok(ordinary_event_json) +} - tauri::async_runtime::spawn_blocking(move || { - let tags = vec![ - Tag::parse(vec!["relay", &relay_url]) - .map_err(|error| format!("relay tag failed: {error}"))?, - Tag::parse(vec!["challenge", &challenge]) - .map_err(|error| format!("challenge tag failed: {error}"))?, - ]; - - let event = EventBuilder::new(Kind::Custom(22242), "") - .tags(tags) - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; - - Ok(event.as_json()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? +fn build_auth_event_json( + keys: &Keys, + challenge: &str, + relay_url: &str, + scope_tag: Option>, +) -> Result { + let mut tags = vec![ + Tag::parse(vec!["relay", relay_url]) + .map_err(|error| format!("relay tag failed: {error}"))?, + Tag::parse(vec!["challenge", challenge]) + .map_err(|error| format!("challenge tag failed: {error}"))?, + ]; + if let Some(scope_tag) = scope_tag { + tags.push( + Tag::parse(scope_tag) + .map_err(|error| format!("client binding scope tag failed: {error}"))?, + ); + } + EventBuilder::new(Kind::Custom(22242), "") + .tags(tags) + .sign_with_keys(keys) + .map(|event| event.as_json()) + .map_err(|error| format!("sign failed: {error}")) } #[tauri::command] @@ -702,8 +768,9 @@ pub async fn nip44_decrypt_from_self( #[cfg(test)] mod nostr_identity_binding_tests { - use super::build_nostr_identity_binding_event; + use super::{build_auth_event_json, build_nostr_identity_binding_event}; use crate::nostr_bind; + use buzz_core_pkg::client_binding_bootstrap::{ClientBindingScopeV1, CLIENT_BINDING_SCOPE_TAG}; use nostr::{JsonUtil, Keys}; fn tag_values(event: &nostr::Event) -> Vec> { @@ -752,6 +819,49 @@ mod nostr_identity_binding_tests { assert!(tags.contains(&vec!["expires_at".into(), "2999-01-01T00:00:00Z".into(),])); } + #[test] + fn auth_builder_adds_scope_only_when_native_proof_supplies_exact_tag() { + let author = Keys::generate(); + let relay = Keys::generate(); + let ordinary = nostr::Event::from_json( + build_auth_event_json(&author, "challenge", "wss://relay.example/", None) + .expect("ordinary AUTH builds"), + ) + .expect("ordinary AUTH parses"); + assert!(matches!( + ClientBindingScopeV1::from_verified_auth_event(&ordinary), + Err(buzz_core_pkg::client_binding_bootstrap::ClientBindingBootstrapError::MissingScopeTag) + )); + + let scoped = nostr::Event::from_json( + build_auth_event_json( + &author, + "challenge", + "wss://relay.example/", + Some(vec![ + CLIENT_BINDING_SCOPE_TAG.to_string(), + "1".to_string(), + "11111111-1111-4111-8111-111111111111".to_string(), + relay.public_key().to_hex(), + ]), + ) + .expect("scoped AUTH builds"), + ) + .expect("scoped AUTH parses"); + let parsed = + ClientBindingScopeV1::from_verified_auth_event(&scoped).expect("signed scope parses"); + assert_eq!(parsed.relay_signer(), relay.public_key()); + assert_eq!( + scoped + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) + == Some(CLIENT_BINDING_SCOPE_TAG)) + .count(), + 1 + ); + } + #[test] fn build_nostr_identity_binding_event_rejects_malformed_verification_code() { let keys = Keys::generate(); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..ac35a4b3f1 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -132,7 +132,10 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); - tokio::task::spawn_blocking(move || { + app.state::() + .begin_scope_mutation() + .await; + let mutation_result = tokio::task::spawn_blocking(move || { let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -209,7 +212,15 @@ pub async fn apply_workspace( Ok::<(), String>(()) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))??; + .map_err(|e| format!("spawn_blocking failed: {e}")) + .and_then(|result| result); + + // Always exit the fence, including closure errors and blocking-task panics. + restore_app + .state::() + .finish_scope_mutation() + .await; + mutation_result?; let state = restore_app.state::(); // Backfill this exact relay+owner scope only after the workspace has been diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b15232..6c64e13342 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod app_menu; mod app_state; mod archive; mod builderlab; +mod client_binding_status_session; mod commands; mod deep_link; mod egress_guard; diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 128f2df79d..69e2324ae8 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,20 +1,37 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; - use futures_util::{SinkExt, StreamExt}; +use nostr::PublicKey; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio_tungstenite::{ connect_async, - tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, + tungstenite::{ + client::IntoClientRequest, + protocol::{frame::coding::CloseCode, CloseFrame, Message}, + }, }; use tokio_util::sync::CancellationToken; +use crate::{ + app_state::AppState, + client_binding_status_session::{ + is_reserved_text, ClientBindingStatusSession, ProjectionUpdate, + }, +}; +use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const WRITE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); const SEND_QUEUE_CAPACITY: usize = 64; +#[path = "native_websocket_status.rs"] +mod native_websocket_status; +pub(crate) use native_websocket_status::StatusAuthProof; +use native_websocket_status::{ + duration_until_unix_second, monotonic_deadline_after, prepare_status_session, + status_expiry_sleep, unix_now, PreparedStatus, ProjectionOwner, ProjectionState, StatusScope, +}; pub(crate) fn install_crypto_provider() { // Dependencies enable both rustls providers; choose one before TLS setup. let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); @@ -79,19 +96,55 @@ struct ConnectionHandle { sender: mpsc::Sender, cancel: CancellationToken, task: Mutex>>, + status_scope: Mutex>, + #[cfg(test)] + fold_pause: Option>, +} + +#[cfg(test)] +struct TestFoldPause { + entered: std::sync::atomic::AtomicBool, + released: std::sync::Mutex, + released_cv: std::sync::Condvar, +} + +#[cfg(test)] +impl TestFoldPause { + fn new() -> Self { + Self { + entered: std::sync::atomic::AtomicBool::new(false), + released: std::sync::Mutex::new(false), + released_cv: std::sync::Condvar::new(), + } + } + + fn block(&self) { + self.entered + .store(true, std::sync::atomic::Ordering::SeqCst); + let mut released = self.released.lock().expect("fold-pause lock"); + while !*released { + released = self.released_cv.wait(released).expect("fold-pause wait"); + } + } + + fn release(&self) { + *self.released.lock().expect("fold-pause lock") = true; + self.released_cv.notify_all(); + } } #[derive(Clone)] pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, + projection: Arc>, } - impl Default for WebSocketManager { fn default() -> Self { Self { connections: Arc::default(), connect_cancel: Arc::new(Mutex::new(CancellationToken::new())), + projection: Arc::default(), } } } @@ -101,6 +154,393 @@ impl WebSocketManager { self.connections.lock().await.remove(&id) } + async fn remove_if_current(&self, id: Id, handle: &Arc) { + let mut connections = self.connections.lock().await; + if connections + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, handle)) + { + connections.remove(&id); + } + } + + #[cfg(test)] + async fn projection_generation(&self) -> u64 { + self.projection.lock().await.generation + } + + async fn status_head(&self) -> Option<(u64, u64)> { + let projection = self.projection.lock().await; + (!projection.suspended && projection.mutation_depth == 0) + .then_some((projection.generation, projection.attempt_head)) + } + + async fn begin_status_attempt(&self) -> Option<(u64, u64)> { + let mut projection = self.projection.lock().await; + if projection.suspended || projection.mutation_depth != 0 { + return None; + } + let Some(next_attempt) = projection.attempt_head.checked_add(1) else { + projection.suspended = true; + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + return None; + }; + projection.attempt_head = next_attempt; + Some((projection.generation, projection.attempt_head)) + } + + async fn current_connect_cancel(&self) -> CancellationToken { + self.connect_cancel.lock().await.clone() + } + + async fn activate_projection_after_auth(&self, id: Id, proof: &StatusAuthProof) -> bool { + if !self + .connections + .lock() + .await + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, &proof.handle)) + { + return false; + } + let mut projection = self.projection.lock().await; + if projection.suspended + || projection.mutation_depth != 0 + || projection.generation != proof.generation + || projection.attempt_head != proof.attempt + { + return false; + } + let mut status_scope = proof.handle.status_scope.lock().await; + let Some(scope) = status_scope.as_mut() else { + return false; + }; + if scope.generation != proof.generation + || scope.auth_proven + || scope.challenge.as_deref() != Some(proof.challenge.as_str()) + || scope.relay_url != proof.relay_url + || scope.relay_signer != proof.relay_signer + || scope.expected_author != proof.expected_author + || scope.epoch != proof.epoch + || scope.attempt != proof.attempt + { + return false; + } + scope.auth_proven = true; + if let Some(previous) = projection.owner.take() { + let _ = previous.channel.send(serde_json::Value::Null); + } + projection.current = None; + let _ = scope.projection_channel.send(serde_json::Value::Null); + projection.owner = Some(ProjectionOwner { + id, + handle: Arc::clone(&proof.handle), + epoch: proof.epoch.clone(), + attempt: proof.attempt, + presentation_token: 0, + channel: scope.projection_channel.clone(), + }); + true + } + + async fn apply_projection_update( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + update: ProjectionUpdate, + ) { + if matches!(update, ProjectionUpdate::Unchanged) { + return; + } + let mut projection = self.projection.lock().await; + let exhausted_channel = projection.owner.as_ref().and_then(|owner| { + (owner.id == id + && Arc::ptr_eq(&owner.handle, handle) + && owner.epoch == *epoch + && owner.presentation_token == u64::MAX) + .then(|| owner.channel.clone()) + }); + if let Some(channel) = exhausted_channel { + projection.owner = None; + projection.current = None; + let _ = channel.send(serde_json::Value::Null); + return; + } + let owner_state = { + let Some(owner) = projection.owner.as_mut() else { + return; + }; + if owner.id != id || !Arc::ptr_eq(&owner.handle, handle) || owner.epoch != *epoch { + return; + } + owner.presentation_token = owner + .presentation_token + .checked_add(1) + .expect("presentation token exhaustion handled above"); + ( + owner.id, + Arc::clone(&owner.handle), + owner.epoch.clone(), + owner.attempt, + owner.presentation_token, + owner.channel.clone(), + ) + }; + let expiry = match update { + ProjectionUpdate::Current(current) if unix_now() < current.fresh_until => { + let fresh_until = current.fresh_until; + projection.current = Some(current); + Some(( + owner_state.0, + owner_state.1, + owner_state.2, + owner_state.3, + owner_state.4, + fresh_until, + )) + } + ProjectionUpdate::Current(_) + | ProjectionUpdate::Clear + | ProjectionUpdate::Unchanged => { + projection.current = None; + None + } + }; + let value = projection + .current + .as_ref() + .and_then(|current| serde_json::to_value(current).ok()) + .unwrap_or(serde_json::Value::Null); + let _ = owner_state.5.send(value); + drop(projection); + + if let Some((id, handle, epoch, attempt, presentation_token, fresh_until)) = expiry { + let manager = self.clone(); + let expires_at = monotonic_deadline_after(duration_until_unix_second(fresh_until)); + std::mem::drop(tauri::async_runtime::spawn(async move { + status_expiry_sleep(expires_at).await; + manager + .expire_projection_if_owner( + id, + &handle, + &epoch, + attempt, + presentation_token, + fresh_until, + ) + .await; + })); + } + } + + async fn expire_projection_if_owner( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + attempt: u64, + presentation_token: u64, + fresh_until: u64, + ) { + let mut projection = self.projection.lock().await; + let matches_current = projection.owner.as_ref().is_some_and(|owner| { + owner.id == id + && Arc::ptr_eq(&owner.handle, handle) + && owner.epoch == *epoch + && owner.attempt == attempt + && owner.presentation_token == presentation_token + }) && projection + .current + .as_ref() + .is_some_and(|current| current.fresh_until == fresh_until); + if !matches_current { + return; + } + projection.current = None; + if let Some(owner) = projection.owner.as_mut() { + owner.presentation_token = owner.presentation_token.saturating_add(1); + let _ = owner.channel.send(serde_json::Value::Null); + } + } + + async fn clear_projection_if_owner( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + ) { + let mut projection = self.projection.lock().await; + if projection.owner.as_ref().is_some_and(|owner| { + owner.id == id && Arc::ptr_eq(&owner.handle, handle) && owner.epoch == *epoch + }) { + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + } + + /// Invalidate all browser-visible status and revoke the current socket's + /// ownership. Late work from that socket is rejected by the owner fence. + pub(crate) async fn invalidate_projection(&self) { + { + let mut projection = self.projection.lock().await; + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + + /// Enter a fail-closed workspace or identity mutation interval. + /// Overlapping mutations keep status disabled until the last one exits. + pub(crate) async fn begin_scope_mutation(&self) { + { + let mut projection = self.projection.lock().await; + projection.mutation_depth = projection.mutation_depth.saturating_add(1); + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + + /// Exit one workspace or identity mutation interval and fence all work + /// that raced the mutation, including failed or panicked blocking work. + pub(crate) async fn finish_scope_mutation(&self) { + { + let mut projection = self.projection.lock().await; + projection.mutation_depth = projection.mutation_depth.saturating_sub(1); + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + + /// Permanently disable status projection for the remainder of this process. + /// Sign-out uses this before starting restart so no racing webview request + /// can regain presentation ownership with the retiring identity. + pub(crate) async fn suspend_projection(&self) { + { + let mut projection = self.projection.lock().await; + projection.suspended = true; + projection.generation = projection.generation.wrapping_add(1); + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + self.cancel_status_connections().await; + } + + async fn cancel_status_connections(&self) { + let handles = self + .connections + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + if handle.status_scope.lock().await.take().is_some() { + handle.cancel.cancel(); + } + } + } + + async fn record_status_challenge( + &self, + id: Id, + handle: &Arc, + challenge: &str, + ) { + if !self + .connections + .lock() + .await + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, handle)) + { + return; + } + let mut status_scope = handle.status_scope.lock().await; + let Some(scope) = status_scope.as_mut() else { + return; + }; + match scope.challenge.as_deref() { + None => scope.challenge = Some(challenge.to_owned()), + Some(existing) if existing == challenge => {} + Some(_) => { + status_scope.take(); + } + } + } + + pub(crate) async fn status_auth_proof( + &self, + id: Id, + challenge: &str, + relay_url: &str, + expected_author: PublicKey, + ) -> Result { + let handle = self + .connections + .lock() + .await + .get(&id) + .cloned() + .ok_or_else(|| "native WebSocket is not current".to_string())?; + let current_head = self + .status_head() + .await + .ok_or_else(|| "native WebSocket status is suspended".to_string())?; + let scope = handle.status_scope.lock().await; + let scope = scope + .as_ref() + .ok_or_else(|| "native WebSocket is not status-capable".to_string())?; + if scope.auth_proven + || scope.challenge.as_deref() != Some(challenge) + || scope.relay_url != relay_url + || scope.expected_author != expected_author + || (scope.generation, scope.attempt) != current_head + { + return Err("native WebSocket status scope does not match".to_string()); + } + Ok(StatusAuthProof { + handle: Arc::clone(&handle), + challenge: challenge.to_owned(), + relay_url: scope.relay_url.clone(), + relay_signer: scope.relay_signer, + expected_author: scope.expected_author, + epoch: scope.epoch.clone(), + generation: scope.generation, + attempt: scope.attempt, + }) + } + + pub(crate) async fn complete_status_auth( + &self, + id: Id, + proof: &StatusAuthProof, + ) -> Result<(), String> { + if self.activate_projection_after_auth(id, proof).await { + Ok(()) + } else { + Err("native WebSocket status scope changed while signing".to_string()) + } + } + async fn disconnect_handle(handle: Arc) { handle.cancel.cancel(); if let Some(mut task) = handle.task.lock().await.take() { @@ -116,20 +556,60 @@ impl WebSocketManager { async fn disconnect(&self, id: Id) { if let Some(handle) = self.remove(id).await { + let owner_epoch = self + .projection + .lock() + .await + .owner + .as_ref() + .filter(|owner| owner.id == id && Arc::ptr_eq(&owner.handle, &handle)) + .map(|owner| owner.epoch.clone()); + if let Some(epoch) = owner_epoch { + self.clear_projection_if_owner(id, &handle, &epoch).await; + } Self::disconnect_handle(handle).await; } } + + async fn disconnect_all(&self) { + self.invalidate_projection().await; + let mut connect_cancel = self.connect_cancel.lock().await; + connect_cancel.cancel(); + *connect_cancel = CancellationToken::new(); + let handles = { + let mut connections = self.connections.lock().await; + connections + .drain() + .map(|(_, handle)| handle) + .collect::>() + }; + futures_util::future::join_all(handles.into_iter().map(Self::disconnect_handle)).await; + } } +#[cfg(test)] async fn open_connection( manager: &WebSocketManager, url: &str, on_message: Channel, ) -> Result { - let connect_cancel = manager.connect_cancel.lock().await.clone(); + let connect_cancel = manager.current_connect_cancel().await; + open_connection_with_projection(manager, url, on_message, None, connect_cancel).await +} + +async fn open_connection_with_projection( + manager: &WebSocketManager, + url: &str, + on_message: Channel, + prepared_status: Option, + connect_cancel: CancellationToken, +) -> Result { + let request = url + .into_client_request() + .map_err(|error| error.to_string())?; let (socket, _) = tokio::select! { _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), - result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result + result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) => result .map_err(|_| "WebSocket connection timed out".to_string())? .map_err(|error| error.to_string())?, }; @@ -140,6 +620,12 @@ async fn open_connection( if connect_cancel.is_cancelled() { return Err("WebSocket connection cancelled".to_string()); } + let current_head = manager.status_head().await; + if prepared_status.as_ref().is_some_and(|prepared| { + Some((prepared.scope.generation, prepared.scope.attempt)) != current_head + }) { + return Err("WebSocket connection scope changed".to_string()); + } let id = loop { let candidate = uuid::Uuid::new_v4().as_u128() as u32; @@ -153,18 +639,44 @@ async fn open_connection( sender, cancel: cancel.clone(), task: Mutex::new(None), + status_scope: Mutex::new(prepared_status.as_ref().map(|prepared| StatusScope { + relay_url: prepared.scope.relay_url.clone(), + relay_signer: prepared.scope.relay_signer, + expected_author: prepared.scope.expected_author, + epoch: prepared.scope.epoch.clone(), + projection_channel: prepared.scope.projection_channel.clone(), + generation: prepared.scope.generation, + attempt: prepared.scope.attempt, + challenge: None, + auth_proven: false, + })), + #[cfg(test)] + fold_pause: None, }); let mut task_slot = handle.task.lock().await; manager.connections.lock().await.insert(id, handle.clone()); + let registered_head = manager.status_head().await; + if prepared_status.as_ref().is_some_and(|prepared| { + Some((prepared.scope.generation, prepared.scope.attempt)) != registered_head + }) { + manager.remove_if_current(id, &handle).await; + handle.cancel.cancel(); + return Err("WebSocket connection scope changed".to_string()); + } + + let status_session = prepared_status.map(|prepared| prepared.session); + let task_manager = manager.clone(); - let task = tauri::async_runtime::spawn(run_connection( + let task = tauri::async_runtime::spawn(run_connection_inner( id, socket, receiver, cancel, on_message, task_manager, + handle.clone(), + status_session, )); *task_slot = Some(task); drop(task_slot); @@ -175,11 +687,68 @@ async fn open_connection( #[tauri::command] async fn connect( manager: tauri::State<'_, WebSocketManager>, + state: tauri::State<'_, AppState>, url: String, on_message: Channel, _config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + connect_internal(manager.inner(), state.inner(), url, on_message, None).await +} + +#[tauri::command] +async fn connect_with_status( + manager: tauri::State<'_, WebSocketManager>, + state: tauri::State<'_, AppState>, + url: String, + on_message: Channel, + on_projection: Channel, + _config: Option, +) -> Result { + connect_internal( + manager.inner(), + state.inner(), + url, + on_message, + Some(on_projection), + ) + .await +} + +async fn connect_internal( + manager: &WebSocketManager, + state: &AppState, + url: String, + on_message: Channel, + on_projection: Option>, +) -> Result { + let connect_cancel = manager.current_connect_cancel().await; + let status_candidate = on_projection.is_some() + && url == crate::relay::relay_ws_url_with_override(state) + && state.signing_keys().is_ok(); + let status_head = if status_candidate { + manager.begin_status_attempt().await + } else { + None + }; + let prepared_status = match (on_projection, status_head) { + (Some(channel), Some((generation, attempt))) => tokio::select! { + _ = connect_cancel.cancelled() => { + return Err("WebSocket connection cancelled".to_string()); + } + prepared = prepare_status_session(state, &url, channel, generation, attempt) => prepared, + }, + _ => None, + }; + if prepared_status.is_some() + && manager.status_head().await + != prepared_status + .as_ref() + .map(|prepared| (prepared.scope.generation, prepared.scope.attempt)) + { + return Err("WebSocket connection scope changed".to_string()); + } + open_connection_with_projection(manager, &url, on_message, prepared_status, connect_cancel) + .await } pub(crate) async fn send_message( @@ -241,28 +810,40 @@ async fn disconnect(manager: tauri::State<'_, WebSocketManager>, id: Id) -> Resu #[tauri::command] async fn disconnect_all(manager: tauri::State<'_, WebSocketManager>) -> Result<(), String> { - let mut connect_cancel = manager.connect_cancel.lock().await; - connect_cancel.cancel(); - *connect_cancel = CancellationToken::new(); - let handles = { - let mut connections = manager.connections.lock().await; - connections - .drain() - .map(|(_, handle)| handle) - .collect::>() - }; - futures_util::future::join_all(handles.into_iter().map(WebSocketManager::disconnect_handle)) - .await; + manager.disconnect_all().await; Ok(()) } +#[cfg(test)] async fn run_connection( + id: Id, + socket: tokio_tungstenite::WebSocketStream, + receiver: mpsc::Receiver, + cancel: CancellationToken, + on_message: Channel, + manager: WebSocketManager, +) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let Some(handle) = manager.connections.lock().await.get(&id).cloned() else { + return; + }; + run_connection_inner( + id, socket, receiver, cancel, on_message, manager, handle, None, + ) + .await; +} + +#[allow(clippy::too_many_arguments)] +async fn run_connection_inner( id: Id, mut socket: tokio_tungstenite::WebSocketStream, mut receiver: mpsc::Receiver, cancel: CancellationToken, on_message: Channel, manager: WebSocketManager, + handle: Arc, + mut status_session: Option, ) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { @@ -290,7 +871,59 @@ async fn run_connection( } incoming = socket.next() => { let message = match incoming { - Some(Ok(message)) => outbound_message(message), + Some(Ok(message)) => { + if let Message::Text(text) = &message { + if let Some(challenge) = nip42_challenge(text) { + manager + .record_status_challenge(id, &handle, &challenge) + .await; + } + } + let reserved_text = reserved_text_message(&message); + if let Some(text) = reserved_text { + if let Some(mut session) = status_session.take() { + let epoch = session.connection_epoch().clone(); + #[cfg(test)] + let fold_pause = handle.fold_pause.clone(); + let folded = tauri::async_runtime::spawn_blocking(move || { + #[cfg(test)] + if let Some(fold_pause) = fold_pause { + fold_pause.block(); + } + let update = session.consume_text(&text, unix_now()); + (session, update) + }) + .await; + match folded { + Ok((mut returned_session, update)) => { + let update = if matches!( + returned_session.expire(unix_now()), + ProjectionUpdate::Clear + ) { + Some(ProjectionUpdate::Clear) + } else { + update + }; + status_session = Some(returned_session); + if let Some(update) = update { + manager + .apply_projection_update( + id, &handle, &epoch, update, + ) + .await; + } + } + Err(_) => { + manager + .clear_projection_if_owner(id, &handle, &epoch) + .await; + } + } + } + continue; + } + outbound_message(message) + } Some(Err(error)) => OutboundMessage::Error(error.to_string()), None => OutboundMessage::Close(None), }; @@ -302,7 +935,31 @@ async fn run_connection( } } } - manager.remove(id).await; + if let Some(session) = status_session.as_mut() { + let epoch = session.connection_epoch().clone(); + let update = session.disconnect(); + manager + .apply_projection_update(id, &handle, &epoch, update) + .await; + manager.clear_projection_if_owner(id, &handle, &epoch).await; + } + manager.remove_if_current(id, &handle).await; +} + +fn reserved_text_message(message: &Message) -> Option { + match message { + Message::Text(value) if is_reserved_text(value) => Some(value.to_string()), + _ => None, + } +} + +fn nip42_challenge(text: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let values = value.as_array()?; + if values.len() != 2 || values.first().and_then(serde_json::Value::as_str) != Some("AUTH") { + return None; + } + values.get(1)?.as_str().map(str::to_owned) } fn outbound_message(message: Message) -> OutboundMessage { @@ -324,6 +981,7 @@ pub fn init() -> TauriPlugin { tauri::plugin::Builder::new("websocket") .invoke_handler(tauri::generate_handler![ connect, + connect_with_status, send, disconnect, disconnect_all @@ -336,218 +994,5 @@ pub fn init() -> TauriPlugin { } #[cfg(test)] -mod tests { - use super::*; - use futures_util::FutureExt; - use std::sync::atomic::{AtomicBool, Ordering}; - - use tauri::ipc::InvokeResponseBody; - use tokio::io::duplex; - use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; - - fn silent_channel() -> Channel { - Channel::new(|_: InvokeResponseBody| Ok(())) - } - - #[tokio::test] - async fn secure_websocket_reaches_tls_without_panicking() { - install_crypto_provider(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { - let (_stream, _) = listener.accept().await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let result = std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!( - "wss://{address}" - ))) - .catch_unwind() - .await; - - assert!(result.is_ok(), "TLS setup must not panic"); - server.await.unwrap(); - } - - #[tokio::test] - async fn live_tcp_server_connect_send_and_disconnect() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - let (received_tx, received_rx) = oneshot::channel(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.unwrap(); - let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); - let message = socket.next().await.unwrap().unwrap(); - received_tx.send(message).unwrap(); - while let Some(message) = socket.next().await { - if matches!(message, Ok(Message::Close(_))) { - break; - } - } - }); - - let manager = WebSocketManager::default(); - let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) - .await - .unwrap(); - send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) - .await - .unwrap(); - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), received_rx) - .await - .unwrap() - .unwrap(), - Message::Text("live-probe".into()) - ); - - manager.disconnect(id).await; - assert!(!manager.connections.lock().await.contains_key(&id)); - tokio::time::timeout(Duration::from_secs(1), server) - .await - .expect("live server should observe native socket shutdown") - .unwrap(); - } - - #[tokio::test] - async fn eof_removes_connection() { - let manager = WebSocketManager::default(); - let (client_io, server_io) = duplex(1024); - let (client, server) = tokio::join!( - WebSocketStream::from_raw_socket(client_io, Role::Client, None), - WebSocketStream::from_raw_socket(server_io, Role::Server, None), - ); - let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - }); - manager.connections.lock().await.insert(1, handle.clone()); - let task = tauri::async_runtime::spawn(run_connection( - 1, - client, - receiver, - handle.cancel.clone(), - silent_channel(), - manager.clone(), - )); - *handle.task.lock().await = Some(task); - - drop(server); - tokio::time::timeout(Duration::from_secs(1), async { - while manager.connections.lock().await.contains_key(&1) { - tokio::task::yield_now().await; - } - }) - .await - .expect("EOF should clean up its native connection ID"); - } - - #[tokio::test] - async fn disconnect_removes_and_drops_task_before_returning() { - struct DropGuard(Arc); - impl Drop for DropGuard { - fn drop(&mut self) { - self.0.store(true, Ordering::SeqCst); - } - } - - let manager = WebSocketManager::default(); - let dropped = Arc::new(AtomicBool::new(false)); - let task_dropped = dropped.clone(); - let (ready_tx, ready_rx) = oneshot::channel(); - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async move { - let _guard = DropGuard(task_dropped); - ready_tx.send(()).unwrap(); - std::future::pending::<()>().await; - }))), - }); - manager.connections.lock().await.insert(7, handle); - ready_rx.await.unwrap(); - - tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) - .await - .expect("disconnect should abort an unresponsive task"); - assert!(!manager.connections.lock().await.contains_key(&7)); - assert!(dropped.load(Ordering::SeqCst)); - - // Repeated teardown is intentionally a no-op. - manager.disconnect(7).await; - } - - #[tokio::test] - async fn teardown_gate_stays_closed_until_tasks_stop() { - let manager = WebSocketManager::default(); - let gate = manager.connect_cancel.lock().await; - let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); - let handle = Arc::new(ConnectionHandle { - sender, - cancel: CancellationToken::new(), - task: Mutex::new(Some(tauri::async_runtime::spawn(async { - std::future::pending::<()>().await; - }))), - }); - manager.connections.lock().await.insert(1, handle); - gate.cancel(); - let handles = { - let mut connections = manager.connections.lock().await; - connections - .drain() - .map(|(_, handle)| handle) - .collect::>() - }; - - let shutdown = futures_util::future::join_all( - handles.into_iter().map(WebSocketManager::disconnect_handle), - ); - assert!(manager.connect_cancel.try_lock().is_err()); - shutdown.await; - drop(gate); - assert!(manager.connect_cancel.try_lock().is_ok()); - } - - #[tokio::test] - async fn one_connection_does_not_block_another_send_queue() { - let manager = WebSocketManager::default(); - let (blocked_sender, blocked_receiver) = mpsc::channel(1); - blocked_sender - .send(SendRequest { - message: Message::Text("blocked".into()), - result: oneshot::channel().0, - }) - .await - .unwrap(); - let blocked = Arc::new(ConnectionHandle { - sender: blocked_sender, - cancel: CancellationToken::new(), - task: Mutex::new(None), - }); - manager.connections.lock().await.insert(1, blocked); - - let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); - let healthy = Arc::new(ConnectionHandle { - sender: healthy_sender.clone(), - cancel: CancellationToken::new(), - task: Mutex::new(None), - }); - manager.connections.lock().await.insert(2, healthy); - - let (result, _) = oneshot::channel(); - tokio::time::timeout( - Duration::from_millis(50), - healthy_sender.send(SendRequest { - message: Message::Text("healthy".into()), - result, - }), - ) - .await - .expect("a full queue on one connection must not block another") - .unwrap(); - assert!(healthy_receiver.recv().await.is_some()); - drop(blocked_receiver); - } -} +#[path = "native_websocket_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/native_websocket_status.rs b/desktop/src-tauri/src/native_websocket_status.rs new file mode 100644 index 0000000000..8877142b7a --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_status.rs @@ -0,0 +1,200 @@ +use std::{net::IpAddr, sync::Arc, time::Duration}; + +use futures_util::StreamExt; +use nostr::PublicKey; +use serde::Deserialize; +use tauri::ipc::Channel; +use url::{Host, Url}; + +use buzz_core_pkg::client_binding_bootstrap::ClientBindingEpoch; + +use crate::{ + app_state::AppState, + client_binding_status_session::{ClientBindingStatusSession, CurrentProjection}, +}; + +use super::{ConnectionHandle, Id}; + +const NIP11_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_NIP11_BODY_BYTES: usize = 64 * 1024; + +pub(super) struct StatusScope { + pub(super) relay_url: String, + pub(super) relay_signer: PublicKey, + pub(super) expected_author: PublicKey, + pub(super) epoch: ClientBindingEpoch, + pub(super) projection_channel: Channel, + pub(super) generation: u64, + pub(super) attempt: u64, + pub(super) challenge: Option, + pub(super) auth_proven: bool, +} + +pub(super) struct PreparedStatus { + pub(super) session: ClientBindingStatusSession, + pub(super) scope: StatusScope, +} + +pub(crate) struct StatusAuthProof { + pub(super) handle: Arc, + pub(super) challenge: String, + pub(super) relay_url: String, + pub(super) relay_signer: PublicKey, + pub(super) expected_author: PublicKey, + pub(super) epoch: ClientBindingEpoch, + pub(super) generation: u64, + pub(super) attempt: u64, +} + +impl StatusAuthProof { + pub(crate) fn connection_epoch(&self) -> &ClientBindingEpoch { + &self.epoch + } + + pub(crate) const fn relay_signer(&self) -> PublicKey { + self.relay_signer + } +} + +pub(super) struct ProjectionOwner { + pub(super) id: Id, + pub(super) handle: Arc, + pub(super) epoch: ClientBindingEpoch, + pub(super) attempt: u64, + pub(super) presentation_token: u64, + pub(super) channel: Channel, +} + +#[derive(Default)] +pub(super) struct ProjectionState { + pub(super) generation: u64, + pub(super) attempt_head: u64, + pub(super) mutation_depth: u64, + pub(super) suspended: bool, + pub(super) owner: Option, + pub(super) current: Option, +} + +pub(super) async fn prepare_status_session( + state: &AppState, + requested_url: &str, + projection_channel: Channel, + generation: u64, + attempt: u64, +) -> Option { + if requested_url != crate::relay::relay_ws_url_with_override(state) { + return None; + } + let expected_author = state.signing_keys().ok()?.public_key(); + let relay_signer = fetch_nip11_signer(requested_url).await.ok()?; + let epoch = ClientBindingEpoch::new_v4(); + Some(PreparedStatus { + session: ClientBindingStatusSession::new(relay_signer, expected_author, epoch.clone()), + scope: StatusScope { + relay_url: requested_url.to_owned(), + relay_signer, + expected_author, + epoch, + projection_channel, + generation, + attempt, + challenge: None, + auth_proven: false, + }, + }) +} + +#[derive(Deserialize)] +struct Nip11Identity { + #[serde(rename = "self")] + relay_self: String, +} + +async fn fetch_nip11_signer(relay_url: &str) -> Result { + let url = nip11_url(relay_url)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(NIP11_TIMEOUT) + .build() + .map_err(|_| "NIP-11 unavailable".to_string())?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + .map_err(|_| "NIP-11 unavailable".to_string())?; + if !response.status().is_success() + || response + .content_length() + .is_some_and(|length| length > MAX_NIP11_BODY_BYTES as u64) + { + return Err("NIP-11 unavailable".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "NIP-11 unavailable".to_string())?; + if body.len().saturating_add(chunk.len()) > MAX_NIP11_BODY_BYTES { + return Err("NIP-11 unavailable".to_string()); + } + body.extend_from_slice(&chunk); + } + let identity: Nip11Identity = + serde_json::from_slice(&body).map_err(|_| "NIP-11 unavailable".to_string())?; + if identity.relay_self.len() != 64 + || !identity + .relay_self + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("NIP-11 unavailable".to_string()); + } + PublicKey::from_hex(&identity.relay_self).map_err(|_| "NIP-11 unavailable".to_string()) +} + +pub(super) fn nip11_url(relay_url: &str) -> Result { + let mut url = Url::parse(relay_url).map_err(|_| "NIP-11 unavailable".to_string())?; + match url.scheme() { + "wss" => url + .set_scheme("https") + .map_err(|_| "NIP-11 unavailable".to_string())?, + "ws" if is_loopback_url(&url) => url + .set_scheme("http") + .map_err(|_| "NIP-11 unavailable".to_string())?, + _ => return Err("NIP-11 unavailable".to_string()), + } + Ok(url) +} + +pub(super) fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(Host::Ipv4(address)) => IpAddr::V4(address).is_loopback(), + Some(Host::Ipv6(address)) => IpAddr::V6(address).is_loopback(), + None => false, + } +} + +pub(super) fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +pub(super) fn duration_until_unix_second(unix_second: u64) -> Duration { + let Some(deadline) = std::time::UNIX_EPOCH.checked_add(Duration::from_secs(unix_second)) else { + return Duration::ZERO; + }; + deadline + .duration_since(std::time::SystemTime::now()) + .unwrap_or_default() +} + +pub(super) fn monotonic_deadline_after(delay: Duration) -> tokio::time::Instant { + let now = tokio::time::Instant::now(); + now.checked_add(delay).unwrap_or(now) +} + +pub(super) fn status_expiry_sleep(deadline: tokio::time::Instant) -> tokio::time::Sleep { + tokio::time::sleep_until(deadline) +} diff --git a/desktop/src-tauri/src/native_websocket_tests.rs b/desktop/src-tauri/src/native_websocket_tests.rs new file mode 100644 index 0000000000..efd5a6c0fd --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_tests.rs @@ -0,0 +1,767 @@ +use super::native_websocket_status::{is_loopback_url, nip11_url}; +use super::*; +use crate::client_binding_status_session::CurrentProjection; +use futures_util::FutureExt; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use url::Url; + +use buzz_core_pkg::client_binding_bootstrap::{ + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, +}; +use tauri::ipc::InvokeResponseBody; +use tokio::io::duplex; +use tokio_tungstenite::{tungstenite::protocol::Role, WebSocketStream}; + +fn silent_channel() -> Channel { + Channel::new(|_: InvokeResponseBody| Ok(())) +} + +#[tokio::test] +async fn secure_websocket_reaches_tls_without_panicking() { + install_crypto_provider(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + }); + let result = + std::panic::AssertUnwindSafe(tokio_tungstenite::connect_async(format!("wss://{address}"))) + .catch_unwind() + .await; + + assert!(result.is_ok(), "TLS setup must not panic"); + server.await.unwrap(); +} + +#[tokio::test] +async fn live_tcp_server_connect_send_and_disconnect() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (received_tx, received_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + let message = socket.next().await.unwrap().unwrap(); + received_tx.send(message).unwrap(); + while let Some(message) = socket.next().await { + if matches!(message, Ok(Message::Close(_))) { + break; + } + } + }); + + let manager = WebSocketManager::default(); + let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) + .await + .unwrap(); + send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), received_rx) + .await + .unwrap() + .unwrap(), + Message::Text("live-probe".into()) + ); + + manager.disconnect(id).await; + assert!(!manager.connections.lock().await.contains_key(&id)); + tokio::time::timeout(Duration::from_secs(1), server) + .await + .expect("live server should observe native socket shutdown") + .unwrap(); +} + +#[tokio::test] +async fn eof_removes_connection() { + let manager = WebSocketManager::default(); + let (client_io, server_io) = duplex(1024); + let (client, server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 1, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + drop(server); + tokio::time::timeout(Duration::from_secs(1), async { + while manager.connections.lock().await.contains_key(&1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("EOF should clean up its native connection ID"); +} + +#[tokio::test] +async fn disconnect_removes_and_drops_task_before_returning() { + struct DropGuard(Arc); + impl Drop for DropGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + + let manager = WebSocketManager::default(); + let dropped = Arc::new(AtomicBool::new(false)); + let task_dropped = dropped.clone(); + let (ready_tx, ready_rx) = oneshot::channel(); + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async move { + let _guard = DropGuard(task_dropped); + ready_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(7, handle); + ready_rx.await.unwrap(); + + tokio::time::timeout(Duration::from_secs(1), manager.disconnect(7)) + .await + .expect("disconnect should abort an unresponsive task"); + assert!(!manager.connections.lock().await.contains_key(&7)); + assert!(dropped.load(Ordering::SeqCst)); + + // Repeated teardown is intentionally a no-op. + manager.disconnect(7).await; +} + +#[tokio::test] +async fn teardown_gate_stays_closed_until_tasks_stop() { + let manager = WebSocketManager::default(); + let gate = manager.connect_cancel.lock().await; + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(Some(tauri::async_runtime::spawn(async { + std::future::pending::<()>().await; + }))), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, handle); + gate.cancel(); + let handles = { + let mut connections = manager.connections.lock().await; + connections + .drain() + .map(|(_, handle)| handle) + .collect::>() + }; + + let shutdown = futures_util::future::join_all( + handles.into_iter().map(WebSocketManager::disconnect_handle), + ); + assert!(manager.connect_cancel.try_lock().is_err()); + shutdown.await; + drop(gate); + assert!(manager.connect_cancel.try_lock().is_ok()); +} + +#[tokio::test] +async fn disconnect_all_cancels_pending_connect_generation() { + let manager = WebSocketManager::default(); + let pending = manager.current_connect_cancel().await; + let generation = manager.projection_generation().await; + + manager.disconnect_all().await; + + assert!(pending.is_cancelled()); + assert!(!manager.current_connect_cancel().await.is_cancelled()); + assert_ne!(manager.projection_generation().await, generation); +} + +#[tokio::test] +async fn one_connection_does_not_block_another_send_queue() { + let manager = WebSocketManager::default(); + let (blocked_sender, blocked_receiver) = mpsc::channel(1); + blocked_sender + .send(SendRequest { + message: Message::Text("blocked".into()), + result: oneshot::channel().0, + }) + .await + .unwrap(); + let blocked = Arc::new(ConnectionHandle { + sender: blocked_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(1, blocked); + + let (healthy_sender, mut healthy_receiver) = mpsc::channel(1); + let healthy = Arc::new(ConnectionHandle { + sender: healthy_sender.clone(), + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(2, healthy); + + let (result, _) = oneshot::channel(); + tokio::time::timeout( + Duration::from_millis(50), + healthy_sender.send(SendRequest { + message: Message::Text("healthy".into()), + result, + }), + ) + .await + .expect("a full queue on one connection must not block another") + .unwrap(); + assert!(healthy_receiver.recv().await.is_some()); + drop(blocked_receiver); +} + +#[test] +fn nip11_url_accepts_tls_or_loopback_only() { + assert_eq!( + nip11_url("wss://relay.example.test/community?view=1") + .expect("secure relay URL is eligible") + .as_str(), + "https://relay.example.test/community?view=1" + ); + assert_eq!( + nip11_url("ws://localhost:3000/") + .expect("localhost relay URL is eligible") + .as_str(), + "http://localhost:3000/" + ); + assert!(nip11_url("ws://127.0.0.1:3000/").is_ok()); + assert!(nip11_url("ws://[::1]:3000/").is_ok()); + assert!(nip11_url("ws://relay.example.test/").is_err()); + assert!(nip11_url("http://localhost:3000/").is_err()); + + assert!(is_loopback_url( + &Url::parse("ws://LOCALHOST:3000/").expect("test URL") + )); + assert!(!is_loopback_url( + &Url::parse("ws://localhost.example.test/").expect("test URL") + )); +} + +fn test_epoch(suffix: u8) -> ClientBindingEpoch { + ClientBindingEpoch::parse(&format!("11111111-1111-4111-8111-{suffix:012x}")) + .expect("synthetic epoch") +} + +fn test_handle(status_scope: Option) -> Arc { + let (sender, _receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(status_scope), + fold_pause: None, + }) +} + +fn test_status_scope( + generation: u64, + attempt: u64, + relay: PublicKey, + author: PublicKey, + epoch: ClientBindingEpoch, +) -> StatusScope { + StatusScope { + relay_url: "ws://localhost:3000/".to_string(), + relay_signer: relay, + expected_author: author, + epoch, + projection_channel: silent_channel(), + generation, + attempt, + challenge: None, + auth_proven: false, + } +} + +#[tokio::test] +async fn only_proven_status_socket_owns_projection_and_old_handle_is_fenced() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, old_attempt) = manager.begin_status_attempt().await.unwrap(); + let old_epoch = test_epoch(0x11); + let old = test_handle(Some(test_status_scope( + generation, + old_attempt, + relay, + author, + old_epoch.clone(), + ))); + manager.connections.lock().await.insert(7, old.clone()); + manager + .record_status_challenge(7, &old, "challenge-old") + .await; + let old_proof = manager + .status_auth_proof(7, "challenge-old", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + + assert!(manager + .status_auth_proof(7, "challenge-old", "ws://wrong/", author) + .await + .is_err()); + + let (new_generation, new_attempt) = manager.begin_status_attempt().await.unwrap(); + assert_eq!(new_generation, generation); + assert!(new_attempt > old_attempt); + let new_epoch = test_epoch(0x22); + let new = test_handle(Some(test_status_scope( + new_generation, + new_attempt, + relay, + author, + new_epoch.clone(), + ))); + manager.connections.lock().await.insert(8, new.clone()); + manager + .record_status_challenge(8, &new, "challenge-new") + .await; + let new_proof = manager + .status_auth_proof(8, "challenge-new", "ws://localhost:3000/", author) + .await + .expect("replacement proof"); + manager + .complete_status_auth(8, &new_proof) + .await + .expect("replacement owns projection"); + + // An older eligible attempt cannot replace a newer owner even when its + // exact proof was captured before the newer attempt completed. + assert!(manager.complete_status_auth(7, &old_proof).await.is_err()); + + let current = CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: unix_now() + 60, + connection_epoch: new_epoch.as_str().to_owned(), + }; + manager + .apply_projection_update( + 8, + &new, + &new_epoch, + ProjectionUpdate::Current(current.clone()), + ) + .await; + assert_eq!( + manager.projection.lock().await.current, + Some(current.clone()) + ); + + manager.clear_projection_if_owner(7, &old, &old_epoch).await; + manager + .apply_projection_update( + 7, + &old, + &old_epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: u64::MAX, + connection_epoch: old_epoch.as_str().to_owned(), + }), + ) + .await; + assert_eq!(manager.projection.lock().await.current, Some(current)); + + let read_only = test_handle(None); + manager + .connections + .lock() + .await + .insert(9, read_only.clone()); + assert!(manager + .status_auth_proof(9, "challenge", "ws://localhost:3000/", author) + .await + .is_err()); + + manager.invalidate_projection().await; + assert!(new.cancel.is_cancelled()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager.complete_status_auth(8, &new_proof).await.is_err()); + manager.suspend_projection().await; + assert!(manager.status_head().await.is_none()); +} + +#[tokio::test] +async fn overlapping_scope_mutations_keep_status_fail_closed() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x33); + let handle = test_handle(Some(test_status_scope( + generation, attempt, relay, author, epoch, + ))); + manager.connections.lock().await.insert(10, handle.clone()); + manager + .record_status_challenge(10, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(10, "challenge", "ws://localhost:3000/", author) + .await + .expect("pre-mutation proof"); + + manager.begin_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + assert!(handle.cancel.is_cancelled()); + manager.begin_scope_mutation().await; + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_none()); + + manager.finish_scope_mutation().await; + assert!(manager.status_head().await.is_some()); + assert!(manager.complete_status_auth(10, &proof).await.is_err()); +} + +#[tokio::test] +async fn security_token_exhaustion_fails_closed() { + let manager = WebSocketManager::default(); + manager.projection.lock().await.attempt_head = u64::MAX; + assert!(manager.begin_status_attempt().await.is_none()); + assert!(manager.projection.lock().await.suspended); + + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x34); + let current = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: unix_now() + 60, + connection_epoch: epoch.as_str().to_owned(), + }; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 1, + presentation_token: u64::MAX, + channel: silent_channel(), + }); + projection.current = Some(current.clone()); + } + manager + .apply_projection_update(12, &handle, &epoch, ProjectionUpdate::Current(current)) + .await; + let projection = manager.projection.lock().await; + assert!(projection.owner.is_none()); + assert!(projection.current.is_none()); +} + +#[tokio::test] +async fn matching_monotonic_expiry_clears_when_wall_clock_looks_early() { + let manager = WebSocketManager::default(); + let handle = test_handle(None); + let epoch = test_epoch(0x35); + let fresh_until = unix_now() + 60; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 13, + handle: handle.clone(), + epoch: epoch.clone(), + attempt: 2, + presentation_token: 3, + channel: silent_channel(), + }); + projection.current = Some(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }); + } + + assert!(unix_now() < fresh_until, "test models a backward clock"); + manager + .expire_projection_if_owner(13, &handle, &epoch, 2, 3, fresh_until) + .await; + assert!(manager.projection.lock().await.current.is_none()); +} + +#[tokio::test] +async fn status_expiry_sleep_keeps_deadline_across_delayed_first_poll() { + let deadline = monotonic_deadline_after(Duration::from_millis(1)); + tokio::time::sleep(Duration::from_millis(10)).await; + + let sleep = status_expiry_sleep(deadline); + assert_eq!(sleep.deadline(), deadline); + sleep.await; + + let overflow_deadline = monotonic_deadline_after(Duration::MAX); + assert!(overflow_deadline <= tokio::time::Instant::now()); +} + +#[tokio::test] +async fn projection_expires_while_reserved_fold_is_blocked() { + let manager = WebSocketManager::default(); + let relay = nostr::Keys::generate().public_key(); + let author = nostr::Keys::generate().public_key(); + let (generation, attempt) = manager.begin_status_attempt().await.unwrap(); + let epoch = test_epoch(0x44); + let pause = Arc::new(TestFoldPause::new()); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(Some(test_status_scope( + generation, + attempt, + relay, + author, + epoch.clone(), + ))), + fold_pause: Some(pause.clone()), + }); + manager.connections.lock().await.insert(11, handle.clone()); + manager + .record_status_challenge(11, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(11, "challenge", "ws://localhost:3000/", author) + .await + .expect("exact native proof"); + manager + .complete_status_auth(11, &proof) + .await + .expect("test socket owns projection"); + + let fresh_until = unix_now() + 2; + manager + .apply_projection_update( + 11, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until, + connection_epoch: epoch.as_str().to_owned(), + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_some()); + + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let task = tauri::async_runtime::spawn(run_connection_inner( + 11, + client, + receiver, + handle.cancel.clone(), + silent_channel(), + manager.clone(), + handle.clone(), + Some(ClientBindingStatusSession::new(relay, author, epoch)), + )); + *handle.task.lock().await = Some(task); + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into(), + )) + .await + .expect("send reserved frame"); + + let entered = tokio::time::timeout(Duration::from_secs(1), async { + while !pause.entered.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .is_ok(); + let visible_after_entering_fold = manager.projection.lock().await.current.is_some(); + let expired = if entered { + tokio::time::timeout(Duration::from_secs(3), async { + while manager.projection.lock().await.current.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .is_ok() + } else { + false + }; + + pause.release(); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + let task = handle.task.lock().await.take().expect("registered task"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + + assert!(entered, "reserved fold should reach its blocking section"); + assert!( + visible_after_entering_fold, + "projection should still be visible when the fold blocks" + ); + assert!( + expired, + "deadline must clear while the fold remains blocked" + ); + assert!(unix_now() >= fresh_until); +} + +#[tokio::test] +async fn stale_task_cannot_remove_reused_connection_id() { + let manager = WebSocketManager::default(); + let (old_sender, _old_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let old = Arc::new(ConnectionHandle { + sender: old_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + let (new_sender, _new_receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let current = Arc::new(ConnectionHandle { + sender: new_sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(9, old.clone()); + manager.connections.lock().await.insert(9, current.clone()); + + manager.remove_if_current(9, &old).await; + assert!(manager + .connections + .lock() + .await + .get(&9) + .is_some_and(|handle| Arc::ptr_eq(handle, ¤t))); + manager.remove_if_current(9, ¤t).await; + assert!(!manager.connections.lock().await.contains_key(&9)); +} + +#[tokio::test] +async fn reserved_text_is_swallowed_but_binary_remains_raw_delivery() { + let manager = WebSocketManager::default(); + let delivered = Arc::new(AtomicUsize::new(0)); + let delivered_for_channel = delivered.clone(); + let channel = Channel::new(move |_: InvokeResponseBody| { + delivered_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let (client_io, server_io) = duplex(4096); + let (client, mut server) = tokio::join!( + WebSocketStream::from_raw_socket(client_io, Role::Client, None), + WebSocketStream::from_raw_socket(server_io, Role::Server, None), + ); + let (sender, receiver) = mpsc::channel(SEND_QUEUE_CAPACITY); + let handle = Arc::new(ConnectionHandle { + sender, + cancel: CancellationToken::new(), + task: Mutex::new(None), + status_scope: Mutex::new(None), + fold_pause: None, + }); + manager.connections.lock().await.insert(42, handle.clone()); + let task = tauri::async_runtime::spawn(run_connection( + 42, + client, + receiver, + handle.cancel.clone(), + channel, + manager.clone(), + )); + *handle.task.lock().await = Some(task); + + server + .send(Message::Text( + serde_json::json!(["EVENT", CLIENT_BINDING_BOOTSTRAP_SUB_ID]) + .to_string() + .into(), + )) + .await + .expect("send reserved bootstrap frame"); + server + .send(Message::Binary( + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]) + .to_string() + .into_bytes() + .into(), + )) + .await + .expect("send reserved status frame"); + server + .send(Message::Text("ordinary".into())) + .await + .expect("send ordinary frame"); + server + .send(Message::Close(None)) + .await + .expect("close synthetic socket"); + + let task = handle + .task + .lock() + .await + .take() + .expect("connection task is registered"); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("connection loop exits") + .expect("connection task joins"); + assert_eq!( + delivered.load(Ordering::SeqCst), + 3, + "binary, ordinary text, and terminal close reach raw delivery" + ); + assert!(!manager.connections.lock().await.contains_key(&42)); +} + +#[test] +fn reserved_classifier_never_intercepts_binary() { + let reserved = + serde_json::json!(["EVENT", CLIENT_BINDING_STATUS_SUB_ID, "malformed"]).to_string(); + assert!(reserved_text_message(&Message::Text(reserved.clone().into())).is_some()); + assert!(reserved_text_message(&Message::Binary(reserved.into_bytes().into())).is_none()); +} + +#[test] +fn auth_challenge_recording_requires_exact_frame_shape() { + assert_eq!( + nip42_challenge(&serde_json::json!(["AUTH", "exact"]).to_string()).as_deref(), + Some("exact") + ); + assert!(nip42_challenge(&serde_json::json!(["AUTH", "exact", "extra"]).to_string()).is_none()); + assert!(nip42_challenge("not-json").is_none()); +} diff --git a/desktop/src-tauri/tests/current_binding_status_native_flow.rs b/desktop/src-tauri/tests/current_binding_status_native_flow.rs new file mode 100644 index 0000000000..c3659bb2b1 --- /dev/null +++ b/desktop/src-tauri/tests/current_binding_status_native_flow.rs @@ -0,0 +1,738 @@ +//! Real loopback transport coverage for the native current-binding projection. +//! +//! This target composes the production native WebSocket/session manager with a synthetic, +//! loopback-only relay. The test owns neither a projection fold nor a browser-state fixture: +//! every trace value comes back through the production projection channel and getter. + +#[path = "../src/client_binding_status_session.rs"] +mod client_binding_status_session; + +mod app_state { + use nostr::Keys; + + pub(crate) struct AppState { + keys: Keys, + relay_url: String, + } + + impl AppState { + pub(crate) fn synthetic(keys: Keys, relay_url: String) -> Self { + Self { keys, relay_url } + } + + pub(crate) fn signing_keys(&self) -> Result { + Ok(self.keys.clone()) + } + + pub(crate) fn relay_url(&self) -> &str { + &self.relay_url + } + } +} + +mod relay { + pub(crate) fn relay_ws_url_with_override(state: &crate::app_state::AppState) -> String { + state.relay_url().to_owned() + } +} + +mod egress_guard { + pub(crate) fn assert_no_key_backup(_: &str, _: &str) -> Result<(), String> { + Ok(()) + } + + pub(crate) fn assert_no_key_backup_bytes(_: &[u8], _: &str) -> Result<(), String> { + Ok(()) + } +} + +#[allow(dead_code)] +mod native_websocket { + include!("../src/native_websocket.rs"); + + pub(super) async fn connect_status_for_test( + manager: &WebSocketManager, + state: &crate::app_state::AppState, + url: String, + on_message: Channel, + on_projection: Channel, + ) -> Result { + connect_internal(manager, state, url, on_message, Some(on_projection)).await + } + + pub(super) async fn current_projection_for_test( + manager: &WebSocketManager, + ) -> Option { + manager.projection.lock().await.current.clone() + } + + pub(super) async fn connection_present_for_test(manager: &WebSocketManager, id: Id) -> bool { + manager.connections.lock().await.contains_key(&id) + } + + pub(super) fn unix_now_for_test() -> u64 { + unix_now() + } +} + +use std::{ + env, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use buzz_core_pkg::{ + client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingEpoch, ClientBindingScopeV1, + CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_SCOPE_TAG, CLIENT_BINDING_STATUS_SUB_ID, + }, + client_binding_status::ClientBindingStatusInputV1, + kind::{KIND_CLIENT_BINDING_STATUS, KIND_USER_TRUSTED_ASSERTION}, + CommunityId, +}; +use client_binding_status_session::CurrentProjection; +use futures_util::{SinkExt, StreamExt}; +use native_websocket::{WebSocketManager, WebSocketMessage}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, Tag, Timestamp}; +use serde::Serialize; +use serde_json::json; +use tauri::ipc::{Channel, InvokeResponseBody}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream}; +use uuid::Uuid; + +const RECEIVE_TIMEOUT: Duration = Duration::from_secs(2); +const ORDINARY_SUB_ID: &str = "synthetic-ordinary-events"; + +type RelaySocket = WebSocketStream; + +#[derive(Serialize)] +struct ProjectionTrace { + version: u64, + steps: Vec, +} + +#[derive(Serialize)] +struct TraceStep { + case: &'static str, + // This is the production DTO, not a test-owned projection lookalike. + projection: Option, +} + +impl ProjectionTrace { + fn new() -> Self { + Self { + version: 1, + steps: Vec::new(), + } + } + + async fn record(&mut self, case: &'static str, flow: &NativeFlow) { + self.steps.push(TraceStep { + case, + projection: flow.projection().await, + }); + } + + fn assert_contract_and_export_if_requested(&self) { + let value = serde_json::to_value(self).expect("production projection trace serializes"); + let steps = value["steps"].as_array().expect("trace steps are an array"); + for step in steps { + if let Some(projection) = step["projection"].as_object() { + let mut keys = projection.keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + ["connectionEpoch", "eventAuthorPubkey", "freshUntil"], + "trace projection must remain the production current-only DTO", + ); + } + } + + let Some(raw_path) = env::var_os("BUZZ_J3C_PROJECTION_TRACE_OUT") else { + return; + }; + let path = PathBuf::from(raw_path); + assert!( + path.is_absolute(), + "BUZZ_J3C_PROJECTION_TRACE_OUT must be an absolute test-artifact path" + ); + let parent = path + .parent() + .expect("trace output must have a parent directory"); + std::fs::create_dir_all(parent).expect("create projection trace directory"); + let mut bytes = serde_json::to_vec_pretty(self).expect("serialize projection trace"); + bytes.push(b'\n'); + std::fs::write(&path, bytes).expect("write projection trace"); + } +} + +struct NativeFlow { + relay_socket: RelaySocket, + manager: WebSocketManager, + id: u32, + raw_deliveries: Arc, + projection_deliveries: Arc, + epoch: ClientBindingEpoch, +} + +impl NativeFlow { + async fn connect(relay_keys: &Keys, author_keys: &Keys) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic relay to an OS-assigned loopback port"); + let address = listener.local_addr().expect("read synthetic relay address"); + assert!(address.ip().is_loopback()); + assert_ne!(address.port(), 0); + + let relay_pubkey = relay_keys.public_key(); + let challenge = Uuid::new_v4().to_string(); + let server_challenge = challenge.clone(); + let relay = tokio::spawn(async move { + let (mut nip11, peer) = listener.accept().await.expect("accept NIP-11 client"); + assert!(peer.ip().is_loopback()); + let mut request = [0_u8; 4096]; + let read = nip11.read(&mut request).await.expect("read NIP-11 request"); + assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET / HTTP/1.1")); + let body = json!({ "self": relay_pubkey.to_hex() }).to_string(); + nip11 + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/nostr+json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), body + ) + .as_bytes(), + ) + .await + .expect("write NIP-11 identity"); + let (stream, peer) = listener.accept().await.expect("accept native WebSocket"); + assert!(peer.ip().is_loopback()); + let mut socket = accept_async(stream) + .await + .expect("accept WebSocket upgrade"); + socket + .send(Message::Text( + json!(["AUTH", server_challenge]).to_string().into(), + )) + .await + .expect("send NIP-42 challenge"); + socket + }); + let relay_url = format!("ws://{address}/"); + let state = app_state::AppState::synthetic(author_keys.clone(), relay_url.clone()); + let manager = WebSocketManager::default(); + let raw_deliveries = Arc::new(AtomicUsize::new(0)); + let raw_for_channel = raw_deliveries.clone(); + let on_message = Channel::new(move |_: InvokeResponseBody| { + raw_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let projection_deliveries = Arc::new(AtomicUsize::new(0)); + let projection_for_channel = projection_deliveries.clone(); + let on_projection = Channel::new(move |_: InvokeResponseBody| { + projection_for_channel.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let id = native_websocket::connect_status_for_test( + &manager, + &state, + relay_url.clone(), + on_message, + on_projection, + ) + .await + .expect("open production status-capable WebSocket"); + let mut relay_socket = relay.await.expect("join synthetic relay accept task"); + + let proof = tokio::time::timeout(RECEIVE_TIMEOUT, async { + loop { + if let Ok(proof) = manager + .status_auth_proof(id, &challenge, &relay_url, author_keys.public_key()) + .await + { + break proof; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("production socket records exact NIP-42 challenge"); + let epoch = proof.connection_epoch().clone(); + ClientBindingEpoch::parse(epoch.as_str()).expect("production epoch is canonical UUIDv4"); + let auth = EventBuilder::new(Kind::Custom(22242), "") + .tags([ + Tag::parse(["relay", relay_url.as_str()]).expect("relay tag"), + Tag::parse(["challenge", challenge.as_str()]).expect("challenge tag"), + Tag::parse([ + CLIENT_BINDING_SCOPE_TAG, + "1", + epoch.as_str(), + proof.relay_signer().to_hex().as_str(), + ]) + .expect("binding scope tag"), + ]) + .sign_with_keys(author_keys) + .expect("sign scoped NIP-42 AUTH"); + let scope = ClientBindingScopeV1::from_verified_auth_event(&auth) + .expect("relay accepts signed binding scope"); + assert_eq!(scope.connection_epoch(), &epoch); + assert_eq!(scope.relay_signer(), relay_keys.public_key()); + manager + .complete_status_auth(id, &proof) + .await + .expect("production manager activates authenticated projection owner"); + native_websocket::send_message( + &manager, + id, + WebSocketMessage::Text(json!(["AUTH", auth]).to_string()), + ) + .await + .expect("send scoped AUTH through production native socket"); + let auth_frame = tokio::time::timeout(RECEIVE_TIMEOUT, relay_socket.next()) + .await + .expect("relay receives AUTH") + .expect("AUTH frame exists") + .expect("AUTH frame valid"); + let Message::Text(auth_text) = auth_frame else { + panic!("scoped AUTH must be text") + }; + let auth_wire: serde_json::Value = serde_json::from_str(&auth_text).expect("AUTH JSON"); + let verified = Event::from_json(auth_wire[1].to_string()).expect("AUTH event parses"); + assert!(verified.verify_id() && verified.verify_signature()); + ClientBindingScopeV1::from_verified_auth_event(&verified) + .expect("relay revalidates signed scope"); + + Self { + relay_socket, + manager, + id, + raw_deliveries, + projection_deliveries, + epoch, + } + } + + async fn send_reserved_event(&mut self, sub_id: &str, event: &Event, now: u64) { + assert!( + matches!( + sub_id, + CLIENT_BINDING_BOOTSTRAP_SUB_ID | CLIENT_BINDING_STATUS_SUB_ID + ), + "reserved helper requires an exact native-owned subscription id" + ); + let _ = now; + self.send_event(sub_id, event, true).await; + } + + async fn send_ordinary_event(&mut self, event: &Event, now: u64) { + let _ = now; + self.send_event(ORDINARY_SUB_ID, event, false).await; + } + + async fn send_event(&mut self, sub_id: &str, event: &Event, reserved: bool) { + let raw_before = self.raw_deliveries.load(Ordering::SeqCst); + let event_json = serde_json::to_value(event).expect("serialize synthetic Nostr event"); + let frame = json!(["EVENT", sub_id, event_json]).to_string(); + self.relay_socket + .send(Message::Text(frame.into())) + .await + .expect("send synthetic relay frame"); + self.relay_socket + .send(Message::Text("projection-fold-barrier".into())) + .await + .expect("send ordered fold barrier"); + let expected = raw_before + if reserved { 1 } else { 2 }; + tokio::time::timeout(RECEIVE_TIMEOUT, async { + while self.raw_deliveries.load(Ordering::SeqCst) < expected { + tokio::task::yield_now().await; + } + }) + .await + .expect("production raw channel reaches ordered barrier"); + assert_eq!( + self.raw_deliveries.load(Ordering::SeqCst), + expected, + "only exact reserved text frames are swallowed" + ); + } + + async fn wait_for_expiry(&self) { + tokio::time::timeout(Duration::from_secs(5), async { + while self.projection().await.is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("production monotonic expiry clears projection"); + } + + async fn physical_disconnect(&mut self) { + self.relay_socket + .send(Message::Close(None)) + .await + .expect("relay closes physical WebSocket"); + tokio::time::timeout(RECEIVE_TIMEOUT, async { + while native_websocket::connection_present_for_test(&self.manager, self.id).await { + tokio::task::yield_now().await; + } + }) + .await + .expect("production manager observes physical disconnect"); + assert!(self.projection().await.is_none()); + } + + async fn projection(&self) -> Option { + assert!( + self.projection_deliveries.load(Ordering::SeqCst) > 0, + "authenticated production projection channel is active" + ); + native_websocket::current_projection_for_test(&self.manager).await + } + + async fn logout(&self) { + self.manager.suspend_projection().await; + assert!(self.projection().await.is_none()); + } +} + +fn random_epoch() -> ClientBindingEpoch { + ClientBindingEpoch::new_v4() +} + +fn random_domain() -> CommunityId { + CommunityId::from_uuid(Uuid::new_v4()) +} + +fn bootstrap_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + epoch: ClientBindingEpoch, + issued_at: u64, +) -> Event { + ClientBindingBootstrapInputV1::new(domain, author, epoch, issued_at) + .expect("construct synthetic bootstrap") + .sign_with_relay_keys(relay) + .expect("sign synthetic bootstrap") +} + +fn current_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + revision: u64, + issued_at: u64, + fresh_until: u64, +) -> Event { + ClientBindingStatusInputV1::current( + domain, + author, + 1, + "policy.synthetic.example.invalid/v1", + revision, + issued_at, + fresh_until, + Some("Synthetic Example".to_string()), + ) + .expect("construct synthetic current status") + .sign_with_relay_keys(relay) + .expect("sign synthetic current status") +} + +fn withdrawal_event( + relay: &Keys, + domain: CommunityId, + author: PublicKey, + revision: u64, + issued_at: u64, + fresh_until: u64, +) -> Event { + ClientBindingStatusInputV1::withdrawn(domain, author, revision, issued_at, fresh_until) + .expect("construct synthetic withdrawal") + .sign_with_relay_keys(relay) + .expect("sign synthetic withdrawal") +} + +fn raw_status_event(relay: &Keys, content: &str, issued_at: u64) -> Event { + EventBuilder::new( + Kind::Custom(KIND_CLIENT_BINDING_STATUS as u16), + content.to_string(), + ) + .tags([]) + .custom_created_at(Timestamp::from(issued_at)) + .sign_with_keys(relay) + .expect("sign synthetic raw status") +} + +async fn established_flow( + relay: &Keys, + author: &Keys, + domain: CommunityId, + now: u64, +) -> NativeFlow { + let mut flow = NativeFlow::connect(relay, author).await; + let bootstrap = bootstrap_event(relay, domain, author.public_key(), flow.epoch.clone(), now); + flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, now) + .await; + let current = current_event(relay, domain, author.public_key(), 1, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) + .await; + assert!(flow.projection().await.is_some()); + flow +} + +#[tokio::test] +async fn loopback_relay_drives_production_projection_and_trace() { + let now = native_websocket::unix_now_for_test(); + let relay = Keys::generate(); + let wrong_signer = Keys::generate(); + let author = Keys::generate(); + let other_author = Keys::generate(); + let profile_spoofer = Keys::generate(); + let domain = random_domain(); + let other_domain = random_domain(); + assert_ne!(domain, other_domain); + + let mut trace = ProjectionTrace::new(); + + // One physical connection exercises the revision fold as a sequence, proving that + // duplicate delivery retains current state while trusted-invalid evidence clears it. + let mut flow = NativeFlow::connect(&relay, &author).await; + let epoch = flow.epoch.clone(); + let bootstrap = bootstrap_event(&relay, domain, author.public_key(), epoch.clone(), now); + flow.send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap, now) + .await; + trace.record("bootstrap", &flow).await; + + let current = current_event(&relay, domain, author.public_key(), 10, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) + .await; + let first_projection = serde_json::to_value(flow.projection().await) + .expect("serialize first production projection"); + let projected = flow.projection().await.expect("current status projects"); + assert_eq!(projected.event_author_pubkey, author.public_key().to_hex()); + assert_eq!(projected.fresh_until, now + 120); + assert_eq!(projected.connection_epoch, epoch.as_str()); + trace.record("current", &flow).await; + + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, ¤t, now) + .await; + assert_eq!( + serde_json::to_value(flow.projection().await).expect("serialize duplicate projection"), + first_projection + ); + trace.record("duplicate", &flow).await; + + let equal_conflict = current_event(&relay, domain, author.public_key(), 10, now, now + 121); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &equal_conflict, now) + .await; + assert!(flow.projection().await.is_none()); + trace.record("equal-conflict", &flow).await; + + let rollback = current_event(&relay, domain, author.public_key(), 9, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &rollback, now) + .await; + assert!(flow.projection().await.is_none()); + trace.record("rollback", &flow).await; + + let newer = current_event(&relay, domain, author.public_key(), 11, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &newer, now) + .await; + assert!(flow.projection().await.is_some()); + trace.record("newer-restoration", &flow).await; + + let withdrawal = withdrawal_event(&relay, domain, author.public_key(), 12, now, now + 120); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &withdrawal, now) + .await; + assert!(flow.projection().await.is_none()); + trace.record("withdrawal", &flow).await; + + let short_now = native_websocket::unix_now_for_test(); + let short_current = current_event( + &relay, + domain, + author.public_key(), + 13, + short_now, + short_now + 2, + ); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &short_current, short_now) + .await; + flow.wait_for_expiry().await; + trace.record("passive-expiry", &flow).await; + + let disconnect_current = current_event(&relay, domain, author.public_key(), 14, now, now + 123); + flow.send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &disconnect_current, now) + .await; + assert!(flow.projection().await.is_some()); + flow.physical_disconnect().await; + trace.record("disconnect", &flow).await; + + let reconnected = established_flow(&relay, &author, domain, now).await; + trace.record("reconnect", &reconnected).await; + reconnected.logout().await; + trace.record("logout", &reconnected).await; + + let mut restarted = established_flow(&relay, &author, domain, now).await; + restarted.physical_disconnect().await; + trace.record("restart", &restarted).await; + + // A different physical relay connection starts empty even when the signer is reused. + let relay_scope = NativeFlow::connect(&relay, &author).await; + trace.record("relay-scope-change", &relay_scope).await; + + // Wrong-signer traffic is untrusted noise and cannot create or clear presentation. + let mut signer_scope = NativeFlow::connect(&wrong_signer, &author).await; + let old_signer_bootstrap = bootstrap_event( + &relay, + domain, + author.public_key(), + signer_scope.epoch.clone(), + now, + ); + signer_scope + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &old_signer_bootstrap, now) + .await; + trace.record("signer-scope-change", &signer_scope).await; + + let mut author_scope = NativeFlow::connect(&relay, &other_author).await; + let old_author_bootstrap = bootstrap_event( + &relay, + domain, + author.public_key(), + author_scope.epoch.clone(), + now, + ); + author_scope + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &old_author_bootstrap, now) + .await; + trace.record("author-scope-change", &author_scope).await; + + let mut domain_scope = NativeFlow::connect(&relay, &author).await; + let domain_bootstrap = bootstrap_event( + &relay, + other_domain, + author.public_key(), + domain_scope.epoch.clone(), + now, + ); + domain_scope + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &domain_bootstrap, now) + .await; + let old_domain_status = current_event(&relay, domain, author.public_key(), 1, now, now + 152); + domain_scope + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &old_domain_status, now) + .await; + trace.record("domain-scope-change", &domain_scope).await; + + let old_epoch = random_epoch(); + let mut epoch_scope = NativeFlow::connect(&relay, &author).await; + assert_ne!(old_epoch, epoch_scope.epoch); + let stale_epoch_bootstrap = + bootstrap_event(&relay, domain, author.public_key(), old_epoch, now); + epoch_scope + .send_reserved_event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &stale_epoch_bootstrap, now) + .await; + trace.record("epoch-scope-change", &epoch_scope).await; + + let mut malformed = established_flow(&relay, &author, domain, now).await; + let malformed_status = raw_status_event(&relay, r#"{"version":1,"domain":"broken"}"#, now); + malformed + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &malformed_status, now) + .await; + trace.record("malformed-trusted", &malformed).await; + + let mut unsupported = established_flow(&relay, &author, domain, now).await; + let unsupported_status = raw_status_event(&relay, r#"{"version":2}"#, now); + unsupported + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &unsupported_status, now) + .await; + trace.record("unsupported-version", &unsupported).await; + + let mut mismatched_author = established_flow(&relay, &author, domain, now).await; + let author_mismatch = + current_event(&relay, domain, other_author.public_key(), 2, now, now + 162); + mismatched_author + .send_reserved_event(CLIENT_BINDING_STATUS_SUB_ID, &author_mismatch, now) + .await; + trace.record("author-mismatch", &mismatched_author).await; + + // Ordinary kind-0 and NIP-85 traffic crosses the same socket but never enters the + // reserved production fold, so neither can manufacture a current projection. + let mut legacy = NativeFlow::connect(&relay, &author).await; + let spoofed_profile = EventBuilder::new( + Kind::Metadata, + r#"{"display_name":"Spoofed Verified User","nip05":"spoof@identity.example.invalid"}"#, + ) + .sign_with_keys(&profile_spoofer) + .expect("sign synthetic profile spoof"); + legacy.send_ordinary_event(&spoofed_profile, now + 50).await; + trace.record("profile-spoof", &legacy).await; + + let subject = author.public_key().to_hex(); + let expiry = (now + 170).to_string(); + let nip85 = EventBuilder::new( + Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + String::new(), + ) + .tags([ + Tag::parse(["d", subject.as_str()]).expect("synthetic d tag"), + Tag::parse(["p", subject.as_str()]).expect("synthetic p tag"), + Tag::parse(["verified", "relay"]).expect("synthetic verified tag"), + Tag::parse(["active", "true"]).expect("synthetic active tag"), + Tag::parse(["expiration", expiry.as_str()]).expect("synthetic expiration tag"), + Tag::parse(["display_name", "Spoofed Legacy Assertion"]) + .expect("synthetic display-name tag"), + ]) + .sign_with_keys(&relay) + .expect("sign synthetic NIP-85 assertion"); + legacy.send_ordinary_event(&nip85, now + 50).await; + trace.record("nip85-no-fallback", &legacy).await; + + let cases = trace.steps.iter().map(|step| step.case).collect::>(); + assert_eq!( + cases, + [ + "bootstrap", + "current", + "duplicate", + "equal-conflict", + "rollback", + "newer-restoration", + "withdrawal", + "passive-expiry", + "disconnect", + "reconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "malformed-trusted", + "unsupported-version", + "author-mismatch", + "profile-spoof", + "nip85-no-fallback", + ] + ); + for step in &trace.steps { + let expected_current = matches!( + step.case, + "current" | "duplicate" | "newer-restoration" | "reconnect" + ); + assert_eq!( + step.projection.is_some(), + expected_current, + "unexpected retained projection for {}", + step.case + ); + } + + trace.assert_contract_and_export_if_requested(); +} diff --git a/desktop/src/features/binding-status/currentProjectionStore.test.mjs b/desktop/src/features/binding-status/currentProjectionStore.test.mjs new file mode 100644 index 0000000000..9d108960ab --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.test.mjs @@ -0,0 +1,253 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createCurrentProjectionStore, + parseCurrentProjection, +} from "./currentProjectionStore.ts"; + +const AUTHOR_A = "ab".repeat(32); +const AUTHOR_B = "cd".repeat(32); + +function projection(overrides = {}) { + return { + eventAuthorPubkey: AUTHOR_A, + freshUntil: 200, + connectionEpoch: "opaque-epoch-a", + ...overrides, + }; +} + +function makeTimerHost(initialNow = 100) { + let now = initialNow; + let nextId = 1; + const pending = new Map(); + const callbacks = new Map(); + const delays = []; + let schedulesToThrow = 0; + + return { + options: { + nowSeconds: () => now, + setTimeout: (callback, delayMs) => { + if (schedulesToThrow > 0) { + schedulesToThrow -= 1; + throw new Error("synthetic scheduler failure"); + } + const id = nextId++; + pending.set(id, callback); + callbacks.set(id, callback); + delays.push(delayMs); + return id; + }, + clearTimeout: (id) => pending.delete(id), + }, + setNow: (value) => { + now = value; + }, + fire: (id) => { + pending.delete(id); + callbacks.get(id)?.(); + }, + pendingIds: () => [...pending.keys()], + throwNextSchedules: (count = 1) => { + schedulesToThrow = count; + }, + delays, + }; +} + +test("parses only the exact frozen narrow DTO", () => { + const parsed = parseCurrentProjection(projection(), 100); + + assert.deepEqual(parsed, projection()); + assert.deepEqual(Object.keys(parsed), [ + "eventAuthorPubkey", + "freshUntil", + "connectionEpoch", + ]); + assert.equal(Object.isFrozen(parsed), true); + assert.throws(() => { + parsed.connectionEpoch = "mutated"; + }, TypeError); + + assert.equal( + parseCurrentProjection( + projection({ rawEvent: "must-not-cross", revision: 42 }), + 100, + ), + null, + ); +}); + +test("rejects noncanonical authors, invalid deadlines, and empty epochs", () => { + const invalid = [ + null, + [], + projection({ eventAuthorPubkey: AUTHOR_A.toUpperCase() }), + projection({ eventAuthorPubkey: "a".repeat(63) }), + projection({ eventAuthorPubkey: `${"a".repeat(63)}g` }), + projection({ freshUntil: 0 }), + projection({ freshUntil: 100 }), + projection({ freshUntil: 99 }), + projection({ freshUntil: 100.5 }), + projection({ freshUntil: Number.MAX_SAFE_INTEGER + 1 }), + projection({ connectionEpoch: "" }), + ]; + + for (const candidate of invalid) { + assert.equal(parseCurrentProjection(candidate, 100), null); + } + assert.equal(parseCurrentProjection(projection(), Number.NaN), null); +}); + +test("expires at the exclusive deadline without another input", () => { + const timers = makeTimerHost(100.25); + const store = createCurrentProjectionStore(timers.options); + let changes = 0; + store.subscribe(() => { + changes += 1; + }); + + store.replaceFromNative(projection({ freshUntil: 101 })); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_A); + assert.deepEqual(timers.delays, [750]); + + timers.setNow(101); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 2); +}); + +test("caps long timers and rearms after early fire or clock rollback", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore({ + ...timers.options, + maxTimerDelayMs: 1_000, + }); + + store.replaceFromNative(projection({ freshUntil: 103 })); + assert.deepEqual(timers.delays, [1_000]); + + timers.setNow(99); + timers.fire(timers.pendingIds()[0]); + assert.deepEqual(timers.delays, [1_000, 1_000]); + assert.notEqual(store.getSnapshot(), null); + + timers.setNow(103); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); +}); + +test("captured tokens reject old timers across replacement and clear", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + let changes = 0; + store.subscribe(() => { + changes += 1; + }); + + store.replaceFromNative(projection({ freshUntil: 110 })); + const oldTimer = timers.pendingIds()[0]; + store.replaceFromNative( + projection({ + eventAuthorPubkey: AUTHOR_B, + freshUntil: 120, + connectionEpoch: "opaque-epoch-b", + }), + ); + const currentTimer = timers.pendingIds()[0]; + + timers.setNow(110); + timers.fire(oldTimer); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_B); + assert.deepEqual(timers.pendingIds(), [currentTimer]); + + store.clear(); + store.clear(); + timers.setNow(120); + timers.fire(currentTimer); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 3, "the second clear remains notification-idempotent"); +}); + +test("invalid native input clears a current projection", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + store.replaceFromNative(projection()); + store.replaceFromNative({ ...projection(), connectionEpoch: "" }); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); + +test("a throwing subscriber cannot abort expiry or later subscribers", () => { + const timers = makeTimerHost(100); + const logCalls = []; + const store = createCurrentProjectionStore({ + ...timers.options, + onListenerError: (...args) => logCalls.push(args), + }); + const observed = []; + + store.subscribe(() => { + assert.equal( + timers.pendingIds().length, + store.getSnapshot() === null ? 0 : 1, + "expiry is armed before a current snapshot is announced", + ); + throw new Error("synthetic subscriber failure"); + }); + store.subscribe(() => observed.push(store.getSnapshot())); + + store.replaceFromNative(projection({ freshUntil: 101 })); + assert.equal(store.getSnapshot()?.eventAuthorPubkey, AUTHOR_A); + assert.equal(observed.length, 1); + assert.deepEqual(logCalls, [[]], "logging receives no DTO or thrown value"); + + timers.setNow(101); + timers.fire(timers.pendingIds()[0]); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(observed, [projection({ freshUntil: 101 }), null]); + assert.deepEqual(logCalls, [[], []]); +}); + +test("initial and replacement scheduler failures leave the store null", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + timers.throwNextSchedules(); + store.replaceFromNative(projection({ freshUntil: 110 })); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); + + store.replaceFromNative(projection({ freshUntil: 110 })); + assert.notEqual(store.getSnapshot(), null); + timers.throwNextSchedules(); + store.replaceFromNative( + projection({ + eventAuthorPubkey: AUTHOR_B, + freshUntil: 120, + connectionEpoch: "opaque-epoch-b", + }), + ); + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); + +test("rearm scheduler failure clears the current projection", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore({ + ...timers.options, + maxTimerDelayMs: 1_000, + }); + + store.replaceFromNative(projection({ freshUntil: 103 })); + assert.notEqual(store.getSnapshot(), null); + timers.throwNextSchedules(); + timers.setNow(100.5); + timers.fire(timers.pendingIds()[0]); + + assert.equal(store.getSnapshot(), null); + assert.deepEqual(timers.pendingIds(), []); +}); diff --git a/desktop/src/features/binding-status/currentProjectionStore.ts b/desktop/src/features/binding-status/currentProjectionStore.ts new file mode 100644 index 0000000000..1666780928 --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -0,0 +1,254 @@ +import { Channel } from "@tauri-apps/api/core"; +import * as React from "react"; + +export type CurrentProjection = Readonly<{ + eventAuthorPubkey: string; + freshUntil: number; + connectionEpoch: string; +}>; + +type TimerHandle = ReturnType; + +type CurrentProjectionStoreOptions = { + nowSeconds?: () => number; + setTimeout?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimeout?: (handle: TimerHandle) => void; + maxTimerDelayMs?: number; + onListenerError?: () => void; +}; + +export type CurrentProjectionStore = { + getSnapshot: () => CurrentProjection | null; + subscribe: (listener: () => void) => () => void; + replaceFromNative: (candidate: unknown) => void; + clear: () => void; +}; + +const LOWERCASE_HEX_PUBKEY = /^[0-9a-f]{64}$/; +const CURRENT_PROJECTION_KEYS = [ + "connectionEpoch", + "eventAuthorPubkey", + "freshUntil", +] as const; +const DEFAULT_MAX_TIMER_DELAY_MS = 2_147_483_647; + +function logListenerError(): void { + // Do not include the exception or current DTO: either could contain native + // payload data outside the browser projection contract. + console.error("[currentProjectionStore] subscriber failed"); +} + +/** + * Copy the narrow native DTO into a frozen browser-owned value. + * + * Unknown properties fail closed. Expired projections are represented by + * null; the deadline is exclusive. + */ +export function parseCurrentProjection( + candidate: unknown, + nowSeconds: number, +): CurrentProjection | null { + if ( + candidate === null || + typeof candidate !== "object" || + Array.isArray(candidate) || + !Number.isFinite(nowSeconds) + ) { + return null; + } + + const value = candidate as Record; + const keys = Object.keys(value).sort(); + if ( + keys.length !== CURRENT_PROJECTION_KEYS.length || + !CURRENT_PROJECTION_KEYS.every((key, index) => keys[index] === key) + ) { + return null; + } + const { eventAuthorPubkey, freshUntil, connectionEpoch } = value; + if ( + typeof eventAuthorPubkey !== "string" || + !LOWERCASE_HEX_PUBKEY.test(eventAuthorPubkey) || + typeof freshUntil !== "number" || + !Number.isSafeInteger(freshUntil) || + freshUntil <= 0 || + freshUntil <= nowSeconds || + typeof connectionEpoch !== "string" || + connectionEpoch.length === 0 + ) { + return null; + } + + return Object.freeze({ + eventAuthorPubkey, + freshUntil, + connectionEpoch, + }); +} + +export function createCurrentProjectionStore( + options: CurrentProjectionStoreOptions = {}, +): CurrentProjectionStore { + const nowSeconds = options.nowSeconds ?? (() => Date.now() / 1_000); + const schedule = options.setTimeout ?? globalThis.setTimeout.bind(globalThis); + const cancel = + options.clearTimeout ?? globalThis.clearTimeout.bind(globalThis); + const onListenerError = options.onListenerError ?? logListenerError; + const configuredMaxDelay = options.maxTimerDelayMs; + const maxTimerDelayMs = + typeof configuredMaxDelay === "number" && + Number.isFinite(configuredMaxDelay) && + configuredMaxDelay >= 1 + ? Math.min(Math.floor(configuredMaxDelay), DEFAULT_MAX_TIMER_DELAY_MS) + : DEFAULT_MAX_TIMER_DELAY_MS; + + let snapshot: CurrentProjection | null = null; + let expiryTimer: TimerHandle | null = null; + let workToken = 0; + const listeners = new Set<() => void>(); + + const emitChange = () => { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + try { + onListenerError(); + } catch { + // Logging is best-effort and must not break the state transition. + } + } + } + }; + + const invalidatePendingWork = (): number => { + workToken += 1; + if (expiryTimer !== null) { + cancel(expiryTimer); + expiryTimer = null; + } + return workToken; + }; + + const clear = () => { + // Invalidate even when already clear: a late callback must never be able + // to restore or disturb state after an idempotent boundary reset. + invalidatePendingWork(); + if (snapshot === null) return; + snapshot = null; + emitChange(); + }; + + const armExpiry = ( + projection: CurrentProjection, + capturedToken: number, + ): boolean => { + if (capturedToken !== workToken) return false; + + const now = nowSeconds(); + if (!Number.isFinite(now) || now >= projection.freshUntil) { + return false; + } + + const remainingSeconds = projection.freshUntil - now; + const delayMs = + remainingSeconds >= maxTimerDelayMs / 1_000 + ? maxTimerDelayMs + : Math.max(1, Math.ceil(remainingSeconds * 1_000)); + + let scheduledTimer: TimerHandle; + try { + scheduledTimer = schedule(() => { + if (expiryTimer === scheduledTimer) expiryTimer = null; + if (capturedToken !== workToken) return; + + // Timers are capped and wall clocks can move backwards. Rearm until + // the exclusive Unix-seconds boundary has actually been reached. + const firedAt = nowSeconds(); + if (Number.isFinite(firedAt) && firedAt < projection.freshUntil) { + if ( + !armExpiry(projection, capturedToken) && + capturedToken === workToken + ) { + clear(); + } + return; + } + clear(); + }, delayMs); + } catch { + return false; + } + expiryTimer = scheduledTimer; + return true; + }; + + return { + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + replaceFromNative(candidate) { + const projection = parseCurrentProjection(candidate, nowSeconds()); + const capturedToken = invalidatePendingWork(); + if (projection === null) { + if (snapshot === null) return; + snapshot = null; + emitChange(); + return; + } + + // Arm passive expiry before making the projection observable. A timer + // host failure therefore cannot leave a current snapshot without an + // expiry path. + if (!armExpiry(projection, capturedToken)) { + if (capturedToken === workToken) clear(); + return; + } + snapshot = projection; + emitChange(); + }, + clear, + }; +} + +const currentProjectionStore = createCurrentProjectionStore(); + +/** + * Create the sole non-null intake for the browser-owned projection store. + * The caller's fence binds delivery to one current native status connection. + */ +export function createCurrentProjectionChannel( + isCurrentConnection: () => boolean, +): Channel { + return new Channel((candidate) => { + if (!isCurrentConnection()) return; + currentProjectionStore.replaceFromNative(candidate); + }); +} + +export function clearCurrentProjection(): void { + currentProjectionStore.clear(); +} + +/** Community-boundary reset for the module-level, memory-only singleton. */ +export function resetCurrentProjectionStore(): void { + currentProjectionStore.clear(); +} + +export function getCurrentProjectionSnapshot(): CurrentProjection | null { + return currentProjectionStore.getSnapshot(); +} + +export function subscribeToCurrentProjection(listener: () => void): () => void { + return currentProjectionStore.subscribe(listener); +} + +export function useCurrentProjection(): CurrentProjection | null { + return React.useSyncExternalStore( + subscribeToCurrentProjection, + getCurrentProjectionSnapshot, + () => null, + ); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 1bd1e090a7..56e4711c45 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -27,6 +27,7 @@ import { } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; +import { resetCurrentProjectionStore } from "@/features/binding-status/currentProjectionStore"; import { resetAvatarPresentations } from "@/features/profile/avatarPresentationStore"; import { resetAvatarProfileSync } from "@/features/profile/avatarProfileSync"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; @@ -57,6 +58,7 @@ function resetCommunityState({ resetAgentObserverStore(); resetActiveAgentTurnsStore(); resetAgentWorkingSignal(); + resetCurrentProjectionStore(); if (isTauri() && isMacPlatform()) { void clearTrayAgentActivity(); } diff --git a/desktop/src/features/messages/lib/currentRelayBinding.test.mjs b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs new file mode 100644 index 0000000000..12ffc22839 --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hasCurrentRelayBindingForAuthor } from "./currentRelayBinding.ts"; + +const DISPLAYED_ACTOR_PUBKEY = "a".repeat(64); +const EVENT_SIGNER_PUBKEY = "b".repeat(64); +const OTHER_PUBKEY = "c".repeat(64); + +const projection = { + eventAuthorPubkey: EVENT_SIGNER_PUBKEY, +}; + +test("returns false without a current projection", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(null, EVENT_SIGNER_PUBKEY), + false, + ); +}); + +test("returns true for the exact event author", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER_PUBKEY), + true, + ); +}); + +test("does not normalize author case", () => { + assert.equal( + hasCurrentRelayBindingForAuthor( + projection, + EVENT_SIGNER_PUBKEY.toUpperCase(), + ), + false, + ); +}); + +test("does not trim author whitespace", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(projection, ` ${EVENT_SIGNER_PUBKEY}`), + false, + ); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, `${EVENT_SIGNER_PUBKEY} `), + false, + ); +}); + +test("returns false for a different or absent event author", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(projection, OTHER_PUBKEY), + false, + ); + assert.equal(hasCurrentRelayBindingForAuthor(projection, null), false); + assert.equal(hasCurrentRelayBindingForAuthor(projection, undefined), false); +}); + +test("uses the raw event signer instead of a relay-attributed display actor", () => { + const relayAttributedMessage = { + pubkey: DISPLAYED_ACTOR_PUBKEY, + signerPubkey: EVENT_SIGNER_PUBKEY, + }; + const displayedActorProjection = { + eventAuthorPubkey: DISPLAYED_ACTOR_PUBKEY, + }; + + assert.equal( + relayAttributedMessage.pubkey, + displayedActorProjection.eventAuthorPubkey, + "the displayed actor matches the projection in this regression case", + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + displayedActorProjection, + relayAttributedMessage.signerPubkey, + ), + false, + "a displayed-actor match must not badge an event signed by another key", + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + projection, + relayAttributedMessage.signerPubkey, + ), + true, + "an exact raw event-signer match may display the badge", + ); +}); diff --git a/desktop/src/features/messages/lib/currentRelayBinding.ts b/desktop/src/features/messages/lib/currentRelayBinding.ts new file mode 100644 index 0000000000..67c5095269 --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.ts @@ -0,0 +1,12 @@ +type CurrentProjectionAuthor = Readonly<{ + eventAuthorPubkey: string; +}>; + +export function hasCurrentRelayBindingForAuthor( + projection: CurrentProjectionAuthor | null, + eventAuthorPubkey: string | null | undefined, +): boolean { + return ( + projection !== null && projection.eventAuthorPubkey === eventAuthorPubkey + ); +} diff --git a/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx new file mode 100644 index 0000000000..6921f43ca9 --- /dev/null +++ b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx @@ -0,0 +1,22 @@ +export function CurrentRelayBindingBadge() { + return ( + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4a7bab5441..26fe7a546f 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -7,15 +7,14 @@ import { reactionsEqual, tagsEqual, } from "@/features/messages/lib/messageRowEquality"; +import { hasCurrentRelayBindingForAuthor } from "@/features/messages/lib/currentRelayBinding"; import type { TimelineMessage } from "@/features/messages/types"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; +import { useCurrentProjection } from "@/features/binding-status/currentProjectionStore"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; -import { - resolveUserVerification, - type UserProfileLookup, -} from "@/features/profile/lib/identity"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { @@ -35,7 +34,6 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; @@ -47,6 +45,7 @@ import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; +import { CurrentRelayBindingBadge } from "./CurrentRelayBindingBadge"; import { MessageTimestamp } from "./MessageTimestamp"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -158,6 +157,7 @@ export const MessageRow = React.memo( const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); + const currentProjection = useCurrentProjection(); const handleEntranceAnimationEnd = React.useCallback( (event: React.AnimationEvent) => { if ( @@ -481,9 +481,10 @@ export const MessageRow = React.memo( ) : ( {message.author} ); - const verifiedName = message.pubkey - ? resolveUserVerification({ pubkey: message.pubkey, profiles }) - : null; + const showCurrentRelayBinding = hasCurrentRelayBindingForAuthor( + currentProjection, + message.signerPubkey, + ); const agentOwnerNode = message.isAgent ? ( - ) : null} + {showCurrentRelayBinding ? : null} {agentOwnerNode} {inlineMetadataNode} {message.personaDisplayName && diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 2acb1ec3f4..2a90e56fab 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -46,77 +46,12 @@ import { } from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; -import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; -import { - type VerifiedIdentityFields, - withCurrentVerifiedIdentity, -} from "@/shared/lib/verifiedIdentity"; export const profileQueryKey = ["profile"] as const; export const contactListQueryKey = (pubkey: string) => ["contact-list", pubkey] as const; export const allPulseTimelinesQueryKey = ["pulse-timeline"] as const; -function useCurrentVerifiedIdentity( - identity: T | undefined, -): T | undefined { - const revision = useVerifiedIdentityExpiryRevision([ - identity?.verifiedNameExpiresAt, - ]); - return React.useMemo(() => { - // `revision` is the timer-driven cache key for an otherwise unchanged - // React Query value. - void revision; - return identity ? withCurrentVerifiedIdentity(identity) : undefined; - }, [identity, revision]); -} - -function useCurrentVerifiedIdentityRecord( - identities: Record | undefined, -): Record | undefined { - const revision = useVerifiedIdentityExpiryRevision( - identities - ? Object.values(identities).map( - (identity) => identity.verifiedNameExpiresAt, - ) - : [], - ); - return React.useMemo(() => { - void revision; - if (!identities) return undefined; - - let changed = false; - const current = Object.fromEntries( - Object.entries(identities).map(([pubkey, identity]) => { - const next = withCurrentVerifiedIdentity(identity); - changed ||= next !== identity; - return [pubkey, next]; - }), - ); - return changed ? current : identities; - }, [identities, revision]); -} - -function useCurrentVerifiedIdentityList( - identities: T[] | undefined, -): T[] | undefined { - const revision = useVerifiedIdentityExpiryRevision( - identities?.map((identity) => identity.verifiedNameExpiresAt) ?? [], - ); - return React.useMemo(() => { - void revision; - if (!identities) return undefined; - - let changed = false; - const current = identities.map((identity) => { - const next = withCurrentVerifiedIdentity(identity); - changed ||= next !== identity; - return next; - }); - return changed ? current : identities; - }, [identities, revision]); -} - /** * Persists a freshly-fetched profile to localStorage as the offline fallback. * Reuses an existing avatar data URL when the avatar URL is unchanged to avoid @@ -218,8 +153,7 @@ export function useProfileQuery(enabled = true) { staleTime: 30_000, ...seedOptions, }); - const profile = useCurrentVerifiedIdentity(query.data); - return profile === query.data ? query : { ...query, data: profile }; + return query; } /** @@ -347,8 +281,7 @@ export function useUserProfileQuery(pubkey?: string) { queryFn: () => getUserProfile(pubkey), staleTime: 60_000, }); - const profile = useCurrentVerifiedIdentity(query.data); - return profile === query.data ? query : { ...query, data: profile }; + return query; } // Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch. @@ -478,15 +411,7 @@ export function useUsersBatchQuery( } }, [query.data, query.dataUpdatedAt, queryClient]); - const profiles = useCurrentVerifiedIdentityRecord(query.data?.profiles); - return profiles === query.data?.profiles - ? query - : { - ...query, - data: query.data - ? { ...query.data, profiles: profiles ?? {} } - : query.data, - }; + return query; } export function useUserSearchQuery( @@ -510,10 +435,7 @@ export function useUserSearchQuery( staleTime: 30_000, gcTime: 5 * 60 * 1_000, }); - const users = useCurrentVerifiedIdentityList(searchQuery.data); - return users === searchQuery.data - ? searchQuery - : { ...searchQuery, data: users }; + return searchQuery; } export function useInfiniteUserSearchQuery( diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index 126eb195d2..1627fe004d 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -3,16 +3,16 @@ import test from "node:test"; import { formatOwnerLabel, - formatVerifiedUserLabel, + formatProfileLabel, profileLookupsEqual, + resolveSecondaryNip05Label, resolveUserLabel, + truncatePubkey, } from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const USER_PUBKEY = "11".repeat(32); -const NOW_MS = 1_800_000_000_000; -const FUTURE_EXPIRATION = NOW_MS / 1_000 + 60; const summary = (over = {}) => ({ displayName: "Ada", @@ -66,8 +66,7 @@ test("profileLookupsEqual: same count, different keys is not equal", () => { test("profileLookupsEqual: a changed field is not equal", () => { for (const field of [ "displayName", - "verifiedName", - "verifiedNameExpiresAt", + "name", "avatarUrl", "nip05Handle", "ownerPubkey", @@ -84,6 +83,26 @@ test("profileLookupsEqual: a changed field is not equal", () => { } }); +test("profileLookupsEqual ignores dormant verified transport fields", () => { + assert.equal( + profileLookupsEqual( + { + p1: summary({ + verifiedName: "old alias", + verifiedNameExpiresAt: 1, + }), + }, + { + p1: summary({ + verifiedName: "new alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + ), + true, + ); +}); + test("profileLookupsEqual: two empty lookups are equal", () => { assert.equal(profileLookupsEqual({}, {}), true); }); @@ -131,39 +150,87 @@ test("stabiliser: a real profile change swaps the reference (re-render fires)", assert.equal(held, changed, "must re-stabilise around the new value"); }); -test("formats a chosen name followed by the authoritative display name", () => { +test("profile labels use display name and ignore verified fields", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", FUTURE_EXPIRATION, NOW_MS), - "Example (example)", + formatProfileLabel({ + displayName: " Example ", + nip05Handle: "example@nip05.test", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + "Example", ); }); -test("does not duplicate equal chosen and authoritative names", () => { +test("profile labels fall back to NIP-05 and ignore verified fields", () => { assert.equal( - formatVerifiedUserLabel("example", "example", FUTURE_EXPIRATION, NOW_MS), - "example", + formatProfileLabel({ + displayName: " ", + nip05Handle: " example@nip05.test ", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + "example@nip05.test", ); }); -test("expired authoritative names fail closed", () => { +test("resolved user labels use display name without verified aliases", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", NOW_MS / 1_000, NOW_MS), + resolveUserLabel({ + pubkey: USER_PUBKEY, + fallbackName: "Safe fallback", + profiles: { + [USER_PUBKEY]: summary({ + displayName: "Example", + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + }), "Example", ); }); -test("resolved user labels keep the chosen name first", () => { +test("resolved user labels preserve the safe fallback chain", () => { assert.equal( resolveUserLabel({ pubkey: USER_PUBKEY, + fallbackName: " Safe fallback ", profiles: { [USER_PUBKEY]: summary({ - displayName: "Example", - verifiedName: "example", - verifiedNameExpiresAt: Math.floor(Date.now() / 1_000) + 60, + displayName: null, + nip05Handle: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, }), }, }), - "Example (example)", + "Safe fallback", + ); + assert.equal( + resolveUserLabel({ + pubkey: USER_PUBKEY, + profiles: { + [USER_PUBKEY]: summary({ + displayName: null, + nip05Handle: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + }), + }, + }), + truncatePubkey(USER_PUBKEY), + ); +}); + +test("secondary NIP-05 labels do not duplicate the primary label", () => { + assert.equal( + resolveSecondaryNip05Label("user@nip05.test", " user@nip05.test "), + null, + ); + assert.equal( + resolveSecondaryNip05Label("Profile name", " user@nip05.test "), + "user@nip05.test", ); + assert.equal(resolveSecondaryNip05Label("Profile name", " "), null); }); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 95d9b0aeb6..27ac246f0f 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,42 +1,14 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export type UserProfileLookup = Record; export { truncatePubkey }; -export function formatVerifiedUserLabel( - chosenName: string | null | undefined, - verifiedName: string | null | undefined, - verifiedNameExpiresAt: number | null | undefined, - nowMs = Date.now(), +export function formatProfileLabel( + profile: Pick | null | undefined, ): string | null { - const chosen = chosenName?.trim(); - const verified = getCurrentVerifiedName( - verifiedName, - verifiedNameExpiresAt, - nowMs, - ); - - if (chosen && verified && chosen !== verified) { - return `${chosen} (${verified})`; - } - - return chosen || verified || null; -} - -export function formatVerifiedProfileLabel( - profile: - | Pick - | null - | undefined, -): string | null { - return formatVerifiedUserLabel( - profile?.displayName, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); + return profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null; } /** @@ -71,8 +43,6 @@ export function profileLookupsEqual( if ( next === undefined || prev.displayName !== next.displayName || - prev.verifiedName !== next.verifiedName || - prev.verifiedNameExpiresAt !== next.verifiedNameExpiresAt || prev.name !== next.name || prev.avatarUrl !== next.avatarUrl || prev.nip05Handle !== next.nip05Handle || @@ -100,15 +70,7 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick< - Profile, - | "pubkey" - | "displayName" - | "verifiedName" - | "verifiedNameExpiresAt" - | "avatarUrl" - | "nip05Handle" - > + | Pick | null | undefined, ) { @@ -120,8 +82,6 @@ export function mergeCurrentProfileIntoLookup( ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { displayName: currentProfile.displayName, - verifiedName: currentProfile.verifiedName ?? null, - verifiedNameExpiresAt: currentProfile.verifiedNameExpiresAt ?? null, // `Profile` does not carry the kind-0 `name`; keep whatever the batch // lookup already resolved so mention aliases survive the merge. name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null, @@ -162,11 +122,7 @@ export function resolveUserLabel(input: { const displayName = profile?.displayName?.trim(); const nip05Handle = profile?.nip05Handle?.trim(); const safeFallback = fallbackName?.trim(); - const label = formatVerifiedUserLabel( - displayName || nip05Handle || safeFallback, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); + const label = displayName || nip05Handle || safeFallback; if (label) { return label; } @@ -174,17 +130,6 @@ export function resolveUserLabel(input: { return truncatePubkey(pubkey); } -export function resolveUserVerification(input: { - pubkey: string; - profiles?: UserProfileLookup; -}): string | null { - const profile = getResolvedProfile(input.pubkey, input.profiles); - return getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); -} - /** * Returns true when the current user owns the agent that authored a message. * Mirrors the relay's `is_agent_owner` gate: ownership is determined by the @@ -217,6 +162,15 @@ export function resolveUserSecondaryLabel(input: { return null; } +/** Returns a trimmed NIP-05 label only when it adds distinct secondary text. */ +export function resolveSecondaryNip05Label( + primaryLabel: string, + nip05Handle: string | null | undefined, +): string | null { + const handle = nip05Handle?.trim(); + return handle && handle !== primaryLabel.trim() ? handle : null; +} + /** * Label for an agent's owner: "you" when the current user owns it, otherwise * the owner's display name, NIP-05 handle, or truncated pubkey. diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 456ea42a31..d70b05fc98 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -17,14 +17,11 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; avatarUrl: string | null; avatarDataUrl?: string | null; currentStatus: PresenceStatus; @@ -55,8 +52,6 @@ export function ProfilePopover({ open, onOpenChange, displayName, - verifiedName, - verifiedNameExpiresAt, avatarUrl, avatarDataUrl, currentStatus, @@ -144,17 +139,9 @@ export function ProfilePopover({ />
-
-

- {displayName} -

- {verifiedName ? ( - - ) : null} -
+

+ {displayName} +

{/* ── Presence chip (opens status chooser) ─────────── */} = { goose: "Goose", @@ -50,7 +47,6 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", - "Relay-verified identity", "Managed by", "NIP-05", "Agent type", @@ -178,28 +174,6 @@ export function buildPublicFields({ }); } - const verifiedName = getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ); - if (verifiedName) { - fields.push({ - displayValue: verifiedName, - icon: BadgeCheck, - label: "Relay-verified identity", - testId: "user-profile-relay-verified-identity", - trailingNode: ( - - - Binding active - - ), - }); - } - if (profile?.nip05Handle) { fields.push({ copyValue: profile.nip05Handle, @@ -437,19 +411,16 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const visibilityLabel = "Visibility"; const publicKeyLabel = "Public key"; - const relayVerifiedIdentityLabel = "Relay-verified identity"; const managedByLabel = "Managed by"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === visibilityLabel), ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === relayVerifiedIdentityLabel), ...fields.filter((field) => field.label === managedByLabel), ...fields.filter( (field) => field.label !== visibilityLabel && field.label !== publicKeyLabel && - field.label !== relayVerifiedIdentityLabel && field.label !== managedByLabel && field.copyValue, ), @@ -458,7 +429,6 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === visibilityLabel || field.label === publicKeyLabel || - field.label === relayVerifiedIdentityLabel || field.label === managedByLabel || field.label === statusLabel ) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index a2a3fcac8b..966c6686c1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -16,6 +16,7 @@ import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; import { getPresenceLabel } from "@/features/presence/lib/presence"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { ProfileActivityAgent } from "@/features/profile/lib/profileActivityAgent"; +import { resolveSecondaryNip05Label } from "@/features/profile/lib/identity"; import type { useFollowMutation, useUnfollowMutation, @@ -51,8 +52,6 @@ import type { import { cn } from "@/shared/lib/cn"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; @@ -489,9 +488,9 @@ function ProfileHero({ userStatus: ProfileSummaryViewProps["userStatus"]; }) { const presenceDotClassName = isBot ? "h-4.5 w-4.5" : "h-3.5 w-3.5"; - const verifiedName = getCurrentVerifiedName( - profile?.verifiedName, - profile?.verifiedNameExpiresAt, + const nip05Handle = resolveSecondaryNip05Label( + displayName, + profile?.nip05Handle, ); return ( @@ -543,19 +542,6 @@ function ProfileHero({ ) : null}
- {verifiedName ? ( -
- {verifiedName} - -
- ) : null} - {profile?.about?.trim() ? ( ) : null} - {profile?.nip05Handle ? ( -

{profile.nip05Handle}

+ {nip05Handle ? ( +

{nip05Handle}

) : null} {userStatus ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index 89837f6017..845deeb56e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -7,6 +7,7 @@ import { personaManagedAgentUpdate, profilePanelTabFromSearch, profilePanelViewFromSearch, + resolveProfileDisplayName, } from "./UserProfilePanelUtils.ts"; function agent(overrides = {}) { @@ -92,6 +93,46 @@ test("personaManagedAgentUpdate syncs edited persona identity to linked agent", }); }); +test("resolveProfileDisplayName keeps the safe profile naming chain", () => { + const pubkey = "12".repeat(32); + const baseProfile = { + pubkey, + displayName: null, + verifiedName: "relay alias", + verifiedNameExpiresAt: 9_999_999_999, + avatarUrl: null, + about: null, + nip05Handle: "user@nip05.test", + ownerPubkey: null, + hasProfileEvent: true, + }; + + assert.equal( + resolveProfileDisplayName({ + profile: { ...baseProfile, displayName: "Profile name" }, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "Profile name", + ); + assert.equal( + resolveProfileDisplayName({ + profile: baseProfile, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "user@nip05.test", + ); + assert.equal( + resolveProfileDisplayName({ + profile: { ...baseProfile, nip05Handle: null }, + persona: persona({ displayName: "Safe persona fallback" }), + pubkey, + }), + "Safe persona fallback", + ); +}); + test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => { assert.equal( personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()), diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b4..afb5450405 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -229,8 +229,9 @@ export function resolveProfileDisplayName({ pubkey: string | null; }) { return ( - profile?.displayName ?? - persona?.displayName ?? + profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || + persona?.displayName?.trim() || (pubkey ? truncatePubkey(pubkey) : "Agent") ); } @@ -244,8 +245,8 @@ export function resolveOwnerHandle( } return ( - profile?.nip05Handle?.trim() || profile?.displayName?.trim() || + profile?.nip05Handle?.trim() || truncatePubkey(currentPubkey) ); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d2b4b1bc22..430e0f855c 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -25,9 +25,10 @@ import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { - formatVerifiedUserLabel, + formatProfileLabel, formatOwnerLabel, ownsAuthorAgent, + resolveSecondaryNip05Label, } from "@/features/profile/lib/identity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { usePresenceQuery } from "@/features/presence/hooks"; @@ -52,7 +53,6 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -236,12 +236,7 @@ export function UserProfilePopover({ relayAgentsQuery.isPending || managedAgentsQuery.isPending || usersBatchQuery.isPending); - const displayName = - formatVerifiedUserLabel( - profile?.displayName, - profile?.verifiedName, - profile?.verifiedNameExpiresAt, - ) ?? truncatePubkey(pubkey); + const displayName = formatProfileLabel(profile) ?? truncatePubkey(pubkey); // Owner signal mirrors UserProfilePanel: a declared NIP-OA owner whose agent // runs elsewhere holds no local seckey, so key custody (`isOwner`) alone // wrongly hides the affordance from them — and gating on bot-ness alone shows @@ -286,7 +281,9 @@ export function UserProfilePopover({ const userStatusText = userStatus?.text.trim() ?? ""; const hasUserStatus = Boolean(userStatusText || userStatus?.emoji); const profileDescription = profile?.about?.trim() ?? ""; - const profileSubheader = profileDescription || profile?.nip05Handle?.trim(); + const profileSubheader = + profileDescription || + resolveSecondaryNip05Label(displayName, profile?.nip05Handle); const activeTurns = useAgentWorking(isBotProfile ? pubkey : null).channels; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { @@ -535,12 +532,6 @@ export function UserProfilePopover({
- {profile?.verifiedName ? ( - - ) : null} {isBotProfile && botIdenticonValue ? ( @@ -505,7 +505,7 @@ export function AppSidebar({ streamChannels, }); const resolvedDisplayName = - formatVerifiedProfileLabel(profile) || + formatProfileLabel(profile) || fallbackDisplayName?.trim() || "Current identity"; const isCreatingAny = diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 46ba9608e6..e2d9e37620 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -16,7 +16,6 @@ import { useMyRelayMembershipLookupQuery } from "@/features/community-members/ho import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type SidebarProfileCardProps = { activeCommunity: Community | null; @@ -153,8 +152,6 @@ export function SidebarProfileCard({ avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} displayName={resolvedDisplayName} - verifiedName={profile?.verifiedName} - verifiedNameExpiresAt={profile?.verifiedNameExpiresAt} isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} @@ -193,19 +190,11 @@ export function SidebarProfileCard({ data-testid="open-settings" type="button" > - - - {resolvedDisplayName} - - {profile?.verifiedName ? ( - - ) : null} + + {resolvedDisplayName} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index fd6758f791..9677a18d43 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -1,9 +1,5 @@ import { Channel, invoke } from "@tauri-apps/api/core"; -import { - createAuthEvent, - getRelayWsUrl, - signRelayEvent, -} from "@/shared/api/tauri"; +import { getRelayWsUrl, signRelayEvent } from "@/shared/api/tauri"; import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE, @@ -67,8 +63,8 @@ import { } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; +import { RelayClientStatusConnection } from "@/shared/api/relayClientStatusConnection"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; - export class RelayClient { private wsId: number | null = null; private relayUrl: string | null = null; @@ -91,6 +87,7 @@ export class RelayClient { private hasConnectedOnce = false; private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; + private statusConnection: RelayClientStatusConnection | null = null; private connectionGeneration = 0; private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; @@ -172,6 +169,8 @@ export class RelayClient { this.reconnectListeners.clear(); this.connectionStateEmitter.clear(); this.onMessageChannel = null; + this.statusConnection?.retire(); + this.statusConnection = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; } @@ -529,36 +528,52 @@ export class RelayClient { window.clearTimeout(this.stabilityTimer); this.stabilityTimer = null; } - this.connectionStateEmitter.set( this.hasConnectedOnce ? "reconnecting" : "connecting", ); - const generation = ++this.connectionGeneration; + let statusConnection!: RelayClientStatusConnection; this.onMessageChannel = new Channel((message) => { - void this.handleWsMessage(message, generation).catch((error) => { - if (generation !== this.connectionGeneration) return; - this.resetConnection( - this.normalizeRelayError(error, "Relay connection errored."), - ); - }); + void this.handleWsMessage(message, generation, statusConnection).catch( + (error) => { + if (generation !== this.connectionGeneration) return; + this.resetConnection( + this.normalizeRelayError(error, "Relay connection errored."), + ); + }, + ); }); - + statusConnection = new RelayClientStatusConnection( + (id) => + generation === this.connectionGeneration && + this.wsId === id && + this.statusConnection === statusConnection, + (id) => + generation === this.connectionGeneration && + this.wsId === id && + this.authRequest !== null, + (eventId) => { + if (this.authRequest) this.authRequest.pendingEventId = eventId; + }, + (event) => this.sendRaw(["AUTH", event]), + ); + this.statusConnection = statusConnection; try { if (!this.relayUrl) { this.relayUrl = await getRelayWsUrl(); } - const wsId = await invoke("plugin:websocket|connect", { - url: this.relayUrl, - onMessage: this.onMessageChannel, - config: {}, - }); + const connectionRelayUrl = this.relayUrl; + const wsId = await statusConnection.connect( + connectionRelayUrl, + this.onMessageChannel, + ); if (generation !== this.connectionGeneration) { + statusConnection.retire(); void closeWebSocket(wsId, "stale connection attempt"); throw new Error("Relay connection attempt was superseded."); } this.wsId = wsId; - + statusConnection.bind(wsId, connectionRelayUrl); await new Promise((resolve, reject) => { const timeout = window.setTimeout(() => { const error = new Error("Relay authentication timed out."); @@ -566,7 +581,6 @@ export class RelayClient { this.resetConnection(error); reject(error); }, AUTH_TIMEOUT_MS); - this.authRequest = { pendingEventId: "", resolve, @@ -574,17 +588,16 @@ export class RelayClient { timeout, }; }); - this.stabilityTimer = window.setTimeout(() => { this.stabilityTimer = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); - this.connectionStateEmitter.set("connected"); await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { + statusConnection.retire(); const connectionError = this.normalizeRelayError( error, "Failed to connect to relay.", @@ -595,7 +608,6 @@ export class RelayClient { throw connectionError; } } - private async subscribe( filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, @@ -753,7 +765,11 @@ export class RelayClient { }); } - private async handleWsMessage(message: unknown, generation: number) { + private async handleWsMessage( + message: unknown, + generation: number, + statusConnection: RelayClientStatusConnection, + ) { if (generation !== this.connectionGeneration) return; this.stallWatchdog.recordInbound(); @@ -786,7 +802,7 @@ export class RelayClient { const [type, ...rest] = data; if (type === "AUTH" && typeof rest[0] === "string") { - await this.handleAuthChallenge(rest[0], generation); + await statusConnection.handleAuthChallenge(rest[0]); return; } if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) { @@ -835,24 +851,6 @@ export class RelayClient { } } - private async handleAuthChallenge(challenge: string, generation: number) { - if (!this.relayUrl) { - this.relayUrl = await getRelayWsUrl(); - } - - const event = await createAuthEvent({ - challenge, - relayUrl: this.relayUrl, - }); - - if (generation !== this.connectionGeneration || !this.authRequest) { - return; - } - - this.authRequest.pendingEventId = event.id; - await this.sendRaw(["AUTH", event]); - } - private handleEvent(subId: string, event: RelayEvent) { const subscription = this.subscriptions.get(subId); if (!subscription) { @@ -1014,6 +1012,8 @@ export class RelayClient { }, ) { this.onMessageChannel = null; + this.statusConnection?.retire(); + this.statusConnection = null; this.stallWatchdog.stop(); this.connectionGeneration++; if (this.stabilityTimer !== null) { diff --git a/desktop/src/shared/api/relayClientStatusConnection.test.mjs b/desktop/src/shared/api/relayClientStatusConnection.test.mjs new file mode 100644 index 0000000000..98a68a592b --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.test.mjs @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const RELAY_URL = "wss://relay.example/"; +const AUTH_EVENT_ID = "aa".repeat(32); +const AUTHOR = "bb".repeat(32); +const calls = []; +const callbacks = new Map(); +let nextCallbackId = 1; +let messageChannel; +let projectionChannel; +let socketId = 4_242; +let authDelivery = "early"; + +globalThis.isTauri = true; +globalThis.window = globalThis; +globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + calls.push({ command, args }); + switch (command) { + case "get_relay_ws_url": + return Promise.resolve(RELAY_URL); + case "plugin:websocket|connect_with_status": { + messageChannel = args.onMessage; + projectionChannel = args.onProjection; + // Native channels can deliver before the invoke response reaches JS. + // A projection is not eligible until this exact connection is bound. + projectionChannel.onmessage({ + connectionEpoch: "too-early", + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + const deliverAuth = () => + messageChannel.onmessage({ + type: "Text", + data: JSON.stringify(["AUTH", `${authDelivery}-challenge`]), + }); + if (authDelivery === "early") deliverAuth(); + else window.setTimeout(deliverAuth, 0); + return Promise.resolve(socketId); + } + case "create_auth_event": + return Promise.resolve( + JSON.stringify({ + content: "", + created_at: 1, + id: AUTH_EVENT_ID, + kind: 22_242, + pubkey: AUTHOR, + sig: "cc".repeat(64), + tags: [], + }), + ); + case "plugin:websocket|send": { + const payload = JSON.parse(args.message.data); + if (payload[0] === "AUTH") { + queueMicrotask(() => { + messageChannel.onmessage({ + type: "Text", + data: JSON.stringify(["OK", AUTH_EVENT_ID, true, ""]), + }); + }); + } + return Promise.resolve(); + } + case "plugin:websocket|disconnect": + return Promise.resolve(); + default: + throw new Error(`Unexpected Tauri command: ${command}`); + } + }, + transformCallback(callback) { + const id = nextCallbackId++; + callbacks.set(id, callback); + return id; + }, + unregisterCallback(id) { + callbacks.delete(id); + }, +}; + +const { RelayClient } = await import("./relayClientSession.ts"); +const projectionStore = await import( + "@/features/binding-status/currentProjectionStore.ts" +); + +test("primary status connection binds early AUTH and projection to its native socket", async () => { + const client = new RelayClient(); + await client.preconnect(); + + const connectCalls = calls.filter(({ command }) => + command.startsWith("plugin:websocket|connect"), + ); + assert.equal(connectCalls.length, 1); + assert.equal(connectCalls[0].command, "plugin:websocket|connect_with_status"); + assert.equal(connectCalls[0].args.url, RELAY_URL); + assert.equal(connectCalls[0].args.onMessage, messageChannel); + assert.equal(connectCalls[0].args.onProjection, projectionChannel); + assert.equal( + projectionStore.getCurrentProjectionSnapshot(), + null, + "projection delivered before the native id is bound stays fenced", + ); + + const authCall = calls.find(({ command }) => command === "create_auth_event"); + assert.deepEqual(authCall?.args, { + challenge: "early-challenge", + nativeWebsocketId: socketId, + relayUrl: RELAY_URL, + }); + const authSend = calls.find(({ command, args }) => { + if (command !== "plugin:websocket|send") return false; + return JSON.parse(args.message.data)[0] === "AUTH"; + }); + assert.equal(authSend?.args.id, socketId); + + const current = { + connectionEpoch: "opaque-native-epoch", + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }; + projectionChannel.onmessage(current); + assert.deepEqual(projectionStore.getCurrentProjectionSnapshot(), current); + + client.disconnect(); + assert.equal(projectionStore.getCurrentProjectionSnapshot(), null); + projectionChannel.onmessage(current); + assert.equal( + projectionStore.getCurrentProjectionSnapshot(), + null, + "a retired connection channel cannot repopulate the store", + ); + assert.equal( + "applyCurrentProjectionFromNative" in projectionStore, + false, + "the singleton has no browser-callable direct population fixture", + ); +}); + +test("AUTH delivered after connect uses the same returned native socket id", async () => { + calls.length = 0; + authDelivery = "normal"; + socketId = 7_777; + + const client = new RelayClient(); + await client.preconnect(); + + const authCall = calls.find(({ command }) => command === "create_auth_event"); + assert.deepEqual(authCall?.args, { + challenge: "normal-challenge", + nativeWebsocketId: socketId, + relayUrl: RELAY_URL, + }); + const authSend = calls.find(({ command, args }) => { + if (command !== "plugin:websocket|send") return false; + return JSON.parse(args.message.data)[0] === "AUTH"; + }); + assert.equal(authSend?.args.id, socketId); + client.disconnect(); +}); diff --git a/desktop/src/shared/api/relayClientStatusConnection.ts b/desktop/src/shared/api/relayClientStatusConnection.ts new file mode 100644 index 0000000000..3af61168a5 --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.ts @@ -0,0 +1,89 @@ +import { type Channel, invoke } from "@tauri-apps/api/core"; +import { createAuthEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { + clearCurrentProjection, + createCurrentProjectionChannel, + type CurrentProjection, +} from "@/features/binding-status/currentProjectionStore"; + +type NativeSocketBinding = Readonly<{ + id: number; + relayUrl: string; +}>; + +export class RelayClientStatusConnection { + readonly projectionChannel: Channel; + private readonly nativeSocketBinding: Promise; + private resolveNativeSocketBinding!: ( + binding: NativeSocketBinding | null, + ) => void; + private nativeSocketId: number | null = null; + private settled = false; + private readonly isActive: (nativeSocketId: number) => boolean; + private readonly isAuthActive: (nativeSocketId: number) => boolean; + private readonly setPendingEventId: (eventId: string) => void; + private readonly sendAuth: (event: RelayEvent) => Promise; + + constructor( + isActive: (nativeSocketId: number) => boolean, + isAuthActive: (nativeSocketId: number) => boolean, + setPendingEventId: (eventId: string) => void, + sendAuth: (event: RelayEvent) => Promise, + ) { + this.isActive = isActive; + this.isAuthActive = isAuthActive; + this.setPendingEventId = setPendingEventId; + this.sendAuth = sendAuth; + this.nativeSocketBinding = new Promise((resolve) => { + this.resolveNativeSocketBinding = resolve; + }); + this.projectionChannel = createCurrentProjectionChannel( + () => this.nativeSocketId !== null && this.isActive(this.nativeSocketId), + ); + clearCurrentProjection(); + } + + async connect( + relayUrl: string, + onMessage: Channel, + ): Promise { + const id = await invoke("plugin:websocket|connect_with_status", { + url: relayUrl, + onMessage, + onProjection: this.projectionChannel, + config: {}, + }); + return id; + } + + bind(id: number, relayUrl: string) { + this.nativeSocketId = id; + this.settle({ id, relayUrl }); + } + + retire() { + this.settle(null); + this.nativeSocketId = null; + clearCurrentProjection(); + } + + async handleAuthChallenge(challenge: string) { + const binding = await this.nativeSocketBinding; + if (!binding) return; + const event = await createAuthEvent({ + challenge, + nativeWebsocketId: binding.id, + relayUrl: binding.relayUrl, + }); + if (!this.isAuthActive(binding.id)) return; + this.setPendingEventId(event.id); + await this.sendAuth(event); + } + + private settle(binding: NativeSocketBinding | null) { + if (this.settled) return; + this.settled = true; + this.resolveNativeSocketBinding(binding); + } +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c44fd3b1c0..1bbfa26382 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -665,9 +665,9 @@ export async function signRelayEvent(input: { const eventJson = await invokeTauri("sign_event", input); return JSON.parse(eventJson) as RelayEvent; } - export async function createAuthEvent(input: { challenge: string; + nativeWebsocketId?: number; relayUrl: string; }): Promise { const eventJson = await invokeTauri("create_auth_event", input); diff --git a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts b/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts deleted file mode 100644 index 4012f6fc5d..0000000000 --- a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as React from "react"; - -import { millisecondsUntilVerifiedIdentityExpiry } from "@/shared/lib/verifiedIdentity"; - -const MAX_TIMEOUT_MS = 2_147_483_647; - -/** - * Force a render at the earliest assertion cutoff. Callers then re-run the - * local-clock sanitizer, even when React Query is serving an offline cache. - */ -export function useVerifiedIdentityExpiryRevision( - expirations: ReadonlyArray, -): number { - const nowMs = Date.now(); - let nextDelayMs: number | null = null; - for (const expiresAt of expirations) { - const delayMs = millisecondsUntilVerifiedIdentityExpiry(expiresAt, nowMs); - if ( - delayMs !== null && - delayMs > 0 && - (nextDelayMs === null || delayMs < nextDelayMs) - ) { - nextDelayMs = delayMs; - } - } - - const [revision, setRevision] = React.useState(0); - React.useEffect(() => { - // Re-arm after a timer tick even if a wall-clock adjustment happens to - // produce the same remaining delay as the previous render. - void revision; - if (nextDelayMs === null) { - return; - } - - const timeout = setTimeout( - () => setRevision((current) => current + 1), - Math.min(nextDelayMs + 1, MAX_TIMEOUT_MS), - ); - return () => clearTimeout(timeout); - }, [nextDelayMs, revision]); - - return revision; -} diff --git a/desktop/src/shared/lib/verifiedIdentity.test.mjs b/desktop/src/shared/lib/verifiedIdentity.test.mjs deleted file mode 100644 index db640aa8d9..0000000000 --- a/desktop/src/shared/lib/verifiedIdentity.test.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - getCurrentVerifiedName, - millisecondsUntilVerifiedIdentityExpiry, - withCurrentVerifiedIdentity, -} from "./verifiedIdentity.ts"; - -const NOW_MS = 1_800_000_000_000; -const NOW_SECONDS = NOW_MS / 1_000; - -test("returns a verified name only before its local expiration", () => { - assert.equal( - getCurrentVerifiedName(" Example ", NOW_SECONDS + 60, NOW_MS), - "Example", - ); - assert.equal(getCurrentVerifiedName("Example", NOW_SECONDS, NOW_MS), null); -}); - -test("missing and malformed expirations fail closed", () => { - assert.equal(getCurrentVerifiedName("Example", null, NOW_MS), null); - assert.equal( - getCurrentVerifiedName("Example", NOW_SECONDS + 0.5, NOW_MS), - null, - ); - assert.equal(getCurrentVerifiedName("Example", Number.NaN, NOW_MS), null); -}); - -test("computes the exact cutoff delay used by expiry render timers", () => { - assert.equal( - millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS + 60, NOW_MS), - 60_000, - ); - assert.equal( - millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS - 1, NOW_MS), - 0, - ); -}); - -test("sanitizes cached identity objects without churning valid values", () => { - const valid = { - verifiedName: "Example", - verifiedNameExpiresAt: NOW_SECONDS + 60, - }; - assert.equal(withCurrentVerifiedIdentity(valid, NOW_MS), valid); - assert.deepEqual(withCurrentVerifiedIdentity(valid, NOW_MS + 60_000), { - verifiedName: null, - verifiedNameExpiresAt: NOW_SECONDS + 60, - }); -}); diff --git a/desktop/src/shared/lib/verifiedIdentity.ts b/desktop/src/shared/lib/verifiedIdentity.ts deleted file mode 100644 index 2c0312d7a3..0000000000 --- a/desktop/src/shared/lib/verifiedIdentity.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type VerifiedIdentityFields = { - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; -}; - -function verifiedIdentityExpiryMs( - expiresAt: number | null | undefined, -): number | null { - if (!Number.isSafeInteger(expiresAt) || (expiresAt ?? 0) <= 0) { - return null; - } - - const expiresAtMs = (expiresAt as number) * 1_000; - return Number.isSafeInteger(expiresAtMs) ? expiresAtMs : null; -} - -/** - * Return a verified name only while its relay assertion is still valid. - * Missing or malformed expirations fail closed so old cached responses cannot - * keep a trust label alive while the relay is unreachable. - */ -export function getCurrentVerifiedName( - verifiedName: string | null | undefined, - expiresAt: number | null | undefined, - nowMs = Date.now(), -): string | null { - const name = verifiedName?.trim(); - const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); - if (!name || expiresAtMs === null || expiresAtMs <= nowMs) { - return null; - } - - return name; -} - -export function millisecondsUntilVerifiedIdentityExpiry( - expiresAt: number | null | undefined, - nowMs = Date.now(), -): number | null { - const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); - return expiresAtMs === null ? null : Math.max(0, expiresAtMs - nowMs); -} - -/** Preserve object identity until the verified-name view actually changes. */ -export function withCurrentVerifiedIdentity( - identity: T, - nowMs = Date.now(), -): T { - const verifiedName = getCurrentVerifiedName( - identity.verifiedName, - identity.verifiedNameExpiresAt, - nowMs, - ); - return identity.verifiedName === verifiedName - ? identity - : { ...identity, verifiedName }; -} diff --git a/desktop/src/shared/ui/VerifiedBadge.tsx b/desktop/src/shared/ui/VerifiedBadge.tsx deleted file mode 100644 index 359639ac0f..0000000000 --- a/desktop/src/shared/ui/VerifiedBadge.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; - -export function VerifiedBadge({ - verifiedName, - verifiedNameExpiresAt, -}: { - verifiedName: string; - verifiedNameExpiresAt: number | null | undefined; -}) { - useVerifiedIdentityExpiryRevision([verifiedNameExpiresAt]); - const currentVerifiedName = getCurrentVerifiedName( - verifiedName, - verifiedNameExpiresAt, - ); - if (!currentVerifiedName) { - return null; - } - - return ( - - - - - - - -

Verified as {currentVerifiedName}

-
-
- ); -} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index c6027fec67..72bcd0076c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -951,6 +951,7 @@ type MockFilter = { type MockSocket = { handler: WsHandler; + projectionHandler?: WsHandler; subscriptions: Map; }; @@ -1118,6 +1119,7 @@ declare global { event: string, payload: unknown, ) => Promise; + __BUZZ_E2E_EMIT_CURRENT_PROJECTION__?: (payload: unknown) => boolean; __BUZZ_E2E_SET_MOCK_HUDDLE_SNAPSHOT__?: (input: { members: MockHuddleMemberSeed[]; transcriptionEnabled: boolean; @@ -9315,7 +9317,11 @@ async function resolveGetEvent( return JSON.stringify(events[0]); } -async function connectRealSocket(args: { url?: string; onMessage: unknown }) { +async function connectRealSocket(args: { + url?: string; + onMessage: unknown; + onProjection?: unknown; +}) { relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); @@ -9344,7 +9350,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { }); } -async function connectMockSocket(args: { onMessage: unknown }) { +async function connectMockSocket(args: { + onMessage: unknown; + onProjection?: unknown; +}) { relayWebsocketConnectAttemptStarts.push(Date.now()); if (mockWebsocketUnavailable) { throw new Error("mock relay unavailable"); @@ -9363,6 +9372,10 @@ async function connectMockSocket(args: { onMessage: unknown }) { mockSockets.set(wsId, { handler, + projectionHandler: + args.onProjection === undefined + ? undefined + : resolveHandler(args.onProjection), subscriptions: new Map(), }); @@ -12477,6 +12490,7 @@ export function maybeInstallE2eTauriMocks() { ]), ); case "plugin:websocket|connect": + case "plugin:websocket|connect_with_status": if (isRelayMode(activeConfig)) { return connectRealSocket( payload as Parameters[0], @@ -12765,6 +12779,14 @@ export function maybeInstallE2eTauriMocks() { }; window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) => handleMockCommand(command, payload ?? null); + window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__ = (payload) => { + const socket = [...mockSockets.values()] + .reverse() + .find((candidate) => candidate.projectionHandler !== undefined); + if (!socket?.projectionHandler) return false; + socket.projectionHandler(payload); + return true; + }; window.__BUZZ_E2E_EMIT_TAURI_EVENT__ = (event, payload) => emit(event, payload); mockIPC(handleMockCommand, { shouldMockEvents: true }); diff --git a/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts new file mode 100644 index 0000000000..cae86c56dc --- /dev/null +++ b/desktop/tests/e2e/j3c/current-binding-status-native-trace.spec.ts @@ -0,0 +1,246 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../../helpers/bridge"; +import { + forwardTraceStep, + installNativeProjectionTraceAdapter, + loadCurrentBindingStatusTrace, + traceStep, + type NativeCurrentProjection, + waitForNativeProjectionTraceAdapter, +} from "./currentBindingStatusTrace"; + +const trace = loadCurrentBindingStatusTrace(); +const LEGACY_VERIFIED_NAME_MARKER = "legacy-verified-name-must-not-authorize"; +const LEGACY_ALIAS_MARKER = "legacy-relay-alias-must-not-authorize"; +const PROJECTION_SETUP_HEADROOM_SECONDS = 60; + +async function waitForMockLiveSubscription(page: Page) { + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }) ?? false, + ), + ) + .toBe(true); +} + +function otherSyntheticAuthor(projectedAuthors: ReadonlySet): string { + for (const identity of [TEST_IDENTITIES.bob, TEST_IDENTITIES.charlie]) { + if (!projectedAuthors.has(identity.pubkey)) return identity.pubkey; + } + throw new Error( + "Native trace unexpectedly contains both comparison authors.", + ); +} + +async function emitMessage( + page: Page, + input: { content: string; pubkey: string; createdAt: number }, +) { + await page.evaluate((message) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock live-message bridge is not installed."); + emit({ channelName: "general", ...message }); + }, input); +} + +async function expectOnlyAuthorBadge( + page: Page, + rows: ReadonlyMap, + projection: NativeCurrentProjection, +) { + const matchingRow = rows.get(projection.eventAuthorPubkey); + if (!matchingRow) { + throw new Error("No message row was created for the projected author."); + } + + const badge = matchingRow.getByTestId("current-relay-binding"); + await expect(badge).toHaveCount(1); + await expect(badge).toHaveAccessibleName("Current relay binding"); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(1); + + for (const [author, row] of rows) { + if (author !== projection.eventAuthorPubkey) { + await expect(row.getByTestId("current-relay-binding")).toHaveCount(0); + } + } + + const badgeMarkup = ( + await badge.evaluate((element) => element.outerHTML) + ).toLowerCase(); + for (const hiddenValue of [ + projection.eventAuthorPubkey, + String(projection.freshUntil), + projection.connectionEpoch, + LEGACY_VERIFIED_NAME_MARKER, + LEGACY_ALIAS_MARKER, + "eventauthorpubkey", + "freshuntil", + "connectionepoch", + ]) { + expect(badgeMarkup).not.toContain(hiddenValue.toLowerCase()); + } +} + +async function expectNoLegacyTrustPresentation(page: Page) { + await expect(page.getByTestId("relay-verified-identity")).toHaveCount(0); + await expect( + page.locator('[aria-label^="Relay-verified identity"]'), + ).toHaveCount(0); + await expect(page.getByText("Binding active", { exact: false })).toHaveCount( + 0, + ); + await expect(page.getByText("Verified as", { exact: false })).toHaveCount(0); +} + +test("Rust native-flow trace drives exact-author lifecycle presentation", async ({ + page, +}) => { + const currentProjections = trace.steps.flatMap((step) => + step.projection === null ? [] : [step.projection], + ); + const projectedAuthors = new Set( + currentProjections.map((projection) => projection.eventAuthorPubkey), + ); + const expiryProjection = currentProjections.reduce((earliest, projection) => + projection.freshUntil < earliest.freshUntil ? projection : earliest, + ); + const activationStep = traceStep(trace, "current"); + const activationProjection = activationStep.projection; + if (activationProjection === null) { + throw new Error("Native current trace step must contain a projection."); + } + const clockStartSeconds = + expiryProjection.freshUntil - PROJECTION_SETUP_HEADROOM_SECONDS; + if (clockStartSeconds <= 0) { + throw new Error( + "Native trace freshUntil is too small for expiry coverage.", + ); + } + + await page.clock.install({ time: clockStartSeconds * 1_000 }); + await installMockBridge(page, { + searchProfiles: [...projectedAuthors].map((pubkey, index) => ({ + pubkey, + displayName: `${LEGACY_VERIFIED_NAME_MARKER}-${index}`, + nip05Handle: `${LEGACY_ALIAS_MARKER}-${index}@example.invalid`, + })), + }); + await installNativeProjectionTraceAdapter(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page); + await waitForNativeProjectionTraceAdapter(page); + + const rows = new Map(); + let createdAt = clockStartSeconds - projectedAuthors.size - 1; + for (const [index, pubkey] of [...projectedAuthors].entries()) { + const content = `Native projection author ${index}`; + await emitMessage(page, { content, pubkey, createdAt: createdAt++ }); + const row = page.getByTestId("message-row").filter({ hasText: content }); + await expect(row).toBeVisible(); + await expect(row.getByTestId("message-author")).toContainText( + `${LEGACY_VERIFIED_NAME_MARKER}-${index}`, + ); + rows.set(pubkey, row); + } + + const otherAuthor = otherSyntheticAuthor(projectedAuthors); + const otherContent = "Non-projected comparison author"; + await emitMessage(page, { + content: otherContent, + pubkey: otherAuthor, + createdAt, + }); + const otherRow = page + .getByTestId("message-row") + .filter({ hasText: otherContent }); + await expect(otherRow).toBeVisible(); + rows.set(otherAuthor, otherRow); + + for (const step of trace.steps) { + if (step.case === "passive-expiry") continue; + + await test.step(`${step.case} projects its retained browser state`, async () => { + if (step.projection === null) { + // Every clear transition starts from a visible Rust-produced current + // projection so a pre-cleared store can never make the assertion pass. + await forwardTraceStep(page, activationStep); + await expectOnlyAuthorBadge(page, rows, activationProjection); + } + + await forwardTraceStep(page, step); + if (step.projection === null) { + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + } else { + await expectOnlyAuthorBadge(page, rows, step.projection); + } + }); + } + + // The existing mock profile seed reaches ordinary displayName/NIP-05 fields + // but intentionally exposes no dormant verifiedName field. Prove those + // legacy-looking markers cannot enter trust-specific badge or panel UI. + const activationRow = rows.get(activationProjection.eventAuthorPubkey); + if (!activationRow) throw new Error("Activation author row is absent."); + await activationRow.getByRole("button").first().click(); + const profilePanel = page.getByTestId("user-profile-panel"); + await expect(profilePanel).toBeVisible(); + await expect(profilePanel).toContainText(LEGACY_VERIFIED_NAME_MARKER); + await expect(profilePanel).toContainText(LEGACY_ALIAS_MARKER); + await expect(profilePanel.getByTestId("relay-verified-identity")).toHaveCount( + 0, + ); + await expect( + profilePanel.locator('[aria-label^="Relay-verified identity"]'), + ).toHaveCount(0); + await expect( + profilePanel.getByText("Binding active", { exact: false }), + ).toHaveCount(0); + await expect( + profilePanel.getByText("Verified as", { exact: false }), + ).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(profilePanel).toHaveCount(0); + + // These native lifecycle outputs must all clear presentation. Naming them + // explicitly keeps this browser layer non-vacuous if the trace grows later. + for (const caseName of [ + "withdrawal", + "passive-expiry", + "disconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "profile-spoof", + "nip85-no-fallback", + ] as const) { + expect(traceStep(trace, caseName).projection).toBeNull(); + } + expect(traceStep(trace, "reconnect").projection).not.toBeNull(); + await expectNoLegacyTrustPresentation(page); + + // Deliver an unchanged DTO produced by Rust while the browser clock is + // before its deadline, then advance to the exclusive boundary. No later + // trace event, render fixture, or navigation clears it. + const expiryStep = trace.steps.find( + (step) => step.projection === expiryProjection, + ); + if (!expiryStep) throw new Error("Expiry projection is absent from trace."); + await forwardTraceStep(page, expiryStep); + await expectOnlyAuthorBadge(page, rows, expiryProjection); + await page.clock.fastForward( + (expiryProjection.freshUntil - clockStartSeconds) * 1_000, + ); + await expect(page.getByTestId("current-relay-binding")).toHaveCount(0); + await expectNoLegacyTrustPresentation(page); +}); diff --git a/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts new file mode 100644 index 0000000000..f20afa0ea9 --- /dev/null +++ b/desktop/tests/e2e/j3c/currentBindingStatusTrace.ts @@ -0,0 +1,285 @@ +import { readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; + +import type { Page } from "@playwright/test"; + +import type { CurrentProjection } from "../../../src/features/binding-status/currentProjectionStore"; + +const TRACE_ENV = "BUZZ_J3C_PROJECTION_TRACE"; +const LOWERCASE_HEX_256 = /^[0-9a-f]{64}$/; +const CANONICAL_UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +export const CURRENT_BINDING_TRACE_CASES = [ + "bootstrap", + "current", + "duplicate", + "equal-conflict", + "rollback", + "newer-restoration", + "withdrawal", + "passive-expiry", + "disconnect", + "reconnect", + "logout", + "restart", + "relay-scope-change", + "signer-scope-change", + "author-scope-change", + "domain-scope-change", + "epoch-scope-change", + "malformed-trusted", + "unsupported-version", + "author-mismatch", + "profile-spoof", + "nip85-no-fallback", +] as const; + +const CASES_WITH_CURRENT_PROJECTION = new Set([ + "current", + "duplicate", + "newer-restoration", + "reconnect", +]); + +export type CurrentBindingTraceCase = + (typeof CURRENT_BINDING_TRACE_CASES)[number]; + +export type NativeCurrentProjection = CurrentProjection; + +export type CurrentBindingTraceStep = Readonly<{ + case: CurrentBindingTraceCase; + projection: NativeCurrentProjection | null; +}>; + +export type CurrentBindingStatusTrace = Readonly<{ + version: 1; + steps: readonly CurrentBindingTraceStep[]; +}>; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasExactKeys(value: Record, expected: string[]) { + const actual = Object.keys(value).sort(); + const sortedExpected = [...expected].sort(); + return ( + actual.length === sortedExpected.length && + actual.every((key, index) => key === sortedExpected[index]) + ); +} + +function isCurrentProjection(value: unknown): value is NativeCurrentProjection { + if ( + !isRecord(value) || + !hasExactKeys(value, ["connectionEpoch", "eventAuthorPubkey", "freshUntil"]) + ) { + return false; + } + + return ( + typeof value.eventAuthorPubkey === "string" && + LOWERCASE_HEX_256.test(value.eventAuthorPubkey) && + typeof value.freshUntil === "number" && + Number.isSafeInteger(value.freshUntil) && + value.freshUntil > 0 && + typeof value.connectionEpoch === "string" && + CANONICAL_UUID_V4.test(value.connectionEpoch) + ); +} + +function parseTrace(value: unknown, path: string): CurrentBindingStatusTrace { + if (!isRecord(value) || !hasExactKeys(value, ["steps", "version"])) { + throw new Error(`${path} is not an exact J3C projection trace object.`); + } + if (value.version !== 1 || !Array.isArray(value.steps)) { + throw new Error(`${path} must contain trace version 1 and a steps array.`); + } + if (value.steps.length !== CURRENT_BINDING_TRACE_CASES.length) { + throw new Error( + `${path} must contain exactly ${CURRENT_BINDING_TRACE_CASES.length} trace steps.`, + ); + } + + for (const [index, expectedCase] of CURRENT_BINDING_TRACE_CASES.entries()) { + const step = value.steps[index]; + if (!isRecord(step) || !hasExactKeys(step, ["case", "projection"])) { + throw new Error(`${path} step ${index} is not an exact trace step.`); + } + if (step.case !== expectedCase) { + throw new Error( + `${path} step ${index} must be case ${expectedCase}, received ${String(step.case)}.`, + ); + } + + const expectsCurrent = CASES_WITH_CURRENT_PROJECTION.has(expectedCase); + if ( + (expectsCurrent && !isCurrentProjection(step.projection)) || + (!expectsCurrent && step.projection !== null) + ) { + throw new Error( + `${path} case ${expectedCase} has an invalid retained projection.`, + ); + } + } + + // Return the parsed objects themselves. The Playwright boundary forwards the + // native DTO without rebuilding, enriching, or substituting it. + return value as CurrentBindingStatusTrace; +} + +export function loadCurrentBindingStatusTrace(): CurrentBindingStatusTrace { + const path = process.env[TRACE_ENV]; + if (!path) { + throw new Error( + `${TRACE_ENV} is required and must name the Rust native-flow trace.`, + ); + } + if (!isAbsolute(path)) { + throw new Error(`${TRACE_ENV} must be an absolute path; received ${path}.`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + const reason = error instanceof Error ? error.message : "unknown error"; + throw new Error(`Unable to read ${TRACE_ENV} at ${path}: ${reason}.`); + } + return parseTrace(parsed, path); +} + +export function traceStep( + trace: CurrentBindingStatusTrace, + caseName: CurrentBindingTraceCase, +): CurrentBindingTraceStep { + const step = trace.steps.find((candidate) => candidate.case === caseName); + if (!step) { + throw new Error(`Native projection trace is missing case ${caseName}.`); + } + return step; +} + +export async function installNativeProjectionTraceAdapter( + page: Page, +): Promise { + await page.addInitScript(() => { + type Invoke = ( + command: string, + args: Record, + options: unknown, + ) => unknown; + type ProjectionChannel = { + onmessage: (projection: unknown) => void; + }; + type TraceAdapterWindow = typeof window & { + __TAURI_INTERNALS__?: Record; + __BUZZ_J3C_FORWARD_NATIVE_PROJECTION__?: (projection: unknown) => void; + __BUZZ_J3C_STATUS_AUTH_BOUND__?: () => boolean; + }; + + const tauriWindow = window as TraceAdapterWindow; + const internals = tauriWindow.__TAURI_INTERNALS__ ?? {}; + let sharedInvoke = + typeof internals.invoke === "function" + ? (internals.invoke as Invoke) + : null; + let projectionChannel: ProjectionChannel | null = null; + let statusSocketId: number | null = null; + let authBound = false; + + const invoke: Invoke = (command, args, options) => { + if (!sharedInvoke) { + throw new Error(`Shared mock bridge is not installed for ${command}.`); + } + + if (command === "plugin:websocket|connect_with_status") { + const { onProjection, ...ordinaryArgs } = args as { + onProjection?: ProjectionChannel; + } & Record; + if (typeof onProjection?.onmessage !== "function") { + throw new Error( + "Status connection omitted its native projection Channel.", + ); + } + + authBound = false; + projectionChannel = onProjection; + statusSocketId = null; + return Promise.resolve( + sharedInvoke("plugin:websocket|connect", ordinaryArgs, options), + ).then((id) => { + if (typeof id !== "number" || !Number.isSafeInteger(id)) { + throw new Error( + "Shared mock bridge returned an invalid socket ID.", + ); + } + statusSocketId = id; + return id; + }); + } + + if (command === "create_auth_event") { + if ( + statusSocketId === null || + args.nativeWebsocketId !== statusSocketId + ) { + throw new Error( + "Auth event is not bound to the current native status socket ID.", + ); + } + authBound = true; + } + + return sharedInvoke(command, args, options); + }; + + Object.defineProperty(internals, "invoke", { + configurable: true, + get: () => invoke, + set: (nextInvoke: Invoke) => { + sharedInvoke = nextInvoke; + }, + }); + tauriWindow.__TAURI_INTERNALS__ = internals; + tauriWindow.__BUZZ_J3C_STATUS_AUTH_BOUND__ = () => authBound; + tauriWindow.__BUZZ_J3C_FORWARD_NATIVE_PROJECTION__ = (projection) => { + if (!authBound || statusSocketId === null || !projectionChannel) { + throw new Error( + "Native status projection Channel is not authenticated.", + ); + } + projectionChannel.onmessage(projection); + }; + }); +} + +export async function waitForNativeProjectionTraceAdapter( + page: Page, +): Promise { + await page.waitForFunction( + () => + ( + window as typeof window & { + __BUZZ_J3C_STATUS_AUTH_BOUND__?: () => boolean; + } + ).__BUZZ_J3C_STATUS_AUTH_BOUND__?.() === true, + ); +} + +export async function forwardTraceStep( + page: Page, + step: CurrentBindingTraceStep, +): Promise { + await page.evaluate((projection) => { + const forward = ( + window as typeof window & { + __BUZZ_J3C_FORWARD_NATIVE_PROJECTION__?: (projection: unknown) => void; + } + ).__BUZZ_J3C_FORWARD_NATIVE_PROJECTION__; + if (!forward) + throw new Error("Native projection adapter is not installed."); + forward(projection); + }, step.projection); +} diff --git a/scripts/test-j3c-current-binding-status.sh b/scripts/test-j3c-current-binding-status.sh new file mode 100755 index 0000000000..bd1d3275fe --- /dev/null +++ b/scripts/test-j3c-current-binding-status.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +artifact_dir="$(mktemp -d "${TMPDIR:-/tmp}/buzz-j3c-binding-status.XXXXXX")" +trace_path="$artifact_dir/current-binding-status-projection.json" + +cd "$repo_root" + +TAURI_CONFIG='{"bundle":{"externalBin":[]}}' \ +BUZZ_J3C_PROJECTION_TRACE_OUT="$trace_path" \ +CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-2}" \ +RUST_TEST_THREADS="${RUST_TEST_THREADS:-2}" \ + bin/cargo test \ + --manifest-path desktop/src-tauri/Cargo.toml \ + --test current_binding_status_native_flow \ + --locked \ + loopback_relay_drives_production_projection_and_trace \ + -- \ + --exact \ + --nocapture + +test -s "$trace_path" + +cd "$repo_root/desktop" +NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=2048}" \ + ../bin/pnpm build:e2e +BUZZ_J3C_PROJECTION_TRACE="$trace_path" \ + ../bin/pnpm exec playwright test --config=playwright.j3c.config.ts + +printf 'J3C projection trace: %s\n' "$trace_path"