diff --git a/Cargo.lock b/Cargo.lock index 464ead9721e..6cbf76c5d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7493,7 +7493,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/crates/buzz-auth/src/evidence.rs b/crates/buzz-auth/src/evidence.rs index c78142fc68c..1cca3269a87 100644 --- a/crates/buzz-auth/src/evidence.rs +++ b/crates/buzz-auth/src/evidence.rs @@ -132,6 +132,38 @@ impl SealedTransportEvidence { transport: ProofTransport, proxy_expires_at: DateTime, authenticated_client_peer: AuthenticatedClientPeer, + ) -> Self { + Self::for_test_with_nonce( + authorization_domain, + assertion, + method, + authority, + path_and_query, + body_digest, + transport, + proxy_expires_at, + authenticated_client_peer, + [0x91; 32], + ) + } + + /// Construct test evidence with an explicit opaque replay identity. + /// + /// This permits independent connections in one domain to exercise the + /// production nonce ledger without deleting or bypassing prior claims. + #[cfg(any(test, feature = "dev"))] + #[allow(clippy::too_many_arguments)] + pub fn for_test_with_nonce( + authorization_domain: CommunityId, + assertion: impl Into>, + method: &[u8], + authority: &[u8], + path_and_query: &[u8], + body_digest: [u8; 32], + transport: ProofTransport, + proxy_expires_at: DateTime, + authenticated_client_peer: AuthenticatedClientPeer, + nonce_claim: [u8; 32], ) -> Self { let assertion = assertion.into(); let assertion_digest: [u8; 32] = Sha256::digest(assertion.as_bytes()).into(); @@ -145,7 +177,7 @@ impl SealedTransportEvidence { body_digest, transport, proxy_expires_at, - TrustedProxyNonceClaim::new([0x91; 32], proxy_expires_at), + TrustedProxyNonceClaim::new(nonce_claim, proxy_expires_at), AssertionTransportProfile::TrustedProxyHmacV2, Some(authenticated_client_peer), ) diff --git a/crates/buzz-auth/src/foundation.rs b/crates/buzz-auth/src/foundation.rs index c36b8fdac71..e40c52b1e70 100644 --- a/crates/buzz-auth/src/foundation.rs +++ b/crates/buzz-auth/src/foundation.rs @@ -1060,6 +1060,25 @@ impl VerifiedFederatedAssertion { (self.not_before, self.expires_at) } + pub(crate) fn with_request_binding( + &self, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> Option { + if request_fingerprint == [0; 32] + || target_fingerprint == [0; 32] + || transport_context_fingerprint == [0; 32] + { + return None; + } + let mut rebound = self.clone(); + rebound.request_fingerprint = request_fingerprint; + rebound.target_fingerprint = target_fingerprint; + rebound.transport_context_fingerprint = transport_context_fingerprint; + Some(rebound) + } + #[cfg(test)] pub(crate) fn with_test_attested_event_author(mut self, author: PublicKey) -> Self { self.attested_event_author_pubkey = Some(author); diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index cd0a81cf7c1..4262349acc5 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -65,8 +65,8 @@ pub use foundation::{ }; pub use nip42::{ generate_challenge, verify_nip42_authorization_proof, verify_nip42_binding_status_proof, - verify_nip42_event, Nip42AuthorizationProofError, Nip42BindingStatusCoordinates, - VerifiedBindingStatusProof, + verify_nip42_event, verify_nip42_session_event_proof, Nip42AuthorizationProofError, + Nip42BindingStatusCoordinates, VerifiedBindingStatusProof, }; pub use nip98::{ verify_nip42_moderation_command_proof, verify_nip98_authorization_proof, verify_nip98_event, diff --git a/crates/buzz-auth/src/nip42.rs b/crates/buzz-auth/src/nip42.rs index a0fa2b8c057..81236f1a046 100644 --- a/crates/buzz-auth/src/nip42.rs +++ b/crates/buzz-auth/src/nip42.rs @@ -17,9 +17,10 @@ use uuid::Uuid; use crate::error::AuthError; use crate::foundation::{ - ActiveLocalBinding, AuthorizationError, AuthorizationFinalizer, AuthorizationInput, - LocalAuthorizationPolicy, LocalBindingResolution, PreparedAuthorization, ProofTransport, - RouteCapability, VerifiedNostrProof, + ActiveLocalBinding, AuthContext as FinalizedAuthContext, AuthorizationError, + AuthorizationFinalizer, AuthorizationInput, LocalAuthorizationPolicy, LocalBindingResolution, + PreparedAuthorization, ProofTransport, RouteCapability, VerifiedFederatedAssertion, + VerifiedNostrProof, }; /// Normalize a relay URL for comparison. @@ -147,6 +148,60 @@ pub fn verify_nip42_authorization_proof( .ok_or(Nip42AuthorizationProofError::InvalidBinding) } +/// Bind one signed WebSocket EVENT to an already finalized NIP-42 session. +/// +/// The session and assertion are origin-sealed values produced by canonical +/// AUTH. This verifier rechecks the submitted event signature and derives an +/// exact request/target binding without treating a reusable session grant as +/// the mutation itself. Gift wraps may use their protocol-defined ephemeral +/// envelope signer; every other event must be signed by the authenticated +/// actor. +pub fn verify_nip42_session_event_proof( + event: &Event, + session: &FinalizedAuthContext, + assertion: &VerifiedFederatedAssertion, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +) -> Result<(VerifiedFederatedAssertion, VerifiedNostrProof), Nip42AuthorizationProofError> { + buzz_core::verify_event(event).map_err(|_| Nip42AuthorizationProofError::InvalidBinding)?; + let now = Utc::now(); + let (assertion_transport, _, _, _) = assertion.request_binding(); + let (_, assertion_expires_at) = assertion.time_bounds(); + let expires_at = assertion_expires_at.min(session.lease().expires_at()); + if event.kind == Kind::Authentication + || (event.pubkey != session.actor_pubkey() && event.kind != Kind::GiftWrap) + || session.authorization_domain() != assertion.authorization_domain() + || session.capability() != RouteCapability::MessagesRead + || session.transport() != ProofTransport::Nip42 + || assertion_transport != ProofTransport::Nip42 + || assertion.attested_event_author_pubkey() != Some(session.actor_pubkey()) + || expires_at <= now + { + return Err(Nip42AuthorizationProofError::InvalidBinding); + } + let assertion = assertion + .with_request_binding( + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + ) + .ok_or(Nip42AuthorizationProofError::InvalidBinding)?; + let proof = VerifiedNostrProof::from_verifier( + session.authorization_domain(), + session.actor_pubkey(), + ProofTransport::Nip42, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + Some(*assertion.assertion_fingerprint()), + None, + expires_at, + ) + .ok_or(Nip42AuthorizationProofError::InvalidBinding)?; + Ok((assertion, proof)) +} + /// Exact server-derived coordinates for one opted-in status connection. pub struct Nip42BindingStatusCoordinates { authorization_domain: CommunityId, @@ -227,6 +282,16 @@ impl Nip42BindingStatusCoordinates { pub const fn target_fingerprint(&self) -> [u8; 32] { self.target_fingerprint } + + /// Exact request, target, and transport fingerprints sealed into the + /// purpose-specific status proof. + pub const fn request_binding(&self) -> (&[u8; 32], &[u8; 32], &[u8; 32]) { + ( + &self.request_fingerprint, + &self.target_fingerprint, + &self.transport_context_fingerprint, + ) + } } impl std::fmt::Debug for Nip42BindingStatusCoordinates { diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index ff6a2e90fe4..621b3163fc6 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -68,10 +68,6 @@ pub const KIND_LONG_FORM: u32 = 30023; /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. pub const KIND_USER_STATUS: u32 = 30315; -/// NIP-85: relay-signed binding assertion about a user public key. -/// -/// The relay authors the event and keys it by the subject public key in `d`. -pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382; /// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync. /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. diff --git a/crates/buzz-db/src/authorization_admission.rs b/crates/buzz-db/src/authorization_admission.rs index 97337981846..a206644e250 100644 --- a/crates/buzz-db/src/authorization_admission.rs +++ b/crates/buzz-db/src/authorization_admission.rs @@ -127,6 +127,27 @@ impl AdmissionObject { ) } + /// Bind one canonical AUTH mutation to its signed event and domain. + /// + /// Binding-status presentation remains connection-local, so independent + /// connections for the same actor must not compete for one latest-wins + /// protected-object epoch. + pub fn binding_status_auth_event( + authorization_domain: CommunityId, + auth_event_id: [u8; 32], + ) -> Option { + if authorization_domain.as_uuid().is_nil() || auth_event_id == [0; 32] { + return None; + } + Self::new( + AdmissionObjectKind::BindingStatus, + admission_framed_digest( + b"buzz:nip-fi:binding-status-auth-event:v1", + &[authorization_domain.as_uuid().as_bytes(), &auth_event_id], + ), + ) + } + const fn event_kind() -> AdmissionObjectKind { AdmissionObjectKind::Event } @@ -4465,6 +4486,7 @@ mod tests { const TEST_VERIFIER_ISSUER: &str = "https://verifier.example"; const TEST_VERIFIER_AUDIENCE: &str = "buzz-relay-test"; + fn loopback_test_database_url() -> String { format!( "{}://{}:{}@{}:{}/{}", diff --git a/crates/buzz-db/src/authorization_resolver.rs b/crates/buzz-db/src/authorization_resolver.rs index 7674aa316b0..bd322b2ab4b 100644 --- a/crates/buzz-db/src/authorization_resolver.rs +++ b/crates/buzz-db/src/authorization_resolver.rs @@ -408,6 +408,31 @@ async fn read_current_status_evidence( if authority_epoch != protected_authority_epoch || fence != protected_fence { return Err(AuthorizationResolverError::PolicyUnavailable); } + let binding_id = row.try_get("binding_id").map_err(DbError::from)?; + let binding_version = database_u64( + row.try_get("binding_version").map_err(DbError::from)?, + "status binding version", + )?; + let policy_revision = database_u64( + row.try_get("policy_revision").map_err(DbError::from)?, + "status policy revision", + )?; + let invalidation_generation = database_u64( + row.try_get("current_generation").map_err(DbError::from)?, + "status invalidation generation", + )?; + let status_authority_epoch = invalidation_generation + .checked_add(1) + .ok_or(AuthorizationResolverError::PolicyUnavailable)?; + let status_fence = crate::client_status_delivery::derive_status_evidence_fence( + request.authorization_domain(), + request.event_author_pubkey(), + binding_id, + binding_version, + policy_revision, + invalidation_generation, + status_authority_epoch, + )?; let observed_at: DateTime = row.try_get("authoritative_now").map_err(DbError::from)?; let mut fresh_until = observed_at + chrono::Duration::seconds(300); for deadline in [ @@ -427,21 +452,12 @@ async fn read_current_status_evidence( CanonicalCurrentBindingEvidence::new( request.authorization_domain(), request.event_author_pubkey(), - row.try_get("binding_id").map_err(DbError::from)?, - database_u64( - row.try_get("binding_version").map_err(DbError::from)?, - "status binding version", - )?, - database_u64( - row.try_get("policy_revision").map_err(DbError::from)?, - "status policy revision", - )?, - database_u64( - row.try_get("current_generation").map_err(DbError::from)?, - "status invalidation generation", - )?, - authority_epoch, - fence, + binding_id, + binding_version, + policy_revision, + invalidation_generation, + status_authority_epoch, + status_fence, observed_at, fresh_until, ) diff --git a/crates/buzz-db/src/client_status_delivery.rs b/crates/buzz-db/src/client_status_delivery.rs index 35895e45c2d..3f41467beb7 100644 --- a/crates/buzz-db/src/client_status_delivery.rs +++ b/crates/buzz-db/src/client_status_delivery.rs @@ -1513,9 +1513,17 @@ async fn recheck_status_evidence_tx( transaction: &mut Transaction<'_, Postgres>, evidence: &CanonicalCurrentBindingEvidence, authoritative_now: DateTime, +) -> Result { + recheck_connection_status_evidence_tx(transaction, evidence, authoritative_now).await +} + +async fn recheck_connection_status_evidence_tx( + transaction: &mut Transaction<'_, Postgres>, + evidence: &CanonicalCurrentBindingEvidence, + authoritative_now: DateTime, ) -> Result { let row = sqlx::query( - "SELECT binding.binding_id, binding.binding_version, binding.policy_revision, \ + "SELECT binding.binding_id,binding.binding_version,binding.policy_revision, \ domain.current_generation \ FROM identity_bindings binding \ JOIN authorization_invalidation_domains domain \ @@ -1525,14 +1533,17 @@ async fn recheck_status_evidence_tx( AND policy.policy_revision=binding.policy_revision \ WHERE binding.community_id=$1 AND binding.event_author_pubkey=$2 \ AND binding.binding_state=1 AND binding.lifecycle_revision=1 \ - AND (binding.expires_at IS NULL OR binding.expires_at > transaction_timestamp()) \ + AND (binding.expires_at IS NULL OR binding.expires_at > $3) \ + AND policy.effective_at <= $3 \ + AND (policy.expires_at IS NULL OR policy.expires_at > $3) \ AND NOT EXISTS (SELECT 1 FROM identity_enrollment_policies newer \ WHERE newer.community_id=binding.community_id \ AND newer.policy_revision > binding.policy_revision) \ - FOR SHARE OF binding, domain, policy", + FOR SHARE OF binding,domain,policy", ) .bind(evidence.authorization_domain().as_uuid()) .bind(evidence.event_author_pubkey().as_bytes()) + .bind(authoritative_now) .fetch_optional(&mut **transaction) .await?; let Some(row) = row else { @@ -1565,7 +1576,7 @@ async fn recheck_status_evidence_tx( } #[allow(clippy::too_many_arguments)] -fn derive_status_evidence_fence( +pub(crate) fn derive_status_evidence_fence( community_id: CommunityId, author: PublicKey, binding_id: Uuid, diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index bad1bc82062..e6491cf19dc 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -4084,7 +4084,11 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + fn test_database_url() -> Option { + std::env::var("BUZZ_TEST_DATABASE_URL") + .ok() + .or_else(|| std::env::var("DATABASE_URL").ok()) + } /// Build an AppState suitable for handler-level bridge tests. /// @@ -4130,7 +4134,7 @@ mod tests { database_url: Option<&str>, ) -> Option<(Arc, sqlx::PgPool)> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = database_url.unwrap_or(TEST_DB_URL).to_owned(); + config.database_url = database_url.map(str::to_owned).or_else(test_database_url)?; // Use the real local Redis so enforce_http_admission can pass. config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); @@ -4215,10 +4219,15 @@ mod tests { } } - struct UnavailableStatusEvidence; + struct BlockingUnavailableStatusEvidence { + entered: tokio::sync::Notify, + release: tokio::sync::Notify, + } #[async_trait] - impl crate::authorization_runtime::CurrentStatusEvidenceSource for UnavailableStatusEvidence { + impl crate::authorization_runtime::CurrentStatusEvidenceSource + for BlockingUnavailableStatusEvidence + { async fn current( &self, _request: &buzz_auth::CurrentBindingStatusEvidenceRequest, @@ -4226,6 +4235,8 @@ mod tests { buzz_core::CanonicalCurrentBindingEvidence, crate::authorization_runtime::StatusSessionError, > { + self.entered.notify_one(); + self.release.notified().await; Err(crate::authorization_runtime::StatusSessionError::EvidenceUnavailable) } @@ -4375,13 +4386,16 @@ mod tests { use axum::body::{to_bytes, Body}; use axum::http::{header, Request}; use base64::Engine as _; + use buzz_auth::LocalStatusEvidenceResolver as _; use sha2::{Digest as _, Sha256}; use tower::ServiceExt; const KID: &str = "bridge-canonical-test"; const ISSUER: &str = "https://bridge-issuer.example"; const AUDIENCE: &str = "buzz-bridge-test"; - let admin_url = TEST_DB_URL + let test_db_url = test_database_url() + .expect("BUZZ_TEST_DATABASE_URL must name a disposable PostgreSQL database"); + let admin_url = test_db_url .rsplit_once('/') .map(|(prefix, _)| format!("{prefix}/postgres")) .expect("test database URL has a database name"); @@ -4415,7 +4429,7 @@ mod tests { .execute(&admin) .await .expect("create disposable bridge database"); - let database_url = TEST_DB_URL + let database_url = test_db_url .rsplit_once('/') .map(|(prefix, _)| format!("{prefix}/{database_name}")) .expect("derive disposable bridge database URL"); @@ -4806,6 +4820,66 @@ mod tests { assert_eq!(context.authorization().pubkey, actor.public_key()); } assert_eq!(status_results, 1); + let durable_status: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM client_status_delivery_capacity \ + WHERE community_id=$1 AND healthy), \ + (SELECT count(*) FROM client_status_transitions \ + WHERE community_id=$1 AND delivery_kind=1 AND status_revision=1), \ + (SELECT count(*) FROM client_status_delivery_outbox outbox \ + JOIN client_status_transitions transition \ + ON transition.community_id=outbox.community_id \ + AND transition.transition_id=outbox.transition_id \ + WHERE outbox.community_id=$1 AND outbox.delivery_state=2 \ + AND transition.delivery_kind=1)", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("read physically acknowledged current-binding status"); + assert_eq!(durable_status, (1, 1, 1)); + + // The live WS EVENT handler must prepare a fresh operation-bound + // admission from the canonical session. An exact replay returns the + // immutable typed result without a second event write or dispatch. + let websocket_event = EventBuilder::new( + Kind::TextNote, + format!("canonical websocket event {}", uuid::Uuid::new_v4()), + ) + .sign_with_keys(&actor) + .expect("sign canonical WebSocket event"); + crate::handlers::event::handle_event( + websocket_event.clone(), + Arc::clone(&conn), + Arc::clone(&state), + ) + .await; + crate::handlers::event::handle_event( + websocket_event.clone(), + Arc::clone(&conn), + Arc::clone(&state), + ) + .await; + let websocket_event_counts: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=7 AND object_key=$2)", + ) + .bind(community.as_uuid()) + .bind(websocket_event.id.as_bytes()) + .fetch_one(&pool) + .await + .expect("read canonical WebSocket event result"); + assert_eq!(websocket_event_counts, (1, 1)); + let status_evidence_request = + buzz_auth::CurrentBindingStatusEvidenceRequest::new(community, actor.public_key()) + .expect("build current-status evidence request"); + let first_status_evidence = state + .db + .current_status_evidence(&status_evidence_request) + .await + .expect("resolve first connection status evidence"); assert!(conn.canonical_authorization.read().await.is_some()); let bootstrap_index = frames .iter() @@ -4830,11 +4904,255 @@ mod tests { assert!(control_frames.is_empty()); assert!(conn.clear_client_binding_status_task().await); conn.cancel.cancel(); + let prior_connection = match Arc::try_unwrap(conn) { + Ok(connection) => connection, + Err(_) => panic!("first status connection remained shared after task shutdown"), + }; + + // A second opted-in connection for the same actor must bind its own + // socket target while advancing the canonical object epoch. This + // catches accidental equality between the connection-local evidence + // epoch and the independently monotonic admission epoch. + let reconnect_challenge = format!("bridge-auth-reconnect-{}", uuid::Uuid::new_v4()); + let reconnect_event = EventBuilder::auth( + &reconnect_challenge, + nostr::RelayUrl::parse(&relay_url).expect("parse reconnect relay URL"), + ) + .tag( + Tag::parse([ + buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG, + "1", + uuid::Uuid::new_v4().to_string().as_str(), + state.relay_keypair.public_key().to_hex().as_str(), + ]) + .expect("build reconnect binding-status scope"), + ) + .sign_with_keys(&actor) + .expect("sign reconnect AUTH event"); + let reconnect_peer = buzz_auth::AuthenticatedClientPeer::for_test([0x93; 32]); + let reconnect_evidence = buzz_auth::SealedTransportEvidence::for_test_with_nonce( + community, + assertion.clone(), + b"GET", + host.as_bytes(), + b"/", + [0; 32], + buzz_auth::ProofTransport::Nip42, + chrono::Utc::now() + chrono::Duration::seconds(300), + reconnect_peer, + [0x92; 32], + ); + let (reconnect_send_tx, mut reconnect_send_rx) = tokio::sync::mpsc::channel(16); + let (reconnect_ctrl_tx, mut reconnect_ctrl_rx) = tokio::sync::mpsc::channel(8); + let reconnect_status_writer = test_status_writer(reconnect_send_tx.clone()); + let reconnect = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + canonical_transport_evidence: tokio::sync::Mutex::new(Some(reconnect_evidence)), + canonical_authorization: tokio::sync::RwLock::new(None), + auth_state: tokio::sync::RwLock::new(crate::connection::AuthState::Pending { + challenge: reconnect_challenge, + }), + status_scope: tokio::sync::RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx: reconnect_send_tx, + status_writer: reconnect_status_writer, + ctrl_tx: reconnect_ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + ..prior_connection + }); + crate::handlers::auth::handle_auth( + reconnect_event, + Arc::clone(&reconnect), + Arc::clone(&state), + ) + .await; + let mut reconnect_frames = Vec::new(); + while let Ok(frame) = reconnect_send_rx.try_recv() { + reconnect_frames.push(format!("{frame:?}")); + } + let mut reconnect_control_frames = Vec::new(); + while let Ok(frame) = reconnect_ctrl_rx.try_recv() { + reconnect_control_frames.push(format!("{frame:?}")); + } + { + let auth = reconnect.auth_state.read().await; + let crate::connection::AuthState::Authenticated(context) = &*auth else { + panic!( + "reconnect did not authenticate: data={reconnect_frames:?} control={reconnect_control_frames:?}" + ); + }; + assert_eq!(context.authenticated_client_peer(), Some(&reconnect_peer)); + } + assert!(reconnect.canonical_authorization.read().await.is_some()); + assert!(reconnect_frames.iter().any(|frame| frame.contains("true"))); + assert!(reconnect_frames.iter().any(|frame| frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_BOOTSTRAP_SUB_ID))); + assert!(reconnect_frames.iter().any(|frame| frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID))); + assert!(reconnect_control_frames.is_empty()); + let reconnect_status: (i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=8), \ + (SELECT count(*) FROM client_status_transitions \ + WHERE community_id=$1 AND delivery_kind=1 AND status_revision=1), \ + (SELECT count(*) FROM client_status_delivery_outbox outbox \ + JOIN client_status_transitions transition \ + ON transition.community_id=outbox.community_id \ + AND transition.transition_id=outbox.transition_id \ + WHERE outbox.community_id=$1 AND outbox.delivery_state=2 \ + AND transition.delivery_kind=1)", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("read reconnect status completion"); + assert_eq!(reconnect_status, (2, 2, 2)); + let reconnect_status_evidence = state + .db + .current_status_evidence(&status_evidence_request) + .await + .expect("resolve reconnect status evidence"); + assert_eq!( + ( + first_status_evidence.binding_id(), + first_status_evidence.binding_version(), + first_status_evidence.policy_revision(), + first_status_evidence.invalidation_generation(), + first_status_evidence.authority_epoch(), + first_status_evidence.fence(), + ), + ( + reconnect_status_evidence.binding_id(), + reconnect_status_evidence.binding_version(), + reconnect_status_evidence.policy_revision(), + reconnect_status_evidence.invalidation_generation(), + reconnect_status_evidence.authority_epoch(), + reconnect_status_evidence.fence(), + ), + "a later connection must preserve the stable connection-status evidence", + ); + assert!(reconnect.clear_client_binding_status_task().await); + reconnect.cancel.cancel(); + let prior_reconnect = match Arc::try_unwrap(reconnect) { + Ok(connection) => connection, + Err(_) => panic!("reconnect remained shared after task shutdown"), + }; + + // The connection-status scope remains an opt-in. A canonical Enforce + // AUTH without that tag must authenticate normally and must not create + // a bootstrap or status delivery. + let unscoped_challenge = format!("bridge-auth-unscoped-{}", uuid::Uuid::new_v4()); + let unscoped_event = EventBuilder::auth( + &unscoped_challenge, + nostr::RelayUrl::parse(&relay_url).expect("parse unscoped relay URL"), + ) + .sign_with_keys(&actor) + .expect("sign unscoped AUTH event"); + let unscoped_peer = buzz_auth::AuthenticatedClientPeer::for_test([0x94; 32]); + let unscoped_evidence = buzz_auth::SealedTransportEvidence::for_test_with_nonce( + community, + assertion.clone(), + b"GET", + host.as_bytes(), + b"/", + [0; 32], + buzz_auth::ProofTransport::Nip42, + chrono::Utc::now() + chrono::Duration::seconds(300), + unscoped_peer, + [0x93; 32], + ); + let (unscoped_send_tx, mut unscoped_send_rx) = tokio::sync::mpsc::channel(8); + let (unscoped_ctrl_tx, mut unscoped_ctrl_rx) = tokio::sync::mpsc::channel(8); + let unscoped_status_writer = test_status_writer(unscoped_send_tx.clone()); + let unscoped = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + canonical_transport_evidence: tokio::sync::Mutex::new(Some(unscoped_evidence)), + canonical_authorization: tokio::sync::RwLock::new(None), + auth_state: tokio::sync::RwLock::new(crate::connection::AuthState::Pending { + challenge: unscoped_challenge, + }), + status_scope: tokio::sync::RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx: unscoped_send_tx, + status_writer: unscoped_status_writer, + ctrl_tx: unscoped_ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + ..prior_reconnect + }); + crate::handlers::auth::handle_auth( + unscoped_event, + Arc::clone(&unscoped), + Arc::clone(&state), + ) + .await; + let mut unscoped_frames = Vec::new(); + while let Ok(frame) = unscoped_send_rx.try_recv() { + unscoped_frames.push(format!("{frame:?}")); + } + assert!(matches!( + &*unscoped.auth_state.read().await, + crate::connection::AuthState::Authenticated(context) + if context.authenticated_client_peer() == Some(&unscoped_peer) + )); + assert!(unscoped.canonical_authorization.read().await.is_some()); + assert!(unscoped_frames.iter().any(|frame| frame.contains("true"))); + assert!(unscoped_frames.iter().all(|frame| !frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_BOOTSTRAP_SUB_ID))); + assert!(unscoped_frames.iter().all(|frame| !frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID))); + assert!(unscoped_ctrl_rx.try_recv().is_err()); + let unscoped_counts: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=8), \ + (SELECT count(*) FROM client_status_transitions \ + WHERE community_id=$1 AND delivery_kind=1)", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("read unscoped canonical AUTH state"); + assert_eq!(unscoped_counts, (3, 2)); + let unscoped_status_evidence = state + .db + .current_status_evidence(&status_evidence_request) + .await + .expect("resolve status evidence after unscoped AUTH"); + assert_eq!( + ( + first_status_evidence.binding_id(), + first_status_evidence.binding_version(), + first_status_evidence.policy_revision(), + first_status_evidence.invalidation_generation(), + first_status_evidence.authority_epoch(), + first_status_evidence.fence(), + ), + ( + unscoped_status_evidence.binding_id(), + unscoped_status_evidence.binding_version(), + unscoped_status_evidence.policy_revision(), + unscoped_status_evidence.invalidation_generation(), + unscoped_status_evidence.authority_epoch(), + unscoped_status_evidence.fence(), + ), + "unscoped AUTH must preserve the stable connection-status evidence", + ); + unscoped.cancel.cancel(); + let prior_unscoped = match Arc::try_unwrap(unscoped) { + Ok(connection) => connection, + Err(_) => panic!("unscoped connection remained shared after AUTH"), + }; // Keep canonical admission live but fail the evidence source that the // real AUTH owner awaits before it may acknowledge success. - *state.client_status_evidence_override.write().await = - Some(Arc::new(UnavailableStatusEvidence)); + let unavailable_status = Arc::new(BlockingUnavailableStatusEvidence { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + *state.client_status_evidence_override.write().await = Some(unavailable_status.clone()); let failed_actor = Keys::generate(); let failed_subject = format!("bridge-status-failure-{}", uuid::Uuid::new_v4()); let failed_host = format!( @@ -4919,12 +5237,24 @@ mod tests { backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), grace_limit: 3, }); - crate::handlers::auth::handle_auth( + let failed_auth_task = tokio::spawn(crate::handlers::auth::handle_auth( failed_auth_event, Arc::clone(&failed_conn), Arc::clone(&state), + )); + tokio::time::timeout( + std::time::Duration::from_secs(5), + unavailable_status.entered.notified(), ) - .await; + .await + .expect("failed evidence source must be reached"); + assert!(matches!( + &*failed_conn.auth_state.read().await, + crate::connection::AuthState::Pending { .. } + )); + assert!(failed_conn.canonical_authorization.read().await.is_none()); + unavailable_status.release.notify_one(); + failed_auth_task.await.expect("join failed AUTH task"); let mut failed_frames = Vec::new(); while let Ok(frame) = failed_send_rx.try_recv() { failed_frames.push(format!("{frame:?}")); @@ -4957,6 +5287,135 @@ mod tests { assert!(failed_conn.canonical_authorization.read().await.is_none()); assert!(failed_conn.cancel.is_cancelled()); assert!(!failed_conn.clear_client_binding_status_task().await); + let prior_failed = match Arc::try_unwrap(failed_conn) { + Ok(connection) => connection, + Err(_) => panic!("failed connection remained shared after AUTH"), + }; + *state.client_status_evidence_override.write().await = None; + + // Two independent AUTH events for the same actor must not compete for + // one actor-global protected-object epoch. Start two opted-in live + // status owners in the same scheduler turn and require both physical + // status activations plus two authenticated sessions. + let concurrent_connection = |marker: u8, prior: crate::connection::ConnectionState| { + let challenge = format!("bridge-auth-concurrent-{marker}-{}", uuid::Uuid::new_v4()); + let relay_signer = state.relay_keypair.public_key().to_hex(); + let connection_epoch = uuid::Uuid::new_v4().to_string(); + let auth_event = EventBuilder::auth( + &challenge, + nostr::RelayUrl::parse(&relay_url).expect("parse concurrent relay URL"), + ) + .tag( + Tag::parse([ + buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG, + "1", + connection_epoch.as_str(), + relay_signer.as_str(), + ]) + .expect("build concurrent binding-status scope"), + ) + .sign_with_keys(&actor) + .expect("sign concurrent AUTH event"); + let peer = buzz_auth::AuthenticatedClientPeer::for_test([marker; 32]); + let evidence = buzz_auth::SealedTransportEvidence::for_test_with_nonce( + community, + assertion.clone(), + b"GET", + host.as_bytes(), + b"/", + [0; 32], + buzz_auth::ProofTransport::Nip42, + chrono::Utc::now() + chrono::Duration::seconds(300), + peer, + [marker.wrapping_add(1); 32], + ); + let (send_tx, send_rx) = tokio::sync::mpsc::channel(16); + let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel(8); + let connection = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: TenantContext::resolved(community, &host), + canonical_transport_evidence: tokio::sync::Mutex::new(Some(evidence)), + canonical_authorization: tokio::sync::RwLock::new(None), + auth_state: tokio::sync::RwLock::new(crate::connection::AuthState::Pending { + challenge, + }), + status_scope: tokio::sync::RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + status_writer: test_status_writer(send_tx.clone()), + send_tx, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + ..prior + }); + (auth_event, peer, connection, send_rx, ctrl_rx) + }; + let ( + concurrent_event_a, + concurrent_peer_a, + concurrent_a, + mut concurrent_send_rx_a, + mut concurrent_ctrl_rx_a, + ) = concurrent_connection(0xa1, prior_unscoped); + let ( + concurrent_event_b, + concurrent_peer_b, + concurrent_b, + mut concurrent_send_rx_b, + mut concurrent_ctrl_rx_b, + ) = concurrent_connection(0xb1, prior_failed); + tokio::join!( + crate::handlers::auth::handle_auth( + concurrent_event_a, + Arc::clone(&concurrent_a), + Arc::clone(&state), + ), + crate::handlers::auth::handle_auth( + concurrent_event_b, + Arc::clone(&concurrent_b), + Arc::clone(&state), + ), + ); + assert!(matches!( + &*concurrent_a.auth_state.read().await, + crate::connection::AuthState::Authenticated(context) + if context.authenticated_client_peer() == Some(&concurrent_peer_a) + )); + assert!(matches!( + &*concurrent_b.auth_state.read().await, + crate::connection::AuthState::Authenticated(context) + if context.authenticated_client_peer() == Some(&concurrent_peer_b) + )); + assert!(concurrent_a.canonical_authorization.read().await.is_some()); + assert!(concurrent_b.canonical_authorization.read().await.is_some()); + for frames in [&mut concurrent_send_rx_a, &mut concurrent_send_rx_b] { + let mut delivered = Vec::new(); + while let Ok(frame) = frames.try_recv() { + delivered.push(format!("{frame:?}")); + } + assert!(delivered.iter().any(|frame| frame.contains("true"))); + assert!(delivered.iter().any(|frame| frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_BOOTSTRAP_SUB_ID))); + assert!(delivered.iter().any(|frame| frame + .contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID))); + } + assert!(concurrent_ctrl_rx_a.try_recv().is_err()); + assert!(concurrent_ctrl_rx_b.try_recv().is_err()); + let concurrent_results: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=8), \ + (SELECT count(*) FROM client_status_transitions \ + WHERE community_id=$1 AND delivery_kind=1 AND status_revision=1)", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count concurrent canonical AUTH and status results"); + assert_eq!(concurrent_results, (5, 4)); + concurrent_a.cancel.cancel(); + concurrent_b.cancel.cancel(); let tenant = TenantContext::resolved(community, &host); let rate_key = buzz_auth::rate_limit::rate_limit_key( diff --git a/crates/buzz-relay/src/authorization_runtime/outbox.rs b/crates/buzz-relay/src/authorization_runtime/outbox.rs index 46c6ab76879..5316a8dc46c 100644 --- a/crates/buzz-relay/src/authorization_runtime/outbox.rs +++ b/crates/buzz-relay/src/authorization_runtime/outbox.rs @@ -32,7 +32,7 @@ use super::status::{ CurrentStatusContract, CurrentStatusEvidenceSource, CurrentStatusSink, StatusSessionError, UnchangedBootstrapDelivery, }; -use crate::connection::{AuthState, ConnectionState, StatusWriteIdentity, StatusWriter}; +use crate::connection::{ConnectionState, StatusWriteIdentity, StatusWriter}; use crate::protocol::RelayMessage; const CLAIM_LEASE: Duration = Duration::from_secs(30); @@ -191,30 +191,102 @@ where in_flight: tokio::sync::Mutex>, } +/// Pair of co-committed admission authority and exact connection authority +/// after validation of their typed application-result binding. +pub(crate) struct CommittedStatusDeliveryAuthority { + admission_lease: BoundedAuthorizationLease, + connection_lease: BoundedAuthorizationLease, + connection_binding: ([u8; 32], [u8; 32], [u8; 32]), +} + +impl CommittedStatusDeliveryAuthority { + pub(crate) fn new( + admission_object: buzz_db::authorization_admission::AdmissionObject, + admission_lease: BoundedAuthorizationLease, + connection_lease: BoundedAuthorizationLease, + connection_binding: ([u8; 32], [u8; 32], [u8; 32]), + ) -> Result { + let (admission_policy, admission_generation, _) = admission_lease.dependency_versions(); + let (connection_policy, connection_generation, _) = connection_lease.dependency_versions(); + let (_, admission_target, _) = admission_lease.request_binding(); + let (connection_request, connection_target, connection_transport) = + connection_lease.request_binding(); + if admission_lease.capability() != RouteCapability::BindingStatus + || connection_lease.capability() != RouteCapability::BindingStatus + || admission_lease.owner_pubkey().is_some() + || connection_lease.owner_pubkey().is_some() + || admission_lease.transport() != ProofTransport::Nip42 + || connection_lease.transport() != ProofTransport::Nip42 + || admission_lease.authorization_domain() != connection_lease.authorization_domain() + || admission_lease.actor_pubkey() != connection_lease.actor_pubkey() + || admission_lease.binding() != connection_lease.binding() + || admission_policy != connection_policy + || admission_generation != connection_generation + || admission_object.kind() + != buzz_db::authorization_admission::AdmissionObjectKind::BindingStatus + || admission_target != admission_object.key() + || (connection_request, connection_target, connection_transport) + != ( + &connection_binding.0, + &connection_binding.1, + &connection_binding.2, + ) + { + return Err(DurableStatusError::Unauthorized); + } + Ok(Self { + admission_lease, + connection_lease, + connection_binding, + }) + } + + pub(crate) const fn admission_lease(&self) -> &BoundedAuthorizationLease { + &self.admission_lease + } + + pub(crate) const fn connection_lease(&self) -> &BoundedAuthorizationLease { + &self.connection_lease + } +} + impl DurableStatusSink where E: CurrentStatusEvidenceSource + ?Sized + 'static, { /// Validate exact connection ownership and physically flush bootstrap. /// - /// The caller must pass the finalized direct binding-status lease that it - /// will convert into `CurrentStatusAuthorization`; legacy AUTH state alone - /// is intentionally insufficient. - pub async fn activate( + /// The caller must pass both the co-committed admission lease and the + /// connection-sealed delivery lease. The first owns durable authority and + /// the second owns the exact socket target; legacy AUTH state alone is + /// intentionally insufficient. + pub(crate) async fn activate( mode: NipFiMode, db: Db, evidence: Arc, connection: Arc, - lease: &BoundedAuthorizationLease, + authority: CommittedStatusDeliveryAuthority, relay_keys: &Keys, authoritative_now: DateTime, ) -> Result<(Self, UnchangedBootstrapDelivery), DurableStatusError> { + let CommittedStatusDeliveryAuthority { + admission_lease, + connection_lease, + connection_binding, + } = authority; if mode != NipFiMode::Enforce - || lease.capability() != RouteCapability::BindingStatus - || lease.owner_pubkey().is_some() - || lease.transport() != ProofTransport::Nip42 - || lease.authorization_domain() != connection.tenant.community() - || !lease.is_valid_at(authoritative_now) + || admission_lease.capability() != RouteCapability::BindingStatus + || connection_lease.capability() != RouteCapability::BindingStatus + || admission_lease.owner_pubkey().is_some() + || connection_lease.owner_pubkey().is_some() + || admission_lease.transport() != ProofTransport::Nip42 + || connection_lease.transport() != ProofTransport::Nip42 + || admission_lease.authorization_domain() != connection.tenant.community() + || connection_lease.authorization_domain() != connection.tenant.community() + || admission_lease.actor_pubkey() != connection_lease.actor_pubkey() + || admission_lease.binding() != connection_lease.binding() + || !admission_lease.is_valid_at(authoritative_now) + || !connection_lease.is_valid_at(authoritative_now) { return Err(DurableStatusError::Unauthorized); } @@ -227,14 +299,8 @@ where if scope.relay_signer() != relay_keys.public_key() { return Err(DurableStatusError::Unauthorized); } - let author = lease.actor_pubkey(); - let directly_authenticated = match &*connection.auth_state.read().await { - AuthState::Authenticated(context) => { - context.pubkey == author && context.agent_owner_pubkey.is_none() - } - AuthState::Pending { .. } | AuthState::Failed => false, - }; - if !directly_authenticated || connection.cancel.is_cancelled() { + let author = admission_lease.actor_pubkey(); + if connection.cancel.is_cancelled() { return Err(DurableStatusError::Unauthorized); } let connection_fingerprint = status_fingerprint( @@ -247,8 +313,7 @@ where scope.connection_epoch().as_str().as_bytes(), ], ); - let (_, authorized_target, _) = lease.request_binding(); - if authorized_target != &connection_fingerprint { + if connection_binding.1 != connection_fingerprint { return Err(DurableStatusError::Unauthorized); } db.install_status_delivery_capacity(connection.tenant.community()) diff --git a/crates/buzz-relay/src/authorization_runtime/routes.rs b/crates/buzz-relay/src/authorization_runtime/routes.rs index 62bf2789e84..457c89b6f17 100644 --- a/crates/buzz-relay/src/authorization_runtime/routes.rs +++ b/crates/buzz-relay/src/authorization_runtime/routes.rs @@ -122,6 +122,7 @@ impl ProtectedIngress { pub const fn required_effect(self) -> ProtectedEffect { match self { Self::WebSocketAuthenticate + | Self::WebSocketEvent | Self::BridgeEvent | Self::ModerationWrite | Self::OperatorWrite diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs index 25b645b2a95..f57fe0ca264 100644 --- a/crates/buzz-relay/src/authorization_runtime/status.rs +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -7,7 +7,7 @@ use std::time::Duration; use async_trait::async_trait; use buzz_auth::{ BoundedAuthorizationLease, CurrentBindingStatusEvidenceRequest, LocalStatusEvidenceResolver, - RouteCapability, + ProofTransport, RouteCapability, }; use buzz_core::client_binding_status::ClientBindingStatusInputV1; use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; @@ -84,24 +84,44 @@ pub struct CurrentStatusAuthorization { } impl CurrentStatusAuthorization { - /// Capture the exact domain, actor, and exclusive lease bound. - pub fn from_lease(lease: &BoundedAuthorizationLease) -> Result { - if lease.capability() != RouteCapability::BindingStatus || lease.owner_pubkey().is_some() { + /// Bind stable connection evidence to the shorter lifetime of its fresh + /// co-committed canonical admission. + pub(crate) fn from_committed_connection( + connection: &BoundedAuthorizationLease, + admission: &BoundedAuthorizationLease, + ) -> Result { + let (connection_policy, connection_generation, connection_epoch) = + connection.dependency_versions(); + let (admission_policy, admission_generation, _) = admission.dependency_versions(); + if connection.capability() != RouteCapability::BindingStatus + || admission.capability() != RouteCapability::BindingStatus + || connection.owner_pubkey().is_some() + || admission.owner_pubkey().is_some() + || connection.transport() != ProofTransport::Nip42 + || admission.transport() != ProofTransport::Nip42 + || connection.authorization_domain() != admission.authorization_domain() + || connection.actor_pubkey() != admission.actor_pubkey() + || connection.binding() != admission.binding() + || connection_policy != admission_policy + || connection_generation != admission_generation + { return Err(StatusSessionError::EvidenceUnavailable); } - let (binding_id, binding_version) = lease.binding(); - let (policy_revision, invalidation_generation, authority_epoch) = - lease.dependency_versions(); + let expires_at = connection.expires_at().min(admission.expires_at()); + if expires_at <= connection.issued_at().max(admission.issued_at()) { + return Err(StatusSessionError::Expired); + } + let (binding_id, binding_version) = connection.binding(); Ok(Self { - domain: lease.authorization_domain(), - author: lease.actor_pubkey(), + domain: connection.authorization_domain(), + author: connection.actor_pubkey(), binding_id, binding_version, - policy_revision, - invalidation_generation, - authority_epoch, - fence: lease.fence(), - expires_at: lease.expires_at(), + policy_revision: connection_policy, + invalidation_generation: connection_generation, + authority_epoch: connection_epoch, + fence: connection.fence(), + expires_at, }) } diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 0bb2a00c671..c1c51408f78 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -179,15 +179,27 @@ pub enum AuthState { #[derive(Clone)] pub(crate) struct CanonicalWebsocketSession { read: buzz_auth::FinalizedAuthContext, - write: buzz_auth::FinalizedAuthContext, + event_assertion: buzz_auth::VerifiedFederatedAssertion, } impl CanonicalWebsocketSession { pub(crate) fn new( read: buzz_auth::FinalizedAuthContext, - write: buzz_auth::FinalizedAuthContext, + event_assertion: buzz_auth::VerifiedFederatedAssertion, ) -> Self { - Self { read, write } + Self { + read, + event_assertion, + } + } + + pub(crate) fn event_admission( + &self, + ) -> ( + buzz_auth::FinalizedAuthContext, + buzz_auth::VerifiedFederatedAssertion, + ) { + (self.read.clone(), self.event_assertion.clone()) } fn authorization( @@ -195,7 +207,6 @@ impl CanonicalWebsocketSession { ingress: crate::authorization_runtime::ProtectedIngress, ) -> Option<&buzz_auth::FinalizedAuthContext> { match ingress { - crate::authorization_runtime::ProtectedIngress::WebSocketEvent => Some(&self.write), crate::authorization_runtime::ProtectedIngress::WebSocketQuery | crate::authorization_runtime::ProtectedIngress::WebSocketCount => Some(&self.read), _ => None, @@ -1098,18 +1109,28 @@ async fn enforce_ws_admission( } _ => return true, }; + let session_ingress = if is_event { + // EVENT uses the read-only canonical session only as its AUTH + // origin. The handler independently prepares and commits the + // exact MessagesWrite mutation before any event effect. + crate::authorization_runtime::ProtectedIngress::WebSocketQuery + } else { + ingress + }; let admitted = { let authorization = conn.canonical_authorization.read().await; authorization.as_ref().is_some_and(|session| { - session.authorization(ingress).is_some_and(|authorization| { - crate::protected_ingress::session_authorizes( - state, - ingress, - authorization, - conn.tenant.community(), - pubkey, - ) - }) + session + .authorization(session_ingress) + .is_some_and(|authorization| { + crate::protected_ingress::session_authorizes( + state, + session_ingress, + authorization, + conn.tenant.community(), + pubkey, + ) + }) }) }; if !admitted { @@ -1125,6 +1146,12 @@ async fn enforce_ws_admission( )); return false; } + if is_event { + // Enforce EVENT owns quota/replay and application mutation inside + // the canonical PostgreSQL transaction. Legacy Redis admission + // here would mutate before the exact event can be authorized. + return true; + } } let limits = &state.auth.config().rate_limits; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index e86da03d014..8e0c215e198 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -20,7 +20,9 @@ use buzz_db::authorization_admission::{ }; use tracing::{debug, info, warn}; -use crate::authorization_runtime::outbox::{DurableStatusContract, DurableStatusSink}; +use crate::authorization_runtime::outbox::{ + CommittedStatusDeliveryAuthority, DurableStatusContract, DurableStatusSink, +}; use crate::authorization_runtime::{ ConnectionStatusSession, CurrentStatusAuthorization, CurrentStatusEvidenceSource, ProviderFreeRuntimeMode, StatusCadence, StatusSessionError, @@ -29,22 +31,59 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -struct BindingStatusAdmissionEffect { +#[derive(Clone, Copy, PartialEq, Eq)] +struct StatusConnectionBinding { + request: [u8; 32], + target: [u8; 32], + transport: [u8; 32], +} + +impl StatusConnectionBinding { + fn from_coordinates(coordinates: &buzz_auth::Nip42BindingStatusCoordinates) -> Self { + let (request, target, transport) = coordinates.request_binding(); + Self { + request: *request, + target: *target, + transport: *transport, + } + } +} + +fn committed_status_receipt_matches( + authorization_request: &[u8; 32], + receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, + expected_result_digest: &[u8; 32], +) -> bool { + authorization_request == receipt.request_fingerprint() + && receipt.application_result_digest() == Some(expected_result_digest) +} + +#[derive(Clone, Copy)] +struct BindingStatusApplicationBinding { object: AdmissionObject, actor: nostr::PublicKey, + event_id: [u8; 32], + connection: Option, intent_digest: [u8; 32], } -impl BindingStatusAdmissionEffect { +impl BindingStatusApplicationBinding { fn new( object: AdmissionObject, actor: nostr::PublicKey, event_id: [u8; 32], challenge: &str, + connection: Option, ) -> Self { + let connection_enabled = [u8::from(connection.is_some())]; + let connection_request = connection.map_or([0; 32], |binding| binding.request); + let connection_target = connection.map_or([0; 32], |binding| binding.target); + let connection_transport = connection.map_or([0; 32], |binding| binding.transport); Self { object, actor, + event_id, + connection, intent_digest: crate::protected_ingress::fingerprint( b"buzz:nip-fi:binding-status-admission-intent:v1", &[ @@ -52,15 +91,128 @@ impl BindingStatusAdmissionEffect { actor.as_bytes(), &event_id, challenge.as_bytes(), + &connection_enabled, + &connection_request, + &connection_target, + &connection_transport, ], ), } } + + fn application_result(self) -> Result { + let mut payload = Vec::with_capacity(130); + payload.push(1); + payload.extend_from_slice(&self.event_id); + payload.push(u8::from(self.connection.is_some())); + if let Some(connection) = self.connection { + payload.extend_from_slice(&connection.request); + payload.extend_from_slice(&connection.target); + payload.extend_from_slice(&connection.transport); + } + AdmissionApplicationResult::new( + AdmissionApplicationResultSchema::binding_status(), + 1, + payload, + ) + } + + fn application_result_digest( + self, + result_binding: buzz_db::authorization_admission::AdmissionApplicationResultBinding, + result: &AdmissionApplicationResult, + ) -> Result<[u8; 32], AdmissionCommitError> { + let authorization_domain = result_binding.authorization_domain(); + let semantic_fingerprint = result_binding.semantic_fingerprint(); + let application_intent_digest = result_binding.application_intent_digest(); + if authorization_domain.as_uuid().is_nil() + || semantic_fingerprint == &[0; 32] + || application_intent_digest == &[0; 32] + { + return Err(AdmissionCommitError::InvalidRequest); + } + let schema = result.schema(); + Ok(crate::protected_ingress::fingerprint( + b"buzz:canonical-application-result:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + &self.object.kind().database_code().to_be_bytes(), + self.object.key(), + semantic_fingerprint, + application_intent_digest, + schema.type_key(), + &schema.version().to_be_bytes(), + &result.code().to_be_bytes(), + result.payload(), + ], + )) + } + + fn bind_committed( + self, + authorization: buzz_auth::FinalizedAuthContext, + receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, + application_result: AdmissionApplicationResult, + result_binding: buzz_db::authorization_admission::AdmissionApplicationResultBinding, + connection_lease: Option, + ) -> Result, AdmissionCommitError> { + let expected_result = self.application_result()?; + let expected_result_digest = + self.application_result_digest(result_binding, &expected_result)?; + let admission_lease = authorization.lease().clone(); + let (_, admission_target, _) = admission_lease.request_binding(); + if authorization.authorization_domain() != receipt.authorization_domain() + || authorization.authorization_domain() != result_binding.authorization_domain() + || authorization.authorization_domain() != admission_lease.authorization_domain() + || authorization.actor_pubkey() != self.actor + || authorization.capability() != buzz_auth::RouteCapability::BindingStatus + || authorization.owner_pubkey().is_some() + || !committed_status_receipt_matches( + authorization.request_fingerprint(), + receipt, + &expected_result_digest, + ) + || admission_target != self.object.key() + || receipt.object() != self.object + || result_binding.object() != self.object + || receipt.semantic_fingerprint() != result_binding.semantic_fingerprint() + || result_binding.application_intent_digest() != &self.intent_digest + || application_result != expected_result + { + return Err(AdmissionCommitError::IntentConflict); + } + match (self.connection, connection_lease) { + (None, None) => Ok(None), + (Some(binding), Some(connection_lease)) => { + let delivery_authority = CommittedStatusDeliveryAuthority::new( + self.object, + admission_lease, + connection_lease, + (binding.request, binding.target, binding.transport), + ) + .map_err(|_| AdmissionCommitError::IntentConflict)?; + let authorization = CurrentStatusAuthorization::from_committed_connection( + delivery_authority.connection_lease(), + delivery_authority.admission_lease(), + ) + .map_err(|_| AdmissionCommitError::IntentConflict)?; + Ok(Some(CommittedStatusActivation { + authorization, + delivery_authority, + })) + } + (None, Some(_)) | (Some(_), None) => Err(AdmissionCommitError::IntentConflict), + } + } +} + +struct BindingStatusAdmissionEffect { + binding: BindingStatusApplicationBinding, } impl AdmissionApplicationEffect for BindingStatusAdmissionEffect { fn intent_digest(&self) -> [u8; 32] { - self.intent_digest + self.binding.intent_digest } fn result_schema(&self) -> AdmissionApplicationResultSchema { @@ -80,20 +232,20 @@ impl AdmissionApplicationEffect for BindingStatusAdmissionEffect { >, > { Box::pin(async move { - if context.object() != self.object - || context.authorization().actor_pubkey() != self.actor + if context.object() != self.binding.object + || context.authorization().actor_pubkey() != self.binding.actor || context.authorization().capability() != buzz_auth::RouteCapability::BindingStatus { return Err(AdmissionCommitError::AuthorizationDenied); } - let result = AdmissionApplicationResult::new(self.result_schema(), 1, Vec::new())?; + let result = self.binding.application_result()?; let effect_digest = crate::protected_ingress::fingerprint( b"buzz:nip-fi:binding-status-admission-effect:v1", &[ context.authorization_domain().as_uuid().as_bytes(), context.operation_id().as_bytes(), context.request_fingerprint(), - &self.intent_digest, + &self.binding.intent_digest, ], ); AdmissionApplicationOutcome::new(result, effect_digest) @@ -127,6 +279,11 @@ fn status_activation_scope_matches( authorization_domain == tenant_domain && authorization_author == verified_auth_event.pubkey } +struct CommittedStatusActivation { + authorization: CurrentStatusAuthorization, + delivery_authority: CommittedStatusDeliveryAuthority, +} + /// Activate current-only status after the caller has completed canonical /// scoped NIP-42 finalization and obtained a status authorization. /// @@ -137,10 +294,10 @@ fn status_activation_scope_matches( /// bootstrap, then becomes sole owner of the new task. `Ok(None)` means the /// optional presentation was withheld by authenticated-peer admission without /// changing the completed AUTH decision. -pub async fn activate_client_binding_status( +async fn activate_client_binding_status( verified_auth_event: &nostr::Event, - authorization: CurrentStatusAuthorization, - status_lease: buzz_auth::BoundedAuthorizationLease, + activation: CommittedStatusActivation, + authenticated_peer: buzz_auth::AuthenticatedClientPeer, evidence: Arc, conn: Arc, state: Arc, @@ -148,6 +305,10 @@ pub async fn activate_client_binding_status( where E: CurrentStatusEvidenceSource + ?Sized + 'static, { + let CommittedStatusActivation { + authorization, + delivery_authority, + } = activation; if state.authorization_runtime.mode() != ProviderFreeRuntimeMode::Enforce || !state.authorization_runtime.is_ready() { @@ -167,29 +328,10 @@ where conn.cancel.cancel(); StatusSessionError::ContractUnavailable })?; - let authenticated_peer = { - let auth = conn.auth_state.read().await; - match &*auth { - AuthState::Authenticated(context) if context.pubkey == verified_auth_event.pubkey => { - context.authenticated_client_peer().copied() - } - AuthState::Authenticated(_) => { - conn.cancel.cancel(); - return Err(StatusSessionError::EvidenceChanged); - } - AuthState::Pending { .. } | AuthState::Failed => { - conn.cancel.cancel(); - return Err(StatusSessionError::EvidenceUnavailable); - } - } - }; if !conn.clear_client_binding_status_task().await { conn.cancel.cancel(); return Err(StatusSessionError::DeliveryFailed); } - let Some(authenticated_peer) = authenticated_peer else { - return Ok(None); - }; let admission_policy = state .config .nip_fi @@ -222,7 +364,7 @@ where state.db.clone(), Arc::clone(&evidence), Arc::clone(&conn), - &status_lease, + delivery_authority, &state.relay_keypair, activation_now, ) @@ -696,37 +838,16 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } - let authenticated_peer = canonical_status - .as_ref() - .map(|admission| admission.authenticated_peer); - *conn.auth_state.write().await = - AuthState::Authenticated(crate::connection::AuthenticatedConnectionContext::new( - auth_ctx, - authenticated_peer, - )); + let mut authenticated_peer = None; + let mut canonical_session = None; if let Some(admission) = canonical_status { let CanonicalWebsocketAdmission { - status_authorization, - status_lease, + status_activation, + authenticated_peer: canonical_peer, read_authorization, - write_authorization, - .. + event_assertion, } = admission; - *conn.canonical_authorization.write().await = - Some(crate::connection::CanonicalWebsocketSession::new( - read_authorization, - write_authorization, - )); - let opted_in = canonical_event.tags.iter().any(|tag| { - tag.as_slice().first().map(String::as_str) - == Some(buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG) - }); - if opted_in { - let resolver = Arc::new( - buzz_db::authorization_resolver::PostgresLocalBindingResolver::new( - state.db.clone(), - ), - ); + if let Some(status_activation) = status_activation { let evidence: Arc< dyn crate::authorization_runtime::CurrentStatusEvidenceSource, > = { @@ -738,21 +859,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } else { Arc::new( crate::authorization_runtime::LocalBindingStatusEvidenceSource::new( - resolver, + Arc::new(state.db.clone()), ), ) } #[cfg(not(test))] Arc::new( crate::authorization_runtime::LocalBindingStatusEvidenceSource::new( - resolver, + Arc::new(state.db.clone()), ), ) }; if let Err(error) = activate_client_binding_status( &canonical_event, - status_authorization, - status_lease, + status_activation, + canonical_peer, evidence, Arc::clone(&conn), Arc::clone(&state), @@ -761,7 +882,6 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { warn!(conn_id = %conn_id, error = %error, "current-binding status activation failed closed"); *conn.auth_state.write().await = AuthState::Failed; - *conn.canonical_authorization.write().await = None; let _ = conn.ctrl_tx.try_send(WsMessage::Text( RelayMessage::ok( &event_id_hex, @@ -774,7 +894,20 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } } + authenticated_peer = Some(canonical_peer); + canonical_session = Some(crate::connection::CanonicalWebsocketSession::new( + read_authorization, + event_assertion, + )); + } + if let Some(canonical_session) = canonical_session { + *conn.canonical_authorization.write().await = Some(canonical_session); } + *conn.auth_state.write().await = + AuthState::Authenticated(crate::connection::AuthenticatedConnectionContext::new( + auth_ctx, + authenticated_peer, + )); info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); state .conn_manager @@ -804,18 +937,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } struct CanonicalWebsocketAdmission { - status_authorization: CurrentStatusAuthorization, - status_lease: buzz_auth::BoundedAuthorizationLease, + status_activation: Option, authenticated_peer: buzz_auth::AuthenticatedClientPeer, read_authorization: buzz_auth::FinalizedAuthContext, - write_authorization: buzz_auth::FinalizedAuthContext, + event_assertion: buzz_auth::VerifiedFederatedAssertion, } struct PreparedCanonicalWebsocketAdmission { status_request: AdmissionCommitRequest, + status_binding: BindingStatusApplicationBinding, + connection_status_lease: Option, authenticated_peer: buzz_auth::AuthenticatedClientPeer, read_authorization: buzz_auth::FinalizedAuthContext, - write_authorization: buzz_auth::FinalizedAuthContext, + event_assertion: buzz_auth::VerifiedFederatedAssertion, } async fn prepare_canonical_websocket( @@ -827,7 +961,7 @@ async fn prepare_canonical_websocket( ) -> Result { let domain = conn.tenant.community(); let assertion_object = crate::protected_ingress::domain_object(domain)?; - let object = AdmissionObject::binding_status(domain, event.pubkey) + let object = AdmissionObject::binding_status_auth_event(domain, event.id.to_bytes()) .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; let event_id = event.id.to_bytes(); let evidence = conn @@ -862,6 +996,48 @@ async fn prepare_canonical_websocket( assertion_coordinates, ) .await?; + let status_opted_in = event.tags.iter().any(|tag| { + tag.as_slice().first().map(String::as_str) + == Some(buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG) + }); + let (connection_binding, connection_status_lease) = if status_opted_in { + let authoritative_now = state + .db + .status_delivery_authoritative_now() + .await + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Unavailable)?; + let coordinates = buzz_auth::Nip42BindingStatusCoordinates::new( + domain, + relay_url, + conn.conn_id, + state.relay_keypair.public_key(), + event, + ) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let binding = StatusConnectionBinding::from_coordinates(&coordinates); + let proof = buzz_auth::verify_nip42_binding_status_proof( + event, + challenge, + &coordinates, + authoritative_now, + ) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let lease = state + .db + .finalize_binding_status_authorization(proof) + .await + .map_err(|error| match error { + buzz_db::DbError::InvalidData(_) => { + crate::protected_ingress::ProtectedIngressError::Denied + } + _ => crate::protected_ingress::ProtectedIngressError::Unavailable, + })? + .lease() + .clone(); + (Some(binding), Some(lease)) + } else { + (None, None) + }; let session_assertion_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( crate::authorization_runtime::ProtectedIngress::WebSocketQuery, domain, @@ -904,16 +1080,21 @@ async fn prepare_canonical_websocket( let request = crate::protected_ingress::prepare_mutation(state, coordinates, status_assertion, proof) .await?; + let status_binding = BindingStatusApplicationBinding::new( + object, + event.pubkey, + event_id, + challenge, + connection_binding, + ); let request = request - .with_application_effect(Box::new(BindingStatusAdmissionEffect::new( - object, - event.pubkey, - event_id, - challenge, - ))) + .with_application_effect(Box::new(BindingStatusAdmissionEffect { + binding: status_binding, + })) .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; let request = crate::api::media::ProtectedTransportInstaller::install(request, evidence) .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let event_assertion = session_assertion.clone(); let read_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( crate::authorization_runtime::ProtectedIngress::WebSocketQuery, domain, @@ -944,40 +1125,13 @@ async fn prepare_canonical_websocket( ) .await?; - let write_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( - crate::authorization_runtime::ProtectedIngress::WebSocketEvent, - domain, - buzz_auth::RouteCapability::MessagesWrite, - assertion_object, - buzz_auth::ProofTransport::Nip42, - request_fingerprint, - transport_context_fingerprint, - )?; - let write_proof = buzz_auth::verify_nip42_authorization_proof( - event, - challenge, - relay_url, - domain, - request_fingerprint, - *assertion_object.key(), - transport_context_fingerprint, - Some(*session_assertion.assertion_fingerprint()), - None, - session_assertion_expires_at, - ) - .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; - let write_authorization = crate::protected_ingress::authorize_read( - state, - write_coordinates, - session_assertion, - write_proof, - ) - .await?; Ok(PreparedCanonicalWebsocketAdmission { status_request: request, + status_binding, + connection_status_lease, authenticated_peer, read_authorization, - write_authorization, + event_assertion, }) } @@ -985,9 +1139,36 @@ async fn commit_canonical_websocket( state: &AppState, prepared: PreparedCanonicalWebsocketAdmission, ) -> Result { + let PreparedCanonicalWebsocketAdmission { + status_request, + status_binding, + connection_status_lease, + authenticated_peer, + read_authorization, + event_assertion, + } = prepared; let committer = crate::protected_ingress::mutation_committer(state)?; - let finalized = match committer.commit(prepared.status_request).await { - Ok(AdmissionCommitOutcome::Committed { authorization, .. }) => *authorization, + let status_activation = match committer.commit(status_request).await { + Ok(AdmissionCommitOutcome::Committed { + authorization, + receipt, + application_result, + application_result_binding, + }) => { + let application_result = application_result + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let application_result_binding = application_result_binding + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + status_binding + .bind_committed( + *authorization, + receipt, + application_result, + application_result_binding, + connection_status_lease, + ) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)? + } Ok(AdmissionCommitOutcome::ExactReplay { .. }) => { return Err(crate::protected_ingress::ProtectedIngressError::Denied); } @@ -998,23 +1179,24 @@ async fn commit_canonical_websocket( ) => return Err(crate::protected_ingress::ProtectedIngressError::Unavailable), Err(_) => return Err(crate::protected_ingress::ProtectedIngressError::Denied), }; - let status_lease = finalized.lease().clone(); - let status_authorization = CurrentStatusAuthorization::from_lease(&status_lease) - .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; Ok(CanonicalWebsocketAdmission { - status_authorization, - status_lease, - authenticated_peer: prepared.authenticated_peer, - read_authorization: prepared.read_authorization, - write_authorization: prepared.write_authorization, + status_activation, + authenticated_peer, + read_authorization, + event_assertion, }) } #[cfg(test)] mod tests { - use super::{extract_auth_tag_json, status_activation_scope_matches}; + use super::{ + committed_status_receipt_matches, extract_auth_tag_json, status_activation_scope_matches, + }; use crate::authorization_runtime::CurrentStatusAuthorization; use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; + use buzz_db::authorization_admission::{ + AdmissionCommitDigests, AdmissionCommitReceipt, AdmissionObject, + }; use chrono::Utc; use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; @@ -1068,6 +1250,44 @@ mod tests { assert_eq!(extract_auth_tag_json(&event), None); } + #[test] + fn status_receipt_requires_exact_request_and_typed_result_digest() { + let domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let object = AdmissionObject::binding_status(domain, Keys::generate().public_key()) + .expect("build binding-status object"); + let receipt = |request_fingerprint, application_result_digest| { + AdmissionCommitReceipt::from_storage( + domain, + object, + Uuid::new_v4(), + request_fingerprint, + [8; 32], + AdmissionCommitDigests::new([9; 32], Some(application_result_digest)) + .expect("build commit digests"), + Uuid::new_v4(), + ) + .expect("build commit receipt") + }; + let request_fingerprint = [7; 32]; + let application_result_digest = [10; 32]; + + assert!(committed_status_receipt_matches( + &request_fingerprint, + receipt(request_fingerprint, application_result_digest), + &application_result_digest, + )); + assert!(!committed_status_receipt_matches( + &request_fingerprint, + receipt([6; 32], application_result_digest), + &application_result_digest, + )); + assert!(!committed_status_receipt_matches( + &request_fingerprint, + receipt(request_fingerprint, [11; 32]), + &application_result_digest, + )); + } + #[test] fn status_activation_requires_exact_auth_author_and_tenant_domain() { let author = Keys::generate(); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 9a8434799a3..22c7e753c12 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -17,6 +17,7 @@ use buzz_core::observer::{ use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; +use buzz_db::authorization_admission::{AdmissionCommitRequest, AdmissionObject}; use buzz_pubsub::EventTopic; use nostr::{Event, PublicKey}; @@ -677,6 +678,54 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc None, + buzz_auth::NipFiMode::DenyProtected => { + reject("auth"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: protected ingress denied", + )); + return; + } + buzz_auth::NipFiMode::Enforce if buzz_core::kind::is_moderation_command_kind(kind_u32) => { + // Moderation commands own their operation-specific NIP-42 proof, + // typed application effect, and canonical transaction. + None + } + buzz_auth::NipFiMode::Enforce + if is_ephemeral(kind_u32) || kind_u32 == KIND_AGENT_OBSERVER_FRAME => + { + reject("auth"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: event kind requires a dedicated canonical mutation owner", + )); + return; + } + buzz_auth::NipFiMode::Enforce => { + if !super::ingest::canonical_bridge_kind_supported(kind_u32) { + reject("auth"); + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: event kind requires a dedicated canonical mutation owner", + )); + return; + } + match prepare_canonical_event_admission(&event, &conn, &state).await { + Ok(request) => Some(request), + Err(error) => { + reject("auth"); + conn.send(RelayMessage::ok(&event_id_hex, false, error.code())); + return; + } + } + } + }; + if kind_u32 == KIND_AGENT_OBSERVER_FRAME { if !scopes.is_empty() && !scopes.contains(&buzz_auth::Scope::MessagesWrite) { reject("scope"); @@ -732,7 +781,19 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc super::ingest::ingest_event_with_canonical_admission( + &state, + &conn.tenant, + event, + ingest_auth, + request, + ) + .await + .map(|(result, _)| result), + None => super::ingest::ingest_event(&state, &conn.tenant, event, ingest_auth).await, + }; + match ingested { Ok(result) => { if result.accepted { // buzz_events_stored_total is emitted inside ingest_event() @@ -765,6 +826,65 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc Result { + let (session, assertion) = conn + .canonical_authorization + .read() + .await + .as_ref() + .map(crate::connection::CanonicalWebsocketSession::event_admission) + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let domain = conn.tenant.community(); + let object = AdmissionObject::event(event.id.to_bytes()) + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:websocket-event-request:v1", + &[ + domain.as_uuid().as_bytes(), + conn.conn_id.as_bytes(), + event.id.as_bytes(), + ], + ); + let (_, _, session_transport) = session.lease().request_binding(); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:websocket-event-transport:v1", + &[ + domain.as_uuid().as_bytes(), + conn.conn_id.as_bytes(), + session.request_fingerprint(), + session_transport, + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::WebSocketEvent, + domain, + buzz_auth::RouteCapability::MessagesWrite, + object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let event = event.clone(); + let verified = tokio::task::spawn_blocking(move || { + buzz_auth::verify_nip42_session_event_proof( + &event, + &session, + &assertion, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + ) + }) + .await + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Unavailable)? + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + crate::protected_ingress::prepare_mutation(state, coordinates, verified.0, verified.1).await +} + /// Handle ephemeral events (kind 20000–29999) — WS-only, never stored. async fn handle_ephemeral_event( event: Event, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index d0f0055a640..ab361056ecb 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -44,7 +44,7 @@ use buzz_db::authorization_admission::{ AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, AdmissionCommitOutcome, AdmissionCommitRequest, AdmissionObject, CanonicalAdmissionCommitter, }; -use nostr::Event; +use nostr::{Event, PublicKey}; use crate::state::AppState; @@ -781,6 +781,7 @@ struct CanonicalBridgeEventResult { struct CanonicalBridgeEventEffect { domain: CommunityId, object: AdmissionObject, + actor: PublicKey, event: Event, channel_id: Option, thread_metadata: Option, @@ -791,6 +792,7 @@ impl CanonicalBridgeEventEffect { fn new( domain: CommunityId, object: AdmissionObject, + actor: PublicKey, event: Event, channel_id: Option, thread_metadata: Option, @@ -822,6 +824,7 @@ impl CanonicalBridgeEventEffect { Self { domain, object, + actor, event, channel_id, thread_metadata, @@ -855,7 +858,7 @@ impl AdmissionApplicationEffect for CanonicalBridgeEventEffect { if context.authorization_domain() != self.domain || context.object() != self.object || context.authorization().capability() != buzz_auth::RouteCapability::MessagesWrite - || context.authorization().actor_pubkey() != self.event.pubkey + || context.authorization().actor_pubkey() != self.actor { return Err(AdmissionCommitError::AuthorizationDenied); } @@ -3217,6 +3220,7 @@ async fn ingest_event_inner( let effect = CanonicalBridgeEventEffect::new( tenant.community(), object, + *auth.pubkey(), event.clone(), channel_id, thread_meta.clone(), diff --git a/desktop/src-tauri/src/app_run_events.rs b/desktop/src-tauri/src/app_run_events.rs new file mode 100644 index 00000000000..5e40d340099 --- /dev/null +++ b/desktop/src-tauri/src/app_run_events.rs @@ -0,0 +1,93 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; + +use crate::{ + app_state::AppState, + commands::ClipboardState, + huddle::HuddlePhase, + shutdown::{is_restart_request, shut_down_app}, +}; + +#[cfg(all(feature = "mesh-llm", target_os = "macos"))] +use crate::shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; +#[cfg(target_os = "macos")] +use crate::tray_menu::show_main_window; + +pub(crate) fn run(app: tauri::App) { + let shutdown_done = Arc::new(AtomicBool::new(false)); + + #[cfg(unix)] + crate::shutdown::install_signal_handler(app.handle().clone(), Arc::clone(&shutdown_done)); + + let run_shutdown_done = Arc::clone(&shutdown_done); + let restart_requested = Arc::new(AtomicBool::new(false)); + app.run(move |app_handle, event| match event { + #[cfg(target_os = "macos")] + RunEvent::Reopen { .. } => show_main_window(app_handle), + #[cfg(target_os = "macos")] + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { api, .. }, + .. + } if label == "main" => { + // Keep the webview alive so Buzz can be reopened from its tray menu. + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.hide() { + eprintln!("buzz-desktop: failed to hide main window: {error}"); + } + } + } + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { .. }, + .. + } if label.starts_with("huddle-") => { + let is_active_huddle_window = + app_handle + .state::() + .huddle() + .ok() + .is_some_and(|huddle| { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle + .ephemeral_channel_id + .as_deref() + .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) + }); + if is_active_huddle_window { + if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { + eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); + } + } + } + RunEvent::ExitRequested { code, .. } => { + if is_restart_request(code) { + restart_requested.store(true, Ordering::SeqCst); + } + shut_down_app(app_handle, &run_shutdown_done); + } + RunEvent::Exit => { + shut_down_app(app_handle, &run_shutdown_done); + app_handle.state::().release(); + + #[cfg(all(feature = "mesh-llm", target_os = "macos"))] + if restart_requested.load(Ordering::SeqCst) { + relaunch_after_mesh_shutdown(app_handle); + } + + // AppKit terminates through libc exit(), which runs C++ static + // destructors. The embedded ggml/Metal runtime currently aborts in + // that destructor phase even after its node has stopped cleanly. + // End the process only after Buzz and Mesh shutdown above, while + // deliberately skipping those native global destructors. + #[cfg(all(feature = "mesh-llm", target_os = "macos"))] + hard_exit_after_mesh_shutdown(); + } + _ => {} + }); +} 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 00000000000..cf31ce09a84 --- /dev/null +++ b/desktop/src-tauri/src/client_binding_status_session.rs @@ -0,0 +1,5 @@ +//! Desktop projection aliases for the shared public status consumer. + +pub(crate) use buzz_core_pkg::client_binding_status_session::{ + is_reserved_text, ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, +}; diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e7..e39422e761f 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(); + let scope_mutation = 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,11 @@ 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); + scope_mutation.finish().await; + let identity = identity_result?; + Ok(identity) } /// Commit an imported identity: durably persist, swap in-memory keys, clear @@ -542,6 +552,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}"); @@ -639,30 +653,82 @@ pub async fn sign_nostr_identity_binding( } #[tauri::command] +/// Build an AUTH event and scope it to a matching live native socket when one +/// is supplied. Missing, stale, or nonmatching socket identifiers produce the +/// ordinary unscoped AUTH event. 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/profile.rs b/desktop/src-tauri/src/commands/profile.rs index c870c8f2808..3e134e15639 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; -use buzz_core_pkg::{kind::KIND_USER_TRUSTED_ASSERTION, PresenceStatus}; +use buzz_core_pkg::PresenceStatus; use serde_json::Value; use tauri::State; use crate::{ app_state::AppState, - commands::identity_archive::fetch_relay_self, events, managed_agents::persona_events::monotonic_created_at, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, @@ -17,152 +16,25 @@ use crate::{ }, }; -async fn query_profiles_with_assertions( - state: &AppState, - pubkeys: &[String], -) -> Result<(Vec, Option), String> { - if pubkeys.is_empty() { - return Ok((Vec::new(), None)); - } - - let relay_self = fetch_relay_self(state).await.unwrap_or(None); - let mut filters = vec![serde_json::json!({ - "kinds": [0], - "authors": pubkeys, - })]; - if let Some(author) = relay_self.as_ref() { - filters.push(serde_json::json!({ - "kinds": [KIND_USER_TRUSTED_ASSERTION], - "authors": [author], - "#d": pubkeys, - })); - } - Ok((query_relay(state, &filters).await?, relay_self)) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct VerifiedIdentity { - display_name: String, - expires_at: u64, -} - -fn verified_identities( - events: &[nostr::Event], - relay_self: Option<&str>, -) -> HashMap { - let Some(relay_self) = relay_self else { - return HashMap::new(); - }; - let mut verified = HashMap::)>::new(); - let now = nostr::Timestamp::now().as_secs(); - for event in events { - if event.kind.as_u16() as u32 != KIND_USER_TRUSTED_ASSERTION - || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self) - || !event.verify_id() - || !event.verify_signature() - { - continue; - } - // The coordinate is recoverable even when the newer payload is - // malformed (for example, an overlong `d` tag). That malformed head - // must suppress the prior assertion rather than being skipped. - let Some(subject) = event.tags.iter().find_map(|tag| { - let parts = tag.as_slice(); - (parts.first().is_some_and(|part| part == "d")) - .then(|| parts.get(1).map(|part| part.as_str())) - .flatten() - }) else { - continue; - }; - if subject.len() != 64 || !subject.chars().all(|value| value.is_ascii_hexdigit()) { - continue; - } - let tag_value = |name: &str| { - let mut matches = event.tags.iter().filter(|tag| { - let parts = tag.as_slice(); - parts.first().is_some_and(|part| part == name) - }); - let tag = matches.next()?; - if matches.next().is_some() { - return None; - } - let parts = tag.as_slice(); - (parts.len() == 2).then(|| parts[1].as_str()) - }; - // Select the signed replaceable-event head before validating its - // payload. Otherwise a newer malformed assertion could be skipped and - // silently resurrect the older active label returned alongside it. - let identity = match (tag_value("d"), tag_value("verified"), tag_value("p")) { - (Some(assertion_d), Some("relay"), Some(asserted_subject)) - if assertion_d == subject && asserted_subject == subject => - { - match tag_value("active") { - Some("false") => None, - Some("true") => match ( - tag_value("expiration") - .and_then(|value| value.parse::().ok()) - .filter(|expiration| *expiration > now), - tag_value("display_name") - .map(str::trim) - .filter(|value| !value.is_empty()), - ) { - (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { - display_name: display_name.to_string(), - expires_at, - }), - _ => None, - }, - _ => None, - } - } - _ => None, - }; - let created_at = event.created_at.as_secs(); - let event_id = event.id.to_hex(); - // NIP-01 replaceable-event ordering: greatest timestamp wins; equal - // timestamps are resolved by the lowest event id. This stays stable - // regardless of relay response order. - match verified.entry(subject.to_ascii_lowercase()) { - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert((created_at, event_id, identity)); - } - std::collections::hash_map::Entry::Occupied(mut entry) - if created_at > entry.get().0 - || (created_at == entry.get().0 && event_id < entry.get().1) => - { - entry.insert((created_at, event_id, identity)); - } - std::collections::hash_map::Entry::Occupied(_) => {} - } - } - verified - .into_iter() - .filter_map(|(pubkey, (_, _, identity))| identity.map(|value| (pubkey, value))) - .collect() -} - -fn apply_verified_identity(profile: &mut ProfileInfo, identity: Option) { - profile.verified_name = identity - .as_ref() - .map(|value| value.display_name.to_string()); - profile.verified_name_expires_at = identity.map(|value| value.expires_at); -} - #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { let my_pubkey = current_pubkey_hex(&state)?; - let (events, relay_self) = - query_profiles_with_assertions(&state, std::slice::from_ref(&my_pubkey)).await?; + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [my_pubkey], + "limit": 1 + })], + ) + .await?; - let mut profile = events + Ok(events .iter() .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == my_pubkey) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state))); - let identity = verified_identities(&events, relay_self.as_deref()).remove(&profile.pubkey); - apply_verified_identity(&mut profile, identity); - Ok(profile) + .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) } #[tauri::command] @@ -316,18 +188,22 @@ pub async fn get_user_profile( None => current_pubkey_hex(&state)?, }; - let (events, relay_self) = - query_profiles_with_assertions(&state, std::slice::from_ref(&target)).await?; + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [target], + "limit": 1 + })], + ) + .await?; - let mut profile = events + Ok(events .iter() .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == target) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(&target)); - let identity = verified_identities(&events, relay_self.as_deref()).remove(&profile.pubkey); - apply_verified_identity(&mut profile, identity); - Ok(profile) + .unwrap_or_else(|| empty_profile_info(&target))) } #[tauri::command] @@ -341,17 +217,15 @@ pub async fn get_users_batch( missing: Vec::new(), }); } - let (events, relay_self) = query_profiles_with_assertions(&state, &pubkeys).await?; - - let mut response = nostr_convert::users_batch_from_events(&events, &pubkeys); - let verified = verified_identities(&events, relay_self.as_deref()); - for (pubkey, profile) in &mut response.profiles { - if let Some(identity) = verified.get(pubkey) { - profile.verified_name = Some(identity.display_name.to_string()); - profile.verified_name_expires_at = Some(identity.expires_at); - } - } - Ok(response) + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + })], + ) + .await?; + Ok(nostr_convert::users_batch_from_events(&events, &pubkeys)) } #[tauri::command] @@ -533,8 +407,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { ProfileInfo { pubkey: pubkey.to_string(), display_name: None, - verified_name: None, - verified_name_expires_at: None, avatar_url: None, about: None, nip05_handle: None, @@ -547,210 +419,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { mod tests { use super::*; - #[test] - fn verified_identity_requires_relay_signed_nip85_assertion() { - let relay = nostr::Keys::generate(); - let subject = nostr::Keys::generate().public_key().to_hex(); - let expires_at = nostr::Timestamp::now().as_secs() + 60; - let event = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Example User"]).unwrap(), - ]) - .sign_with_keys(&relay) - .unwrap(); - - let verified = verified_identities(&[event], Some(&relay.public_key().to_hex())); - assert_eq!( - verified.get(&subject), - Some(&VerifiedIdentity { - display_name: "Example User".to_string(), - expires_at, - }) - ); - } - - #[test] - fn expired_verified_identity_is_rejected() { - let relay = nostr::Keys::generate(); - let subject = nostr::Keys::generate().public_key().to_hex(); - let created_at = nostr::Timestamp::now().as_secs(); - let expires_at = nostr::Timestamp::now().as_secs().saturating_sub(1); - let prior_expiration = created_at + 120; - let prior = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &prior_expiration.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Prior User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(&relay) - .unwrap(); - let expired = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Expired User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at + 1)) - .sign_with_keys(&relay) - .unwrap(); - - assert!( - verified_identities(&[prior, expired], Some(&relay.public_key().to_hex())).is_empty() - ); - } - - #[test] - fn newer_inactive_assertion_removes_verified_identity() { - let relay = nostr::Keys::generate(); - let subject = nostr::Keys::generate().public_key().to_hex(); - let created_at = nostr::Timestamp::now().as_secs(); - let expires_at = created_at + 60; - let active = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Example User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(&relay) - .unwrap(); - let inactive = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "false"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at + 1)) - .sign_with_keys(&relay) - .unwrap(); - - assert!( - verified_identities(&[active, inactive], Some(&relay.public_key().to_hex())).is_empty() - ); - } - - #[test] - fn newer_malformed_assertion_does_not_resurrect_older_identity() { - let relay = nostr::Keys::generate(); - let subject = nostr::Keys::generate().public_key().to_hex(); - let wrong_subject = nostr::Keys::generate().public_key().to_hex(); - let created_at = nostr::Timestamp::now().as_secs(); - let expires_at = created_at + 60; - let active = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Example User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(&relay) - .unwrap(); - let malformed = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", wrong_subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Malformed User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at + 1)) - .sign_with_keys(&relay) - .unwrap(); - - assert!(verified_identities( - &[active.clone(), malformed], - Some(&relay.public_key().to_hex()) - ) - .is_empty()); - - let overlong_d = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str(), "unexpected"]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Malformed User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at + 1)) - .sign_with_keys(&relay) - .unwrap(); - assert!( - verified_identities(&[active, overlong_d], Some(&relay.public_key().to_hex())) - .is_empty() - ); - } - - #[test] - fn equal_timestamp_assertions_use_lowest_event_id_independent_of_response_order() { - let relay = nostr::Keys::generate(); - let subject = nostr::Keys::generate().public_key().to_hex(); - let created_at = nostr::Timestamp::now().as_secs(); - let expires_at = created_at + 60; - let active = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "true"]).unwrap(), - nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), - nostr::Tag::parse(["display_name", "Example User"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(&relay) - .unwrap(); - let inactive = - nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags([ - nostr::Tag::parse(["d", subject.as_str()]).unwrap(), - nostr::Tag::parse(["p", subject.as_str()]).unwrap(), - nostr::Tag::parse(["verified", "relay"]).unwrap(), - nostr::Tag::parse(["active", "false"]).unwrap(), - ]) - .custom_created_at(nostr::Timestamp::from(created_at)) - .sign_with_keys(&relay) - .unwrap(); - let relay_pubkey = relay.public_key().to_hex(); - let expected_active = active.id.to_hex() < inactive.id.to_hex(); - - for events in [ - vec![active.clone(), inactive.clone()], - vec![inactive.clone(), active.clone()], - ] { - let actual = verified_identities(&events, Some(&relay_pubkey)); - assert_eq!(actual.contains_key(&subject), expected_active); - } - } - #[test] fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { let state = crate::app_state::build_app_state(); diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39ac..7cc3b152671 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -132,7 +132,11 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); - tokio::task::spawn_blocking(move || { + let scope_mutation = app + .state::() + .begin_scope_mutation() + .await?; + let mutation_result = tokio::task::spawn_blocking(move || { let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -209,7 +213,12 @@ 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. + scope_mutation.finish().await; + mutation_result?; let state = restore_app.state::(); super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8e2ddfc389b..a09032c6261 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ #![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth. mod app_menu; +mod app_run_events; mod app_state; mod archive; mod builderlab; +mod client_binding_status_session; mod commands; mod deep_link; mod egress_guard; @@ -62,7 +64,7 @@ use huddle::{ get_model_status, get_voice_input_mode, interrupt_huddle_speech, join_huddle, leave_huddle, open_huddle_window, push_audio_pcm, remove_agent_from_huddle, set_huddle_manual_mic_unmuted, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, speak_agent_message, - start_huddle, start_stt_pipeline, HuddlePhase, + start_huddle, start_stt_pipeline, }; use initial_window::*; use managed_agents::{ @@ -73,16 +75,13 @@ use managed_agents::{ }; #[cfg(not(feature = "mesh-llm"))] use mesh_llm_stubs::*; -#[cfg(all(feature = "mesh-llm", target_os = "macos"))] -use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; -use shutdown::{is_restart_request, shut_down_app}; -use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; +use std::sync::atomic::Ordering; +#[cfg(not(test))] +use std::sync::Arc; #[cfg(target_os = "macos")] use tauri::Listener; -use tauri::{Emitter, Manager, RunEvent, WindowEvent}; +use tauri::{Emitter, Manager}; use tauri_plugin_window_state::StateFlags; -#[cfg(target_os = "macos")] -use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -924,76 +923,5 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); - - #[cfg(unix)] - shutdown::install_signal_handler(app.handle().clone(), Arc::clone(&shutdown_done)); - - let run_shutdown_done = Arc::clone(&shutdown_done); - let restart_requested = Arc::new(AtomicBool::new(false)); - app.run(move |app_handle, event| match event { - #[cfg(target_os = "macos")] - RunEvent::Reopen { .. } => show_main_window(app_handle), - #[cfg(target_os = "macos")] - RunEvent::WindowEvent { - label, - event: WindowEvent::CloseRequested { api, .. }, - .. - } if label == "main" => { - // Keep the webview alive so Buzz can be reopened from its tray menu. - api.prevent_close(); - if let Some(window) = app_handle.get_webview_window("main") { - if let Err(error) = window.hide() { - eprintln!("buzz-desktop: failed to hide main window: {error}"); - } - } - } - RunEvent::WindowEvent { - label, - event: WindowEvent::CloseRequested { .. }, - .. - } if label.starts_with("huddle-") => { - let is_active_huddle_window = - app_handle - .state::() - .huddle() - .ok() - .is_some_and(|huddle| { - !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) - && huddle - .ephemeral_channel_id - .as_deref() - .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) - }); - if is_active_huddle_window { - if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { - eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); - } - } - } - RunEvent::ExitRequested { code, .. } => { - if is_restart_request(code) { - restart_requested.store(true, Ordering::SeqCst); - } - shut_down_app(app_handle, &run_shutdown_done); - } - RunEvent::Exit => { - shut_down_app(app_handle, &run_shutdown_done); - app_handle.state::().release(); - - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] - if restart_requested.load(Ordering::SeqCst) { - relaunch_after_mesh_shutdown(app_handle); - } - - // AppKit terminates through libc exit(), which runs C++ static - // destructors. The embedded ggml/Metal runtime currently aborts in - // that destructor phase even after its node has stopped cleanly. - // End the process only after Buzz and Mesh shutdown above, while - // deliberately skipping those native global destructors. - #[cfg(all(feature = "mesh-llm", target_os = "macos"))] - hard_exit_after_mesh_shutdown(); - } - _ => {} - }); + app_run_events::run(app); } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index c284c0da220..5206e8f7500 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -30,11 +30,6 @@ pub struct IdentityInfo { pub struct ProfileInfo { pub pubkey: String, pub display_name: Option, - #[serde(default)] - pub verified_name: Option, - /// Unix timestamp (seconds) after which `verified_name` must not be shown. - #[serde(default)] - pub verified_name_expires_at: Option, pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, @@ -49,11 +44,6 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, - #[serde(default)] - pub verified_name: Option, - /// Unix timestamp (seconds) after which `verified_name` must not be shown. - #[serde(default)] - pub verified_name_expires_at: Option, /// Kind-0 `name` field, carried separately from `display_name` so clients /// can match @mention text against either alias (agents and the CLI /// resolve mentions server-side against `display_name` *or* `name`). @@ -76,11 +66,6 @@ pub struct UsersBatchResponse { pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, - #[serde(default)] - pub verified_name: Option, - /// Unix timestamp (seconds) after which `verified_name` must not be shown. - #[serde(default)] - pub verified_name_expires_at: Option, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, @@ -392,3 +377,84 @@ pub struct ContactEntry { #[serde(default)] pub petname: Option, } + +#[cfg(test)] +mod profile_transport_tests { + use super::{ProfileInfo, UserProfileSummaryInfo, UserSearchResultInfo}; + use std::collections::BTreeSet; + + fn serialized_keys(value: &impl serde::Serialize) -> BTreeSet { + serde_json::to_value(value) + .expect("profile transport serializes") + .as_object() + .expect("profile transport is an object") + .keys() + .cloned() + .collect() + } + + #[test] + fn profile_transports_have_exact_non_trust_shapes() { + let profile = ProfileInfo { + pubkey: "11".repeat(32), + display_name: None, + avatar_url: None, + about: None, + nip05_handle: None, + owner_pubkey: None, + has_profile_event: false, + }; + assert_eq!( + serialized_keys(&profile), + BTreeSet::from([ + "about".to_string(), + "avatar_url".to_string(), + "display_name".to_string(), + "has_profile_event".to_string(), + "nip05_handle".to_string(), + "owner_pubkey".to_string(), + "pubkey".to_string(), + ]) + ); + + let summary = UserProfileSummaryInfo { + display_name: None, + name: None, + avatar_url: None, + nip05_handle: None, + owner_pubkey: None, + is_agent: false, + }; + assert_eq!( + serialized_keys(&summary), + BTreeSet::from([ + "avatar_url".to_string(), + "display_name".to_string(), + "is_agent".to_string(), + "name".to_string(), + "nip05_handle".to_string(), + "owner_pubkey".to_string(), + ]) + ); + + let search = UserSearchResultInfo { + pubkey: "22".repeat(32), + display_name: None, + avatar_url: None, + nip05_handle: None, + owner_pubkey: None, + is_agent: false, + }; + assert_eq!( + serialized_keys(&search), + BTreeSet::from([ + "avatar_url".to_string(), + "display_name".to_string(), + "is_agent".to_string(), + "nip05_handle".to_string(), + "owner_pubkey".to_string(), + "pubkey".to_string(), + ]) + ); + } +} diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 128f2df79dd..96d915c58d7 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,20 +1,39 @@ -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, + }, +}; +#[cfg(test)] +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; +#[cfg(test)] +use native_websocket_status::{monotonic_deadline_after, status_expiry_sleep, ProjectionOwner}; +use native_websocket_status::{ + prepare_status_session, unix_now, PreparedStatus, 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,28 +98,304 @@ 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(), } } } +pub(crate) struct ScopeMutationGuard { + manager: Option, + token: u64, +} + +impl ScopeMutationGuard { + pub(crate) async fn finish(mut self) { + if let Some(manager) = self.manager.take() { + manager.finish_scope_mutation(self.token).await; + } + } +} + +impl Drop for ScopeMutationGuard { + fn drop(&mut self) { + let Some(manager) = self.manager.take() else { + return; + }; + let token = self.token; + std::mem::drop(tauri::async_runtime::spawn(async move { + manager.finish_scope_mutation(token).await; + })); + } +} + impl WebSocketManager { async fn remove(&self, id: Id) -> Option> { 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); + } + } + + async fn current_connect_cancel(&self) -> CancellationToken { + self.connect_cancel.lock().await.clone() + } + + /// 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; + Self::advance_generation(&mut projection); + 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) -> Result { + let token = { + let mut projection = self.projection.lock().await; + match projection.mutation_head.checked_add(1) { + None => { + projection.suspended = true; + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + Err("status scope mutation token exhausted") + } + Some(token) => { + projection.mutation_head = token; + projection.active_mutations.insert(token); + if !Self::advance_generation(&mut projection) { + projection.active_mutations.remove(&token); + Err("status projection generation exhausted") + } else { + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + Ok(token) + } + } + } + }; + let token = match token { + Ok(token) => token, + Err(error) => { + self.cancel_status_connections().await; + return Err(error.to_string()); + } + }; + let guard = ScopeMutationGuard { + manager: Some(self.clone()), + token, + }; + self.cancel_status_connections().await; + Ok(guard) + } + + /// Exit one workspace or identity mutation interval and fence all work + /// that raced the mutation, including failed or panicked blocking work. + async fn finish_scope_mutation(&self, token: u64) { + { + let mut projection = self.projection.lock().await; + if !projection.active_mutations.remove(&token) { + return; + } + Self::advance_generation(&mut projection); + 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; + Self::advance_generation(&mut projection); + 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 revoke_epoch = { + let mut status_scope = handle.status_scope.lock().await; + let Some(scope) = status_scope.as_mut() else { + return; + }; + if scope.auth_proven { + let epoch = scope.epoch.clone(); + status_scope.take(); + Some(epoch) + } else { + match scope.challenge.as_deref() { + None => scope.challenge = Some(challenge.to_owned()), + Some(existing) if existing == challenge => {} + Some(_) => { + status_scope.take(); + } + } + None + } + }; + if let Some(epoch) = revoke_epoch { + self.clear_projection_if_owner(id, handle, &epoch).await; + } + } + + 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 +411,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 +475,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 +494,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 +542,72 @@ 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 { + connect_internal(manager.inner(), state.inner(), url, on_message, None).await +} + +#[tauri::command] +/// Open a native socket with an optional connection-local status projection. +/// +/// Status setup fails closed; ordinary WebSocket traffic remains available +/// when the candidate is ineligible or cannot be prepared. +async fn connect_with_status( + manager: tauri::State<'_, WebSocketManager>, + state: tauri::State<'_, AppState>, url: String, on_message: Channel, + on_projection: Channel, _config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + 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 +669,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 +730,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 +794,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 +840,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 +853,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 00000000000..1c3045b3478 --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_status.rs @@ -0,0 +1,509 @@ +use std::{collections::HashSet, 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, + client_binding_status::MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS, + kind::{KIND_CLIENT_BINDING_BOOTSTRAP, KIND_CLIENT_BINDING_STATUS}, +}; + +use crate::{ + app_state::AppState, + client_binding_status_session::{ + ClientBindingStatusSession, CurrentProjection, ProjectionUpdate, + }, +}; + +use super::{ConnectionHandle, Id, WebSocketManager}; + +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) generation: u64, + 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_head: u64, + pub(super) active_mutations: HashSet, + pub(super) suspended: bool, + pub(super) owner: Option, + pub(super) current: Option, +} + +impl WebSocketManager { + pub(super) fn advance_generation(projection: &mut ProjectionState) -> bool { + let Some(generation) = projection.generation.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 false; + }; + projection.generation = generation; + true + } + + #[cfg(test)] + pub(super) async fn projection_generation(&self) -> u64 { + self.projection.lock().await.generation + } + + pub(super) async fn status_head(&self) -> Option<(u64, u64)> { + let projection = self.projection.lock().await; + (!projection.suspended && projection.active_mutations.is_empty()) + .then_some((projection.generation, projection.attempt_head)) + } + + pub(super) async fn begin_status_attempt(&self) -> Option<(u64, u64)> { + let mut projection = self.projection.lock().await; + if projection.suspended || !projection.active_mutations.is_empty() { + 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)) + } + + pub(super) 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.active_mutations.is_empty() + || 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(), + generation: proof.generation, + attempt: proof.attempt, + presentation_token: 0, + channel: scope.projection_channel.clone(), + }); + true + } + + pub(super) 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.generation == projection.generation + && 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 current_generation = projection.generation; + let Some(owner) = projection.owner.as_mut() else { + return; + }; + if owner.id != id + || !Arc::ptr_eq(&owner.handle, handle) + || owner.epoch != *epoch + || owner.generation != current_generation + { + return; + } + // The exact owner-at-`u64::MAX` case was cleared above while the + // same lock was held. Saturation keeps this path panic-free even + // if that invariant changes later. + owner.presentation_token = owner.presentation_token.saturating_add(1); + ( + owner.id, + Arc::clone(&owner.handle), + owner.epoch.clone(), + owner.generation, + 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, + owner_state.5, + 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.6.send(value); + drop(projection); + + if let Some((id, handle, epoch, generation, attempt, presentation_token, fresh_until)) = + expiry + { + let manager = self.clone(); + std::mem::drop(tauri::async_runtime::spawn(async move { + let mut expires_at = + monotonic_deadline_after(duration_until_unix_second(fresh_until)); + loop { + status_expiry_sleep(expires_at).await; + let Some(next_deadline) = manager + .expire_projection_if_owner( + id, + &handle, + &epoch, + (generation, attempt, presentation_token), + fresh_until, + ) + .await + else { + break; + }; + expires_at = next_deadline; + } + })); + } + } + + pub(super) async fn expire_projection_if_owner( + &self, + id: Id, + handle: &Arc, + epoch: &ClientBindingEpoch, + owner_fence: (u64, u64, u64), + fresh_until: u64, + ) -> Option { + let (generation, attempt, presentation_token) = owner_fence; + 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.generation == generation + && projection.generation == generation + && 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 None; + } + if unix_now() < fresh_until { + drop(projection); + return Some(monotonic_deadline_after(duration_until_unix_second( + fresh_until, + ))); + } + 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); + } + None + } + + pub(super) 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 + && owner.generation == projection.generation + }) { + if let Some(owner) = projection.owner.take() { + let _ = owner.channel.send(serde_json::Value::Null); + } + projection.current = None; + } + } +} + +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, + #[serde(default)] + supported_extensions: Vec, + client_status: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Nip11ClientStatus { + version: u8, + status_kind: u32, + bootstrap_kind: u32, + max_lifetime_seconds: u64, + delivery: String, + authoritative: bool, +} + +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); + } + parse_nip11_status_identity(&body) +} + +pub(super) fn parse_nip11_status_identity(body: &[u8]) -> Result { + let identity: Nip11Identity = + serde_json::from_slice(body).map_err(|_| "NIP-11 unavailable".to_string())?; + let extension_count = identity + .supported_extensions + .iter() + .filter(|extension| extension.as_str() == "buzz-client-binding-status-v1") + .count(); + let status = identity + .client_status + .ok_or_else(|| "NIP-11 unavailable".to_string())?; + if extension_count != 1 + || status.version != 1 + || status.status_kind != KIND_CLIENT_BINDING_STATUS + || status.bootstrap_kind != KIND_CLIENT_BINDING_BOOTSTRAP + || status.max_lifetime_seconds == 0 + || status.max_lifetime_seconds > MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS + || status.delivery != "authenticated-connection" + || status.authoritative + { + return 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 00000000000..9635d4149f6 --- /dev/null +++ b/desktop/src-tauri/src/native_websocket_tests.rs @@ -0,0 +1,999 @@ +use super::native_websocket_status::{is_loopback_url, nip11_url, parse_nip11_status_identity}; +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") + )); +} + +#[test] +fn nip11_status_extension_is_optional_and_exactly_bounded() { + let relay = nostr::Keys::generate().public_key().to_hex(); + let compatible = serde_json::json!({ + "self": relay, + "supported_extensions": ["buzz-client-binding-status-v1"], + "client_status": { + "version": 1, + "status_kind": 24244, + "bootstrap_kind": 24245, + "max_lifetime_seconds": 300, + "delivery": "authenticated-connection", + "authoritative": false + } + }); + assert!(parse_nip11_status_identity(&serde_json::to_vec(&compatible).unwrap()).is_ok()); + + for incompatible in [ + serde_json::json!({"self": relay}), + serde_json::json!({ + "self": relay, + "supported_extensions": ["buzz-client-binding-status-v1"], + "client_status": { + "version": 1, + "status_kind": 24244, + "bootstrap_kind": 24245, + "max_lifetime_seconds": 301, + "delivery": "authenticated-connection", + "authoritative": false + } + }), + serde_json::json!({ + "self": relay, + "supported_extensions": ["buzz-client-binding-status-v1"], + "client_status": { + "version": 1, + "status_kind": 24244, + "bootstrap_kind": 24245, + "max_lifetime_seconds": 300, + "delivery": "authenticated-connection", + "authoritative": true + } + }), + ] { + assert!(parse_nip11_status_identity(&serde_json::to_vec(&incompatible).unwrap()).is_err()); + } +} + +#[test] +fn current_projection_ipc_is_exactly_epoch_free() { + let projection = CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: 1_234_567_890, + }; + + let serialized = serde_json::to_value(projection).expect("projection serializes"); + assert_eq!( + serialized, + serde_json::json!({ + "eventAuthorPubkey": "11".repeat(32), + "freshUntil": 1_234_567_890_u64, + }) + ); + assert!(serialized.get("connectionEpoch").is_none()); + assert!(serialized.get("connection_epoch").is_none()); +} + +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, + }; + 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, + }), + ) + .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 post_activation_rechallenge_revokes_projection_and_old_session() { + for (id, epoch_suffix, rechallenge) in + [(20, 0x51, "challenge"), (21, 0x52, "replacement-challenge")] + { + 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(epoch_suffix); + let handle = test_handle(Some(test_status_scope( + generation, + attempt, + relay, + author, + epoch.clone(), + ))); + manager.connections.lock().await.insert(id, handle.clone()); + manager + .record_status_challenge(id, &handle, "challenge") + .await; + let proof = manager + .status_auth_proof(id, "challenge", "ws://localhost:3000/", author) + .await + .expect("initial status proof"); + manager + .complete_status_auth(id, &proof) + .await + .expect("initial status activation"); + manager + .apply_projection_update( + id, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: unix_now() + 60, + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_some()); + + manager + .record_status_challenge(id, &handle, rechallenge) + .await; + assert!(handle.status_scope.lock().await.is_none()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager.projection.lock().await.current.is_none()); + assert!(manager + .status_auth_proof(id, rechallenge, "ws://localhost:3000/", author) + .await + .is_err()); + + manager + .apply_projection_update( + id, + &handle, + &epoch, + ProjectionUpdate::Current(CurrentProjection { + event_author_pubkey: author.to_hex(), + fresh_until: u64::MAX, + }), + ) + .await; + assert!(manager.projection.lock().await.current.is_none()); + } +} + +#[tokio::test] +async fn changed_pre_activation_challenge_removes_status_capability() { + 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 handle = test_handle(Some(test_status_scope( + generation, + attempt, + relay, + author, + test_epoch(0x53), + ))); + manager.connections.lock().await.insert(22, handle.clone()); + manager.record_status_challenge(22, &handle, "first").await; + manager + .record_status_challenge(22, &handle, "changed") + .await; + + assert!(handle.status_scope.lock().await.is_none()); + assert!(manager.projection.lock().await.owner.is_none()); + assert!(manager + .status_auth_proof(22, "changed", "ws://localhost:3000/", author) + .await + .is_err()); +} + +#[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"); + + let first_mutation = manager.begin_scope_mutation().await.unwrap(); + assert!(manager.status_head().await.is_none()); + assert!(handle.cancel.is_cancelled()); + let second_mutation = manager.begin_scope_mutation().await.unwrap(); + second_mutation.finish().await; + assert!(manager.status_head().await.is_none()); + + first_mutation.finish().await; + assert!(manager.status_head().await.is_some()); + assert!(manager.complete_status_auth(10, &proof).await.is_err()); +} + +#[tokio::test] +async fn cancelled_scope_mutation_releases_its_exact_guard() { + let manager = WebSocketManager::default(); + let task_manager = manager.clone(); + let (entered_tx, entered_rx) = oneshot::channel(); + let task = tokio::spawn(async move { + let _mutation = task_manager.begin_scope_mutation().await.unwrap(); + entered_tx.send(()).unwrap(); + std::future::pending::<()>().await; + }); + entered_rx.await.unwrap(); + assert!(manager.status_head().await.is_none()); + assert_eq!(manager.projection.lock().await.active_mutations.len(), 1); + + task.abort(); + assert!(task.await.is_err()); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if manager.status_head().await.is_some() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("dropped mutation guard releases without stranding status"); + assert!(manager.projection.lock().await.active_mutations.is_empty()); +} + +#[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(); + manager.projection.lock().await.generation = u64::MAX; + manager.invalidate_projection().await; + let projection = manager.projection.lock().await; + assert!(projection.suspended); + assert_eq!(projection.generation, u64::MAX); + drop(projection); + + let manager = WebSocketManager::default(); + let handle = test_handle(Some(test_status_scope( + 0, + 1, + nostr::Keys::generate().public_key(), + nostr::Keys::generate().public_key(), + test_epoch(0x33), + ))); + manager.connections.lock().await.insert(12, handle.clone()); + { + let mut projection = manager.projection.lock().await; + projection.mutation_head = u64::MAX; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: test_epoch(0x33), + generation: 0, + attempt: 1, + presentation_token: 0, + channel: silent_channel(), + }); + projection.current = Some(CurrentProjection { + event_author_pubkey: "11".repeat(32), + fresh_until: unix_now() + 60, + }); + } + assert!(manager.begin_scope_mutation().await.is_err()); + let projection = manager.projection.lock().await; + assert!(projection.suspended); + assert!(projection.owner.is_none()); + assert!(projection.current.is_none()); + drop(projection); + assert!(handle.cancel.is_cancelled()); + assert!(handle.status_scope.lock().await.is_none()); + + 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, + }; + { + let mut projection = manager.projection.lock().await; + projection.owner = Some(ProjectionOwner { + id: 12, + handle: handle.clone(), + epoch: epoch.clone(), + generation: 0, + 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 early_monotonic_expiry_rechecks_wall_clock_and_rearms() { + 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(), + generation: 0, + attempt: 2, + presentation_token: 3, + channel: silent_channel(), + }); + projection.current = Some(CurrentProjection { + event_author_pubkey: "22".repeat(32), + fresh_until, + }); + } + + assert!(unix_now() < fresh_until, "test models a backward clock"); + let rearmed = manager + .expire_projection_if_owner(13, &handle, &epoch, (0, 2, 3), fresh_until) + .await; + assert!(rearmed.is_some()); + assert!(manager.projection.lock().await.current.is_some()); +} + +#[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, + }), + ) + .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/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 6e6d2e4d9ef..15e1be4dadf 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -297,8 +297,6 @@ pub fn profile_info_from_event(event: &Event) -> Result { Ok(ProfileInfo { pubkey: event.pubkey.to_hex(), display_name, - verified_name: None, - verified_name_expires_at: None, avatar_url, about, nip05_handle, @@ -339,8 +337,6 @@ pub fn users_batch_from_events( .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), - verified_name: None, - verified_name_expires_at: None, name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index fef06e66c8e..43b4288abbb 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -18,8 +18,6 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), - verified_name: None, - verified_name_expires_at: None, avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), 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 00000000000..6116b420e7e --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.test.mjs @@ -0,0 +1,141 @@ +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, + ...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()], + throwNextSchedule: () => { + schedulesToThrow += 1; + }, + delays, + }; +} + +test("parses only the exact frozen two-key DTO", () => { + const parsed = parseCurrentProjection(projection(), 100); + assert.deepEqual(parsed, projection()); + assert.equal(Object.isFrozen(parsed), true); + for (const extra of [ + { connectionEpoch: "11111111-1111-4111-8111-111111111111" }, + { rawEvent: "must-not-cross" }, + { revision: 42 }, + ]) { + assert.equal(parseCurrentProjection(projection(extra), 100), null); + } +}); + +test("rejects malformed authors and deadlines", () => { + for (const candidate of [ + null, + [], + projection({ eventAuthorPubkey: AUTHOR_A.toUpperCase() }), + projection({ eventAuthorPubkey: "a".repeat(63) }), + projection({ freshUntil: 100 }), + projection({ freshUntil: 100.5 }), + projection({ freshUntil: 401 }), + ]) { + assert.equal(parseCurrentProjection(candidate, 100), null); + } +}); + +test("render-time reads hide exact-bound expiry before a throttled callback", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + let changes = 0; + store.subscribe(() => { + changes += 1; + }); + + store.replaceFromNative(projection({ freshUntil: 101 })); + const delayedTimer = timers.pendingIds()[0]; + assert.notEqual(store.getSnapshot(), null); + + timers.setNow(101); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 1); + + timers.fire(delayedTimer); + assert.equal(store.getSnapshot(), null); + assert.equal(changes, 2); +}); + +test("captured tokens reject stale expiry after replacement and clear", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + store.replaceFromNative(projection({ freshUntil: 110 })); + const oldTimer = timers.pendingIds()[0]; + store.replaceFromNative( + projection({ eventAuthorPubkey: AUTHOR_B, freshUntil: 120 }), + ); + 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(); + timers.setNow(120); + timers.fire(currentTimer); + assert.equal(store.getSnapshot(), null); +}); + +test("invalid native replacement and scheduler failure clear current state", () => { + const timers = makeTimerHost(100); + const store = createCurrentProjectionStore(timers.options); + + store.replaceFromNative(projection()); + store.replaceFromNative({ ...projection(), connectionEpoch: "forbidden" }); + assert.equal(store.getSnapshot(), null); + + timers.throwNextSchedule(); + store.replaceFromNative(projection({ freshUntil: 110 })); + 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 00000000000..46733c44d45 --- /dev/null +++ b/desktop/src/features/binding-status/currentProjectionStore.ts @@ -0,0 +1,242 @@ +import { Channel } from "@tauri-apps/api/core"; +import * as React from "react"; + +/** Fresh connection-local binding status safe to expose to the webview. */ +export type CurrentProjection = Readonly<{ + eventAuthorPubkey: string; + freshUntil: number; +}>; + +type TimerHandle = ReturnType; + +type CurrentProjectionStoreOptions = { + nowSeconds?: () => number; + setTimeout?: (callback: () => void, delayMs: number) => TimerHandle; + clearTimeout?: (handle: TimerHandle) => void; + maxTimerDelayMs?: number; + onListenerError?: () => void; +}; + +/** Fail-closed browser store for one expiring native status projection. */ +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 MAX_CURRENT_PROJECTION_LIFETIME_SECONDS = 300; +const CURRENT_PROJECTION_KEYS = ["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 exact, narrow native DTO into a frozen browser-owned value. */ +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 } = value; + if ( + typeof eventAuthorPubkey !== "string" || + !LOWERCASE_HEX_PUBKEY.test(eventAuthorPubkey) || + typeof freshUntil !== "number" || + !Number.isSafeInteger(freshUntil) || + freshUntil <= 0 || + freshUntil <= nowSeconds || + freshUntil > nowSeconds + MAX_CURRENT_PROJECTION_LIFETIME_SECONDS + ) { + return null; + } + + return Object.freeze({ eventAuthorPubkey, freshUntil }); +} + +/** Create an isolated projection store, primarily for deterministic tests. */ +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 = () => { + 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; + + 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: () => { + if (snapshot === null) return null; + const now = nowSeconds(); + return Number.isFinite(now) && now < snapshot.freshUntil + ? snapshot + : null; + }, + 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; + } + + if (!armExpiry(projection, capturedToken)) { + if (capturedToken === workToken) clear(); + return; + } + snapshot = projection; + emitChange(); + }, + clear, + }; +} + +const currentProjectionStore = createCurrentProjectionStore(); + +/** Create a native channel that accepts updates only while its socket is current. */ +export function createCurrentProjectionChannel( + isCurrentConnection: () => boolean, +): Channel { + return new Channel((candidate) => { + if (!isCurrentConnection()) return; + currentProjectionStore.replaceFromNative(candidate); + }); +} + +/** Clear the browser-visible projection and cancel its expiry work. */ +export function clearCurrentProjection(): void { + currentProjectionStore.clear(); +} + +/** Reset projection state between application sessions or tests. */ +export function resetCurrentProjectionStore(): void { + currentProjectionStore.clear(); +} + +/** Read the current projection, returning null once its deadline has passed. */ +export function getCurrentProjectionSnapshot(): CurrentProjection | null { + return currentProjectionStore.getSnapshot(); +} + +/** Subscribe to projection changes and return an unsubscribe function. */ +export function subscribeToCurrentProjection(listener: () => void): () => void { + return currentProjectionStore.subscribe(listener); +} + +/** Read the fresh current projection from React, or null when unavailable. */ +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 0c27ab0541f..5987271cf9f 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -28,6 +28,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"; @@ -58,6 +59,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 00000000000..0ac2a220c85 --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hasCurrentRelayBindingForAuthor } from "./currentRelayBinding.ts"; + +const DISPLAYED_ACTOR = "a".repeat(64); +const EVENT_SIGNER = "b".repeat(64); +const projection = { eventAuthorPubkey: EVENT_SIGNER, freshUntil: 200 }; + +test("badges only the exact raw event signer", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER, 100), + true, + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + projection, + EVENT_SIGNER.toUpperCase(), + 100, + ), + false, + ); + assert.equal( + hasCurrentRelayBindingForAuthor( + { eventAuthorPubkey: DISPLAYED_ACTOR, freshUntil: 200 }, + EVENT_SIGNER, + 100, + ), + false, + ); + assert.equal(hasCurrentRelayBindingForAuthor(null, EVENT_SIGNER, 100), false); +}); + +test("render predicate rechecks the exclusive freshness deadline", () => { + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER, 199.999), + true, + ); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER, 200), + false, + ); + assert.equal( + hasCurrentRelayBindingForAuthor(projection, EVENT_SIGNER, Number.NaN), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/currentRelayBinding.ts b/desktop/src/features/messages/lib/currentRelayBinding.ts new file mode 100644 index 00000000000..03b7126e369 --- /dev/null +++ b/desktop/src/features/messages/lib/currentRelayBinding.ts @@ -0,0 +1,18 @@ +type CurrentProjectionAuthor = Readonly<{ + eventAuthorPubkey: string; + freshUntil: number; +}>; + +/** Return true only for an exact author match before the projection expires. */ +export function hasCurrentRelayBindingForAuthor( + projection: CurrentProjectionAuthor | null, + eventAuthorPubkey: string | null | undefined, + nowSeconds = Date.now() / 1_000, +): boolean { + return ( + projection !== null && + Number.isFinite(nowSeconds) && + nowSeconds < projection.freshUntil && + projection.eventAuthorPubkey === eventAuthorPubkey + ); +} diff --git a/desktop/src/features/messages/lib/messageGrouping.test.mjs b/desktop/src/features/messages/lib/messageGrouping.test.mjs index 106792767f8..a0c8221c70a 100644 --- a/desktop/src/features/messages/lib/messageGrouping.test.mjs +++ b/desktop/src/features/messages/lib/messageGrouping.test.mjs @@ -36,6 +36,33 @@ test("hasSameMessageAuthor: missing pubkeys never match", () => { assert.equal(hasSameMessageAuthor({ pubkey: "" }, { pubkey: "" }), false); }); +test("hasSameMessageAuthor: raw signer changes break displayed-author grouping", () => { + assert.equal( + hasSameMessageAuthor( + { pubkey: "actor", signerPubkey: "relay" }, + { pubkey: "actor", signerPubkey: "actor" }, + ), + false, + ); + assert.equal( + hasSameMessageAuthor( + { pubkey: " ACTOR ", signerPubkey: " RELAY " }, + { pubkey: "actor", signerPubkey: "relay" }, + ), + true, + ); +}); + +test("hasSameMessageAuthor: missing signer falls back to displayed author", () => { + assert.equal( + hasSameMessageAuthor( + { pubkey: "actor" }, + { pubkey: "actor", signerPubkey: "actor" }, + ), + true, + ); +}); + test("isWithinGroupingWindow: at or under the boundary is in window", () => { const base = 1_000_000; assert.equal(isWithinGroupingWindow(base, base), true); diff --git a/desktop/src/features/messages/lib/messageGrouping.ts b/desktop/src/features/messages/lib/messageGrouping.ts index df8f125bb0e..1bf9f64b283 100644 --- a/desktop/src/features/messages/lib/messageGrouping.ts +++ b/desktop/src/features/messages/lib/messageGrouping.ts @@ -2,6 +2,7 @@ import { getSentFromThreadRootId } from "@/features/messages/lib/sentFromThread" type MessageAuthorCandidate = { pubkey?: string | null; + signerPubkey?: string | null; }; type MessageGroupingCandidate = { @@ -33,9 +34,20 @@ export function hasSameMessageAuthor( ) { const previousPubkey = previous?.pubkey?.trim().toLowerCase(); const currentPubkey = current?.pubkey?.trim().toLowerCase(); + const previousSigner = (previous?.signerPubkey ?? previous?.pubkey) + ?.trim() + .toLowerCase(); + const currentSigner = (current?.signerPubkey ?? current?.pubkey) + ?.trim() + .toLowerCase(); return Boolean( - previousPubkey && currentPubkey && previousPubkey === currentPubkey, + previousPubkey && + currentPubkey && + previousPubkey === currentPubkey && + previousSigner && + currentSigner && + previousSigner === currentSigner, ); } diff --git a/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx new file mode 100644 index 00000000000..c839513b1bb --- /dev/null +++ b/desktop/src/features/messages/ui/CurrentRelayBindingBadge.tsx @@ -0,0 +1,20 @@ +/** Render the informational badge for a fresh connection-local binding. */ +export function CurrentRelayBindingBadge() { + return ( + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 4c40c58cee2..cd2715f2d62 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -11,15 +11,14 @@ import { assertCanSendMessageToChannel, canSendMessageToChannel, } from "@/features/messages/lib/canSendToChannel"; +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 { @@ -39,7 +38,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"; @@ -58,6 +56,7 @@ import { toast } from "sonner"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageDepthGuides } from "./MessageDepthGuides"; +import { CurrentRelayBindingBadge } from "./CurrentRelayBindingBadge"; import { MessageStatusMetadata } from "./MessageStatusMetadata"; import { MessageTimestamp } from "./MessageTimestamp"; import { SentFromThreadLine } from "./SentFromThreadLine"; @@ -200,6 +199,7 @@ export const MessageRow = React.memo( const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState( null, ); + const currentProjection = useCurrentProjection(); const handleEntranceAnimationEnd = React.useCallback( (event: React.AnimationEvent) => { if ( @@ -560,9 +560,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 2acb1ec3f46..2a90e56fab3 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 126eb195d2f..b3f8740cf99 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + formatProfileLabel, formatOwnerLabel, - formatVerifiedUserLabel, profileLookupsEqual, resolveUserLabel, } from "./identity.ts"; @@ -11,8 +11,6 @@ import { 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 +64,6 @@ test("profileLookupsEqual: same count, different keys is not equal", () => { test("profileLookupsEqual: a changed field is not equal", () => { for (const field of [ "displayName", - "verifiedName", - "verifiedNameExpiresAt", "avatarUrl", "nip05Handle", "ownerPubkey", @@ -131,39 +127,43 @@ 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 prefer display name then NIP-05", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", FUTURE_EXPIRATION, NOW_MS), - "Example (example)", + formatProfileLabel({ displayName: " Example ", nip05Handle: "e@x" }), + "Example", ); -}); - -test("does not duplicate equal chosen and authoritative names", () => { assert.equal( - formatVerifiedUserLabel("example", "example", FUTURE_EXPIRATION, NOW_MS), - "example", + formatProfileLabel({ displayName: " ", nip05Handle: " e@x " }), + "e@x", ); + assert.equal(formatProfileLabel(null), null); }); -test("expired authoritative names fail closed", () => { +test("profile labels ignore retired trust-shaped properties", () => { assert.equal( - formatVerifiedUserLabel("Example", "example", NOW_MS / 1_000, NOW_MS), - "Example", + formatProfileLabel({ + displayName: null, + nip05Handle: "profile@nip05", + verifiedName: "must-not-render", + verifiedNameExpiresAt: Number.MAX_SAFE_INTEGER, + }), + "profile@nip05", ); }); -test("resolved user labels keep the chosen name first", () => { +test("resolved user labels never fall back to retired trust-shaped names", () => { assert.equal( resolveUserLabel({ pubkey: USER_PUBKEY, profiles: { [USER_PUBKEY]: summary({ - displayName: "Example", - verifiedName: "example", - verifiedNameExpiresAt: Math.floor(Date.now() / 1_000) + 60, + displayName: null, + nip05Handle: null, + verifiedName: "must-not-render", + verifiedNameExpiresAt: Number.MAX_SAFE_INTEGER, }), }, }), - "Example (example)", + "11111111…1111", ); }); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 45866ec3cd7..b419315042b 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,29 +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; + return profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null; } /** @@ -58,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 || @@ -87,15 +70,7 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick< - Profile, - | "pubkey" - | "displayName" - | "verifiedName" - | "verifiedNameExpiresAt" - | "avatarUrl" - | "nip05Handle" - > + | Pick | null | undefined, ) { @@ -107,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, @@ -149,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; } @@ -161,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 diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 456ea42a310..d70b05fc98b 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 7d8bd0c0dcf..22647eb286a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -51,8 +51,6 @@ import type { import { cn } from "@/shared/lib/cn"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; -import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; @@ -493,10 +491,6 @@ 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, - ); return (
@@ -547,19 +541,6 @@ function ProfileHero({ ) : null}
- {verifiedName ? ( -
- {verifiedName} - -
- ) : null} - {profile?.about?.trim() ? (
- {profile?.verifiedName ? ( - - ) : null} {isBotProfile && botIdenticonValue ? ( @@ -501,7 +496,7 @@ export function AppSidebar({ streamChannels, }); const resolvedDisplayName = - resolvedProfileDisplayName || + 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 d1212bff6a3..da90f04d611 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -17,7 +17,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; @@ -154,8 +153,6 @@ export function SidebarProfileCard({ avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} displayName={resolvedDisplayName} - verifiedName={profile?.verifiedName} - verifiedNameExpiresAt={profile?.verifiedNameExpiresAt} isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} @@ -194,19 +191,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 9ffe84b939c..367a44f5882 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, @@ -68,6 +64,7 @@ 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 { @@ -92,6 +89,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; @@ -173,6 +171,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; } @@ -523,42 +523,57 @@ export class RelayClient { } } } - private async connect() { if (this.stabilityTimer !== null) { 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, @@ -754,7 +766,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(); @@ -787,7 +803,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]) { @@ -836,24 +852,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) { @@ -1016,6 +1014,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 00000000000..4af70c3bf63 --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.test.mjs @@ -0,0 +1,241 @@ +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"; +let authChallengeCopies = 1; + +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; + projectionChannel.onmessage({ + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + const deliverAuth = () => + messageChannel.onmessage({ + type: "Text", + data: JSON.stringify(["AUTH", `${authDelivery}-challenge`]), + }); + const deliverAuthCopies = () => { + for (let index = 0; index < authChallengeCopies; index += 1) { + deliverAuth(); + } + }; + if (authDelivery === "early") deliverAuthCopies(); + else window.setTimeout(deliverAuthCopies, 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 { RelayClientStatusConnection } = await import( + "./relayClientStatusConnection.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( + 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 current = { + 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", + ); +}); + +test("late retirement of connection A cannot clear replacement B", () => { + const active = new Set([8_001, 8_002]); + const makeConnection = () => + new RelayClientStatusConnection( + (id) => active.has(id), + (id) => active.has(id), + () => {}, + async () => {}, + ); + const currentFor = (author) => ({ + eventAuthorPubkey: author, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + + const connectionA = makeConnection(); + connectionA.bind(8_001, RELAY_URL); + connectionA.projectionChannel.onmessage(currentFor(AUTHOR)); + + const connectionB = makeConnection(); + connectionB.bind(8_002, RELAY_URL); + const authorB = "ee".repeat(32); + connectionB.projectionChannel.onmessage(currentFor(authorB)); + assert.equal( + projectionStore.getCurrentProjectionSnapshot()?.eventAuthorPubkey, + authorB, + ); + + active.delete(8_001); + connectionA.retire(); + assert.equal( + projectionStore.getCurrentProjectionSnapshot()?.eventAuthorPubkey, + authorB, + "late A retirement is owner-fenced", + ); + connectionB.retire(); + assert.equal(projectionStore.getCurrentProjectionSnapshot(), null); +}); + +test("duplicate AUTH challenges are handled once per native connection", async () => { + calls.length = 0; + authDelivery = "early"; + authChallengeCopies = 2; + socketId = 6_666; + + const client = new RelayClient(); + await client.preconnect(); + + assert.equal( + calls.filter(({ command }) => command === "create_auth_event").length, + 1, + ); + assert.equal( + calls.filter(({ command, args }) => { + if (command !== "plugin:websocket|send") return false; + return JSON.parse(args.message.data)[0] === "AUTH"; + }).length, + 1, + ); + + authChallengeCopies = 1; + client.disconnect(); +}); + +test("AUTH delivered after connect uses the 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, + }); + client.disconnect(); +}); + +test("ordinary AUTH still succeeds without browser-visible status scope", async () => { + calls.length = 0; + authDelivery = "normal"; + socketId = 8_888; + + const client = new RelayClient(); + await client.preconnect(); + + 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); + assert.deepEqual(JSON.parse(authSend.args.message.data)[1].tags, []); + + const malformed = { + connectionEpoch: "11111111-1111-4111-8111-111111111111", + eventAuthorPubkey: AUTHOR, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }; + projectionChannel.onmessage(malformed); + assert.equal( + projectionStore.getCurrentProjectionSnapshot(), + null, + "connectionEpoch is rejected as an unknown IPC key", + ); + + client.disconnect(); +}); diff --git a/desktop/src/shared/api/relayClientStatusConnection.ts b/desktop/src/shared/api/relayClientStatusConnection.ts new file mode 100644 index 00000000000..01d8b982af3 --- /dev/null +++ b/desktop/src/shared/api/relayClientStatusConnection.ts @@ -0,0 +1,109 @@ +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; +}>; + +let currentProjectionOwner: symbol | null = null; + +/** + * Owns the connection-local status channel and binds scoped AUTH to the same + * native socket. Retirement clears browser-visible status fail closed. + */ +export class RelayClientStatusConnection { + readonly projectionChannel: Channel; + private readonly nativeSocketBinding: Promise; + private resolveNativeSocketBinding!: ( + binding: NativeSocketBinding | null, + ) => void; + private nativeSocketId: number | null = null; + private readonly projectionOwner = Symbol("relay-client-status-connection"); + private settled = false; + private authAttempted = 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; + }); + currentProjectionOwner = this.projectionOwner; + this.projectionChannel = createCurrentProjectionChannel( + () => + currentProjectionOwner === this.projectionOwner && + this.nativeSocketId !== null && + this.isActive(this.nativeSocketId), + ); + clearCurrentProjection(); + } + + /** Open the native socket and attach its guarded projection channel. */ + async connect( + relayUrl: string, + onMessage: Channel, + ): Promise { + return invoke("plugin:websocket|connect_with_status", { + url: relayUrl, + onMessage, + onProjection: this.projectionChannel, + config: {}, + }); + } + + /** Bind the native socket identifier returned by a successful connection. */ + bind(id: number, relayUrl: string) { + this.nativeSocketId = id; + this.settle({ id, relayUrl }); + } + + /** Retire this owner; late native updates can no longer change projection. */ + retire() { + this.settle(null); + this.nativeSocketId = null; + if (currentProjectionOwner === this.projectionOwner) { + currentProjectionOwner = null; + clearCurrentProjection(); + } + } + + /** Answer one challenge with scoped AUTH when this socket is still current. */ + async handleAuthChallenge(challenge: string) { + if (this.authAttempted) return; + this.authAttempted = true; + 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 8eb626a81dd..917123317e2 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -644,15 +644,15 @@ export async function signRelayEvent(input: { const eventJson = await invokeTauri("sign_event", input); return JSON.parse(eventJson) as RelayEvent; } - +/** Create ordinary or exact-native-socket-scoped relay AUTH. */ export async function createAuthEvent(input: { challenge: string; + nativeWebsocketId?: number; relayUrl: string; }): Promise { const eventJson = await invokeTauri("create_auth_event", input); return JSON.parse(eventJson) as RelayEvent; } - function fromRawRelayAgent(agent: RawRelayAgent): RelayAgent { return { pubkey: agent.pubkey, diff --git a/desktop/src/shared/api/tauriProfiles.ts b/desktop/src/shared/api/tauriProfiles.ts index abc6cbd8d7e..c8e52f51693 100644 --- a/desktop/src/shared/api/tauriProfiles.ts +++ b/desktop/src/shared/api/tauriProfiles.ts @@ -11,8 +11,6 @@ import type { type RawProfile = { pubkey: string; display_name: string | null; - verified_name?: string | null; - verified_name_expires_at?: number | null; avatar_url: string | null; about: string | null; nip05_handle: string | null; @@ -41,8 +39,6 @@ function fromRawProfile(profile: RawProfile): Profile { return { pubkey: profile.pubkey, displayName: profile.display_name, - verifiedName: profile.verified_name ?? null, - verifiedNameExpiresAt: profile.verified_name_expires_at ?? null, avatarUrl: profile.avatar_url, about: profile.about, nip05Handle: profile.nip05_handle, @@ -56,8 +52,6 @@ function fromRawUserProfileSummary( ): UserProfileSummary { return { displayName: profile.display_name, - verifiedName: profile.verified_name ?? null, - verifiedNameExpiresAt: profile.verified_name_expires_at ?? null, name: profile.name ?? null, avatarUrl: profile.avatar_url, nip05Handle: profile.nip05_handle, @@ -70,8 +64,6 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { return { pubkey: user.pubkey, displayName: user.display_name, - verifiedName: user.verified_name ?? null, - verifiedNameExpiresAt: user.verified_name_expires_at ?? null, avatarUrl: user.avatar_url, nip05Handle: user.nip05_handle, ownerPubkey: user.owner_pubkey, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 0f82d2047e7..fe3f2c7794b 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -108,9 +108,6 @@ export type { Identity, IdentityStorage } from "./identityTypes"; export type Profile = { pubkey: string; displayName: string | null; - /** Relay-authoritative name, trusted until `verifiedNameExpiresAt`. */ - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; avatarUrl: string | null; about: string | null; nip05Handle: string | null; @@ -124,9 +121,6 @@ export type Profile = { export type UserProfileSummary = { displayName: string | null; - /** Relay-authoritative name, trusted until `verifiedNameExpiresAt`. */ - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; /** Kind-0 `name` field, kept separate from `displayName` so @mention text * can be matched against either alias (agents/CLI resolve mentions against * `display_name` *or* `name` at send time). */ @@ -145,8 +139,6 @@ export type UsersBatchResponse = { export type UserSearchResult = { pubkey: string; displayName: string | null; - verifiedName?: string | null; - verifiedNameExpiresAt?: number | null; avatarUrl: string | null; nip05Handle: string | null; ownerPubkey: string | null; diff --git a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts b/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts deleted file mode 100644 index 4012f6fc5d9..00000000000 --- 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 db640aa8d9c..00000000000 --- 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 2c0312d7a3d..00000000000 --- 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 359639ac0fa..00000000000 --- 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 709647d7039..5ff22eb4f88 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -995,6 +995,7 @@ type MockFilter = { type MockSocket = { handler: WsHandler; + projectionHandler?: WsHandler; subscriptions: Map; }; @@ -1139,6 +1140,8 @@ declare global { /** 64-hex id required for the event to be a valid reaction target. */ id?: string; }) => RelayEvent; + /** Deliver the narrow native current-binding projection to active mock sockets. */ + __BUZZ_E2E_EMIT_CURRENT_PROJECTION__?: (projection: unknown) => void; /** Prepend `count` synthetic older messages to a channel's mock store so * an older-history fetch has something to paginate. Mirrors how the real * relay backfills history. Returns the created events. */ @@ -9566,7 +9569,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"); @@ -9585,6 +9591,10 @@ async function connectMockSocket(args: { onMessage: unknown }) { mockSockets.set(wsId, { handler, + projectionHandler: + args.onProjection === undefined + ? undefined + : resolveHandler(args.onProjection), subscriptions: new Map(), }); @@ -10104,6 +10114,11 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_COMMAND_LOG__ = []; window.__BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__ = (payload) => emit("huddle-tts-speaker-level", payload); + window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__ = (projection) => { + for (const socket of mockSockets.values()) { + socket.projectionHandler?.(projection); + } + }; window.__BUZZ_E2E_SIGNED_EVENTS__ = []; window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1; window.__BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__ = async (input) => { @@ -12790,6 +12805,7 @@ export function maybeInstallE2eTauriMocks() { ]), ); case "plugin:websocket|connect": + case "plugin:websocket|connect_with_status": if (isRelayMode(activeConfig)) { return connectRealSocket( payload as Parameters[0], diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 98bb2dadef9..a99b8c174ab 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -88,6 +88,69 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +test("current relay binding is informational, exact-author only, and fail-closed", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await waitForMockLiveSubscription(page, "general"); + + await page.evaluate( + ({ alicePubkey, bobPubkey }) => { + const testWindow = window as Window & { + __BUZZ_E2E_EMIT_CURRENT_PROJECTION__?: (projection: unknown) => void; + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey: string; + }) => unknown; + }; + testWindow.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.({ + eventAuthorPubkey: alicePubkey, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + testWindow.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "Current relay key author", + pubkey: alicePubkey, + }); + testWindow.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "Different raw event author", + pubkey: bobPubkey, + }); + }, + { + alicePubkey: TEST_IDENTITIES.alice.pubkey, + bobPubkey: TEST_IDENTITIES.bob.pubkey, + }, + ); + + const currentAuthorRow = page + .getByTestId("message-row") + .filter({ hasText: "Current relay key author" }); + const otherAuthorRow = page + .getByTestId("message-row") + .filter({ hasText: "Different raw event author" }); + await expect( + currentAuthorRow.getByLabel("Relay-reported current key (informational)"), + ).toHaveCount(1); + await expect(otherAuthorRow.getByTestId("current-relay-binding")).toHaveCount( + 0, + ); + + await page.evaluate((alicePubkey) => { + window.__BUZZ_E2E_EMIT_CURRENT_PROJECTION__?.({ + connectionEpoch: "11111111-1111-4111-8111-111111111111", + eventAuthorPubkey: alicePubkey, + freshUntil: Math.floor(Date.now() / 1_000) + 60, + }); + }, TEST_IDENTITIES.alice.pubkey); + await expect( + currentAuthorRow.getByTestId("current-relay-binding"), + ).toHaveCount(0); +}); + test("selected Inbox and Agents rows keep their highlight without bold text", async ({ page, }) => {