diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1af1f9bbe24..a46bd18b89e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -506,6 +506,9 @@ jobs: PGSCHEMA_PLAN_USER: buzz PGSCHEMA_PLAN_PASSWORD: buzz_dev run: | + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 \ + -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql @@ -666,6 +669,9 @@ jobs: PGSCHEMA_PLAN_USER: buzz PGSCHEMA_PLAN_PASSWORD: buzz_dev run: | + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -v ON_ERROR_STOP=1 \ + -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql @@ -715,6 +721,12 @@ jobs: docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \ psql -U buzz -d postgres -v ON_ERROR_STOP=1 \ -c "CREATE DATABASE buzz_identity_tests" + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(/migration::tests::(migration_nip_fi_lifecycle_tests|migration_nip_fi_operator_audit_tests|nip_fi_invitation_object_tests)::/)' \ + --no-tests=fail \ + --test-threads 1 \ + --run-ignored all cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-db) and test(/migration::tests::nip_fi_(authorization_tests|direct_final_tests)::/)' \ diff --git a/crates/buzz-auth/src/blossom.rs b/crates/buzz-auth/src/blossom.rs new file mode 100644 index 00000000000..ce90e16cab7 --- /dev/null +++ b/crates/buzz-auth/src/blossom.rs @@ -0,0 +1,152 @@ +//! Origin-sealed Blossom transport proofs for canonical authorization. + +use chrono::{DateTime, Utc}; +use nostr::Event; + +use crate::{AuthError, ProofTransport, VerifiedFederatedAssertion, VerifiedNostrProof}; + +/// Closed Blossom operation bound by a kind:24242 authorization event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlossomAuthorizationVerb { + /// Upload one exact content digest. + Upload, + /// Read one exact content digest, optionally under a server-wide grant. + Get, +} + +impl BlossomAuthorizationVerb { + const fn tag(self) -> &'static str { + match self { + Self::Upload => "upload", + Self::Get => "get", + } + } +} + +/// Verify Blossom transport and bind it to one canonical federated assertion. +/// +/// The event must name exactly one verb and expiration. Uploads require the +/// exact content digest; reads require either that digest or a matching server +/// grant. The returned proof is bound to the assertion's exact fingerprints. +#[allow(clippy::too_many_arguments)] +pub fn verify_blossom_authorization_proof( + event: &Event, + verb: BlossomAuthorizationVerb, + expected_server: &str, + content_sha256: &str, + maximum_age_seconds: u64, + assertion: &VerifiedFederatedAssertion, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +) -> Result { + if maximum_age_seconds == 0 + || content_sha256.len() != 64 + || !content_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(AuthError::BlossomInvalid); + } + event.verify().map_err(|_| AuthError::BlossomInvalid)?; + if event.kind.as_u16() != 24242 || event.content.trim().is_empty() { + return Err(AuthError::BlossomInvalid); + } + + let mut verb_tag = None; + let mut expiration_tag = None; + let mut digest_matches = false; + let mut server_seen = false; + let mut server_matches = false; + let expected_server = normalized_server(expected_server); + for tag in event.tags.iter() { + match tag.kind().to_string().as_str() { + "t" if verb_tag.replace(tag.content()).is_some() => { + return Err(AuthError::BlossomInvalid); + } + "t" => {} + "expiration" if expiration_tag.replace(tag.content()).is_some() => { + return Err(AuthError::BlossomInvalid); + } + "expiration" => {} + "x" => { + digest_matches |= tag.content() == Some(content_sha256); + } + "server" => { + server_seen = true; + server_matches |= tag + .content() + .map(normalized_server) + .is_some_and(|server| server == expected_server); + } + _ => {} + } + } + if verb_tag.flatten() != Some(verb.tag()) + || (server_seen && !server_matches) + || match verb { + BlossomAuthorizationVerb::Upload => !digest_matches, + BlossomAuthorizationVerb::Get => !digest_matches && !server_matches, + } + { + return Err(AuthError::BlossomInvalid); + } + + let expiration_seconds = expiration_tag + .flatten() + .and_then(|value| value.parse::().ok()) + .and_then(|value| i64::try_from(value).ok()) + .ok_or(AuthError::BlossomInvalid)?; + let event_expires_at = + DateTime::::from_timestamp(expiration_seconds, 0).ok_or(AuthError::BlossomInvalid)?; + let now_seconds = Utc::now().timestamp(); + let created_seconds = + i64::try_from(event.created_at.as_secs()).map_err(|_| AuthError::BlossomInvalid)?; + let maximum_age_seconds = + i64::try_from(maximum_age_seconds).map_err(|_| AuthError::BlossomInvalid)?; + if created_seconds > now_seconds.saturating_add(5) + || now_seconds.saturating_sub(created_seconds) > maximum_age_seconds + { + return Err(AuthError::BlossomInvalid); + } + + let (_, assertion_expires_at) = assertion.time_bounds(); + let expires_at = event_expires_at.min(assertion_expires_at); + let (assertion_transport, assertion_request, assertion_target, assertion_context) = + assertion.request_binding(); + if assertion_transport != ProofTransport::Blossom + || assertion_request != &request_fingerprint + || assertion_target != &target_fingerprint + || assertion_context != &transport_context_fingerprint + || expires_at <= Utc::now() + { + return Err(AuthError::BlossomInvalid); + } + + VerifiedNostrProof::from_verifier( + assertion.authorization_domain(), + event.pubkey, + ProofTransport::Blossom, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + Some(*assertion.assertion_fingerprint()), + None, + expires_at, + ) + .ok_or(AuthError::BlossomInvalid) +} + +fn normalized_server(value: &str) -> String { + let authority = match value.split_once("://") { + Some((_scheme, rest)) => match rest.split('/').next() { + Some(authority) => authority, + None => rest, + }, + None => match value.split('/').next() { + Some(authority) => authority, + None => value, + }, + }; + buzz_core::tenant::normalize_host(authority) +} diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 7f8131bc30f..00f252c67c3 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -30,6 +30,10 @@ pub enum AuthError { #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), + /// Blossom transport proof failed closed before canonical authorization. + #[error("Blossom authorization proof is invalid")] + BlossomInvalid, + /// A NIP-98 event with the same id has already been observed within the /// replay-prevention window. The event itself was structurally valid; the /// rejection is on freshness, not validity. diff --git a/crates/buzz-auth/src/evidence.rs b/crates/buzz-auth/src/evidence.rs index 9168d58c551..c78142fc68c 100644 --- a/crates/buzz-auth/src/evidence.rs +++ b/crates/buzz-auth/src/evidence.rs @@ -18,6 +18,44 @@ use crate::ProofTransport; pub enum AssertionTransportProfile { /// A request-bound `trusted-proxy-hmac-v1` provenance field. TrustedProxyHmacV1, + /// A request-bound `trusted-proxy-hmac-v2` field with authenticated peer. + TrustedProxyHmacV2, +} + +/// Opaque, authenticated end-client peer identity for bounded admission. +/// +/// The key is a domain-separated keyed digest of the canonical peer address. +/// It may be used as a status-admission coordinate, but cannot reveal the raw +/// address or be constructed from an unauthenticated forwarding header. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AuthenticatedClientPeer { + admission_key: [u8; 32], +} + +impl AuthenticatedClientPeer { + pub(crate) const fn new(admission_key: [u8; 32]) -> Self { + Self { admission_key } + } + + /// Construct deterministic opaque peer evidence for dev/test harnesses. + /// + /// This bypasses transport verification and is unavailable unless a test + /// build or the explicitly development-only `dev` feature is selected. + #[cfg(any(test, feature = "dev"))] + pub const fn for_test(admission_key: [u8; 32]) -> Self { + Self { admission_key } + } + + /// Privacy-safe key for an admission counter. + pub const fn admission_key(&self) -> &[u8; 32] { + &self.admission_key + } +} + +impl fmt::Debug for AuthenticatedClientPeer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthenticatedClientPeer([REDACTED])") + } } /// Opaque identity for one trusted-proxy nonce that final admission must claim. @@ -74,9 +112,45 @@ pub struct SealedTransportEvidence { proxy_expires_at: DateTime, nonce_claim: TrustedProxyNonceClaim, profile: AssertionTransportProfile, + authenticated_client_peer: Option, } impl SealedTransportEvidence { + /// Construct origin-shaped opaque evidence for cross-crate dev tests. + /// + /// The caller supplies only an already opaque peer key; raw addresses are + /// deliberately not accepted by this test seam. + #[cfg(any(test, feature = "dev"))] + #[allow(clippy::too_many_arguments)] + pub fn for_test( + 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, + ) -> Self { + let assertion = assertion.into(); + let assertion_digest: [u8; 32] = Sha256::digest(assertion.as_bytes()).into(); + Self::from_trusted_proxy( + authorization_domain, + assertion, + assertion_digest, + method, + authority, + path_and_query, + body_digest, + transport, + proxy_expires_at, + TrustedProxyNonceClaim::new([0x91; 32], proxy_expires_at), + AssertionTransportProfile::TrustedProxyHmacV2, + Some(authenticated_client_peer), + ) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn from_trusted_proxy( authorization_domain: CommunityId, @@ -89,27 +163,72 @@ impl SealedTransportEvidence { transport: ProofTransport, proxy_expires_at: DateTime, nonce_claim: TrustedProxyNonceClaim, + profile: AssertionTransportProfile, + authenticated_client_peer: Option, ) -> Self { - let request_fingerprint = framed_fingerprint( - b"buzz:nip-fi:trusted-proxy-request:v1", - &[ - authorization_domain.as_uuid().as_bytes(), - method, - authority, - path_and_query, - &body_digest, - &[proof_transport_code(transport)], - ], - ); - let transport_context_fingerprint = framed_fingerprint( - b"buzz:nip-fi:assertion-transport:v1", - &[ - b"trusted-proxy-hmac-v1", - authorization_domain.as_uuid().as_bytes(), - authority, - &[proof_transport_code(transport)], - ], - ); + let peer_key = authenticated_client_peer + .as_ref() + .map(AuthenticatedClientPeer::admission_key) + .map(<[u8; 32]>::as_slice) + .unwrap_or_default(); + let (request_domain, transport_profile) = match profile { + AssertionTransportProfile::TrustedProxyHmacV1 => ( + b"buzz:nip-fi:trusted-proxy-request:v1".as_slice(), + b"trusted-proxy-hmac-v1".as_slice(), + ), + AssertionTransportProfile::TrustedProxyHmacV2 => ( + b"buzz:nip-fi:trusted-proxy-request:v2".as_slice(), + b"trusted-proxy-hmac-v2".as_slice(), + ), + }; + let request_fingerprint = if authenticated_client_peer.is_some() { + framed_fingerprint( + request_domain, + &[ + authorization_domain.as_uuid().as_bytes(), + method, + authority, + path_and_query, + &body_digest, + &[proof_transport_code(transport)], + peer_key, + ], + ) + } else { + framed_fingerprint( + request_domain, + &[ + authorization_domain.as_uuid().as_bytes(), + method, + authority, + path_and_query, + &body_digest, + &[proof_transport_code(transport)], + ], + ) + }; + let transport_context_fingerprint = if authenticated_client_peer.is_some() { + framed_fingerprint( + b"buzz:nip-fi:assertion-transport:v2", + &[ + transport_profile, + authorization_domain.as_uuid().as_bytes(), + authority, + &[proof_transport_code(transport)], + peer_key, + ], + ) + } else { + framed_fingerprint( + b"buzz:nip-fi:assertion-transport:v1", + &[ + transport_profile, + authorization_domain.as_uuid().as_bytes(), + authority, + &[proof_transport_code(transport)], + ], + ) + }; Self { authorization_domain, assertion, @@ -119,7 +238,8 @@ impl SealedTransportEvidence { transport, proxy_expires_at, nonce_claim, - profile: AssertionTransportProfile::TrustedProxyHmacV1, + profile, + authenticated_client_peer, } } @@ -170,6 +290,11 @@ impl SealedTransportEvidence { pub const fn profile(&self) -> AssertionTransportProfile { self.profile } + + /// Authenticated end-client peer key, when the proxy used the v2 profile. + pub const fn authenticated_client_peer(&self) -> Option<&AuthenticatedClientPeer> { + self.authenticated_client_peer.as_ref() + } } pub(crate) const fn proof_transport_code(transport: ProofTransport) -> u8 { diff --git a/crates/buzz-auth/src/foundation.rs b/crates/buzz-auth/src/foundation.rs index c3d1df462f4..c36b8fdac71 100644 --- a/crates/buzz-auth/src/foundation.rs +++ b/crates/buzz-auth/src/foundation.rs @@ -421,8 +421,17 @@ impl CanonicalFederatedAssertionVerifier { validation.set_required_spec_claims(&["exp", "iat", "iss", "aud"]); validation.validate_exp = true; validation.validate_nbf = true; - let decoded = decode::(token, &key, &validation) - .map_err(|_| CanonicalVerifierError::InvalidToken)?; + let decoded = + decode::(token, &key, &validation).map_err(|error| { + if matches!( + error.kind(), + jsonwebtoken::errors::ErrorKind::ExpiredSignature + ) { + CanonicalVerifierError::Expired + } else { + CanonicalVerifierError::InvalidToken + } + })?; let subject = canonical_claim_string(&decoded.claims.claims, &self.policy.subject_claim)?; let issued_at = canonical_claim_i64(&decoded.claims.claims, "iat")?; @@ -508,6 +517,9 @@ pub enum CanonicalVerifierError { /// JWT lifetime was empty, malformed, or exceeded policy. #[error("canonical verifier rejected time bounds")] InvalidTimeBounds, + /// The signed assertion crossed its exact expiry bound. + #[error("canonical verifier assertion expired")] + Expired, } impl CanonicalVerifierError { @@ -523,6 +535,7 @@ impl CanonicalVerifierError { Self::InvalidKey => "nip_fi_verifier_invalid_key", Self::InvalidClaim => "nip_fi_verifier_invalid_claim", Self::InvalidTimeBounds => "nip_fi_verifier_invalid_time_bounds", + Self::Expired => "nip_fi_auth_expired", } } } @@ -778,6 +791,26 @@ impl fmt::Debug for CurrentBindingStatusEvidenceRequest { /// the configured resolver implementation and must use its atomic recheck plus /// `CanonicalCurrentBindingEvidence::accepts_exact_recheck` before delivery; /// an arbitrary constructed tuple is never authority by itself. +pub trait LocalStatusEvidenceResolver: Send + Sync { + /// Storage/read failure returned fail closed to composition. + type Error: Send; + + /// Read privacy-safe current status without mutating identity state. + fn current_status_evidence<'a>( + &'a self, + request: &'a CurrentBindingStatusEvidenceRequest, + ) -> impl Future> + Send + 'a; + + /// Recheck the exact tuple and return PostgreSQL time from the same read. + fn recheck_current_status_evidence<'a>( + &'a self, + evidence: &'a CanonicalCurrentBindingEvidence, + ) -> impl Future), Self::Error>> + + Send + + 'a; +} + +/// Complete local resolver used by protected authorization composition. pub trait LocalBindingResolver: Send + Sync { /// Storage/read failure returned fail closed to composition. type Error: Send; @@ -799,12 +832,39 @@ pub trait LocalBindingResolver: Send + Sync { ) -> impl Future> + Send + 'a; /// Recheck the complete evidence tuple atomically against PostgreSQL - /// immediately before presentation. Error, staleness, or any tuple mismatch - /// withholds presentation without changing authorization. + /// immediately before presentation, returning PostgreSQL's time from that + /// same read. Error, staleness, or any tuple mismatch withholds + /// presentation without changing authorization. fn recheck_current_status_evidence<'a>( &'a self, evidence: &'a CanonicalCurrentBindingEvidence, - ) -> impl Future> + Send + 'a; + ) -> impl Future), Self::Error>> + + Send + + 'a; +} + +impl LocalStatusEvidenceResolver for R +where + R: LocalBindingResolver, +{ + type Error = R::Error; + + fn current_status_evidence<'a>( + &'a self, + request: &'a CurrentBindingStatusEvidenceRequest, + ) -> impl Future> + Send + 'a + { + LocalBindingResolver::current_status_evidence(self, request) + } + + fn recheck_current_status_evidence<'a>( + &'a self, + evidence: &'a CanonicalCurrentBindingEvidence, + ) -> impl Future), Self::Error>> + + Send + + 'a { + LocalBindingResolver::recheck_current_status_evidence(self, evidence) + } } /// Whether a route participates in protected composition. @@ -3155,6 +3215,7 @@ mod tests { CanonicalVerifierError::InvalidKey, CanonicalVerifierError::InvalidClaim, CanonicalVerifierError::InvalidTimeBounds, + CanonicalVerifierError::Expired, ]; assert_eq!( verifier_errors diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 0453bb32bb6..cd0a81cf7c1 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,6 +17,8 @@ /// Channel access checking trait and helpers. pub mod access; +/// Blossom transport proof verification for canonical authorization. +pub mod blossom; /// Authentication error types. pub mod error; /// Sealed transport provenance shared by protected authorization paths. @@ -39,8 +41,12 @@ pub mod scope; pub mod trusted_proxy; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; +pub use blossom::{verify_blossom_authorization_proof, BlossomAuthorizationVerb}; pub use error::AuthError; -pub use evidence::{AssertionTransportProfile, SealedTransportEvidence, TrustedProxyNonceClaim}; +pub use evidence::{ + AssertionTransportProfile, AuthenticatedClientPeer, SealedTransportEvidence, + TrustedProxyNonceClaim, +}; pub use foundation::{ ActiveLocalBinding, AuthContext as FinalizedAuthContext, AuthoritativeAuthorizationRecheck, AuthorizationAuditConfig, AuthorizationAuditConfigError, AuthorizationError, @@ -52,18 +58,21 @@ pub use foundation::{ CanonicalVerifierPolicyId, CurrentBindingStatusEvidenceRequest, DirectEnrollmentMode, DirectEnrollmentProposal, FederatedPrincipalStorageKey, LocalAuthorizationPolicy, LocalBindingResolution, LocalBindingResolver, LocalBindingResolverCapability, - LocalEnrollmentAuthority, NipFiMode, PreparedAuthorization, PreparedAuthorizationRecheck, - ProofTransport, RouteCapability, RouteProtection, VerifiedDelegation, - VerifiedFederatedAssertion, VerifiedNostrProof, VerifierKeyGeneration, VerifierPolicyStamp, + LocalEnrollmentAuthority, LocalStatusEvidenceResolver, NipFiMode, PreparedAuthorization, + PreparedAuthorizationRecheck, ProofTransport, RouteCapability, RouteProtection, + VerifiedDelegation, VerifiedFederatedAssertion, VerifiedNostrProof, VerifierKeyGeneration, + VerifierPolicyStamp, }; pub use nip42::{ - generate_challenge, verify_nip42_authorization_proof, verify_nip42_event, - Nip42AuthorizationProofError, + generate_challenge, verify_nip42_authorization_proof, verify_nip42_binding_status_proof, + verify_nip42_event, Nip42AuthorizationProofError, Nip42BindingStatusCoordinates, + VerifiedBindingStatusProof, }; pub use nip98::{ - verify_nip42_moderation_command_proof, verify_nip98_event, verify_nip98_invite_claim_proof, - verify_nip98_moderation_command_proof, Nip42ModerationCommandCoordinates, - Nip98InviteClaimCoordinates, Nip98ModerationCommandCoordinates, VerifiedModerationCommandProof, + verify_nip42_moderation_command_proof, verify_nip98_authorization_proof, verify_nip98_event, + verify_nip98_invite_claim_proof, verify_nip98_moderation_command_proof, + Nip42ModerationCommandCoordinates, Nip98InviteClaimCoordinates, + Nip98ModerationCommandCoordinates, VerifiedModerationCommandProof, VerifiedNip98InviteClaimProof, }; pub use nip98_replay::{ @@ -77,7 +86,7 @@ pub use scope::{parse_scopes, Scope}; pub use trusted_proxy::{ HttpHeaderField, TrustedProxyError, TrustedProxyNonceReplayReader, TrustedProxyProvenanceVerifier, TrustedProxyReplayReadError, TrustedProxyRequest, - ASSERTION_HEADER_NAME, PROVENANCE_HEADER_NAME, + ASSERTION_HEADER_NAME, CLIENT_PEER_HEADER_NAME, PROVENANCE_HEADER_NAME, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip42.rs b/crates/buzz-auth/src/nip42.rs index 221176a335c..a0fa2b8c057 100644 --- a/crates/buzz-auth/src/nip42.rs +++ b/crates/buzz-auth/src/nip42.rs @@ -6,14 +6,21 @@ //! //! AUTH events are **never** stored or logged (may contain bearer tokens). +use buzz_core::client_binding_bootstrap::ClientBindingScopeV1; use buzz_core::CommunityId; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, TimeDelta, Utc}; use nostr::{Event, Kind, TagKind, Timestamp}; +use sha2::{Digest, Sha256}; use thiserror::Error; use url::Url; +use uuid::Uuid; use crate::error::AuthError; -use crate::foundation::{ProofTransport, VerifiedNostrProof}; +use crate::foundation::{ + ActiveLocalBinding, AuthorizationError, AuthorizationFinalizer, AuthorizationInput, + LocalAuthorizationPolicy, LocalBindingResolution, PreparedAuthorization, ProofTransport, + RouteCapability, VerifiedNostrProof, +}; /// Normalize a relay URL for comparison. /// @@ -140,9 +147,199 @@ pub fn verify_nip42_authorization_proof( .ok_or(Nip42AuthorizationProofError::InvalidBinding) } +/// Exact server-derived coordinates for one opted-in status connection. +pub struct Nip42BindingStatusCoordinates { + authorization_domain: CommunityId, + relay_url: Box, + event_id: [u8; 32], + actor: nostr::PublicKey, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +} + +impl Nip42BindingStatusCoordinates { + /// Bind the signed scope to the relay-owned connection generation. + pub fn new( + authorization_domain: CommunityId, + relay_url: &str, + connection_id: Uuid, + relay_signer: nostr::PublicKey, + event: &Event, + ) -> Result { + let scope = ClientBindingScopeV1::from_verified_auth_event(event) + .map_err(|_| Nip42AuthorizationProofError::InvalidBinding)?; + let parsed_relay = + Url::parse(relay_url).map_err(|_| Nip42AuthorizationProofError::InvalidBinding)?; + if authorization_domain.as_uuid().is_nil() + || connection_id.is_nil() + || event.id.to_bytes() == [0; 32] + || scope.relay_signer() != relay_signer + || !matches!(parsed_relay.scheme(), "ws" | "wss") + || parsed_relay.host_str().is_none() + || !parsed_relay.username().is_empty() + || parsed_relay.password().is_some() + || parsed_relay.query().is_some() + || parsed_relay.fragment().is_some() + { + return Err(Nip42AuthorizationProofError::InvalidBinding); + } + let event_id = event.id.to_bytes(); + let target_fingerprint = nip42_status_digest( + b"buzz:client-status-connection:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + connection_id.as_bytes(), + event.pubkey.as_bytes(), + relay_signer.as_bytes(), + scope.connection_epoch().as_str().as_bytes(), + ], + ); + let request_fingerprint = nip42_status_digest( + b"buzz:nip-fi:nip42-binding-status-request:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + &event_id, + &target_fingerprint, + ], + ); + let transport_context_fingerprint = nip42_status_digest( + b"buzz:nip-fi:nip42-binding-status-transport:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + parsed_relay.as_str().as_bytes(), + connection_id.as_bytes(), + &event_id, + ], + ); + Ok(Self { + authorization_domain, + relay_url: parsed_relay.as_str().into(), + event_id, + actor: event.pubkey, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + }) + } + + /// Exact opaque connection target used by the delivery owner. + pub const fn target_fingerprint(&self) -> [u8; 32] { + self.target_fingerprint + } +} + +impl std::fmt::Debug for Nip42BindingStatusCoordinates { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Nip42BindingStatusCoordinates([REDACTED])") + } +} + +/// Purpose-sealed proof for the fixed binding-status capability. +pub struct VerifiedBindingStatusProof { + proof: VerifiedNostrProof, +} + +impl VerifiedBindingStatusProof { + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.proof.authorization_domain() + } + + /// Exact directly authenticated author. + pub const fn actor_pubkey(&self) -> nostr::PublicKey { + self.proof.actor_pubkey() + } + + /// Exact connection target sealed into the proof. + pub const fn target_fingerprint(&self) -> &[u8; 32] { + self.proof.target_fingerprint() + } + + /// Exclusive proof lifetime bound. + pub const fn expires_at(&self) -> DateTime { + self.proof.expires_at() + } + + /// Prepare the fixed direct binding-status authorization. + pub fn prepare_authorization( + self, + binding: ActiveLocalBinding, + policy: LocalAuthorizationPolicy, + authoritative_now: DateTime, + ) -> Result { + let domain = self.proof.authorization_domain(); + let input = AuthorizationInput::new( + domain, + Uuid::new_v4(), + self.proof, + RouteCapability::BindingStatus, + )?; + AuthorizationFinalizer::prepare( + input, + LocalBindingResolution::bound_key(binding), + policy, + authoritative_now, + ) + } +} + +impl std::fmt::Debug for VerifiedBindingStatusProof { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("VerifiedBindingStatusProof([REDACTED])") + } +} + +/// Verify the exact AUTH event and mint a fixed-purpose status proof. +pub fn verify_nip42_binding_status_proof( + event: &Event, + expected_challenge: &str, + coordinates: &Nip42BindingStatusCoordinates, + authoritative_now: DateTime, +) -> Result { + verify_nip42_event(event, expected_challenge, &coordinates.relay_url)?; + if event.id.to_bytes() != coordinates.event_id + || event.pubkey != coordinates.actor + || event.created_at.as_secs().abs_diff( + u64::try_from(authoritative_now.timestamp()) + .map_err(|_| Nip42AuthorizationProofError::InvalidBinding)?, + ) > TIMESTAMP_TOLERANCE_SECS + { + return Err(Nip42AuthorizationProofError::InvalidBinding); + } + let expires_at = authoritative_now + .checked_add_signed(TimeDelta::minutes(5)) + .ok_or(Nip42AuthorizationProofError::InvalidBinding)?; + let proof = VerifiedNostrProof::from_verifier( + coordinates.authorization_domain, + event.pubkey, + ProofTransport::Nip42, + coordinates.request_fingerprint, + coordinates.target_fingerprint, + coordinates.transport_context_fingerprint, + None, + None, + expires_at, + ) + .ok_or(Nip42AuthorizationProofError::InvalidBinding)?; + Ok(VerifiedBindingStatusProof { proof }) +} + +fn nip42_status_digest(label: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((label.len() as u64).to_be_bytes()); + digest.update(label); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().into() +} + #[cfg(test)] mod tests { use super::*; + use buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG; use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag, Timestamp}; const TEST_RELAY: &str = "wss://relay.example.com"; @@ -154,6 +351,72 @@ mod tests { .expect("signing failed") } + fn make_status_auth_event( + keys: &Keys, + relay: &Keys, + challenge: &str, + relay_url: &str, + ) -> Event { + let url = RelayUrl::parse(relay_url).expect("valid relay url"); + let relay_signer = relay.public_key().to_hex(); + let epoch = Uuid::new_v4().to_string(); + EventBuilder::auth(challenge, url) + .tag( + Tag::parse([ + CLIENT_BINDING_SCOPE_TAG, + "1", + epoch.as_str(), + relay_signer.as_str(), + ]) + .expect("status scope"), + ) + .sign_with_keys(keys) + .expect("signing failed") + } + + #[test] + fn binding_status_proof_is_exact_connection_and_relay_bound() { + let author = Keys::generate(); + let relay = Keys::generate(); + let challenge = generate_challenge(); + let event = make_status_auth_event(&author, &relay, &challenge, TEST_RELAY); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let connection = Uuid::new_v4(); + let coordinates = Nip42BindingStatusCoordinates::new( + domain, + TEST_RELAY, + connection, + relay.public_key(), + &event, + ) + .expect("coordinates"); + let other = Nip42BindingStatusCoordinates::new( + domain, + TEST_RELAY, + Uuid::new_v4(), + relay.public_key(), + &event, + ) + .expect("other coordinates"); + assert_ne!(coordinates.target_fingerprint(), other.target_fingerprint()); + let proof = verify_nip42_binding_status_proof(&event, &challenge, &coordinates, Utc::now()) + .expect("purpose-sealed status proof"); + assert_eq!(proof.authorization_domain(), domain); + assert_eq!(proof.actor_pubkey(), author.public_key()); + assert_eq!( + proof.target_fingerprint(), + &coordinates.target_fingerprint() + ); + assert!(Nip42BindingStatusCoordinates::new( + domain, + TEST_RELAY, + connection, + Keys::generate().public_key(), + &event, + ) + .is_err()); + } + fn make_auth_event_with_tags(keys: &Keys, tags: Vec) -> Event { EventBuilder::new(Kind::Authentication, "") .tags(tags) diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index cf998fa3a6c..c05fc38c0cc 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -35,6 +35,7 @@ use crate::{ }; const TIMESTAMP_TOLERANCE_SECS: u64 = 60; +const GIT_SMART_HTTP_SESSION_SECS: u64 = 300; /// Verify a NIP-98 HTTP Auth event (kind:27235). /// @@ -135,6 +136,81 @@ pub fn verify_nip98_event( Ok(event.pubkey) } +/// Verify NIP-98 transport and bind it to one canonical federated assertion. +/// +/// The assertion and Nostr event are verified independently, then required to +/// name byte-identical request, target, transport, and context coordinates. +/// Git Smart HTTP may select its closed session transport because one signed +/// NIP-98 credential is intentionally reused across the bounded Git exchange. +#[allow(clippy::too_many_arguments)] +pub fn verify_nip98_authorization_proof( + event_json: &str, + expected_url: &str, + expected_method: &str, + body: Option<&[u8]>, + assertion: &VerifiedFederatedAssertion, + transport: ProofTransport, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +) -> Result { + if !matches!( + transport, + ProofTransport::Nip98 | ProofTransport::GitSmartHttpSession + ) { + return Err(AuthError::Nip98Invalid( + "invalid canonical NIP-98 transport".to_owned(), + )); + } + let actor = verify_nip98_event(event_json, expected_url, expected_method, body)?; + let event: Event = serde_json::from_str(event_json) + .map_err(|_| AuthError::Nip98Invalid("invalid canonical NIP-98 event".to_owned()))?; + let event_expiry_seconds = event + .created_at + .as_secs() + .checked_add(match transport { + ProofTransport::GitSmartHttpSession => GIT_SMART_HTTP_SESSION_SECS, + ProofTransport::Nip98 => TIMESTAMP_TOLERANCE_SECS, + _ => { + return Err(AuthError::Nip98Invalid( + "invalid canonical NIP-98 transport".to_owned(), + )) + } + }) + .ok_or_else(|| AuthError::Nip98Invalid("invalid canonical NIP-98 expiry".to_owned()))?; + let event_expiry_seconds = i64::try_from(event_expiry_seconds) + .map_err(|_| AuthError::Nip98Invalid("invalid canonical NIP-98 expiry".to_owned()))?; + let event_expires_at = DateTime::::from_timestamp(event_expiry_seconds, 0) + .ok_or_else(|| AuthError::Nip98Invalid("invalid canonical NIP-98 expiry".to_owned()))?; + let (_, assertion_expires_at) = assertion.time_bounds(); + let expires_at = event_expires_at.min(assertion_expires_at); + let (assertion_transport, assertion_request, assertion_target, assertion_context) = + assertion.request_binding(); + if assertion.authorization_domain().as_uuid().is_nil() + || assertion_transport != transport + || assertion_request != &request_fingerprint + || assertion_target != &target_fingerprint + || assertion_context != &transport_context_fingerprint + || expires_at <= Utc::now() + { + return Err(AuthError::Nip98Invalid( + "canonical NIP-98 binding mismatch".to_owned(), + )); + } + VerifiedNostrProof::from_verifier( + assertion.authorization_domain(), + actor, + transport, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + Some(*assertion.assertion_fingerprint()), + None, + expires_at, + ) + .ok_or_else(|| AuthError::Nip98Invalid("invalid canonical NIP-98 binding".to_owned())) +} + /// Exact server-derived coordinates for one body-bound invite claim. /// /// The request fingerprint intentionally excludes event id, signature, and diff --git a/crates/buzz-auth/src/trusted_proxy.rs b/crates/buzz-auth/src/trusted_proxy.rs index 99cb53e0cc9..c8deb396704 100644 --- a/crates/buzz-auth/src/trusted_proxy.rs +++ b/crates/buzz-auth/src/trusted_proxy.rs @@ -8,7 +8,7 @@ use std::{ fmt, future::Future, - net::{Ipv4Addr, Ipv6Addr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, pin::Pin, str::FromStr, time::Duration, @@ -20,15 +20,21 @@ use hmac::{Hmac, KeyInit, Mac}; use sha2::{Digest, Sha256}; use thiserror::Error; -use crate::evidence::{proof_transport_code, SealedTransportEvidence, TrustedProxyNonceClaim}; +use crate::evidence::{ + proof_transport_code, AssertionTransportProfile, AuthenticatedClientPeer, + SealedTransportEvidence, TrustedProxyNonceClaim, +}; use crate::ProofTransport; /// Exact NIP-FI assertion header name. pub const ASSERTION_HEADER_NAME: &str = "nostr-federated-identity"; /// Exact NIP-FI trusted-proxy provenance header name. pub const PROVENANCE_HEADER_NAME: &str = "nostr-federated-identity-provenance"; +/// Exact authenticated end-client peer header name for v2 provenance. +pub const CLIENT_PEER_HEADER_NAME: &str = "nostr-federated-identity-client-peer"; -const MAC_DOMAIN: &[u8] = b"NIP-FI-PROXY-1"; +const MAC_DOMAIN_V1: &[u8] = b"NIP-FI-PROXY-1"; +const MAC_DOMAIN_V2: &[u8] = b"NIP-FI-PROXY-2"; const MIN_SECRET_BYTES: usize = 32; const MAX_SECRET_BYTES: usize = 4096; const MAX_ACTIVE_SECRETS: usize = 4; @@ -163,6 +169,9 @@ pub enum TrustedProxyError { /// Required provenance field was absent; direct ingress cannot fall back. #[error("trusted-proxy provenance is missing")] MissingProvenance, + /// V2 provenance did not include the proxy-authenticated end-client peer. + #[error("trusted-proxy client peer is missing")] + MissingClientPeer, /// A protected field was repeated, combined, or ambiguously encoded. #[error("trusted-proxy header is ambiguous")] AmbiguousHeader, @@ -172,6 +181,9 @@ pub enum TrustedProxyError { /// Provenance framing, timestamp, nonce, or MAC was malformed. #[error("trusted-proxy provenance is malformed")] MalformedProvenance, + /// End-client peer framing was non-canonical or malformed. + #[error("trusted-proxy client peer is malformed")] + MalformedClientPeer, /// Server-resolved request coordinates were not canonical. #[error("trusted-proxy request binding is invalid")] InvalidRequest, @@ -199,9 +211,11 @@ impl TrustedProxyError { Self::InvalidConfiguration => "nip_fi_proxy_invalid_configuration", Self::MissingAssertion => "nip_fi_proxy_missing_assertion", Self::MissingProvenance => "nip_fi_proxy_missing_provenance", + Self::MissingClientPeer => "nip_fi_proxy_missing_client_peer", Self::AmbiguousHeader => "nip_fi_proxy_ambiguous_header", Self::MalformedAssertion => "nip_fi_proxy_malformed_assertion", Self::MalformedProvenance => "nip_fi_proxy_malformed_provenance", + Self::MalformedClientPeer => "nip_fi_proxy_malformed_client_peer", Self::InvalidRequest => "nip_fi_proxy_invalid_request", Self::Expired => "nip_fi_proxy_expired", Self::FutureDated => "nip_fi_proxy_future_dated", @@ -212,7 +226,11 @@ impl TrustedProxyError { } } -/// Verifies `trusted-proxy-hmac-v1` provenance under a finite secret set. +/// Verifies trusted-proxy HMAC v1/v2 provenance under a finite secret set. +/// +/// V2 additionally authenticates a canonical end-client IP address and seals it +/// as an opaque admission key. Callers never need to retain or key on the raw +/// address. pub struct TrustedProxyProvenanceVerifier { active_secrets: Vec>, maximum_provenance_age_seconds: u64, @@ -303,8 +321,27 @@ impl TrustedProxyProvenanceVerifier { )?; let assertion = parse_bearer_assertion(assertion_field)?; let parsed = self.parse_provenance(provenance_field, now)?; + let authenticated_client_peer = match parsed.version { + ProvenanceVersion::V1 => None, + ProvenanceVersion::V2 => { + let field = exact_header( + headers, + CLIENT_PEER_HEADER_NAME, + TrustedProxyError::MissingClientPeer, + 64, + )?; + Some(parse_client_peer(field)?) + } + }; let assertion_digest: [u8; 32] = Sha256::digest(assertion.as_bytes()).into(); - let mac_input = mac_input(parsed.timestamp, &parsed.nonce, &assertion_digest, request); + let mac_input = mac_input( + parsed.version, + parsed.timestamp, + &parsed.nonce, + &assertion_digest, + request, + authenticated_client_peer.as_ref(), + ); let mut authenticated = 0_u8; for secret in &self.active_secrets { let mut mac = ::new_from_slice(secret) @@ -315,6 +352,10 @@ impl TrustedProxyProvenanceVerifier { if authenticated != 1 { return Err(TrustedProxyError::InvalidMac); } + let authenticated_client_peer = authenticated_client_peer + .as_ref() + .map(|peer| self.seal_client_peer(peer)) + .transpose()?; let claim_key = framed_digest(b"buzz:nip-fi:trusted-proxy-nonce:v1", &[&parsed.nonce]); let claim = TrustedProxyNonceClaim::new(claim_key, parsed.expires_at); @@ -338,6 +379,29 @@ impl TrustedProxyProvenanceVerifier { request.transport, parsed.expires_at, claim, + parsed.version.profile(), + authenticated_client_peer, + )) + } + + fn seal_client_peer( + &self, + peer: &ParsedClientPeer, + ) -> Result { + // The first configured secret is the stable admission-key secret for + // the active rotation window, regardless of which accepted secret + // authenticated this request. Rotation may reset only finite counters. + let secret = self + .active_secrets + .first() + .ok_or(TrustedProxyError::InvalidConfiguration)?; + let mut mac = ::new_from_slice(secret) + .map_err(|_| TrustedProxyError::InvalidConfiguration)?; + mac.update(b"buzz:nip-fi:authenticated-client-peer:v1"); + mac.update(&(peer.canonical.len() as u64).to_be_bytes()); + mac.update(peer.canonical.as_bytes()); + Ok(AuthenticatedClientPeer::new( + mac.finalize().into_bytes().into(), )) } @@ -349,9 +413,11 @@ impl TrustedProxyProvenanceVerifier { let field = std::str::from_utf8(field).map_err(|_| TrustedProxyError::MalformedProvenance)?; let mut components = field.split('.'); - if components.next() != Some("v1") { - return Err(TrustedProxyError::MalformedProvenance); - } + let version = match components.next() { + Some("v1") => ProvenanceVersion::V1, + Some("v2") => ProvenanceVersion::V2, + _ => return Err(TrustedProxyError::MalformedProvenance), + }; let timestamp_text = components .next() .ok_or(TrustedProxyError::MalformedProvenance)?; @@ -393,6 +459,7 @@ impl TrustedProxyProvenanceVerifier { .and_then(|seconds| DateTime::from_timestamp(seconds, 0)) .ok_or(TrustedProxyError::MalformedProvenance)?; Ok(ParsedProvenance { + version, timestamp, nonce, mac, @@ -408,12 +475,57 @@ impl fmt::Debug for TrustedProxyProvenanceVerifier { } struct ParsedProvenance { + version: ProvenanceVersion, timestamp: u64, nonce: Vec, mac: [u8; 32], expires_at: DateTime, } +#[derive(Clone, Copy)] +enum ProvenanceVersion { + V1, + V2, +} + +impl ProvenanceVersion { + const fn profile(self) -> AssertionTransportProfile { + match self { + Self::V1 => AssertionTransportProfile::TrustedProxyHmacV1, + Self::V2 => AssertionTransportProfile::TrustedProxyHmacV2, + } + } + + const fn mac_domain(self) -> &'static [u8] { + match self { + Self::V1 => MAC_DOMAIN_V1, + Self::V2 => MAC_DOMAIN_V2, + } + } +} + +struct ParsedClientPeer { + canonical: Box, +} + +fn parse_client_peer(field: &[u8]) -> Result { + let text = std::str::from_utf8(field).map_err(|_| TrustedProxyError::MalformedClientPeer)?; + let parsed = IpAddr::from_str(text).map_err(|_| TrustedProxyError::MalformedClientPeer)?; + let canonical = match parsed { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map_or(IpAddr::V6(address), IpAddr::V4), + address => address, + } + .to_string(); + if canonical != text { + return Err(TrustedProxyError::MalformedClientPeer); + } + Ok(ParsedClientPeer { + canonical: canonical.into_boxed_str(), + }) +} + fn exact_header<'a>( headers: &[HttpHeaderField<'a>], expected_name: &str, @@ -588,14 +700,20 @@ fn canonical_unsigned_decimal(value: &str) -> bool { } fn mac_input( + version: ProvenanceVersion, timestamp: u64, nonce: &[u8], assertion_digest: &[u8; 32], request: &TrustedProxyRequest, + authenticated_client_peer: Option<&ParsedClientPeer>, ) -> Vec { + let mac_domain = version.mac_domain(); + let peer_bytes = authenticated_client_peer + .map(|peer| peer.canonical.as_bytes()) + .unwrap_or_default(); let mut input = Vec::with_capacity( - MAC_DOMAIN.len() - + 8 * 9 + mac_domain.len() + + 8 * 10 + 8 + 1 + nonce.len() @@ -604,9 +722,10 @@ fn mac_input( + request.method.len() + request.authority.len() + request.path_and_query.len() - + request.body_digest.len(), + + request.body_digest.len() + + peer_bytes.len(), ); - input.extend_from_slice(MAC_DOMAIN); + input.extend_from_slice(mac_domain); append_length_prefixed(&mut input, ×tamp.to_be_bytes()); append_length_prefixed(&mut input, nonce); append_length_prefixed(&mut input, assertion_digest); @@ -619,6 +738,9 @@ fn mac_input( append_length_prefixed(&mut input, request.path_and_query.as_bytes()); append_length_prefixed(&mut input, &request.body_digest); append_length_prefixed(&mut input, &[proof_transport_code(request.transport)]); + if matches!(version, ProvenanceVersion::V2) { + append_length_prefixed(&mut input, peer_bytes); + } input } diff --git a/crates/buzz-auth/tests/local_binding_resolver_contract.rs b/crates/buzz-auth/tests/local_binding_resolver_contract.rs index aca031d8d3c..be42ffe1b80 100644 --- a/crates/buzz-auth/tests/local_binding_resolver_contract.rs +++ b/crates/buzz-auth/tests/local_binding_resolver_contract.rs @@ -39,8 +39,9 @@ impl LocalBindingResolver for ExternalPostgresResolver { fn recheck_current_status_evidence<'a>( &'a self, _evidence: &'a CanonicalCurrentBindingEvidence, - ) -> impl Future> + Send + 'a - { + ) -> impl Future), Self::Error>> + + Send + + 'a { pending() } } diff --git a/crates/buzz-auth/tests/trusted_proxy_provenance.rs b/crates/buzz-auth/tests/trusted_proxy_provenance.rs index 350aa483637..4c91004f708 100644 --- a/crates/buzz-auth/tests/trusted_proxy_provenance.rs +++ b/crates/buzz-auth/tests/trusted_proxy_provenance.rs @@ -13,7 +13,7 @@ use buzz_auth::{ AssertionTransportProfile, HttpHeaderField, ProofTransport, TrustedProxyError, TrustedProxyNonceClaim, TrustedProxyNonceReplayReader, TrustedProxyProvenanceVerifier, TrustedProxyReplayReadError, TrustedProxyRequest, ASSERTION_HEADER_NAME, - PROVENANCE_HEADER_NAME, + CLIENT_PEER_HEADER_NAME, PROVENANCE_HEADER_NAME, }; use buzz_core::CommunityId; use chrono::{TimeZone, Utc}; @@ -157,6 +157,61 @@ fn sign_provenance_in_domain( ) } +struct PeerProvenance<'a> { + timestamp: u64, + nonce: &'a [u8], + client_peer: &'a str, +} + +fn sign_peer_provenance( + assertion: &str, + method: &str, + authority: &str, + path_and_query: &str, + body: &[u8], + provenance: PeerProvenance<'_>, +) -> String { + let PeerProvenance { + timestamp, + nonce, + client_peer, + } = provenance; + let canonical_path = if path_and_query.is_empty() { + "/".to_owned() + } else if path_and_query.starts_with('?') { + format!("/{path_and_query}") + } else { + path_and_query.to_owned() + }; + let assertion_digest: [u8; 32] = Sha256::digest(assertion.as_bytes()).into(); + let body_digest: [u8; 32] = Sha256::digest(body).into(); + let transport = [2_u8]; // ProofTransport::Nip98, frozen wire code. + let mut input = b"NIP-FI-PROXY-2".to_vec(); + for value in [ + timestamp.to_be_bytes().as_slice(), + nonce, + &assertion_digest, + DOMAIN_A.as_uuid().as_bytes(), + method.as_bytes(), + authority.as_bytes(), + canonical_path.as_bytes(), + &body_digest, + &transport, + client_peer.as_bytes(), + ] { + input.extend_from_slice(&(value.len() as u64).to_be_bytes()); + input.extend_from_slice(value); + } + let mut mac = ::new_from_slice(&PRIMARY_SECRET) + .expect("HMAC-SHA-256 accepts the test key"); + mac.update(&input); + format!( + "v2.{timestamp}.{}.{}", + base64url(nonce), + base64url(&mac.finalize().into_bytes()) + ) +} + fn base64url(value: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut output = String::new(); @@ -215,6 +270,19 @@ fn headers<'a>(assertion: &'a str, provenance: &'a str) -> Vec( + assertion: &'a str, + provenance: &'a str, + client_peer: &'a str, +) -> Vec> { + let mut headers = headers(assertion, provenance); + headers.push(HttpHeaderField::new( + CLIENT_PEER_HEADER_NAME, + client_peer.as_bytes(), + )); + headers +} + #[tokio::test] async fn valid_provenance_seals_redacted_move_only_evidence() { let verifier = verifier(); @@ -243,6 +311,7 @@ async fn valid_provenance_seals_redacted_move_only_evidence() { AssertionTransportProfile::TrustedProxyHmacV1 ); assert_eq!(evidence.transport(), ProofTransport::Nip98); + assert!(evidence.authenticated_client_peer().is_none()); assert_eq!(evidence.proxy_expires_at().timestamp(), (NOW + 60) as i64); assert_eq!( evidence.nonce_claim().retain_until().timestamp(), @@ -261,6 +330,169 @@ async fn valid_provenance_seals_redacted_move_only_evidence() { ); } +#[tokio::test] +async fn v2_provenance_preserves_clients_behind_proxy_fan_in() { + let verifier = verifier(); + let request = request("GET", "relay.example.com:443", "/", b""); + let assertion = assertion_header(proxy_assertion()); + let replay = ReplayReader::default(); + let peer_a = "192.0.2.10"; + let peer_b = "192.0.2.11"; + let provenance_a = sign_peer_provenance( + proxy_assertion(), + "GET", + "relay.example.com:443", + "/", + b"", + PeerProvenance { + timestamp: NOW, + nonce: &[0x1a; 16], + client_peer: peer_a, + }, + ); + let provenance_b = sign_peer_provenance( + proxy_assertion(), + "GET", + "relay.example.com:443", + "/", + b"", + PeerProvenance { + timestamp: NOW, + nonce: &[0x1b; 16], + client_peer: peer_b, + }, + ); + let provenance_a_again = sign_peer_provenance( + proxy_assertion(), + "GET", + "relay.example.com:443", + "/", + b"", + PeerProvenance { + timestamp: NOW, + nonce: &[0x1d; 16], + client_peer: peer_a, + }, + ); + + let evidence_a = verifier + .verify( + &peer_headers(&assertion, &provenance_a, peer_a), + &request, + now(), + &replay, + ) + .await + .expect("authenticated peer A"); + let evidence_b = verifier + .verify( + &peer_headers(&assertion, &provenance_b, peer_b), + &request, + now(), + &replay, + ) + .await + .expect("authenticated peer B"); + let evidence_a_again = verifier + .verify( + &peer_headers(&assertion, &provenance_a_again, peer_a), + &request, + now(), + &replay, + ) + .await + .expect("authenticated peer A with a new nonce"); + + assert_eq!( + evidence_a.profile(), + AssertionTransportProfile::TrustedProxyHmacV2 + ); + let authenticated_a = evidence_a.authenticated_client_peer().expect("v2 peer key"); + let authenticated_b = evidence_b.authenticated_client_peer().expect("v2 peer key"); + let authenticated_a_again = evidence_a_again + .authenticated_client_peer() + .expect("stable v2 peer key"); + assert_eq!( + authenticated_a.admission_key(), + authenticated_a_again.admission_key() + ); + assert_ne!( + authenticated_a.admission_key(), + authenticated_b.admission_key() + ); + assert_eq!( + format!("{authenticated_a:?}"), + "AuthenticatedClientPeer([REDACTED])" + ); + assert!(!format!("{evidence_a:?}").contains(peer_a)); +} + +#[tokio::test] +async fn v2_peer_is_required_canonical_and_mac_bound() { + let verifier = verifier(); + let request = request("GET", "relay.example.com:443", "/", b""); + let assertion = assertion_header(proxy_assertion()); + let replay = ReplayReader::default(); + let peer = "198.51.100.20"; + let provenance = sign_peer_provenance( + proxy_assertion(), + "GET", + "relay.example.com:443", + "/", + b"", + PeerProvenance { + timestamp: NOW, + nonce: &[0x1c; 16], + client_peer: peer, + }, + ); + + assert_eq!( + verifier + .verify(&headers(&assertion, &provenance), &request, now(), &replay) + .await + .unwrap_err(), + TrustedProxyError::MissingClientPeer + ); + assert_eq!( + verifier + .verify( + &peer_headers(&assertion, &provenance, "198.51.100.21"), + &request, + now(), + &replay, + ) + .await + .unwrap_err(), + TrustedProxyError::InvalidMac + ); + assert_eq!( + verifier + .verify( + &peer_headers(&assertion, &provenance, "::ffff:198.51.100.20"), + &request, + now(), + &replay, + ) + .await + .unwrap_err(), + TrustedProxyError::MalformedClientPeer + ); + + let mut duplicate = peer_headers(&assertion, &provenance, peer); + duplicate.push(HttpHeaderField::new( + "Nostr-Federated-Identity-Client-Peer", + peer.as_bytes(), + )); + assert_eq!( + verifier + .verify(&duplicate, &request, now(), &replay) + .await + .unwrap_err(), + TrustedProxyError::AmbiguousHeader + ); +} + #[tokio::test] async fn provenance_binds_authorization_domain_and_transport_context() { let verifier = verifier(); diff --git a/crates/buzz-db/src/authorization_admission.rs b/crates/buzz-db/src/authorization_admission.rs index 12ab267fc36..97337981846 100644 --- a/crates/buzz-db/src/authorization_admission.rs +++ b/crates/buzz-db/src/authorization_admission.rs @@ -16,10 +16,11 @@ use std::{ use buzz_auth::{ ActiveLocalBinding, AuthoritativeAuthorizationRecheck, AuthorizationError, AuthorizationFinalizationRechecker, AuthorizationFinalizer, AuthorizationInput, - AuthorizationReason, DirectEnrollmentProposal, FinalizedAuthContext, LocalAuthorizationPolicy, - LocalBindingResolution, PreparedAuthorization, PreparedAuthorizationRecheck, ProofTransport, - RouteCapability, VerifiedFederatedAssertion, VerifiedModerationCommandProof, - VerifiedNip98InviteClaimProof, VerifiedNostrProof, VerifierPolicyStamp, + AuthorizationReason, BindingResolutionRequest, DirectEnrollmentProposal, FinalizedAuthContext, + LocalAuthorizationPolicy, LocalBindingResolution, LocalBindingResolver, PreparedAuthorization, + PreparedAuthorizationRecheck, ProofTransport, RouteCapability, VerifiedFederatedAssertion, + VerifiedModerationCommandProof, VerifiedNip98InviteClaimProof, VerifiedNostrProof, + VerifierPolicyStamp, }; use buzz_core::{AuthorizationLeaseFence, CommunityId}; use chrono::{DateTime, Datelike, Duration as TimeDelta, Utc}; @@ -32,6 +33,7 @@ use crate::authorization_events::{ AuthorizationEventKind, AuthorizationEventOutcome, AuthorizationEventWriteError, AuthorizationReasonCode, NewAuthorizationEvent, }; +use crate::authorization_resolver::{AuthorizationResolverError, PostgresLocalBindingResolver}; use crate::identity_enrollment::{ actor_fingerprint, execute_authoritative_enrollment_tx, EnrollmentDisposition, IdentityEnrollmentError, PreparedDirectEnrollment, @@ -52,10 +54,23 @@ pub enum AdmissionObjectKind { ModerationTarget, /// One audio session. AudioSession, + /// One signed event submitted through a protected mutation route. + Event, + /// One actor's connection-local binding-status authority. + BindingStatus, /// One server-resolved invitation. Invitation, } +/// Whether exact protected authority is being prepared for observation or mutation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanonicalProtectedIntent { + /// Side-effect-free authorization for a protected read or connection admission. + Read, + /// Authorization whose final transaction advances the protected-object authority. + Mutation, +} + impl AdmissionObjectKind { /// Stable durable namespace code shared with `protected_object_authority`. pub const fn database_code(self) -> i16 { @@ -66,6 +81,8 @@ impl AdmissionObjectKind { Self::Media => 4, Self::ModerationTarget => 5, Self::AudioSession => 6, + Self::Event => 7, + Self::BindingStatus => 8, Self::Invitation => 9, } } @@ -88,6 +105,32 @@ impl AdmissionObject { } } + /// Bind a protected event mutation to its signed event identifier. + pub fn event(event_id: [u8; 32]) -> Option { + Self::new(Self::event_kind(), event_id) + } + + /// Bind current-status admission to one server-resolved domain and actor. + pub fn binding_status( + authorization_domain: CommunityId, + actor: nostr::PublicKey, + ) -> Option { + if authorization_domain.as_uuid().is_nil() { + return None; + } + Self::new( + AdmissionObjectKind::BindingStatus, + admission_framed_digest( + b"buzz:nip-fi:binding-status-target:v1", + &[authorization_domain.as_uuid().as_bytes(), actor.as_bytes()], + ), + ) + } + + const fn event_kind() -> AdmissionObjectKind { + AdmissionObjectKind::Event + } + /// Closed object namespace. pub const fn kind(self) -> AdmissionObjectKind { self.kind @@ -904,6 +947,11 @@ const INVITE_CLAIM_RESULT_TYPE: [u8; 32] = [ 0x1e, 0x04, 0x37, 0xbc, 0x43, 0xd1, 0xe4, 0x71, 0x6d, 0xe6, 0xc2, 0xda, 0x2a, 0x39, 0x4e, 0xcd, ]; +const INVITE_MINT_RESULT_TYPE: [u8; 32] = [ + 0x51, 0xd2, 0x9d, 0xea, 0x74, 0xaa, 0x04, 0xc6, 0xce, 0x07, 0x68, 0x25, 0x38, 0x12, 0x6e, 0xbb, + 0x38, 0xb8, 0xd8, 0x92, 0xb1, 0x61, 0xda, 0x39, 0xc9, 0x98, 0x21, 0xca, 0x26, 0x19, 0xe5, 0x83, +]; + const PROTECTED_PUBLICATION_RESULT_TYPE: [u8; 32] = [ 0x07, 0xa2, 0x9a, 0x3f, 0x15, 0x45, 0xf0, 0x3b, 0x1b, 0xda, 0x4e, 0xee, 0x5a, 0x25, 0xf0, 0x45, 0x3f, 0x58, 0x8d, 0x01, 0x81, 0xf8, 0x49, 0x68, 0x87, 0x94, 0x5f, 0xa6, 0xbf, 0x70, 0x0c, 0x9c, @@ -914,6 +962,16 @@ const MODERATION_RESULT_TYPE: [u8; 32] = [ 0x26, 0xf3, 0x20, 0x4f, 0x10, 0x0c, 0x74, 0x93, 0x63, 0x76, 0xf8, 0x14, 0x0d, 0x7b, 0x3f, 0x79, ]; +const BRIDGE_EVENT_RESULT_TYPE: [u8; 32] = [ + 0x3b, 0x08, 0xcc, 0x43, 0x7a, 0x1e, 0x15, 0x24, 0xc8, 0x9a, 0x86, 0xd0, 0x46, 0xd5, 0x6a, 0x7f, + 0xe5, 0x93, 0x46, 0x18, 0x32, 0xe8, 0xb6, 0xeb, 0x8d, 0x4d, 0x54, 0xfc, 0x6c, 0x14, 0x1a, 0x46, +]; + +const BINDING_STATUS_RESULT_TYPE: [u8; 32] = [ + 0x0f, 0xab, 0xfb, 0xfd, 0x11, 0x7a, 0xe2, 0x8c, 0xe6, 0xe5, 0x02, 0xf5, 0x5a, 0xa9, 0xf1, 0x04, + 0x0d, 0xcf, 0x8d, 0xc9, 0x9f, 0xb5, 0x67, 0xfc, 0xc3, 0xf9, 0x41, 0x2b, 0x00, 0x3b, 0x86, 0x09, +]; + /// Versioned type binding for one bounded canonical application result. /// /// The type key is a server-owned, domain-separated 32-byte identifier. It is @@ -944,6 +1002,14 @@ impl AdmissionApplicationResultSchema { } } + /// Stable schema for a credential-free canonical relay-invite mint result. + pub const fn invite_mint() -> Self { + Self { + type_key: INVITE_MINT_RESULT_TYPE, + version: 1, + } + } + /// Stable schema for canonical protected-publication results. pub const fn protected_publication() -> Self { Self { @@ -960,6 +1026,22 @@ impl AdmissionApplicationResultSchema { } } + /// Stable schema for canonical HTTP bridge event results. + pub const fn bridge_event() -> Self { + Self { + type_key: BRIDGE_EVENT_RESULT_TYPE, + version: 1, + } + } + + /// Stable schema for canonical connection binding-status admission. + pub const fn binding_status() -> Self { + Self { + type_key: BINDING_STATUS_RESULT_TYPE, + version: 1, + } + } + /// Opaque server-owned application result type key. pub const fn type_key(&self) -> &[u8; 32] { &self.type_key @@ -1566,6 +1648,534 @@ pub trait AdmissionVerifierRechecker: Send + Sync { ) -> Pin> + Send + 'a>>; } +/// Read-only final rechecker for one exact protected object. +pub struct PostgresCanonicalProtectedReadRechecker { + pool: PgPool, + object: AdmissionObject, + verifier: Arc, +} + +impl fmt::Debug for PostgresCanonicalProtectedReadRechecker { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PostgresCanonicalProtectedReadRechecker([REDACTED])") + } +} + +impl AuthorizationFinalizationRechecker for PostgresCanonicalProtectedReadRechecker { + async fn recheck<'a>( + &'a self, + request: &'a PreparedAuthorizationRecheck, + ) -> Result { + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| AuthorizationError::StaleRecheck)?; + let observation = authoritative_protected_recheck( + &mut transaction, + self.verifier.as_ref(), + request, + self.object, + CanonicalProtectedIntent::Read, + ) + .await + .map_err(|_| AuthorizationError::StaleRecheck)?; + transaction + .rollback() + .await + .map_err(|_| AuthorizationError::StaleRecheck)?; + Ok(observation) + } +} + +struct CanonicalProtectedFinalRechecker { + verifier: Arc, +} + +impl AdmissionFinalRechecker for CanonicalProtectedFinalRechecker { + fn authoritative_recheck<'a, 'transaction>( + &'a self, + transaction: &'a mut Transaction<'transaction, Postgres>, + request: &'a PreparedAuthorizationRecheck, + object: AdmissionObject, + ) -> Pin< + Box< + dyn Future> + + Send + + 'a, + >, + > { + Box::pin(authoritative_protected_recheck( + transaction, + self.verifier.as_ref(), + request, + object, + CanonicalProtectedIntent::Mutation, + )) + } +} + +impl crate::Db { + /// Read whether one verifier-sealed trusted-proxy nonce is already committed. + /// + /// This is an early rejection only; canonical final admission still owns + /// the compare-and-insert in the same transaction as its receipt. + pub async fn trusted_proxy_nonce_is_committed( + &self, + authorization_domain: CommunityId, + claim: &buzz_auth::TrustedProxyNonceClaim, + ) -> Result { + let committed: bool = sqlx::query_scalar( + "SELECT EXISTS( \ + SELECT 1 FROM authorization_proxy_nonce_claims \ + WHERE authorization_domain=$1 AND claim_kind=$2 AND claim_key=$3)", + ) + .bind(authorization_domain.as_uuid()) + .bind(AdmissionReplayClaimKind::TrustedProxyNonce.database_code()) + .bind(claim.claim_key().as_slice()) + .fetch_one(&self.pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + Ok(committed) + } + + /// Prepare existing-binding authority for one exact protected object. + /// + /// Preparation is observation-only. Reads must pass the returned value to + /// [`AuthorizationFinalizer`] with [`Self::canonical_protected_read_rechecker`]. + /// Mutations must wrap it in [`AdmissionCommitRequest::existing`] and use + /// [`Self::canonical_protected_committer`]. + pub async fn prepare_canonical_protected_authorization( + &self, + assertion: VerifiedFederatedAssertion, + proof: VerifiedNostrProof, + capability: RouteCapability, + object: AdmissionObject, + intent: CanonicalProtectedIntent, + ) -> Result { + if !protected_capability_matches(object.kind(), capability, intent) + || assertion.authorization_domain() != proof.authorization_domain() + || proof.target_fingerprint() != object.key() + { + return Err(AdmissionCommitError::InvalidRequest); + } + let domain = assertion.authorization_domain(); + let proof_expires_at = proof.expires_at(); + let resolution_request = BindingResolutionRequest::Direct { + assertion, + proof: proof.clone(), + capability, + }; + let resolver = PostgresLocalBindingResolver::new(self.clone()); + let resolution = resolver + .resolve(&resolution_request) + .await + .map_err(map_resolver_error)?; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&self.pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let policy = prepare_protected_authorization_policy( + &self.pool, + domain, + object, + capability, + proof_expires_at, + authoritative_now, + intent, + ) + .await?; + let input = AuthorizationInput::new(domain, Uuid::new_v4(), proof, capability) + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + AuthorizationFinalizer::prepare(input, resolution, policy, authoritative_now) + .map_err(|_| AdmissionCommitError::AuthorizationDenied) + } + + /// Construct the read-only exact-object rechecker paired with preparation. + pub fn canonical_protected_read_rechecker( + &self, + object: AdmissionObject, + verifier: Arc, + ) -> PostgresCanonicalProtectedReadRechecker { + PostgresCanonicalProtectedReadRechecker { + pool: self.pool.clone(), + object, + verifier, + } + } + + /// Construct the sole transaction committer for exact protected mutations. + pub fn canonical_protected_committer( + &self, + verifier: Arc, + ) -> PostgresCanonicalAdmissionCommitter { + PostgresCanonicalAdmissionCommitter::new( + self.pool.clone(), + Arc::new(CanonicalProtectedFinalRechecker { verifier }), + ) + } +} + +fn map_resolver_error(error: AuthorizationResolverError) -> AdmissionCommitError { + match error { + AuthorizationResolverError::BindingUnavailable + | AuthorizationResolverError::DelegationAuthorityUnavailable + | AuthorizationResolverError::PolicyUnavailable => { + AdmissionCommitError::AuthorizationDenied + } + AuthorizationResolverError::Database(_) + | AuthorizationResolverError::ContractUnavailable => { + AdmissionCommitError::DependencyUnavailable + } + } +} + +/// Return whether one closed protected-object namespace admits a route capability. +/// +/// This is the canonical provider-neutral matrix used by preparation and by +/// cross-route conformance tests; transport adapters must not maintain copies. +pub fn protected_capability_matches( + kind: AdmissionObjectKind, + capability: RouteCapability, + intent: CanonicalProtectedIntent, +) -> bool { + match (kind, intent) { + (AdmissionObjectKind::Repository, CanonicalProtectedIntent::Read) => matches!( + capability, + RouteCapability::ReposRead | RouteCapability::GitRead | RouteCapability::GitStream + ), + (AdmissionObjectKind::Repository, CanonicalProtectedIntent::Mutation) => matches!( + capability, + RouteCapability::ReposWrite | RouteCapability::GitWrite + ), + (AdmissionObjectKind::Media, CanonicalProtectedIntent::Read) => { + capability == RouteCapability::MediaRead + } + (AdmissionObjectKind::Media, CanonicalProtectedIntent::Mutation) => { + capability == RouteCapability::MediaWrite + } + (AdmissionObjectKind::ModerationTarget, _) => capability == RouteCapability::Moderation, + (AdmissionObjectKind::AudioSession, CanonicalProtectedIntent::Read) => matches!( + capability, + RouteCapability::AudioJoin | RouteCapability::AudioMedia + ), + (AdmissionObjectKind::Event, CanonicalProtectedIntent::Mutation) => { + capability == RouteCapability::MessagesWrite + } + (AdmissionObjectKind::BindingStatus, CanonicalProtectedIntent::Mutation) => { + capability == RouteCapability::BindingStatus + } + (AdmissionObjectKind::Invitation, CanonicalProtectedIntent::Mutation) => { + capability == RouteCapability::InviteMint + } + (AdmissionObjectKind::Domain, CanonicalProtectedIntent::Read) => matches!( + capability, + RouteCapability::MessagesRead + | RouteCapability::MessagesWrite + | RouteCapability::Discovery + ), + (AdmissionObjectKind::Channel, CanonicalProtectedIntent::Read) => matches!( + capability, + RouteCapability::ChannelsRead + | RouteCapability::ChannelsWrite + | RouteCapability::MessagesRead + | RouteCapability::MessagesWrite + ), + _ => false, + } +} + +async fn prepare_protected_authorization_policy( + pool: &PgPool, + domain: CommunityId, + object: AdmissionObject, + capability: RouteCapability, + proof_expires_at: DateTime, + authoritative_now: DateTime, + intent: CanonicalProtectedIntent, +) -> Result { + let policy = sqlx::query( + "SELECT policy_revision, expires_at FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= $2 \ + AND (expires_at IS NULL OR $2 < expires_at) \ + ORDER BY policy_revision DESC LIMIT 1", + ) + .bind(domain.as_uuid()) + .bind(authoritative_now) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + let policy_revision = u64::try_from( + policy + .try_get::("policy_revision") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .ok() + .filter(|value| *value > 0) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let policy_expires_at: Option> = policy + .try_get("expires_at") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation: i64 = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation = u64::try_from(invalidation_generation) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch = read_protected_epoch_pool(pool, domain, object).await?; + let lease_id = Uuid::new_v4(); + let (authority_epoch, fence) = + prepared_object_authority(domain, object, lease_id, current_epoch, intent)?; + let mut expires_at = proof_expires_at.min(authoritative_now + TimeDelta::minutes(10)); + if let Some(policy_expires_at) = policy_expires_at { + expires_at = expires_at.min(policy_expires_at); + } + if expires_at <= authoritative_now { + return Err(AdmissionCommitError::AuthorizationDenied); + } + LocalAuthorizationPolicy::from_database( + domain, + lease_id, + policy_revision, + invalidation_generation, + authority_epoch, + fence, + capability, + expires_at, + None, + None, + ) + .ok_or(AdmissionCommitError::DependencyUnavailable) +} + +async fn authoritative_protected_recheck( + transaction: &mut Transaction<'_, Postgres>, + verifier: &dyn AdmissionVerifierRechecker, + request: &PreparedAuthorizationRecheck, + object: AdmissionObject, + intent: CanonicalProtectedIntent, +) -> Result { + let snapshot = request.lease_dependencies(); + let (lease_id, domain) = snapshot.identity(); + let (capability, actor, owner) = snapshot.authority(); + let (binding_id, binding_version) = snapshot.binding(); + let (request_fingerprint, target, _, _) = snapshot.request_binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + snapshot.dependency_versions(); + if !protected_capability_matches(object.kind(), capability, intent) + || target != object.key() + || owner.is_some() + || request_fingerprint == &[0; 32] + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let verifier_stamp = request + .verifier_stamp() + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + verifier.recheck(verifier_stamp).await?; + + let binding_current: Option = sqlx::query_scalar( + "SELECT binding_state=1 \ + AND (expires_at IS NULL OR transaction_timestamp() < expires_at) \ + FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2 AND binding_version=$3 \ + AND event_author_pubkey=$4 FOR SHARE", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .bind(to_i64(binding_version)?) + .bind(actor.to_bytes().as_slice()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + sqlx::query("LOCK TABLE identity_enrollment_policies IN SHARE MODE") + .execute(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_policy: Option = sqlx::query_scalar( + "SELECT MAX(policy_revision) FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= transaction_timestamp() \ + AND (expires_at IS NULL OR transaction_timestamp() < expires_at)", + ) + .bind(domain.as_uuid()) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_generation: Option = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR SHARE", + ) + .bind(domain.as_uuid()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch = read_protected_epoch_transaction(transaction, domain, object).await?; + let (expected_epoch, expected_fence) = + prepared_object_authority(domain, object, lease_id, current_epoch, intent)?; + if binding_current != Some(true) + || current_policy != Some(to_i64(policy_revision)?) + || current_generation != Some(to_i64_allow_zero(invalidation_generation)?) + || expected_epoch != authority_epoch + || expected_fence != snapshot.fence() + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + Ok(AuthoritativeAuthorizationRecheck::from_authoritative_parts( + snapshot, + Some(verifier_stamp), + authoritative_now, + )) +} + +async fn read_protected_epoch_pool( + pool: &PgPool, + domain: CommunityId, + object: AdmissionObject, +) -> Result, AdmissionCommitError> { + let row = sqlx::query( + "SELECT \ + (SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3) AS authority_epoch, \ + (SELECT fence FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3) AS fence, \ + (SELECT authority_epoch FROM protected_object_authority \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3) AS protected_epoch, \ + (SELECT fence FROM protected_object_authority \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3) AS protected_fence", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_one(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + parse_protected_epoch(Some(row)) +} + +async fn read_protected_epoch_transaction( + transaction: &mut Transaction<'_, Postgres>, + domain: CommunityId, + object: AdmissionObject, +) -> Result, AdmissionCommitError> { + let row = sqlx::query( + "SELECT \ + (SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR UPDATE) \ + AS authority_epoch, \ + (SELECT fence FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR UPDATE) AS fence, \ + (SELECT authority_epoch FROM protected_object_authority \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR UPDATE) \ + AS protected_epoch, \ + (SELECT fence FROM protected_object_authority \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR UPDATE) \ + AS protected_fence", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + parse_protected_epoch(Some(row)) +} + +fn parse_protected_epoch( + row: Option, +) -> Result, AdmissionCommitError> { + let Some(row) = row else { + return Ok(None); + }; + let epoch = row + .try_get::, _>("authority_epoch") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fence = row + .try_get::>, _>("fence") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let protected_epoch = row + .try_get::, _>("protected_epoch") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let protected_fence = row + .try_get::>, _>("protected_fence") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + match (epoch, fence, protected_epoch, protected_fence) { + (None, None, None, None) => Ok(None), + (Some(epoch), Some(fence), Some(protected_epoch), Some(protected_fence)) + if epoch == protected_epoch && fence == protected_fence => + { + let epoch = u64::try_from(epoch) + .ok() + .filter(|value| *value > 0) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let fence = AuthorizationLeaseFence::from_bytes( + fence + .try_into() + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + Ok(Some((epoch, fence))) + } + _ => Err(AdmissionCommitError::DependencyUnavailable), + } +} + +fn prepared_object_authority( + domain: CommunityId, + object: AdmissionObject, + lease_id: Uuid, + current: Option<(u64, AuthorizationLeaseFence)>, + intent: CanonicalProtectedIntent, +) -> Result<(u64, AuthorizationLeaseFence), AdmissionCommitError> { + match (current, intent) { + (Some((epoch, fence)), CanonicalProtectedIntent::Read) => Ok((epoch, fence)), + (None, CanonicalProtectedIntent::Read) => { + Ok((1, protected_object_fence(domain, object, lease_id, 1)?)) + } + (current, CanonicalProtectedIntent::Mutation) => { + let current_epoch = match current { + Some((epoch, _fence)) => epoch, + None => 0, + }; + let epoch = current_epoch + .checked_add(1) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + Ok(( + epoch, + protected_object_fence(domain, object, lease_id, epoch)?, + )) + } + } +} + +fn protected_object_fence( + domain: CommunityId, + object: AdmissionObject, + lease_id: Uuid, + epoch: u64, +) -> Result { + let digest = admission_framed_digest( + b"buzz:canonical-protected-object-fence:v1", + &[ + domain.as_uuid().as_bytes(), + &object.kind().database_code().to_be_bytes(), + object.key(), + lease_id.as_bytes(), + &epoch.to_be_bytes(), + ], + ); + AuthorizationLeaseFence::from_bytes(digest) + .map_err(|_| AdmissionCommitError::DependencyUnavailable) +} + /// Server-resolved invitation resource accepted by canonical admission. /// /// Construction is restricted to the read-only database resolver so callers @@ -3615,7 +4225,7 @@ fn reason_code(reason: AuthorizationReason) -> i16 { } } -fn capability_code(capability: RouteCapability) -> i16 { +pub(crate) fn capability_code(capability: RouteCapability) -> i16 { match capability { RouteCapability::MessagesRead => 1, RouteCapability::MessagesWrite => 2, @@ -3855,13 +4465,13 @@ 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!( "{}://{}:{}@{}:{}/{}", "postgres", "buzz", "buzz_dev", "localhost", 5432, "buzz" ) } + fn base64_url(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; diff --git a/crates/buzz-db/src/authorization_resolver.rs b/crates/buzz-db/src/authorization_resolver.rs index d9968f05508..7674aa316b0 100644 --- a/crates/buzz-db/src/authorization_resolver.rs +++ b/crates/buzz-db/src/authorization_resolver.rs @@ -303,19 +303,165 @@ impl LocalBindingResolver for PostgresLocalBindingResolver { &'a self, request: &'a CurrentBindingStatusEvidenceRequest, ) -> std::result::Result { - let _ = request; - Err(AuthorizationResolverError::ContractUnavailable) + read_current_status_evidence(&self.db, request).await } async fn recheck_current_status_evidence<'a>( &'a self, evidence: &'a CanonicalCurrentBindingEvidence, - ) -> std::result::Result { - let _ = evidence; - Err(AuthorizationResolverError::ContractUnavailable) + ) -> std::result::Result<(CanonicalCurrentBindingEvidence, DateTime), Self::Error> { + let request = CurrentBindingStatusEvidenceRequest::new( + evidence.authorization_domain(), + evidence.event_author_pubkey(), + ) + .map_err(|_| AuthorizationResolverError::ContractUnavailable)?; + let current = read_current_status_evidence(&self.db, &request).await?; + if !same_status_coordinates(evidence, ¤t) + || !evidence.is_fresh_at(current.observed_at()) + { + return Err(AuthorizationResolverError::BindingUnavailable); + } + Ok((evidence.clone(), current.observed_at())) } } +async fn read_current_status_evidence( + db: &Db, + request: &CurrentBindingStatusEvidenceRequest, +) -> std::result::Result { + use crate::authorization_admission::{capability_code, AdmissionObject}; + + let object = AdmissionObject::binding_status( + request.authorization_domain(), + request.event_author_pubkey(), + ) + .ok_or(AuthorizationResolverError::ContractUnavailable)?; + let mut transaction = db.pool.begin().await.map_err(DbError::from)?; + sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") + .execute(&mut *transaction) + .await + .map_err(DbError::from)?; + let row = sqlx::query( + "SELECT binding.binding_id,binding.binding_version,policy.policy_revision, \ + invalidation.current_generation,epoch.authority_epoch,epoch.fence, \ + protected.authority_epoch AS protected_authority_epoch, \ + protected.fence AS protected_fence,protected.expires_at AS authority_expires_at, \ + binding.expires_at AS binding_expires_at,policy.expires_at AS policy_expires_at, \ + clock_timestamp() AS authoritative_now \ + FROM protected_object_authority protected \ + JOIN authorization_authority_epochs epoch \ + ON epoch.community_id=protected.community_id \ + AND epoch.object_kind=protected.object_kind AND epoch.object_key=protected.object_key \ + JOIN identity_bindings binding \ + ON binding.community_id=protected.community_id \ + AND binding.binding_id=protected.binding_id \ + AND binding.binding_version=protected.binding_version \ + AND binding.event_author_pubkey=protected.actor_pubkey \ + JOIN identity_enrollment_policies policy \ + ON policy.community_id=protected.community_id \ + AND policy.policy_revision=protected.policy_revision \ + JOIN authorization_invalidation_domains invalidation \ + ON invalidation.community_id=protected.community_id \ + WHERE protected.community_id=$1 AND protected.object_kind=$2 \ + AND protected.object_key=$3 AND protected.capability=$4 \ + AND protected.actor_pubkey=$5 AND protected.owner_pubkey IS NULL \ + AND binding.binding_state=1 \ + AND (binding.expires_at IS NULL OR clock_timestamp() < binding.expires_at) \ + AND policy.effective_at <= clock_timestamp() \ + AND (policy.expires_at IS NULL OR clock_timestamp() < policy.expires_at) \ + AND protected.expires_at > clock_timestamp() \ + AND policy.policy_revision=( \ + SELECT MAX(current_policy.policy_revision) \ + FROM identity_enrollment_policies current_policy \ + WHERE current_policy.community_id=protected.community_id \ + AND current_policy.effective_at <= clock_timestamp() \ + AND (current_policy.expires_at IS NULL \ + OR clock_timestamp() < current_policy.expires_at))", + ) + .bind(request.authorization_domain().as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .bind(capability_code(buzz_auth::RouteCapability::BindingStatus)) + .bind(request.event_author_pubkey().to_bytes().as_slice()) + .fetch_optional(&mut *transaction) + .await + .map_err(DbError::from)?; + transaction.commit().await.map_err(DbError::from)?; + let row = row.ok_or(AuthorizationResolverError::BindingUnavailable)?; + let authority_epoch = database_u64( + row.try_get("authority_epoch").map_err(DbError::from)?, + "status authority epoch", + )?; + let protected_authority_epoch = database_u64( + row.try_get("protected_authority_epoch") + .map_err(DbError::from)?, + "status protected authority epoch", + )?; + let fence = parsed_fence( + row.try_get("fence").map_err(DbError::from)?, + "status authority fence", + )?; + let protected_fence = parsed_fence( + row.try_get("protected_fence").map_err(DbError::from)?, + "status protected authority fence", + )?; + if authority_epoch != protected_authority_epoch || fence != protected_fence { + return Err(AuthorizationResolverError::PolicyUnavailable); + } + 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 [ + row.try_get::, _>("authority_expires_at") + .map(Some) + .map_err(DbError::from)?, + row.try_get::>, _>("binding_expires_at") + .map_err(DbError::from)?, + row.try_get::>, _>("policy_expires_at") + .map_err(DbError::from)?, + ] + .into_iter() + .flatten() + { + fresh_until = fresh_until.min(deadline); + } + 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, + observed_at, + fresh_until, + ) + .map_err(|_| AuthorizationResolverError::PolicyUnavailable) +} + +fn same_status_coordinates( + expected: &CanonicalCurrentBindingEvidence, + current: &CanonicalCurrentBindingEvidence, +) -> bool { + expected.authorization_domain() == current.authorization_domain() + && expected.event_author_pubkey() == current.event_author_pubkey() + && expected.binding_id() == current.binding_id() + && expected.binding_version() == current.binding_version() + && expected.policy_revision() == current.policy_revision() + && expected.invalidation_generation() == current.invalidation_generation() + && expected.authority_epoch() == current.authority_epoch() + && expected.fence() == current.fence() +} + #[derive(Clone)] struct ActiveBindingRow { issuer: String, diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 67bdef68677..8597b669703 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -1711,15 +1711,6 @@ mod tests { } } - async fn trusted_assertion_count(pool: &PgPool, community: CommunityId) -> i64 { - sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND kind = $2") - .bind(community.as_uuid()) - .bind(buzz_core::kind::KIND_USER_TRUSTED_ASSERTION as i32) - .fetch_one(pool) - .await - .expect("trusted assertion count") - } - async fn active_membership_count( pool: &PgPool, community: CommunityId, @@ -1786,7 +1777,6 @@ mod tests { .expect("binding lookup") .is_none() ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1842,7 +1832,6 @@ mod tests { active_membership_count(&pool, community, channel.id, &joiner).await, 0 ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -1923,7 +1912,6 @@ mod tests { .expect("binding lookup") .is_none() ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } #[tokio::test] @@ -2080,7 +2068,6 @@ mod tests { .expect("binding lookup") .is_none() ); - assert_eq!(trusted_assertion_count(&pool, community).await, 0); } async fn insert_channel_with_id( diff --git a/crates/buzz-db/src/client_status_delivery.rs b/crates/buzz-db/src/client_status_delivery.rs new file mode 100644 index 00000000000..35895e45c2d --- /dev/null +++ b/crates/buzz-db/src/client_status_delivery.rs @@ -0,0 +1,2867 @@ +//! Crash-recoverable delivery journal for connection-local binding status. +//! +//! The status producer inserts the exact signed event and its authoritative +//! transition in a caller-owned transaction. Workers then use a fenced claim +//! token to acknowledge delivery or record a bounded failure. Expired claims +//! are reclaimable, and every claim/result is appended to a compact audit +//! timeline. Routing fields accept only domain-scoped fingerprints. The +//! payload is opaque producer-prevalidated wire data; typed kind-24244 parsing +//! and privacy validation belong to the relay adapter and remain mandatory. + +use std::{fmt, time::Duration}; + +use buzz_auth::{ + ActiveLocalBinding, AuthoritativeAuthorizationRecheck, AuthorizationFinalizationRechecker, + AuthorizationFinalizer, CurrentBindingStatusEvidenceRequest, FinalizedAuthContext, + LocalAuthorizationPolicy, LocalStatusEvidenceResolver, PreparedAuthorizationRecheck, + ProofTransport, RouteCapability, VerifiedBindingStatusProof, +}; +use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use sha2::{Digest, Sha256}; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{Db, DbError, Result}; + +const MAX_ATTEMPTS: i16 = 16; +const MAX_CLAIM_SECONDS: u64 = 300; +const MAX_RETRY_SECONDS: u64 = 300; +const WRITE_DEADLINE_MARGIN_SECONDS: i64 = 1; +const MAX_STATUS_WRITE_SECONDS: u64 = 5; +const STATUS_EVIDENCE_LIFETIME_SECONDS: i64 = 300; + +/// Status event placed on the dedicated authenticated connection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i16)] +pub enum StatusDeliveryKind { + /// Current binding presentation. + Current = 1, + /// Opaque withdrawal superseding a prior presentation. + Withdrawal = 2, +} + +/// Closed, privacy-safe delivery failure taxonomy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i16)] +pub enum StatusDeliveryFailure { + /// Writer or transport unavailable; retry is permitted. + Transient = 1, + /// The target authenticated connection no longer exists. + ConnectionGone = 2, + /// The final authorization fence no longer permits presentation. + StaleFence = 3, + /// The stored signed payload failed local validation. + InvalidPayload = 4, + /// The presentation reached its exclusive freshness bound. + Expired = 5, + /// The bounded retry budget was consumed. + AttemptsExhausted = 6, + /// A worker claim expired after visibility became ambiguous. + LeaseExpiredUnknown = 7, +} + +impl StatusDeliveryFailure { + const fn is_retryable(self) -> bool { + matches!(self, Self::Transient) + } +} + +/// Immutable enqueue input created only after allocation, signing, and the +/// final presentation fence have all succeeded. +#[derive(Clone, Copy)] +pub struct NewStatusDelivery<'a> { + /// Server-resolved tenant. + pub community_id: CommunityId, + /// Stable status delivery identity. + pub delivery_id: Uuid, + /// Immutable transition identity for this exact connection generation. + pub transition_id: Uuid, + /// Stable causal operation identity. + pub operation_id: Uuid, + /// Digest of the complete status request. + pub request_fingerprint: [u8; 32], + /// Current or withdrawal wire event. + pub kind: StatusDeliveryKind, + /// Domain-scoped digest of the status subject. + pub subject_fingerprint: [u8; 32], + /// Domain-scoped digest of the relay signing identity. + pub signer_fingerprint: [u8; 32], + /// Domain-scoped digest of the authenticated connection. + pub connection_fingerprint: [u8; 32], + /// Strictly increasing status revision for this connection generation. + pub status_revision: u64, + /// Exact prior revision superseded by a withdrawal. + pub supersedes_revision: Option, + /// Exclusive validity bound encoded into the signed event. + pub fresh_until: DateTime, + /// Opaque producer-prevalidated relay-signed event bytes. + pub signed_payload: &'a [u8], + /// Private authoritative tuple required to re-fence a current delivery + /// after a worker or process crash. Withdrawals never carry this value. + pub current_evidence: Option<&'a CanonicalCurrentBindingEvidence>, +} + +impl fmt::Debug for NewStatusDelivery<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("NewStatusDelivery([REDACTED])") + } +} + +/// Result of idempotently recording one authoritative status transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EnqueueStatusDeliveryOutcome { + /// Transition and pending delivery were inserted together. + Enqueued, + /// Existing still-fresh transition acquired a distinct reconnect target. + TargetEnqueued, + /// The exact immutable transition already exists. + ExactReplay, +} + +/// One exclusively claimed signed status event. +pub struct ClaimedStatusDelivery { + community_id: CommunityId, + delivery_id: Uuid, + claim_id: Uuid, + connection_fingerprint: [u8; 32], + kind: StatusDeliveryKind, + status_revision: u64, + signed_payload: Vec, + payload_digest: [u8; 32], + current_evidence: Option, + claimed_until: DateTime, + attempt: u16, +} + +impl ClaimedStatusDelivery { + /// Server-resolved tenant owning this delivery. + pub const fn community_id(&self) -> CommunityId { + self.community_id + } + + /// Stable delivery identity. + pub const fn delivery_id(&self) -> Uuid { + self.delivery_id + } + + /// Fencing token required for completion. + pub const fn claim_id(&self) -> Uuid { + self.claim_id + } + + /// Exact redacted target selected by the authenticated connection owner. + pub const fn connection_fingerprint(&self) -> [u8; 32] { + self.connection_fingerprint + } + + /// Current or withdrawal event. + pub const fn kind(&self) -> StatusDeliveryKind { + self.kind + } + + /// Connection-local revision carried by the signed event. + pub const fn status_revision(&self) -> u64 { + self.status_revision + } + + /// Exact relay-signed event bytes. + pub fn signed_payload(&self) -> &[u8] { + &self.signed_payload + } + + /// Digest of the exact bytes covered by this claim. + pub const fn payload_digest(&self) -> [u8; 32] { + self.payload_digest + } + + /// Original private evidence tuple for a current presentation. + pub fn current_evidence(&self) -> Option<&CanonicalCurrentBindingEvidence> { + self.current_evidence.as_ref() + } + + /// Exclusive database claim deadline. Writer I/O must complete before it. + pub const fn claimed_until(&self) -> DateTime { + self.claimed_until + } + + /// One-based bounded delivery attempt. + pub const fn attempt(&self) -> u16 { + self.attempt + } +} + +impl fmt::Debug for ClaimedStatusDelivery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClaimedStatusDelivery") + .field("kind", &self.kind) + .field("attempt", &self.attempt) + .field("value", &"[REDACTED]") + .finish() + } +} + +/// Database-fenced permission to perform the physical writer flush. +/// +/// Only [`Db::authorize_status_delivery`] can construct this value. A later +/// head transition cannot erase a flush that already occurred, so completion +/// records the physical outcome under this exact claim without rechecking the +/// mutable head. +pub struct AuthorizedStatusDelivery { + community_id: CommunityId, + delivery_id: Uuid, + claim_id: Uuid, + kind: StatusDeliveryKind, + status_revision: u64, + payload_digest: [u8; 32], + write_budget: Duration, + transaction: Option>, +} + +impl AuthorizedStatusDelivery { + /// Stable delivery identity bound to the authorized bytes. + pub const fn delivery_id(&self) -> Uuid { + self.delivery_id + } + + /// Exact claim fence bound to the authorized bytes. + pub const fn claim_id(&self) -> Uuid { + self.claim_id + } + + /// Current or withdrawal disposition bound to the authorization. + pub const fn kind(&self) -> StatusDeliveryKind { + self.kind + } + + /// Connection-local wire revision bound to the authorization. + pub const fn status_revision(&self) -> u64 { + self.status_revision + } + + /// SHA-256 digest of the only bytes this authorization permits. + pub const fn payload_digest(&self) -> [u8; 32] { + self.payload_digest + } + + /// Monotonic writer budget computed from PostgreSQL time and kept strictly + /// inside the database claim lease. + pub const fn write_budget(&self) -> Duration { + self.write_budget + } +} + +impl fmt::Debug for AuthorizedStatusDelivery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizedStatusDelivery([REDACTED])") + } +} + +/// Idempotent completion result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompleteStatusDeliveryOutcome { + /// The claimed delivery became durably delivered. + Delivered, + /// This exact claim had already been acknowledged. + ExactReplay, + /// The row is owned by a newer claim or has a different terminal result. + LostClaim, +} + +/// Failure-recording result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FailStatusDeliveryOutcome { + /// The row returned to pending with a bounded retry time. + RetryScheduled, + /// The row entered a terminal, auditable failure state. + Terminal, + /// This exact terminal failure had already been recorded. + ExactReplay, + /// The row is owned by a newer claim or has another result. + LostClaim, +} + +impl LocalStatusEvidenceResolver for Db { + type Error = DbError; + + async fn current_status_evidence( + &self, + request: &CurrentBindingStatusEvidenceRequest, + ) -> std::result::Result { + load_current_status_evidence(self, request).await + } + + async fn recheck_current_status_evidence( + &self, + evidence: &CanonicalCurrentBindingEvidence, + ) -> std::result::Result<(CanonicalCurrentBindingEvidence, DateTime), Self::Error> { + let request = CurrentBindingStatusEvidenceRequest::new( + evidence.authorization_domain(), + evidence.event_author_pubkey(), + ) + .map_err(|_| invalid_delivery())?; + let (current, authoritative_now) = load_current_status_evidence_at(self, &request).await?; + let exact_coordinates = current.authorization_domain() == evidence.authorization_domain() + && current.event_author_pubkey() == evidence.event_author_pubkey() + && current.binding_id() == evidence.binding_id() + && current.binding_version() == evidence.binding_version() + && current.policy_revision() == evidence.policy_revision() + && current.invalidation_generation() == evidence.invalidation_generation() + && current.authority_epoch() == evidence.authority_epoch() + && current.fence() == evidence.fence(); + if !exact_coordinates || !evidence.is_fresh_at(authoritative_now) { + return Err(DbError::InvalidData( + "client status evidence is no longer current".to_owned(), + )); + } + Ok((evidence.clone(), authoritative_now)) + } +} + +#[derive(Clone)] +struct StatusAuthorizationFinalRechecker { + db: Db, +} + +impl AuthorizationFinalizationRechecker for StatusAuthorizationFinalRechecker { + async fn recheck( + &self, + request: &PreparedAuthorizationRecheck, + ) -> std::result::Result { + let snapshot = request.lease_dependencies(); + let (_, domain) = snapshot.identity(); + let (capability, actor, owner) = snapshot.authority(); + let (binding_id, binding_version) = snapshot.binding(); + let (request_fingerprint, target, transport, _) = snapshot.request_binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + snapshot.dependency_versions(); + if capability != RouteCapability::BindingStatus + || transport != ProofTransport::Nip42 + || owner.is_some() + || request_fingerprint == &[0; 32] + || target == &[0; 32] + || request.verifier_stamp().is_some() + { + return Err(buzz_auth::AuthorizationError::StaleRecheck); + } + let mut transaction = self + .db + .pool + .begin() + .await + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + sqlx::query("LOCK TABLE identity_enrollment_policies IN SHARE MODE") + .execute(&mut *transaction) + .await + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut *transaction) + .await + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + let row = sqlx::query( + "SELECT binding.binding_id, binding.binding_version, binding.policy_revision, \ + domain.current_generation \ + FROM identity_bindings binding \ + JOIN authorization_invalidation_domains domain \ + ON domain.community_id=binding.community_id \ + JOIN identity_enrollment_policies policy \ + ON policy.community_id=binding.community_id \ + AND policy.policy_revision=binding.policy_revision \ + WHERE binding.community_id=$1 AND binding.binding_id=$2 \ + AND binding.binding_version=$3 AND binding.event_author_pubkey=$4 \ + AND binding.binding_state=1 AND binding.lifecycle_revision=1 \ + AND (binding.expires_at IS NULL OR binding.expires_at > $5) \ + AND policy.effective_at <= $5 \ + AND (policy.expires_at IS NULL OR policy.expires_at > $5) \ + 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", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .bind( + i64::try_from(binding_version) + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?, + ) + .bind(actor.as_bytes()) + .bind(authoritative_now) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)? + .ok_or(buzz_auth::AuthorizationError::StaleRecheck)?; + let current_policy = positive_u64( + row.try_get("policy_revision") + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?, + ) + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + let current_generation = u64::try_from( + row.try_get::("current_generation") + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?, + ) + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + let current_epoch = current_generation + .checked_add(1) + .ok_or(buzz_auth::AuthorizationError::StaleRecheck)?; + let current_fence = derive_status_evidence_fence( + domain, + actor, + binding_id, + binding_version, + current_policy, + current_generation, + current_epoch, + ) + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + if current_policy != policy_revision + || current_generation != invalidation_generation + || current_epoch != authority_epoch + || current_fence != snapshot.fence() + { + return Err(buzz_auth::AuthorizationError::StaleRecheck); + } + transaction + .commit() + .await + .map_err(|_| buzz_auth::AuthorizationError::StaleRecheck)?; + Ok(AuthoritativeAuthorizationRecheck::from_authoritative_parts( + snapshot, + None, + authoritative_now, + )) + } +} + +/// Insert a status transition and pending delivery in a caller-owned +/// transaction. Committing that transaction makes both visible atomically; +/// rolling it back makes neither visible. +pub async fn enqueue_status_delivery_tx( + transaction: &mut Transaction<'_, Postgres>, + delivery: &NewStatusDelivery<'_>, +) -> Result { + validate_delivery(delivery)?; + sqlx::query( + "SELECT pg_advisory_xact_lock(hashtextextended(\ + 'buzz:status-delivery-operation:v1:' || $1::text || ':' || $2::text, 0))", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.operation_id) + .execute(&mut **transaction) + .await?; + let payload_digest: [u8; 32] = Sha256::digest(delivery.signed_payload).into(); + if let Some(existing) = sqlx::query( + "SELECT transition_id, request_fingerprint, delivery_kind, subject_fingerprint, \ + signer_fingerprint, connection_fingerprint, status_revision, \ + supersedes_revision, signed_payload, payload_digest, fresh_until, \ + evidence_author_pubkey, evidence_binding_id, evidence_binding_version, \ + evidence_policy_revision, evidence_invalidation_generation, \ + evidence_authority_epoch, evidence_fence, evidence_observed_at \ + FROM client_status_transitions WHERE community_id=$1 AND operation_id=$2", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.operation_id) + .fetch_optional(&mut **transaction) + .await? + { + let supersedes = delivery + .supersedes_revision + .map(|revision| i64::try_from(revision).map_err(|_| invalid_delivery())) + .transpose()?; + let exact = existing.try_get::("transition_id")? == delivery.transition_id + && existing.try_get::, _>("request_fingerprint")? + == delivery.request_fingerprint + && existing.try_get::("delivery_kind")? == delivery.kind as i16 + && existing.try_get::, _>("subject_fingerprint")? + == delivery.subject_fingerprint + && existing.try_get::, _>("signer_fingerprint")? == delivery.signer_fingerprint + && existing.try_get::, _>("connection_fingerprint")? + == delivery.connection_fingerprint + && existing.try_get::("status_revision")? + == i64::try_from(delivery.status_revision).map_err(|_| invalid_delivery())? + && existing.try_get::, _>("supersedes_revision")? == supersedes + && existing.try_get::, _>("signed_payload")? == delivery.signed_payload + && existing.try_get::, _>("payload_digest")? == payload_digest + && existing.try_get::, _>("fresh_until")? == delivery.fresh_until + && evidence_row_matches(&existing, delivery.current_evidence)?; + if !exact { + return Err(DbError::InvalidData( + "client status delivery replay conflicts with prior transition".to_owned(), + )); + } + let still_current: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM client_status_transition_heads head \ + JOIN client_status_transitions transition \ + ON transition.community_id=head.community_id \ + AND transition.transition_id=head.transition_id \ + WHERE head.community_id=$1 AND head.connection_fingerprint=$2 \ + AND head.transition_id=$3 \ + AND transition.fresh_until > transaction_timestamp())", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.connection_fingerprint.as_slice()) + .bind(delivery.transition_id) + .fetch_one(&mut **transaction) + .await?; + if !still_current { + return Err(DbError::InvalidData( + "client status transition is no longer current".to_owned(), + )); + } + if let Some(existing_delivery_id) = sqlx::query_scalar::<_, Uuid>( + "SELECT delivery_id FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND transition_id=$2 AND connection_fingerprint=$3", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.transition_id) + .bind(delivery.connection_fingerprint.as_slice()) + .fetch_optional(&mut **transaction) + .await? + { + if existing_delivery_id != delivery.delivery_id { + return Err(DbError::InvalidData( + "client status delivery replay conflicts with prior target".to_owned(), + )); + } + return Ok(EnqueueStatusDeliveryOutcome::ExactReplay); + } + let inserted = insert_delivery_target(transaction, delivery, false).await?; + return Ok(if inserted { + EnqueueStatusDeliveryOutcome::TargetEnqueued + } else { + EnqueueStatusDeliveryOutcome::ExactReplay + }); + } + + let prior = sqlx::query( + "SELECT status_revision FROM client_status_transition_heads \ + WHERE community_id=$1 AND connection_fingerprint=$2 FOR UPDATE", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.connection_fingerprint.as_slice()) + .fetch_optional(&mut **transaction) + .await?; + let prior_revision = prior + .as_ref() + .map(|row| row.try_get::("status_revision")) + .transpose()?; + let revision = i64::try_from(delivery.status_revision).map_err(|_| invalid_delivery())?; + let expected = prior_revision.map_or(1, |prior| prior.saturating_add(1)); + let supersedes = delivery + .supersedes_revision + .map(|value| i64::try_from(value).map_err(|_| invalid_delivery())) + .transpose()?; + if revision != expected + || (delivery.kind == StatusDeliveryKind::Current && supersedes.is_some()) + || (delivery.kind == StatusDeliveryKind::Withdrawal && supersedes != prior_revision) + { + return Err(DbError::InvalidData( + "client status transition does not advance the authoritative head".to_owned(), + )); + } + let evidence = delivery.current_evidence; + sqlx::query( + "INSERT INTO client_status_transitions \ + (community_id, transition_id, operation_id, request_fingerprint, delivery_kind, \ + subject_fingerprint, signer_fingerprint, connection_fingerprint, status_revision, \ + supersedes_revision, signed_payload, payload_digest, fresh_until, retain_until, \ + evidence_author_pubkey, evidence_binding_id, evidence_binding_version, \ + evidence_policy_revision, evidence_invalidation_generation, \ + evidence_authority_epoch, evidence_fence, evidence_observed_at) \ + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13 + INTERVAL '1 day', \ + $14,$15,$16,$17,$18,$19,$20,$21)", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.transition_id) + .bind(delivery.operation_id) + .bind(delivery.request_fingerprint.as_slice()) + .bind(delivery.kind as i16) + .bind(delivery.subject_fingerprint.as_slice()) + .bind(delivery.signer_fingerprint.as_slice()) + .bind(delivery.connection_fingerprint.as_slice()) + .bind(revision) + .bind(supersedes) + .bind(delivery.signed_payload) + .bind(payload_digest.as_slice()) + .bind(delivery.fresh_until) + .bind(evidence.map(|value| value.event_author_pubkey().to_bytes())) + .bind(evidence.map(CanonicalCurrentBindingEvidence::binding_id)) + .bind( + evidence + .map(CanonicalCurrentBindingEvidence::binding_version) + .map(i64::try_from) + .transpose() + .map_err(|_| invalid_delivery())?, + ) + .bind( + evidence + .map(CanonicalCurrentBindingEvidence::policy_revision) + .map(i64::try_from) + .transpose() + .map_err(|_| invalid_delivery())?, + ) + .bind( + evidence + .map(CanonicalCurrentBindingEvidence::invalidation_generation) + .map(i64::try_from) + .transpose() + .map_err(|_| invalid_delivery())?, + ) + .bind( + evidence + .map(CanonicalCurrentBindingEvidence::authority_epoch) + .map(i64::try_from) + .transpose() + .map_err(|_| invalid_delivery())?, + ) + .bind(evidence.map(|value| value.fence().as_bytes().to_vec())) + .bind(evidence.map(CanonicalCurrentBindingEvidence::observed_at)) + .execute(&mut **transaction) + .await?; + if prior.is_some() { + sqlx::query( + "UPDATE client_status_transition_heads SET transition_id=$3, status_revision=$4, \ + delivery_kind=$5, updated_at=transaction_timestamp() \ + WHERE community_id=$1 AND connection_fingerprint=$2", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.connection_fingerprint.as_slice()) + .bind(delivery.transition_id) + .bind(revision) + .bind(delivery.kind as i16) + .execute(&mut **transaction) + .await?; + } else { + sqlx::query( + "INSERT INTO client_status_transition_heads \ + (community_id, connection_fingerprint, subject_fingerprint, signer_fingerprint, \ + transition_id, status_revision, delivery_kind) VALUES ($1,$2,$3,$4,$5,$6,$7)", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.connection_fingerprint.as_slice()) + .bind(delivery.subject_fingerprint.as_slice()) + .bind(delivery.signer_fingerprint.as_slice()) + .bind(delivery.transition_id) + .bind(revision) + .bind(delivery.kind as i16) + .execute(&mut **transaction) + .await?; + } + let inserted = insert_delivery_target(transaction, delivery, true).await?; + debug_assert!(inserted); + Ok(EnqueueStatusDeliveryOutcome::Enqueued) +} + +impl Db { + /// Read PostgreSQL transaction time for typed payload validation. + pub async fn status_delivery_authoritative_now(&self) -> Result> { + Ok(sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&self.pool) + .await?) + } + + /// Finalize one direct, purpose-sealed connection status authorization. + pub async fn finalize_binding_status_authorization( + &self, + proof: VerifiedBindingStatusProof, + ) -> Result { + let domain = proof.authorization_domain(); + let actor = proof.actor_pubkey(); + let proof_expires_at = proof.expires_at(); + let row = sqlx::query( + "SELECT binding.issuer, binding.subject, binding.binding_id, \ + binding.binding_version, binding.expires_at AS binding_expires_at, \ + binding.policy_revision, policy.expires_at AS policy_expires_at, \ + domain.current_generation, transaction_timestamp() AS now \ + FROM identity_bindings binding \ + JOIN authorization_invalidation_domains domain \ + ON domain.community_id=binding.community_id \ + JOIN identity_enrollment_policies policy \ + ON policy.community_id=binding.community_id \ + 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 policy.effective_at <= transaction_timestamp() \ + AND (policy.expires_at IS NULL OR policy.expires_at > transaction_timestamp()) \ + AND NOT EXISTS (SELECT 1 FROM identity_enrollment_policies newer \ + WHERE newer.community_id=binding.community_id \ + AND newer.policy_revision > binding.policy_revision)", + ) + .bind(domain.as_uuid()) + .bind(actor.as_bytes()) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| DbError::InvalidData("binding status authorization denied".to_owned()))?; + let authoritative_now: DateTime = row.try_get("now")?; + let binding_id: Uuid = row.try_get("binding_id")?; + let binding_version = positive_u64(row.try_get("binding_version")?)?; + let policy_revision = positive_u64(row.try_get("policy_revision")?)?; + let invalidation_generation = u64::try_from(row.try_get::("current_generation")?) + .map_err(|_| invalid_delivery())?; + let authority_epoch = invalidation_generation + .checked_add(1) + .ok_or_else(invalid_delivery)?; + let binding_expires_at: Option> = row.try_get("binding_expires_at")?; + let policy_expires_at: Option> = row.try_get("policy_expires_at")?; + let protocol_expires_at = authoritative_now + .checked_add_signed(chrono::Duration::seconds(STATUS_EVIDENCE_LIFETIME_SECONDS)) + .ok_or_else(invalid_delivery)?; + let mut expires_at = proof_expires_at.min(protocol_expires_at); + if let Some(bound) = binding_expires_at { + expires_at = expires_at.min(bound); + } + if let Some(bound) = policy_expires_at { + expires_at = expires_at.min(bound); + } + if expires_at <= authoritative_now { + return Err(DbError::InvalidData( + "binding status authorization denied".to_owned(), + )); + } + let binding = ActiveLocalBinding::from_storage_parts( + domain, + row.try_get("issuer")?, + row.try_get("subject")?, + binding_id, + binding_version, + actor, + binding_expires_at, + ) + .ok_or_else(invalid_delivery)?; + let fence = derive_status_evidence_fence( + domain, + actor, + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + )?; + let policy = LocalAuthorizationPolicy::from_database( + domain, + Uuid::new_v4(), + policy_revision, + invalidation_generation, + authority_epoch, + fence, + RouteCapability::BindingStatus, + expires_at, + None, + None, + ) + .ok_or_else(invalid_delivery)?; + let prepared = proof + .prepare_authorization(binding, policy, authoritative_now) + .map_err(|_| DbError::InvalidData("binding status authorization denied".to_owned()))?; + let rechecker = StatusAuthorizationFinalRechecker { db: self.clone() }; + let witness = AuthorizationFinalizer::recheck(&prepared, &rechecker) + .await + .map_err(|_| DbError::InvalidData("binding status authorization denied".to_owned()))?; + AuthorizationFinalizer::finalize(prepared, witness) + .map_err(|_| DbError::InvalidData("binding status authorization denied".to_owned())) + } + + /// Install the mandatory bounded delivery-audit capacity row. Status + /// production fails closed until this succeeds. + pub async fn install_status_delivery_capacity(&self, community_id: CommunityId) -> Result<()> { + sqlx::query( + "INSERT INTO client_status_delivery_capacity (community_id) VALUES ($1) \ + ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community_id.as_uuid()) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Latch delivery audit unhealthy after a persistence or reconciliation + /// failure. New transitions then fail closed. + pub async fn latch_status_delivery_failure( + &self, + community_id: CommunityId, + reason: u16, + ) -> Result<()> { + if !(1..=3).contains(&reason) { + return Err(invalid_delivery()); + } + let changed = sqlx::query( + "UPDATE client_status_delivery_capacity SET healthy=FALSE, failure_reason=$2, \ + updated_at=transaction_timestamp() WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .bind(i16::try_from(reason).map_err(|_| invalid_delivery())?) + .execute(&self.pool) + .await? + .rows_affected(); + if changed != 1 { + return Err(DbError::InvalidData( + "client status delivery capacity is not installed".to_owned(), + )); + } + Ok(()) + } + + /// Atomically commit one status transition and its pending delivery. + pub async fn enqueue_status_delivery( + &self, + delivery: &NewStatusDelivery<'_>, + ) -> Result { + let mut transaction = self.pool.begin().await?; + let outcome = enqueue_status_delivery_tx(&mut transaction, delivery).await?; + transaction.commit().await?; + Ok(outcome) + } + + /// Claim one ready delivery. An expired claim may be reclaimed after a + /// worker crash; its signed revision makes duplicate sends replay-safe. + pub async fn claim_status_delivery( + &self, + community_id: CommunityId, + connection_fingerprint: [u8; 32], + claim_lease: Duration, + ) -> Result> { + if connection_fingerprint == [0; 32] { + return Err(invalid_delivery()); + } + let lease_seconds = bounded_seconds(claim_lease, MAX_CLAIM_SECONDS)?; + let mut transaction = self.pool.begin().await?; + let Some(row) = sqlx::query( + "SELECT delivery.delivery_id, transition.delivery_kind, transition.status_revision, \ + transition.signed_payload, transition.payload_digest, transition.fresh_until, \ + transition.evidence_author_pubkey, transition.evidence_binding_id, \ + transition.evidence_binding_version, transition.evidence_policy_revision, \ + transition.evidence_invalidation_generation, \ + transition.evidence_authority_epoch, transition.evidence_fence, \ + transition.evidence_observed_at, delivery.attempt_count, delivery.claim_id, \ + delivery.claimed_until \ + FROM client_status_delivery_outbox delivery \ + JOIN client_status_transitions transition \ + ON transition.community_id=delivery.community_id \ + AND transition.transition_id=delivery.transition_id \ + JOIN client_status_transition_heads head \ + ON head.community_id=transition.community_id \ + AND head.connection_fingerprint=delivery.connection_fingerprint \ + AND head.transition_id=transition.transition_id \ + WHERE delivery.community_id=$1 AND delivery.connection_fingerprint=$2 \ + AND delivery.delivery_state=1 \ + AND delivery.next_attempt_at <= transaction_timestamp() \ + AND transition.fresh_until > transaction_timestamp() \ + AND (claim_id IS NULL OR claimed_until <= transaction_timestamp()) \ + AND attempt_count < $3 \ + ORDER BY delivery.created_at, delivery.delivery_id \ + FOR UPDATE OF delivery SKIP LOCKED LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(connection_fingerprint.as_slice()) + .bind(MAX_ATTEMPTS) + .fetch_optional(&mut *transaction) + .await? + else { + transaction.commit().await?; + return Ok(None); + }; + let delivery_id: Uuid = row.try_get("delivery_id")?; + let next_attempt = row.try_get::("attempt_count")? + 1; + let expired_claim: Option = row.try_get("claim_id")?; + if expired_claim.is_some() { + let sequence = next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + 8, + StatusDeliveryFailure::LeaseExpiredUnknown as i16, + next_attempt - 1, + ) + .await?; + } + let claim_id = Uuid::new_v4(); + let claimed_until: DateTime = sqlx::query_scalar( + "UPDATE client_status_delivery_outbox SET claim_id=$3, \ + claimed_until=transaction_timestamp()+make_interval(secs => $4), \ + attempt_count=$5, attempt_started_at=clock_timestamp(), \ + last_failure_reason=CASE WHEN claim_id IS NULL THEN last_failure_reason ELSE 7 END \ + WHERE community_id=$1 AND delivery_id=$2 RETURNING claimed_until", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(claim_id) + .bind(lease_seconds as f64) + .bind(next_attempt) + .fetch_one(&mut *transaction) + .await?; + let sequence = next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + 4, + 0, + next_attempt, + ) + .await?; + let kind = delivery_kind(row.try_get("delivery_kind")?)?; + let current_evidence = evidence_from_row(&row, community_id, kind)?; + let payload_digest = fixed_digest(row.try_get("payload_digest")?)?; + transaction.commit().await?; + Ok(Some(ClaimedStatusDelivery { + community_id, + delivery_id, + claim_id, + connection_fingerprint, + kind, + status_revision: u64::try_from(row.try_get::("status_revision")?) + .map_err(|_| invalid_delivery())?, + signed_payload: row.try_get("signed_payload")?, + payload_digest, + current_evidence, + claimed_until, + attempt: u16::try_from(next_attempt).map_err(|_| invalid_delivery())?, + })) + } + + /// Recheck the authoritative head immediately before any writer I/O. + /// + /// A stale or expired exact claim is terminalized before the caller can + /// flush bytes. `None` therefore means that no writer I/O is authorized. + pub async fn authorize_status_delivery( + &self, + claimed: &ClaimedStatusDelivery, + ) -> Result> { + let mut transaction = self.pool.begin().await?; + // The relay hard-bounds its fence-owning task at seven seconds. These + // server-side limits independently prevent a disconnected worker from + // retaining mutable tenant state if its runtime can no longer poll the + // transaction rollback. + sqlx::query("SET LOCAL statement_timeout = '2s'") + .execute(&mut *transaction) + .await?; + sqlx::query("SET LOCAL lock_timeout = '2s'") + .execute(&mut *transaction) + .await?; + sqlx::query("SET LOCAL idle_in_transaction_session_timeout = '12s'") + .execute(&mut *transaction) + .await?; + let Some(row) = + delivery_state_for_update(&mut transaction, claimed.community_id, claimed.delivery_id) + .await? + else { + transaction.commit().await?; + return Ok(None); + }; + let state: i16 = row.try_get("delivery_state")?; + let active_claim: Option = row.try_get("claim_id")?; + let claimed_until: Option> = row.try_get("claimed_until")?; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut *transaction) + .await?; + if state != 1 + || active_claim != Some(claimed.claim_id) + || claimed_until.is_none_or(|deadline| deadline <= authoritative_now) + { + transaction.commit().await?; + return Ok(None); + } + let transition_fresh_until: Option> = sqlx::query_scalar( + "SELECT transition.fresh_until \ + FROM client_status_delivery_outbox delivery \ + JOIN client_status_transitions transition \ + ON transition.community_id=delivery.community_id \ + AND transition.transition_id=delivery.transition_id \ + JOIN client_status_transition_heads head \ + ON head.community_id=transition.community_id \ + AND head.connection_fingerprint=delivery.connection_fingerprint \ + AND head.transition_id=transition.transition_id \ + WHERE delivery.community_id=$1 AND delivery.delivery_id=$2 \ + FOR SHARE OF transition, head", + ) + .bind(claimed.community_id.as_uuid()) + .bind(claimed.delivery_id) + .fetch_optional(&mut *transaction) + .await?; + let transition_current = transition_fresh_until.is_some(); + let transition_fresh = + transition_fresh_until.is_some_and(|fresh_until| fresh_until > authoritative_now); + let evidence_current = if transition_current && transition_fresh { + match claimed.current_evidence() { + Some(evidence) => { + // Migration 0038 makes the community row the tenant-scoped + // policy-insert fence. Exact binding, domain, and policy + // rows are locked by the evidence recheck below. + sqlx::query("SELECT id FROM communities WHERE id=$1 FOR SHARE") + .bind(claimed.community_id.as_uuid()) + .fetch_one(&mut *transaction) + .await?; + recheck_status_evidence_tx(&mut transaction, evidence, authoritative_now) + .await? + } + None => claimed.kind == StatusDeliveryKind::Withdrawal, + } + } else { + false + }; + if !transition_current || !transition_fresh || !evidence_current { + let failure = if transition_current && !transition_fresh { + StatusDeliveryFailure::Expired + } else { + StatusDeliveryFailure::StaleFence + }; + let attempt: i16 = row.try_get("attempt_count")?; + sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=3, claim_id=NULL, \ + claimed_until=NULL, completion_claim_id=$3, last_failure_reason=$4, \ + terminal_at=clock_timestamp() WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(claimed.community_id.as_uuid()) + .bind(claimed.delivery_id) + .bind(claimed.claim_id) + .bind(failure as i16) + .execute(&mut *transaction) + .await?; + let sequence = + next_event_sequence(&mut transaction, claimed.community_id, claimed.delivery_id) + .await?; + insert_delivery_event( + &mut transaction, + claimed.community_id, + claimed.delivery_id, + sequence, + 7, + failure as i16, + attempt, + ) + .await?; + transaction.commit().await?; + return Ok(None); + } + let Some(write_deadline) = claimed_until.and_then(|deadline| { + deadline.checked_sub_signed(chrono::Duration::seconds(WRITE_DEADLINE_MARGIN_SECONDS)) + }) else { + transaction.commit().await?; + return Ok(None); + }; + if write_deadline <= authoritative_now { + transaction.commit().await?; + return Ok(None); + } + let write_budget = (write_deadline - authoritative_now) + .to_std() + .map_err(|_| invalid_delivery())? + .min(Duration::from_secs(MAX_STATUS_WRITE_SECONDS)); + Ok(Some(AuthorizedStatusDelivery { + community_id: claimed.community_id, + delivery_id: claimed.delivery_id, + claim_id: claimed.claim_id, + kind: claimed.kind, + status_revision: claimed.status_revision, + payload_digest: claimed.payload_digest, + write_budget, + transaction: Some(transaction), + })) + } + + /// Release an unused pre-send authorization fence without changing its + /// delivery claim. Recovery can reclaim that exact job after the lease. + pub async fn abort_status_delivery_authorization( + &self, + mut authorization: AuthorizedStatusDelivery, + ) -> Result<()> { + if let Some(transaction) = authorization.transaction.take() { + transaction.rollback().await?; + } + Ok(()) + } + + /// Record a physical writer flush authorized by the exact claim token. + /// + /// This method is called only after the writer reports that the bytes were + /// flushed. It intentionally does not recheck the mutable status head: + /// supersession after authorization cannot make already-flushed bytes + /// retroactively undelivered. + pub async fn complete_status_delivery( + &self, + authorization: &mut AuthorizedStatusDelivery, + ) -> Result { + let mut transaction = match authorization.transaction.take() { + Some(transaction) => transaction, + None => self.pool.begin().await?, + }; + let Some(row) = delivery_state_for_update( + &mut transaction, + authorization.community_id, + authorization.delivery_id, + ) + .await? + else { + transaction.commit().await?; + return Ok(CompleteStatusDeliveryOutcome::LostClaim); + }; + let state: i16 = row.try_get("delivery_state")?; + let active_claim: Option = row.try_get("claim_id")?; + let completed_claim: Option = row.try_get("completion_claim_id")?; + if state == 2 && completed_claim == Some(authorization.claim_id) { + transaction.commit().await?; + return Ok(CompleteStatusDeliveryOutcome::ExactReplay); + } + if state != 1 || active_claim != Some(authorization.claim_id) { + transaction.commit().await?; + return Ok(CompleteStatusDeliveryOutcome::LostClaim); + } + let attempt: i16 = row.try_get("attempt_count")?; + sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=2, claim_id=NULL, \ + claimed_until=NULL, completion_claim_id=$3, last_failure_reason=0, \ + delivered_at=clock_timestamp() WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(authorization.community_id.as_uuid()) + .bind(authorization.delivery_id) + .bind(authorization.claim_id) + .execute(&mut *transaction) + .await?; + let sequence = next_event_sequence( + &mut transaction, + authorization.community_id, + authorization.delivery_id, + ) + .await?; + insert_delivery_event( + &mut transaction, + authorization.community_id, + authorization.delivery_id, + sequence, + 5, + 0, + attempt, + ) + .await?; + transaction.commit().await?; + Ok(CompleteStatusDeliveryOutcome::Delivered) + } + + /// Record a retryable or terminal failure under the exact claim token. + pub async fn fail_status_delivery( + &self, + community_id: CommunityId, + delivery_id: Uuid, + claim_id: Uuid, + failure: StatusDeliveryFailure, + retry_after: Duration, + ) -> Result { + let retry_seconds = bounded_seconds(retry_after, MAX_RETRY_SECONDS)?; + let mut transaction = self.pool.begin().await?; + let Some(row) = + delivery_state_for_update(&mut transaction, community_id, delivery_id).await? + else { + transaction.commit().await?; + return Ok(FailStatusDeliveryOutcome::LostClaim); + }; + let state: i16 = row.try_get("delivery_state")?; + let active_claim: Option = row.try_get("claim_id")?; + let completed_claim: Option = row.try_get("completion_claim_id")?; + let prior_reason: i16 = row.try_get("last_failure_reason")?; + if state == 3 && completed_claim == Some(claim_id) && prior_reason == failure as i16 { + transaction.commit().await?; + return Ok(FailStatusDeliveryOutcome::ExactReplay); + } + if state != 1 || active_claim != Some(claim_id) { + transaction.commit().await?; + return Ok(FailStatusDeliveryOutcome::LostClaim); + } + let attempt: i16 = row.try_get("attempt_count")?; + let retry = failure.is_retryable() && attempt < MAX_ATTEMPTS; + let recorded_failure = if failure.is_retryable() && !retry { + StatusDeliveryFailure::AttemptsExhausted + } else { + failure + }; + if retry { + sqlx::query( + "UPDATE client_status_delivery_outbox SET claim_id=NULL, claimed_until=NULL, \ + last_failure_reason=$3, next_attempt_at=transaction_timestamp()+make_interval(secs => $4) \ + WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(recorded_failure as i16) + .bind(retry_seconds as f64) + .execute(&mut *transaction) + .await?; + } else { + sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=3, claim_id=NULL, \ + claimed_until=NULL, completion_claim_id=$3, last_failure_reason=$4, \ + terminal_at=clock_timestamp() WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(claim_id) + .bind(recorded_failure as i16) + .execute(&mut *transaction) + .await?; + } + let sequence = next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + if retry { 6 } else { 7 }, + recorded_failure as i16, + attempt, + ) + .await?; + transaction.commit().await?; + Ok(if retry { + FailStatusDeliveryOutcome::RetryScheduled + } else { + FailStatusDeliveryOutcome::Terminal + }) + } + + /// Terminalize every pending job owned by one exact dead connection. + /// Reconnects use a new fingerprint and can never inherit these rows. + pub async fn terminalize_status_connection( + &self, + community_id: CommunityId, + connection_fingerprint: [u8; 32], + ) -> Result { + if connection_fingerprint == [0; 32] { + return Err(invalid_delivery()); + } + let mut transaction = self.pool.begin().await?; + let rows = sqlx::query( + "SELECT delivery_id, claim_id, attempt_count \ + FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND connection_fingerprint=$2 AND delivery_state=1 \ + ORDER BY delivery_id FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(connection_fingerprint.as_slice()) + .fetch_all(&mut *transaction) + .await?; + for row in &rows { + let delivery_id: Uuid = row.try_get("delivery_id")?; + let completion_claim = row + .try_get::, _>("claim_id")? + .unwrap_or_else(Uuid::new_v4); + let attempt: i16 = row.try_get("attempt_count")?; + sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=3, claim_id=NULL, \ + claimed_until=NULL, completion_claim_id=$3, last_failure_reason=2, \ + terminal_at=clock_timestamp() WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(completion_claim) + .execute(&mut *transaction) + .await?; + let sequence = next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + 7, + StatusDeliveryFailure::ConnectionGone as i16, + attempt, + ) + .await?; + } + transaction.commit().await?; + u64::try_from(rows.len()).map_err(|_| invalid_delivery()) + } + + /// Terminalize expired or retry-exhausted pending jobs so crash recovery + /// cannot pin bounded capacity indefinitely. + pub async fn reconcile_status_deliveries( + &self, + community_id: CommunityId, + limit: u16, + ) -> Result { + if limit == 0 || limit > 1024 { + return Err(invalid_delivery()); + } + let mut transaction = self.pool.begin().await?; + let rows = sqlx::query( + "SELECT delivery.delivery_id, delivery.claim_id, delivery.attempt_count, \ + transition.fresh_until <= transaction_timestamp() AS expired, \ + head.transition_id IS DISTINCT FROM delivery.transition_id AS stale \ + FROM client_status_delivery_outbox delivery \ + JOIN client_status_transitions transition \ + ON transition.community_id=delivery.community_id \ + AND transition.transition_id=delivery.transition_id \ + LEFT JOIN client_status_transition_heads head \ + ON head.community_id=transition.community_id \ + AND head.connection_fingerprint=delivery.connection_fingerprint \ + WHERE delivery.community_id=$1 AND delivery.delivery_state=1 \ + AND (head.transition_id IS DISTINCT FROM delivery.transition_id \ + OR transition.fresh_until <= transaction_timestamp() \ + OR delivery.attempt_count >= $2) \ + AND (delivery.claim_id IS NULL \ + OR delivery.claimed_until <= transaction_timestamp()) \ + ORDER BY delivery.created_at, delivery.delivery_id \ + LIMIT $3 FOR UPDATE OF delivery SKIP LOCKED", + ) + .bind(community_id.as_uuid()) + .bind(MAX_ATTEMPTS) + .bind(i64::from(limit)) + .fetch_all(&mut *transaction) + .await?; + for row in &rows { + let delivery_id: Uuid = row.try_get("delivery_id")?; + let claim_id: Option = row.try_get("claim_id")?; + let attempt: i16 = row.try_get("attempt_count")?; + let expired: bool = row.try_get("expired")?; + let stale: bool = row.try_get("stale")?; + if claim_id.is_some() { + let sequence = + next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + 8, + StatusDeliveryFailure::LeaseExpiredUnknown as i16, + attempt, + ) + .await?; + } + let failure = if stale { + StatusDeliveryFailure::StaleFence + } else if expired { + StatusDeliveryFailure::Expired + } else { + StatusDeliveryFailure::AttemptsExhausted + }; + let completion_claim = claim_id.unwrap_or_else(Uuid::new_v4); + sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=3, claim_id=NULL, \ + claimed_until=NULL, completion_claim_id=$3, last_failure_reason=$4, \ + terminal_at=clock_timestamp() WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(completion_claim) + .bind(failure as i16) + .execute(&mut *transaction) + .await?; + let sequence = next_event_sequence(&mut transaction, community_id, delivery_id).await?; + insert_delivery_event( + &mut transaction, + community_id, + delivery_id, + sequence, + 7, + failure as i16, + attempt, + ) + .await?; + } + transaction.commit().await?; + u64::try_from(rows.len()).map_err(|_| invalid_delivery()) + } + + /// Delete terminal delivery evidence only after its strict retention + /// boundary. Pending work is never reaped. + pub async fn reap_status_deliveries( + &self, + community_id: CommunityId, + limit: u16, + ) -> Result { + if limit == 0 || limit > 1024 { + return Err(invalid_delivery()); + } + let mut transaction = self.pool.begin().await?; + let delivery_ids: Vec = sqlx::query_scalar( + "SELECT delivery_id FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_state IN (2,3) \ + AND transaction_timestamp() > retain_until \ + ORDER BY retain_until, delivery_id LIMIT $2 FOR UPDATE SKIP LOCKED", + ) + .bind(community_id.as_uuid()) + .bind(i64::from(limit)) + .fetch_all(&mut *transaction) + .await?; + if !delivery_ids.is_empty() { + sqlx::query( + "DELETE FROM client_status_delivery_events \ + WHERE community_id=$1 AND delivery_id=ANY($2)", + ) + .bind(community_id.as_uuid()) + .bind(&delivery_ids) + .execute(&mut *transaction) + .await?; + sqlx::query( + "DELETE FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_id=ANY($2)", + ) + .bind(community_id.as_uuid()) + .bind(&delivery_ids) + .execute(&mut *transaction) + .await?; + } + sqlx::query( + "DELETE FROM client_status_transition_heads head USING client_status_transitions transition \ + WHERE head.community_id=$1 AND transition.community_id=head.community_id \ + AND transition.transition_id=head.transition_id \ + AND transition.connection_fingerprint=head.connection_fingerprint \ + AND transaction_timestamp() > transition.retain_until \ + AND NOT EXISTS (SELECT 1 FROM client_status_delivery_outbox delivery \ + WHERE delivery.community_id=transition.community_id \ + AND delivery.transition_id=transition.transition_id)", + ) + .bind(community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + sqlx::query( + "DELETE FROM client_status_transitions transition WHERE transition.community_id=$1 \ + AND transaction_timestamp() > transition.retain_until \ + AND NOT EXISTS (SELECT 1 FROM client_status_transition_heads head \ + WHERE head.community_id=transition.community_id \ + AND head.transition_id=transition.transition_id) \ + AND NOT EXISTS (SELECT 1 FROM client_status_delivery_outbox delivery \ + WHERE delivery.community_id=transition.community_id \ + AND delivery.transition_id=transition.transition_id)", + ) + .bind(community_id.as_uuid()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + u64::try_from(delivery_ids.len()).map_err(|_| invalid_delivery()) + } +} + +async fn load_current_status_evidence( + db: &Db, + request: &CurrentBindingStatusEvidenceRequest, +) -> Result { + load_current_status_evidence_at(db, request) + .await + .map(|(evidence, _)| evidence) +} + +async fn load_current_status_evidence_at( + db: &Db, + request: &CurrentBindingStatusEvidenceRequest, +) -> Result<(CanonicalCurrentBindingEvidence, DateTime)> { + let row = sqlx::query( + "SELECT binding.binding_id, binding.binding_version, binding.policy_revision, \ + domain.current_generation, binding.expires_at, transaction_timestamp() AS now \ + FROM identity_bindings binding \ + JOIN authorization_invalidation_domains domain \ + ON domain.community_id=binding.community_id \ + 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.policy_revision=(SELECT max(policy.policy_revision) \ + FROM identity_enrollment_policies policy WHERE policy.community_id=$1)", + ) + .bind(request.authorization_domain().as_uuid()) + .bind(request.event_author_pubkey().as_bytes()) + .fetch_optional(&db.pool) + .await? + .ok_or_else(|| { + DbError::InvalidData("current client binding status is unavailable".to_owned()) + })?; + let binding_id: Uuid = row.try_get("binding_id")?; + let binding_version = positive_u64(row.try_get("binding_version")?)?; + let policy_revision = positive_u64(row.try_get("policy_revision")?)?; + let invalidation_generation = u64::try_from(row.try_get::("current_generation")?) + .map_err(|_| invalid_delivery())?; + let authority_epoch = invalidation_generation + .checked_add(1) + .ok_or_else(invalid_delivery)?; + let authoritative_now: DateTime = row.try_get("now")?; + let protocol_fresh_until = authoritative_now + .checked_add_signed(chrono::Duration::seconds(STATUS_EVIDENCE_LIFETIME_SECONDS)) + .ok_or_else(invalid_delivery)?; + let binding_expiry: Option> = row.try_get("expires_at")?; + let fresh_until = binding_expiry.map_or(protocol_fresh_until, |expiry| { + expiry.min(protocol_fresh_until) + }); + let fence = derive_status_evidence_fence( + request.authorization_domain(), + request.event_author_pubkey(), + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + )?; + let evidence = CanonicalCurrentBindingEvidence::new( + request.authorization_domain(), + request.event_author_pubkey(), + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + fence, + authoritative_now, + fresh_until, + ) + .map_err(|_| invalid_delivery())?; + Ok((evidence, authoritative_now)) +} + +async fn recheck_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, \ + domain.current_generation \ + FROM identity_bindings binding \ + JOIN authorization_invalidation_domains domain \ + ON domain.community_id=binding.community_id \ + JOIN identity_enrollment_policies policy \ + ON policy.community_id=binding.community_id \ + 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 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", + ) + .bind(evidence.authorization_domain().as_uuid()) + .bind(evidence.event_author_pubkey().as_bytes()) + .fetch_optional(&mut **transaction) + .await?; + let Some(row) = row else { + return Ok(false); + }; + let binding_id: Uuid = row.try_get("binding_id")?; + let binding_version = positive_u64(row.try_get("binding_version")?)?; + let policy_revision = positive_u64(row.try_get("policy_revision")?)?; + let invalidation_generation = u64::try_from(row.try_get::("current_generation")?) + .map_err(|_| invalid_delivery())?; + let authority_epoch = invalidation_generation + .checked_add(1) + .ok_or_else(invalid_delivery)?; + let fence = derive_status_evidence_fence( + evidence.authorization_domain(), + evidence.event_author_pubkey(), + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + )?; + Ok(evidence.binding_id() == binding_id + && evidence.binding_version() == binding_version + && evidence.policy_revision() == policy_revision + && evidence.invalidation_generation() == invalidation_generation + && evidence.authority_epoch() == authority_epoch + && evidence.fence() == fence + && evidence.is_fresh_at(authoritative_now)) +} + +#[allow(clippy::too_many_arguments)] +fn derive_status_evidence_fence( + community_id: CommunityId, + author: PublicKey, + binding_id: Uuid, + binding_version: u64, + policy_revision: u64, + invalidation_generation: u64, + authority_epoch: u64, +) -> Result { + let mut digest = Sha256::new(); + for part in [ + b"buzz:client-status-evidence-fence:v1".as_slice(), + community_id.as_uuid().as_bytes().as_slice(), + author.as_bytes().as_slice(), + binding_id.as_bytes().as_slice(), + binding_version.to_be_bytes().as_slice(), + policy_revision.to_be_bytes().as_slice(), + invalidation_generation.to_be_bytes().as_slice(), + authority_epoch.to_be_bytes().as_slice(), + ] { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + AuthorizationLeaseFence::from_bytes(digest.finalize().into()).map_err(|_| invalid_delivery()) +} + +fn validate_delivery(delivery: &NewStatusDelivery<'_>) -> Result<()> { + if delivery.delivery_id.is_nil() + || delivery.transition_id.is_nil() + || delivery.operation_id.is_nil() + || delivery.request_fingerprint == [0; 32] + || delivery.subject_fingerprint == [0; 32] + || delivery.signer_fingerprint == [0; 32] + || delivery.connection_fingerprint == [0; 32] + || delivery.status_revision == 0 + || delivery.status_revision > i64::MAX as u64 + || delivery.signed_payload.is_empty() + || delivery.signed_payload.len() > 4096 + || (delivery.kind == StatusDeliveryKind::Current && delivery.supersedes_revision.is_some()) + || (delivery.kind == StatusDeliveryKind::Withdrawal + && delivery.supersedes_revision.is_none()) + || (delivery.kind == StatusDeliveryKind::Current && delivery.current_evidence.is_none()) + || (delivery.kind == StatusDeliveryKind::Withdrawal && delivery.current_evidence.is_some()) + { + return Err(invalid_delivery()); + } + if let Some(evidence) = delivery.current_evidence { + if evidence.authorization_domain() != delivery.community_id + || evidence.fresh_until() != delivery.fresh_until + || evidence.binding_version() > i64::MAX as u64 + || evidence.policy_revision() > i64::MAX as u64 + || evidence.invalidation_generation() > i64::MAX as u64 + || evidence.authority_epoch() > i64::MAX as u64 + { + return Err(invalid_delivery()); + } + } + Ok(()) +} + +fn evidence_row_matches( + row: &sqlx::postgres::PgRow, + evidence: Option<&CanonicalCurrentBindingEvidence>, +) -> Result { + let author: Option> = row.try_get("evidence_author_pubkey")?; + let binding_id: Option = row.try_get("evidence_binding_id")?; + let binding_version: Option = row.try_get("evidence_binding_version")?; + let policy_revision: Option = row.try_get("evidence_policy_revision")?; + let invalidation_generation: Option = row.try_get("evidence_invalidation_generation")?; + let authority_epoch: Option = row.try_get("evidence_authority_epoch")?; + let fence: Option> = row.try_get("evidence_fence")?; + let observed_at: Option> = row.try_get("evidence_observed_at")?; + Ok(match evidence { + Some(value) => { + author.as_deref() == Some(value.event_author_pubkey().as_bytes()) + && binding_id == Some(value.binding_id()) + && binding_version == i64::try_from(value.binding_version()).ok() + && policy_revision == i64::try_from(value.policy_revision()).ok() + && invalidation_generation == i64::try_from(value.invalidation_generation()).ok() + && authority_epoch == i64::try_from(value.authority_epoch()).ok() + && fence.as_deref() == Some(value.fence().as_bytes().as_slice()) + && observed_at == Some(value.observed_at()) + } + None => { + author.is_none() + && binding_id.is_none() + && binding_version.is_none() + && policy_revision.is_none() + && invalidation_generation.is_none() + && authority_epoch.is_none() + && fence.is_none() + && observed_at.is_none() + } + }) +} + +fn evidence_from_row( + row: &sqlx::postgres::PgRow, + community_id: CommunityId, + kind: StatusDeliveryKind, +) -> Result> { + if kind == StatusDeliveryKind::Withdrawal { + if !evidence_row_matches(row, None)? { + return Err(invalid_delivery()); + } + return Ok(None); + } + let author_bytes: Vec = row + .try_get::>, _>("evidence_author_pubkey")? + .ok_or_else(invalid_delivery)?; + let author = PublicKey::from_slice(&author_bytes).map_err(|_| invalid_delivery())?; + let binding_id = row + .try_get::, _>("evidence_binding_id")? + .ok_or_else(invalid_delivery)?; + let binding_version = positive_u64( + row.try_get::, _>("evidence_binding_version")? + .ok_or_else(invalid_delivery)?, + )?; + let policy_revision = positive_u64( + row.try_get::, _>("evidence_policy_revision")? + .ok_or_else(invalid_delivery)?, + )?; + let invalidation_generation = u64::try_from( + row.try_get::, _>("evidence_invalidation_generation")? + .ok_or_else(invalid_delivery)?, + ) + .map_err(|_| invalid_delivery())?; + let authority_epoch = positive_u64( + row.try_get::, _>("evidence_authority_epoch")? + .ok_or_else(invalid_delivery)?, + )?; + let fence = AuthorizationLeaseFence::from_bytes(fixed_digest( + row.try_get::>, _>("evidence_fence")? + .ok_or_else(invalid_delivery)?, + )?) + .map_err(|_| invalid_delivery())?; + let observed_at = row + .try_get::>, _>("evidence_observed_at")? + .ok_or_else(invalid_delivery)?; + let fresh_until: DateTime = row.try_get("fresh_until")?; + CanonicalCurrentBindingEvidence::new( + community_id, + author, + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + fence, + observed_at, + fresh_until, + ) + .map(Some) + .map_err(|_| invalid_delivery()) +} + +fn fixed_digest(bytes: Vec) -> Result<[u8; 32]> { + bytes.try_into().map_err(|_| invalid_delivery()) +} + +fn positive_u64(value: i64) -> Result { + let value = u64::try_from(value).map_err(|_| invalid_delivery())?; + if value == 0 { + Err(invalid_delivery()) + } else { + Ok(value) + } +} + +async fn insert_delivery_target( + transaction: &mut Transaction<'_, Postgres>, + delivery: &NewStatusDelivery<'_>, + new_transition: bool, +) -> Result { + let inserted = sqlx::query( + "INSERT INTO client_status_delivery_outbox \ + (community_id, delivery_id, transition_id, connection_fingerprint) \ + VALUES ($1,$2,$3,$4) \ + ON CONFLICT (community_id, transition_id, connection_fingerprint) DO NOTHING \ + RETURNING delivery_id", + ) + .bind(delivery.community_id.as_uuid()) + .bind(delivery.delivery_id) + .bind(delivery.transition_id) + .bind(delivery.connection_fingerprint.as_slice()) + .fetch_optional(&mut **transaction) + .await? + .is_some(); + if inserted { + let event_kinds: &[i16] = if new_transition { &[1, 2, 3] } else { &[3] }; + for (index, event_kind) in event_kinds.iter().enumerate() { + insert_delivery_event( + transaction, + delivery.community_id, + delivery.delivery_id, + i16::try_from(index + 1).map_err(|_| invalid_delivery())?, + *event_kind, + 0, + 0, + ) + .await?; + } + } + Ok(inserted) +} + +fn invalid_delivery() -> DbError { + DbError::InvalidData("client status delivery is invalid".to_owned()) +} + +fn bounded_seconds(value: Duration, maximum: u64) -> Result { + let seconds = value.as_secs(); + if value.subsec_nanos() != 0 || seconds == 0 || seconds > maximum { + return Err(invalid_delivery()); + } + Ok(seconds) +} + +fn delivery_kind(value: i16) -> Result { + match value { + 1 => Ok(StatusDeliveryKind::Current), + 2 => Ok(StatusDeliveryKind::Withdrawal), + _ => Err(invalid_delivery()), + } +} + +async fn delivery_state_for_update<'a>( + transaction: &mut Transaction<'a, Postgres>, + community_id: CommunityId, + delivery_id: Uuid, +) -> Result> { + Ok(sqlx::query( + "SELECT delivery_state, claim_id, completion_claim_id, claimed_until, attempt_count, \ + last_failure_reason \ + FROM client_status_delivery_outbox WHERE community_id=$1 AND delivery_id=$2 FOR UPDATE", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .fetch_optional(&mut **transaction) + .await?) +} + +async fn next_event_sequence( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + delivery_id: Uuid, +) -> Result { + let sequence: i16 = sqlx::query_scalar( + "SELECT (COALESCE(max(event_sequence), 0) + 1)::smallint \ + FROM client_status_delivery_events WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .fetch_one(&mut **transaction) + .await?; + if !(1..=64).contains(&sequence) { + return Err(invalid_delivery()); + } + Ok(sequence) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_delivery_event( + transaction: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + delivery_id: Uuid, + sequence: i16, + event_kind: i16, + reason: i16, + attempt: i16, +) -> Result<()> { + sqlx::query( + "INSERT INTO client_status_delivery_events \ + (community_id, delivery_id, event_sequence, event_kind, reason_code, attempt_count) \ + VALUES ($1,$2,$3,$4,$5,$6)", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .bind(sequence) + .bind(event_kind) + .bind(reason) + .bind(attempt) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +/// Minimal audit view used by tests and operator tooling. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StatusDeliveryAuditEvent { + /// Stable sequence within one delivery. + pub sequence: u16, + /// Closed event kind: allocated, signed, fenced, claimed, delivered, + /// retryable failure, or terminal failure. + pub kind: u16, + /// Closed failure reason code; zero on successful stages. + pub reason: u16, + /// Bounded attempt count. + pub attempt: u16, + /// Database-owned observation time. + pub occurred_at: DateTime, +} + +impl Db { + /// Read the bounded, redaction-safe causal timeline for one delivery. + pub async fn status_delivery_audit( + &self, + community_id: CommunityId, + delivery_id: Uuid, + ) -> Result> { + let rows = sqlx::query( + "SELECT event_sequence, event_kind, reason_code, attempt_count, occurred_at \ + FROM client_status_delivery_events WHERE community_id=$1 AND delivery_id=$2 \ + ORDER BY event_sequence", + ) + .bind(community_id.as_uuid()) + .bind(delivery_id) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(StatusDeliveryAuditEvent { + sequence: u16::try_from(row.try_get::("event_sequence")?) + .map_err(|_| invalid_delivery())?, + kind: u16::try_from(row.try_get::("event_kind")?) + .map_err(|_| invalid_delivery())?, + reason: u16::try_from(row.try_get::("reason_code")?) + .map_err(|_| invalid_delivery())?, + attempt: u16::try_from(row.try_get::("attempt_count")?) + .map_err(|_| invalid_delivery())?, + occurred_at: row.try_get("occurred_at")?, + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::postgres::PgPoolOptions; + + async fn seed_status_binding(pool: &sqlx::PgPool, community_id: CommunityId) -> nostr::Keys { + let author = nostr::Keys::generate(); + let operation_id = Uuid::new_v4(); + let history_id = Uuid::new_v4(); + let binding_id = Uuid::new_v4(); + let request_fingerprint = vec![41_u8; 32]; + let mut transaction = pool.begin().await.expect("begin status binding seed"); + sqlx::query("SET LOCAL session_replication_role = replica") + .execute(&mut *transaction) + .await + .expect("isolate resolver fixture from lifecycle triggers"); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id,operation_id,request_fingerprint,operation_kind,actor_fingerprint, \ + outcome_code,result_digest) VALUES ($1,$2,$3,1,$4,1,$5)", + ) + .bind(community_id.as_uuid()) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![42_u8; 32]) + .bind(vec![43_u8; 32]) + .execute(&mut *transaction) + .await + .expect("insert status binding receipt"); + let binding_version: i64 = sqlx::query_scalar( + "INSERT INTO identity_bindings \ + (community_id,binding_id,issuer,subject,principal_fingerprint,event_author_pubkey, \ + binding_state,lifecycle_revision,binding_provenance,policy_revision, \ + enrollment_evidence_digest,birth_history_id,creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1,$2,'https://issuer.example','status-subject',$3,$4,1,1,2,1,$5,$6,$7,$8) \ + RETURNING binding_version", + ) + .bind(community_id.as_uuid()) + .bind(binding_id) + .bind(vec![44_u8; 32]) + .bind(author.public_key().as_bytes()) + .bind(vec![45_u8; 32]) + .bind(history_id) + .bind(operation_id) + .bind(&request_fingerprint) + .fetch_one(&mut *transaction) + .await + .expect("insert status binding"); + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id,history_id,transition_kind,outcome_code,successor_binding_id, \ + successor_binding_version,successor_lifecycle_revision,successor_state, \ + operation_id,request_fingerprint,transition_digest) \ + VALUES ($1,$2,1,1,$3,$4,1,1,$5,$6,$7)", + ) + .bind(community_id.as_uuid()) + .bind(history_id) + .bind(binding_id) + .bind(binding_version) + .bind(operation_id) + .bind(&request_fingerprint) + .bind(vec![46_u8; 32]) + .execute(&mut *transaction) + .await + .expect("insert status binding history"); + transaction + .commit() + .await + .expect("commit status binding seed"); + author + } + + #[test] + fn debug_output_redacts_delivery_material() { + let community_id = CommunityId::from_uuid(Uuid::from_u128(1)); + let delivery = NewStatusDelivery { + community_id, + delivery_id: Uuid::from_u128(2), + transition_id: Uuid::from_u128(3), + operation_id: Uuid::from_u128(4), + request_fingerprint: [5; 32], + kind: StatusDeliveryKind::Current, + subject_fingerprint: [6; 32], + signer_fingerprint: [7; 32], + connection_fingerprint: [8; 32], + status_revision: 9, + supersedes_revision: None, + fresh_until: Utc::now() + chrono::Duration::minutes(5), + signed_payload: b"secret-payload", + current_evidence: None, + }; + assert_eq!(format!("{delivery:?}"), "NewStatusDelivery([REDACTED])"); + } + + #[test] + fn durations_and_envelopes_are_bounded() { + assert!(bounded_seconds(Duration::ZERO, MAX_CLAIM_SECONDS).is_err()); + assert!(bounded_seconds(Duration::from_millis(1), MAX_CLAIM_SECONDS).is_err()); + assert!(bounded_seconds(Duration::from_secs(301), MAX_CLAIM_SECONDS).is_err()); + assert_eq!( + bounded_seconds(Duration::from_secs(30), MAX_CLAIM_SECONDS).expect("bounded seconds"), + 30 + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn postgres_status_delivery_is_atomic_target_bound_and_crash_recoverable() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .expect("BUZZ_TEST_DATABASE_URL must name a disposable PostgreSQL database"); + let pool = PgPoolOptions::new() + .max_connections(8) + .connect(&url) + .await + .expect("connect disposable PostgreSQL"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let db = Db::from_pool(pool.clone()); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(community_id.as_uuid()) + .bind(format!("{}.example.test", community_id.as_uuid())) + .execute(&pool) + .await + .expect("insert community"); + db.install_status_delivery_capacity(community_id) + .await + .expect("install capacity"); + + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,1,2,$2,transaction_timestamp())", + ) + .bind(community_id.as_uuid()) + .bind(vec![40_u8; 32]) + .execute(&pool) + .await + .expect("insert status policy"); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id,current_generation) \ + VALUES ($1,0)", + ) + .bind(community_id.as_uuid()) + .execute(&pool) + .await + .expect("activate status invalidation domain"); + let resolver_keys = seed_status_binding(&pool, community_id).await; + let resolver_author = resolver_keys.public_key(); + let resolver_request = + CurrentBindingStatusEvidenceRequest::new(community_id, resolver_author) + .expect("status resolver request"); + let resolver_evidence = db + .current_status_evidence(&resolver_request) + .await + .expect("resolve current status evidence"); + let (exact_recheck, recheck_now) = db + .recheck_current_status_evidence(&resolver_evidence) + .await + .expect("authoritative status recheck"); + assert!(resolver_evidence.accepts_exact_recheck(&exact_recheck, recheck_now)); + let relay_keys = nostr::Keys::generate(); + let challenge = buzz_auth::generate_challenge(); + let relay_url = "wss://status.example.test/relay"; + let scope_epoch = Uuid::new_v4().to_string(); + let relay_signer = relay_keys.public_key().to_hex(); + let auth_event = nostr::EventBuilder::auth( + &challenge, + nostr::RelayUrl::parse(relay_url).expect("relay URL"), + ) + .tag( + nostr::Tag::parse([ + buzz_core::client_binding_bootstrap::CLIENT_BINDING_SCOPE_TAG, + "1", + scope_epoch.as_str(), + relay_signer.as_str(), + ]) + .expect("status scope"), + ) + .sign_with_keys(&resolver_keys) + .expect("sign status AUTH"); + let connection_id = Uuid::new_v4(); + let coordinates = buzz_auth::Nip42BindingStatusCoordinates::new( + community_id, + relay_url, + connection_id, + relay_keys.public_key(), + &auth_event, + ) + .expect("status proof coordinates"); + let proof = buzz_auth::verify_nip42_binding_status_proof( + &auth_event, + &challenge, + &coordinates, + db.status_delivery_authoritative_now() + .await + .expect("authoritative proof time"), + ) + .expect("purpose-sealed status proof"); + let finalized = db + .finalize_binding_status_authorization(proof) + .await + .expect("finalize status authorization"); + assert_eq!( + finalized.lease().capability(), + RouteCapability::BindingStatus + ); + assert_eq!(finalized.lease().actor_pubkey(), resolver_author); + assert_eq!( + finalized.lease().request_binding().1, + &coordinates.target_fingerprint() + ); + let fresh_until = resolver_evidence.fresh_until(); + let evidence = resolver_evidence.clone(); + let current = NewStatusDelivery { + community_id, + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [1; 32], + kind: StatusDeliveryKind::Current, + subject_fingerprint: [2; 32], + signer_fingerprint: [3; 32], + connection_fingerprint: [4; 32], + status_revision: 1, + supersedes_revision: None, + fresh_until, + signed_payload: br#"{"kind":24244,"revision":1}"#, + current_evidence: Some(&evidence), + }; + + let mut rolled_back = pool.begin().await.expect("begin rollback transaction"); + assert_eq!( + enqueue_status_delivery_tx(&mut rolled_back, ¤t) + .await + .expect("stage rolled back status"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + rolled_back.rollback().await.expect("rollback status"); + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM client_status_transitions WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("count rolled back transitions"); + assert_eq!(rows, 0); + let pending_after_rollback: i32 = sqlx::query_scalar( + "SELECT pending_count FROM client_status_delivery_capacity WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read capacity after rollback"); + assert_eq!(pending_after_rollback, 0); + + assert_eq!( + db.enqueue_status_delivery(¤t) + .await + .expect("enqueue current"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + assert_eq!( + db.enqueue_status_delivery(¤t) + .await + .expect("replay current"), + EnqueueStatusDeliveryOutcome::ExactReplay + ); + let conflicting_delivery_id = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + ..current + }; + assert!(db + .enqueue_status_delivery(&conflicting_delivery_id) + .await + .is_err()); + + let first = db + .claim_status_delivery( + community_id, + current.connection_fingerprint, + Duration::from_secs(3), + ) + .await + .expect("claim current") + .expect("current ready"); + assert_eq!(first.signed_payload(), current.signed_payload); + let first_authorization = db + .authorize_status_delivery(&first) + .await + .expect("authorize current before writer I/O") + .expect("current claim is sendable"); + assert!(first_authorization.write_budget() > Duration::ZERO); + assert!(first_authorization.write_budget() <= Duration::from_secs(2)); + assert_eq!(first_authorization.payload_digest(), first.payload_digest()); + let mut overwrite_transaction = pool.begin().await.expect("begin overwrite probe"); + let overwrite = sqlx::query( + "UPDATE client_status_delivery_outbox SET claim_id=$3, \ + claimed_until=transaction_timestamp()+INTERVAL '30 seconds', \ + attempt_count=attempt_count+1, attempt_started_at=clock_timestamp() \ + WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(first.delivery_id()) + .bind(Uuid::new_v4()) + .execute(&mut *overwrite_transaction); + assert!(tokio::time::timeout(Duration::from_millis(100), overwrite) + .await + .is_err()); + let mut invalidation_transaction = pool.begin().await.expect("begin invalidation probe"); + let invalidate = sqlx::query( + "UPDATE authorization_invalidation_domains \ + SET current_generation=current_generation+1 WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .execute(&mut *invalidation_transaction); + assert!(tokio::time::timeout(Duration::from_millis(100), invalidate) + .await + .is_err()); + let independent_community = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(independent_community.as_uuid()) + .bind(format!( + "{}.independent.test", + independent_community.as_uuid() + )) + .execute(&pool) + .await + .expect("insert independent tenant"); + let independent_policy = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,1,2,$2,transaction_timestamp())", + ) + .bind(independent_community.as_uuid()) + .bind(vec![47_u8; 32]) + .execute(&pool); + tokio::time::timeout(Duration::from_secs(1), independent_policy) + .await + .expect("other tenant policy is not blocked") + .expect("insert other tenant policy"); + let mut policy_transaction = pool.begin().await.expect("begin policy probe"); + let same_tenant_policy = sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,2,2,$2,transaction_timestamp())", + ) + .bind(community_id.as_uuid()) + .bind(vec![48_u8; 32]) + .execute(&mut *policy_transaction); + assert!( + tokio::time::timeout(Duration::from_millis(100), same_tenant_policy) + .await + .is_err() + ); + db.abort_status_delivery_authorization(first_authorization) + .await + .expect("release abandoned writer fence"); + let _ = overwrite_transaction.rollback().await; + let _ = invalidation_transaction.rollback().await; + let _ = policy_transaction.rollback().await; + let generation: i64 = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read generation after blocked invalidation"); + assert_eq!(generation, 0); + let policy_revision_two_exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_enrollment_policies \ + WHERE community_id=$1 AND policy_revision=2)", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read blocked policy revision"); + assert!(!policy_revision_two_exists); + tokio::time::sleep(Duration::from_millis(3100)).await; + assert!(db + .authorize_status_delivery(&first) + .await + .expect("expired claim authorization query") + .is_none()); + let replay = db + .claim_status_delivery( + community_id, + current.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("reclaim after crash") + .expect("crashed current ready"); + assert_eq!(replay.signed_payload(), first.signed_payload()); + assert_eq!(replay.attempt(), 2); + let mut replay_authorization = db + .authorize_status_delivery(&replay) + .await + .expect("authorize reclaimed delivery before writer I/O") + .expect("reclaimed delivery is sendable"); + assert_eq!( + db.complete_status_delivery(&mut replay_authorization) + .await + .expect("complete replay"), + CompleteStatusDeliveryOutcome::Delivered + ); + assert_eq!( + db.complete_status_delivery(&mut replay_authorization) + .await + .expect("replay completion"), + CompleteStatusDeliveryOutcome::ExactReplay + ); + let detached = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [13; 32], + connection_fingerprint: [13; 32], + ..current + }; + db.enqueue_status_delivery(&detached) + .await + .expect("enqueue detached writer delivery"); + let detached_claim = db + .claim_status_delivery( + community_id, + detached.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("claim detached writer delivery") + .expect("detached writer delivery ready"); + let mut detached_authorization = db + .authorize_status_delivery(&detached_claim) + .await + .expect("authorize detached writer delivery") + .expect("detached writer delivery sendable"); + assert!(detached_authorization.write_budget() <= Duration::from_secs(5)); + let (release_detached, detached_released) = tokio::sync::oneshot::channel(); + let detached_db = db.clone(); + let detached_task = tokio::spawn(async move { + detached_released.await.expect("release detached writer"); + detached_db + .complete_status_delivery(&mut detached_authorization) + .await + .expect("complete detached writer") + }); + drop(detached_task); + let mut detached_invalidation = pool + .begin() + .await + .expect("begin detached invalidation probe"); + let blocked_invalidation = sqlx::query( + "UPDATE authorization_invalidation_domains \ + SET current_generation=current_generation+1 WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .execute(&mut *detached_invalidation); + assert!( + tokio::time::timeout(Duration::from_millis(100), blocked_invalidation) + .await + .is_err() + ); + release_detached.send(()).expect("release detached task"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let delivered: bool = sqlx::query_scalar( + "SELECT delivery_state=2 FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(detached.delivery_id) + .fetch_one(&pool) + .await + .expect("read detached delivery state"); + if delivered { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("detached authorization completed"); + let _ = detached_invalidation.rollback().await; + let audit = db + .status_delivery_audit(community_id, current.delivery_id) + .await + .expect("read delivery audit"); + assert_eq!( + audit.iter().map(|event| event.kind).collect::>(), + vec![1, 2, 3, 4, 8, 4, 5] + ); + assert_eq!( + audit[4].reason, + StatusDeliveryFailure::LeaseExpiredUnknown as u16 + ); + + // A reconnect is a new S5 session: its durable head is scoped to the + // new exact connection and its wire revision starts at one again. + let reconnect = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [5; 32], + connection_fingerprint: [5; 32], + ..current + }; + assert_eq!( + db.enqueue_status_delivery(&reconnect) + .await + .expect("enqueue reconnect session"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + let reconnect_claim = db + .claim_status_delivery( + community_id, + reconnect.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("claim reconnect") + .expect("reconnect ready"); + assert_eq!(reconnect_claim.signed_payload(), reconnect.signed_payload); + let mut reconnect_authorization = db + .authorize_status_delivery(&reconnect_claim) + .await + .expect("authorize reconnect before writer I/O") + .expect("reconnect is sendable"); + + let parallel = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [8; 32], + connection_fingerprint: [8; 32], + ..current + }; + assert_eq!( + db.enqueue_status_delivery(¶llel) + .await + .expect("enqueue parallel session"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + let renewal = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [10; 32], + status_revision: 2, + ..current + }; + assert_eq!( + db.enqueue_status_delivery(&renewal) + .await + .expect("enqueue renewal"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + let renewal_claim = db + .claim_status_delivery( + community_id, + renewal.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("claim renewal before supersession") + .expect("renewal ready"); + + let withdrawal = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [6; 32], + kind: StatusDeliveryKind::Withdrawal, + status_revision: 3, + supersedes_revision: Some(2), + signed_payload: br#"{"kind":24244,"revision":3,"withdrawn":true}"#, + current_evidence: None, + ..current + }; + assert_eq!( + db.enqueue_status_delivery(&withdrawal) + .await + .expect("enqueue withdrawal"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + let withdrawal_pending: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_id=$2 AND delivery_state=1)", + ) + .bind(community_id.as_uuid()) + .bind(withdrawal.delivery_id) + .fetch_one(&pool) + .await + .expect("withdrawal pending query"); + assert!(withdrawal_pending); + assert!(db + .authorize_status_delivery(&renewal_claim) + .await + .expect("reject superseded claim before writer I/O") + .is_none()); + assert_eq!( + db.complete_status_delivery(&mut reconnect_authorization) + .await + .expect("record flush authorized before supersession"), + CompleteStatusDeliveryOutcome::Delivered + ); + assert_eq!( + db.reconcile_status_deliveries(community_id, 16) + .await + .expect("terminalize stale pending delivery"), + 0 + ); + let stale_audit = db + .status_delivery_audit(community_id, renewal.delivery_id) + .await + .expect("read stale target audit"); + assert_eq!(stale_audit.last().expect("stale event").reason, 3); + let stale_target = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + ..renewal + }; + assert!(db.enqueue_status_delivery(&stale_target).await.is_err()); + + // A withdrawal on one connection must not stale another socket for + // the same author; the parallel session retains its own revision-one + // head and remains claimable. + let parallel_claim = db + .claim_status_delivery( + community_id, + parallel.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("parallel claim query") + .expect("parallel delivery ready"); + let mut parallel_authorization = db + .authorize_status_delivery(¶llel_claim) + .await + .expect("authorize parallel connection") + .expect("parallel authorization"); + assert_eq!( + db.complete_status_delivery(&mut parallel_authorization) + .await + .expect("complete parallel connection"), + CompleteStatusDeliveryOutcome::Delivered + ); + let abandoned = NewStatusDelivery { + delivery_id: Uuid::new_v4(), + transition_id: Uuid::new_v4(), + operation_id: Uuid::new_v4(), + request_fingerprint: [12; 32], + connection_fingerprint: [12; 32], + ..current + }; + assert_eq!( + db.enqueue_status_delivery(&abandoned) + .await + .expect("enqueue abandoned connection"), + EnqueueStatusDeliveryOutcome::Enqueued + ); + let abandoned_claim = db + .claim_status_delivery( + community_id, + abandoned.connection_fingerprint, + Duration::from_secs(30), + ) + .await + .expect("claim abandoned connection") + .expect("abandoned connection ready"); + let abandoned_authorization = db + .authorize_status_delivery(&abandoned_claim) + .await + .expect("authorize abandoned connection") + .expect("abandoned connection authorization"); + db.abort_status_delivery_authorization(abandoned_authorization) + .await + .expect("settle failed abandoned write"); + assert_eq!( + db.terminalize_status_connection(community_id, abandoned.connection_fingerprint) + .await + .expect("terminalize abandoned connection"), + 1 + ); + let abandoned_audit = db + .status_delivery_audit(community_id, abandoned.delivery_id) + .await + .expect("abandoned connection audit"); + assert_eq!( + abandoned_audit.last().expect("terminal event").reason, + StatusDeliveryFailure::ConnectionGone as u16 + ); + + let direct_reopen = sqlx::query( + "UPDATE client_status_delivery_outbox SET delivery_state=1, delivered_at=NULL, \ + completion_claim_id=NULL WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.delivery_id) + .execute(&pool) + .await; + assert!(direct_reopen.is_err()); + + let other_community_id = CommunityId::from_uuid(Uuid::new_v4()); + sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") + .bind(other_community_id.as_uuid()) + .bind(format!("{}.example.test", other_community_id.as_uuid())) + .execute(&pool) + .await + .expect("insert isolated community"); + db.install_status_delivery_capacity(other_community_id) + .await + .expect("install isolated capacity"); + assert!(db + .claim_status_delivery( + other_community_id, + withdrawal.connection_fingerprint, + Duration::from_secs(1), + ) + .await + .expect("cross-tenant claim query") + .is_none()); + assert!(db + .status_delivery_audit(other_community_id, withdrawal.delivery_id) + .await + .expect("cross-tenant audit query") + .is_empty()); + assert_eq!( + db.reap_status_deliveries(other_community_id, 16) + .await + .expect("cross-tenant reap"), + 0 + ); + + let pending_before_exhaustion: i32 = sqlx::query_scalar( + "SELECT pending_count FROM client_status_delivery_capacity WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read pending count before exhaustion"); + let pending_before_rows: Vec<(Uuid, i16)> = sqlx::query_as( + "SELECT delivery_id, attempt_count FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_state=1 ORDER BY delivery_id", + ) + .bind(community_id.as_uuid()) + .fetch_all(&pool) + .await + .expect("read pending rows before exhaustion"); + assert_eq!( + pending_before_exhaustion, 1, + "pending rows before exhaustion: {pending_before_rows:?}" + ); + + for expected_attempt in 1..=15 { + let claim = db + .claim_status_delivery( + community_id, + withdrawal.connection_fingerprint, + Duration::from_secs(1), + ) + .await + .expect("claim bounded retry") + .expect("bounded retry is ready"); + assert_eq!(claim.attempt(), expected_attempt); + assert_eq!( + db.fail_status_delivery( + community_id, + claim.delivery_id(), + claim.claim_id(), + StatusDeliveryFailure::Transient, + Duration::from_secs(1), + ) + .await + .expect("schedule bounded retry"), + FailStatusDeliveryOutcome::RetryScheduled + ); + tokio::time::sleep(Duration::from_millis(1050)).await; + } + let final_claim = db + .claim_status_delivery( + community_id, + withdrawal.connection_fingerprint, + Duration::from_secs(1), + ) + .await + .expect("claim final bounded attempt") + .expect("final bounded attempt is ready"); + assert_eq!(final_claim.attempt(), 16); + assert_eq!( + db.reconcile_status_deliveries(community_id, 16) + .await + .expect("preserve unexpired final claim"), + 0 + ); + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!( + db.reconcile_status_deliveries(community_id, 16) + .await + .expect("terminalize expired final claim"), + 1 + ); + let exhausted_audit = db + .status_delivery_audit(community_id, withdrawal.delivery_id) + .await + .expect("read exhausted audit"); + assert_eq!( + exhausted_audit[exhausted_audit.len() - 2].reason, + StatusDeliveryFailure::LeaseExpiredUnknown as u16 + ); + assert_eq!( + exhausted_audit.last().expect("exhausted event").reason, + StatusDeliveryFailure::AttemptsExhausted as u16 + ); + let pending_count: i32 = sqlx::query_scalar( + "SELECT pending_count FROM client_status_delivery_capacity WHERE community_id=$1", + ) + .bind(community_id.as_uuid()) + .fetch_one(&pool) + .await + .expect("read bounded pending count"); + let pending_deliveries: Vec<(Uuid, i16, Option)> = sqlx::query_as( + "SELECT delivery_id, attempt_count, claim_id \ + FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_state=1 ORDER BY delivery_id", + ) + .bind(community_id.as_uuid()) + .fetch_all(&pool) + .await + .expect("read pending delivery identities"); + assert_eq!( + pending_count, 0, + "pending deliveries: {pending_deliveries:?}" + ); + assert!(pending_deliveries.is_empty()); + + let mut equality = pool.begin().await.expect("begin retention equality proof"); + sqlx::query( + "ALTER TABLE client_status_delivery_outbox \ + DISABLE TRIGGER client_status_delivery_state", + ) + .execute(&mut *equality) + .await + .expect("disable state guard for equality fixture"); + sqlx::query( + "UPDATE client_status_delivery_outbox SET retain_until=transaction_timestamp() \ + WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.delivery_id) + .execute(&mut *equality) + .await + .expect("set exact retention boundary"); + sqlx::query( + "ALTER TABLE client_status_delivery_outbox \ + ENABLE TRIGGER client_status_delivery_state", + ) + .execute(&mut *equality) + .await + .expect("restore state guard before equality delete"); + let equality_delete = sqlx::query( + "DELETE FROM client_status_delivery_outbox \ + WHERE community_id=$1 AND delivery_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.delivery_id) + .execute(&mut *equality) + .await; + assert!(equality_delete.is_err()); + equality + .rollback() + .await + .expect("rollback exact-boundary transaction"); + + let mut expired = pool.begin().await.expect("begin strict-after fixture"); + sqlx::query( + "ALTER TABLE client_status_delivery_outbox \ + DISABLE TRIGGER client_status_delivery_state", + ) + .execute(&mut *expired) + .await + .expect("disable state guard for expired fixture"); + sqlx::query( + "UPDATE client_status_delivery_outbox SET \ + retain_until=transaction_timestamp()-INTERVAL '1 microsecond' \ + WHERE community_id=$1 AND transition_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.transition_id) + .execute(&mut *expired) + .await + .expect("expire old target evidence"); + sqlx::query( + "ALTER TABLE client_status_delivery_outbox \ + ENABLE TRIGGER client_status_delivery_state", + ) + .execute(&mut *expired) + .await + .expect("restore state guard before reaping"); + sqlx::query( + "ALTER TABLE client_status_transitions \ + DISABLE TRIGGER client_status_transition_no_update", + ) + .execute(&mut *expired) + .await + .expect("disable transition immutability for expired fixture"); + sqlx::query( + "UPDATE client_status_transitions SET \ + allocated_at=transaction_timestamp()-INTERVAL '5 microseconds', \ + signed_at=transaction_timestamp()-INTERVAL '4 microseconds', \ + fenced_at=transaction_timestamp()-INTERVAL '3 microseconds', \ + fresh_until=transaction_timestamp()-INTERVAL '2 microseconds', \ + retain_until=transaction_timestamp()-INTERVAL '1 microsecond' \ + WHERE community_id=$1 AND transition_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.transition_id) + .execute(&mut *expired) + .await + .expect("expire old transition evidence"); + sqlx::query( + "ALTER TABLE client_status_transitions \ + ENABLE TRIGGER client_status_transition_no_update", + ) + .execute(&mut *expired) + .await + .expect("restore transition immutability before reaping"); + expired.commit().await.expect("commit strict-after fixture"); + assert_eq!( + db.reap_status_deliveries(community_id, 16) + .await + .expect("reap strict-after evidence"), + 1 + ); + let old_transition_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM client_status_transitions \ + WHERE community_id=$1 AND transition_id=$2", + ) + .bind(community_id.as_uuid()) + .bind(current.transition_id) + .fetch_one(&pool) + .await + .expect("count compacted transition"); + assert_eq!(old_transition_count, 0); + + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,2,2,$2,transaction_timestamp())", + ) + .bind(community_id.as_uuid()) + .bind(vec![47_u8; 32]) + .execute(&pool) + .await + .expect("advance status policy"); + assert!(db + .recheck_current_status_evidence(&resolver_evidence) + .await + .is_err()); + } +} diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index e1b45aa3a1d..aff640dcb40 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1111,7 +1111,11 @@ pub struct ThreadMetadataParams<'a> { pub broadcast: bool, } -async fn insert_event_with_thread_metadata_tx( +/// Insert one event and its optional thread metadata inside a caller-owned transaction. +/// +/// Canonical admission uses this boundary so the event row, authorization +/// receipt, replay identity, audit record, and typed result commit together. +pub async fn insert_event_with_thread_metadata_tx( tx: &mut Transaction<'_, Postgres>, community_id: CommunityId, event: &Event, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 04add8aef50..a596980c9cb 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -31,6 +31,8 @@ pub mod authorization_restore; pub mod authorization_version; /// Channel and membership persistence. pub mod channel; +/// Crash-recoverable current-binding status delivery journal. +pub mod client_status_delivery; /// Direct message channel persistence. pub mod dm; /// Database error types. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 83775423759..413f4e8d21d 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -562,7 +562,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 35); + assert_eq!(migrations.len(), 38); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1118,6 +1118,103 @@ mod tests { "final projection insert guard must reserve authority binding for the deferred guard: {required}", ); } + + assert_eq!(migrations[35].version, 38); + let status_delivery = migrations[35].sql.as_str(); + for required in [ + "CREATE TABLE client_status_transition_heads", + "CREATE TABLE client_status_transitions", + "CREATE TABLE client_status_delivery_outbox", + "CREATE TABLE client_status_delivery_events", + "CREATE TABLE client_status_delivery_capacity", + "client_status_delivery_capacity_guard_v1", + "client_status_delivery_capacity_insert_v1", + "client_status_delivery_state_guard_v1", + "client_status_delivery_retain_v1", + "client_status_delivery_event_guard_v1", + "client_status_delivery_outbox_ready", + ] { + assert!( + status_delivery.contains(required), + "migration 0038 missing status-delivery outbox invariant: {required}", + ); + } + assert!(!status_delivery.contains("event_author_pubkey")); + assert!(!status_delivery.contains("connection_id")); + + assert_eq!(migrations[36].version, 39); + let status_connection_scope = migrations[36].sql.as_str(); + for required in [ + "connection_fingerprint BYTEA NOT NULL", + "client_status_transition_private_evidence", + "client_status_transition_connection_revision", + "client_status_transition_connection_identity", + "client_status_policy_connection_fence_v1", + "NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint", + "head.connection_fingerprint=delivery.connection_fingerprint", + ] { + assert!( + status_connection_scope.contains(required), + "migration 0039 missing status connection-scope invariant: {required}", + ); + assert!( + desired_schema.contains(required), + "desired schema missing status connection-scope invariant: {required}", + ); + } + for retired in [ + "PRIMARY KEY (community_id, subject_fingerprint, signer_fingerprint)", + "UNIQUE (community_id, subject_fingerprint, signer_fingerprint, status_revision)", + "head.subject_fingerprint=transition.subject_fingerprint", + "head.signer_fingerprint=transition.signer_fingerprint", + ] { + assert!( + !desired_schema.contains(retired), + "desired schema retains pre-0039 status identity: {retired}", + ); + } + for ordered_columns in [ + "updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(),\n connection_fingerprint BYTEA NOT NULL", + "retain_until TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() + INTERVAL '1 day',\n connection_fingerprint BYTEA NOT NULL", + ] { + assert!( + desired_schema.contains(ordered_columns), + "desired schema column order diverges from migrated status catalog", + ); + } + assert!(desired_schema.contains( + "OR (NEW.last_failure_reason = 2\n AND (OLD.claim_id IS NULL OR NEW.completion_claim_id = OLD.claim_id))" + )); + + assert_eq!(migrations[37].version, 40); + let event_status_object_kinds = migrations[37].sql.as_str(); + for required in [ + "authorization_admission_results_object_kind_check", + "authorization_authority_epochs_object_kind_check", + "protected_object_authority_object_kind_check", + "object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)", + "NOT VALID", + "VALIDATE CONSTRAINT", + ] { + assert!( + event_status_object_kinds.contains(required), + "migration 0040 missing Event/BindingStatus object-kind closure: {required}", + ); + } + for constraint in [ + "authorization_admission_results_object_kind_check", + "authorization_authority_epochs_object_kind_check", + "protected_object_authority_object_kind_check", + ] { + let required = format!( + "CONSTRAINT {constraint} CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9))" + ); + assert_eq!( + desired_schema.matches(&required).count(), + 1, + "desired schema must carry the final 0040 contract for {constraint}", + ); + } } #[test] @@ -1360,7 +1457,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(37)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(40)); } #[tokio::test] diff --git a/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs b/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs index 550d4238be3..9caae96fe1f 100644 --- a/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs +++ b/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs @@ -5,8 +5,11 @@ use uuid::Uuid; const INVITATION_OBJECT_MIGRATION: &str = include_str!("../../../../../migrations/0036_nip_fi_invitation_object_kind.sql"); +const ACTIVE_OBJECT_KINDS_MIGRATION: &str = + include_str!("../../../../../migrations/0040_nip_fi_event_status_object_kinds.sql"); const DESIRED_SCHEMA: &str = include_str!("../../../../../schema/schema.sql"); -const OBJECT_KIND_EXPRESSION: &str = "object_kind IN (1, 2, 3, 4, 5, 6, 9)"; +const INVITATION_OBJECT_KIND_EXPRESSION: &str = "object_kind IN (1, 2, 3, 4, 5, 6, 9)"; +const FINAL_OBJECT_KIND_EXPRESSION: &str = "object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)"; const OBJECT_KIND_CHECKS: [(&str, &str); 3] = [ ( "authorization_admission_results", @@ -41,7 +44,12 @@ fn invitation_object_migration_and_desired_schema_are_exact() { assert_eq!(executable.matches("ADD CONSTRAINT").count(), 3); assert_eq!(executable.matches("NOT VALID").count(), 3); assert_eq!(executable.matches("VALIDATE CONSTRAINT").count(), 3); - assert_eq!(executable.matches(OBJECT_KIND_EXPRESSION).count(), 3); + assert_eq!( + executable + .matches(INVITATION_OBJECT_KIND_EXPRESSION) + .count(), + 3 + ); for forbidden in [ "CREATE TABLE", "DROP TABLE", @@ -62,7 +70,7 @@ fn invitation_object_migration_and_desired_schema_are_exact() { for (table, constraint) in OBJECT_KIND_CHECKS { let drop = format!("ALTER TABLE {table}\n DROP CONSTRAINT {constraint};"); let add = format!( - "ALTER TABLE {table}\n ADD CONSTRAINT {constraint}\n CHECK ({OBJECT_KIND_EXPRESSION}) NOT VALID;" + "ALTER TABLE {table}\n ADD CONSTRAINT {constraint}\n CHECK ({INVITATION_OBJECT_KIND_EXPRESSION}) NOT VALID;" ); let validate = format!("ALTER TABLE {table}\n VALIDATE CONSTRAINT {constraint};"); assert_eq!(executable.matches(&drop).count(), 1, "missing {drop}"); @@ -73,7 +81,18 @@ fn invitation_object_migration_and_desired_schema_are_exact() { "missing {validate}" ); - let desired = format!("CONSTRAINT {constraint} CHECK ({OBJECT_KIND_EXPRESSION})"); + let final_migration = format!( + "ADD CONSTRAINT {constraint}\n CHECK ({FINAL_OBJECT_KIND_EXPRESSION}) NOT VALID;" + ); + assert_eq!( + ACTIVE_OBJECT_KINDS_MIGRATION + .matches(&final_migration) + .count(), + 1, + "migration 0040 must allocate the final active kinds for {constraint}", + ); + + let desired = format!("CONSTRAINT {constraint} CHECK ({FINAL_OBJECT_KIND_EXPRESSION})"); assert_eq!( DESIRED_SCHEMA.matches(&desired).count(), 1, @@ -83,7 +102,7 @@ fn invitation_object_migration_and_desired_schema_are_exact() { for rejected in [7_i16, 8, 10] { assert!( - !OBJECT_KIND_EXPRESSION + !INVITATION_OBJECT_KIND_EXPRESSION .split(|character: char| !character.is_ascii_digit()) .filter_map(|value| value.parse::().ok()) .any(|value| value == rejected), @@ -92,7 +111,7 @@ fn invitation_object_migration_and_desired_schema_are_exact() { } } -async fn assert_exact_catalog(pool: &PgPool) { +async fn assert_exact_catalog(pool: &PgPool, expected_codes: &[i16]) { let rows: Vec<(String, String, bool)> = sqlx::query_as( "SELECT c.conrelid::regclass::text,pg_get_constraintdef(c.oid,true),c.convalidated \ FROM pg_constraint c \ @@ -117,7 +136,7 @@ async fn assert_exact_catalog(pool: &PgPool) { .split(|character: char| !character.is_ascii_digit()) .filter_map(|value| value.parse::().ok()) .collect::>(); - assert_eq!(codes, vec![1, 2, 3, 4, 5, 6, 9]); + assert_eq!(codes, expected_codes); } async fn create_probe_tables(connection: &mut PgConnection) { @@ -206,20 +225,25 @@ async fn probe_kind( Ok(()) } -async fn assert_kind_behavior(connection: &mut PgConnection, invitation_is_allowed: bool) { +async fn assert_kind_behavior( + connection: &mut PgConnection, + allowed_kinds: &[i16], + rejected_kinds: &[i16], +) { for table in [ ProbeTable::AdmissionResults, ProbeTable::AuthorityEpochs, ProbeTable::ProtectedAuthority, ] { - let invitation = probe_kind(connection, table, 9).await; - assert_eq!( - invitation.is_ok(), - invitation_is_allowed, - "Invitation object kind has the wrong admission behavior: {invitation:?}", - ); - for kind in [7, 8, 10] { - let error = probe_kind(connection, table, kind) + for kind in allowed_kinds { + probe_kind(connection, table, *kind) + .await + .unwrap_or_else(|error| { + panic!("active object kind {kind} was rejected: {error:?}") + }); + } + for kind in rejected_kinds { + let error = probe_kind(connection, table, *kind) .await .expect_err("unallocated object kind must remain rejected"); assert_eq!( @@ -247,7 +271,7 @@ async fn invitation_object_kind_is_exact_on_brownfield_and_fresh_catalogs() { .await .expect("acquire brownfield probe session"); create_probe_tables(&mut connection).await; - assert_kind_behavior(&mut connection, false).await; + assert_kind_behavior(&mut connection, &[], &[7, 8, 9, 10]).await; drop_probe_tables(&mut connection).await; drop(connection); @@ -256,13 +280,13 @@ async fn invitation_object_kind_is_exact_on_brownfield_and_fresh_catalogs() { .await .expect("upgrade brownfield catalog through 0036"); assert_eq!(applied_versions(&pool).await.last().copied(), Some(36)); - assert_exact_catalog(&pool).await; + assert_exact_catalog(&pool, &[1, 2, 3, 4, 5, 6, 9]).await; let mut connection = pool .acquire() .await .expect("acquire upgraded probe session"); create_probe_tables(&mut connection).await; - assert_kind_behavior(&mut connection, true).await; + assert_kind_behavior(&mut connection, &[9], &[7, 8, 10]).await; drop_probe_tables(&mut connection).await; drop(connection); @@ -270,5 +294,9 @@ async fn invitation_object_kind_is_exact_on_brownfield_and_fresh_catalogs() { run_migrations(&pool) .await .expect("apply Invitation object kind on a fresh database"); - assert_exact_catalog(&pool).await; + assert_exact_catalog(&pool, &[1, 2, 3, 4, 5, 6, 7, 8, 9]).await; + let mut connection = pool.acquire().await.expect("acquire final probe session"); + create_probe_tables(&mut connection).await; + assert_kind_behavior(&mut connection, &[7, 8, 9], &[10]).await; + drop_probe_tables(&mut connection).await; } diff --git a/crates/buzz-pubsub/Cargo.toml b/crates/buzz-pubsub/Cargo.toml index 2ee2d6b47dd..0db28a55203 100644 --- a/crates/buzz-pubsub/Cargo.toml +++ b/crates/buzz-pubsub/Cargo.toml @@ -24,3 +24,4 @@ futures-util = { workspace = true } [dev-dependencies] tokio = { workspace = true } +buzz-auth = { workspace = true, features = ["dev"] } diff --git a/crates/buzz-pubsub/src/rate_limiter.rs b/crates/buzz-pubsub/src/rate_limiter.rs index 8d5494f1d5a..9d99a754603 100644 --- a/crates/buzz-pubsub/src/rate_limiter.rs +++ b/crates/buzz-pubsub/src/rate_limiter.rs @@ -12,6 +12,7 @@ use std::net::IpAddr; use buzz_auth::{ error::AuthError, rate_limit::{LimitType, RateLimitResult, RateLimiter}, + AuthenticatedClientPeer, }; use buzz_core::TenantContext; use nostr::PublicKey; @@ -30,6 +31,95 @@ local ttl = redis.call('TTL', KEYS[1]) return {count, ttl} "#; +/// Atomically admit one optional client-status presentation across its domain, +/// authenticated actor, and authenticated end-client peer coordinates. +/// +/// All keys share one Redis Cluster hash tag. The script validates every +/// counter before incrementing any of them, so denial cannot partially consume +/// another coordinate or refresh an existing window. +const CLIENT_STATUS_ADMISSION_SCRIPT: &str = r#" +local window = tonumber(ARGV[1]) +local limits = {tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])} +if not window or window < 1 then + return {-1, -1} +end + +local counts = {} +local ttls = {} +for i = 1, 3 do + if not limits[i] or limits[i] < 1 then + return {-1, -1} + end + local raw = redis.call('GET', KEYS[i]) + if raw then + local count = tonumber(raw) + local ttl = redis.call('TTL', KEYS[i]) + if not count or count < 0 or ttl < 0 then + return {-1, -1} + end + counts[i] = count + ttls[i] = ttl + else + counts[i] = 0 + ttls[i] = window + end +end + +local denied = false +local reset = 0 +for i = 1, 3 do + if ttls[i] > reset then + reset = ttls[i] + end + if counts[i] >= limits[i] then + denied = true + end +end +if denied then + return {0, reset} +end + +for i = 1, 3 do + local count = redis.call('INCR', KEYS[i]) + if count == 1 then + redis.call('EXPIRE', KEYS[i], window) + end +end +return {1, reset} +"#; + +/// Result of the bounded client-status admission decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientStatusAdmissionResult { + /// Whether all three coordinates were atomically admitted. + pub allowed: bool, + /// Remaining fixed-window lifetime reported by Redis. + pub reset_in_secs: u64, +} + +/// Validated-by-caller limits for the three atomic status coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClientStatusAdmissionLimits { + max_presentations_per_domain: u64, + max_presentations_per_actor: u64, + max_presentations_per_peer: u64, +} + +impl ClientStatusAdmissionLimits { + /// Bind the explicit domain, authenticated-actor, and authenticated-peer limits. + pub const fn new( + max_presentations_per_domain: u64, + max_presentations_per_actor: u64, + max_presentations_per_peer: u64, + ) -> Self { + Self { + max_presentations_per_domain, + max_presentations_per_actor, + max_presentations_per_peer, + } + } +} + /// Run the atomic rate-limit Lua script against `key` and return a /// [`RateLimitResult`]. /// @@ -94,6 +184,98 @@ impl RedisRateLimiter { pub fn new(pool: deadpool_redis::Pool) -> Self { Self { pool } } + + /// Atomically check one optional current-binding status presentation. + /// + /// The peer type can only be produced by verified trusted-proxy provenance; + /// raw socket and forwarding-header addresses are intentionally rejected by + /// this API boundary. + pub async fn check_client_status_admission( + &self, + tenant: &TenantContext, + actor: &PublicKey, + peer: &AuthenticatedClientPeer, + limits: ClientStatusAdmissionLimits, + ) -> Result { + let window = client_status_positive_i64( + buzz_core::client_binding_status::MAX_CLIENT_BINDING_STATUS_LIFETIME_SECS, + "window", + )?; + let domain_limit = + client_status_positive_i64(limits.max_presentations_per_domain, "domain limit")?; + let actor_limit = + client_status_positive_i64(limits.max_presentations_per_actor, "actor limit")?; + let peer_limit = + client_status_positive_i64(limits.max_presentations_per_peer, "peer limit")?; + let (domain_key, actor_key, peer_key) = + client_status_admission_keys(tenant, actor, peer.admission_key()); + let mut connection = self + .pool + .get() + .await + .map_err(|error| AuthError::Internal(format!("Redis pool: {error}")))?; + let (decision, reset): (i64, i64) = Script::new(CLIENT_STATUS_ADMISSION_SCRIPT) + .key(domain_key) + .key(actor_key) + .key(peer_key) + .arg(window) + .arg(domain_limit) + .arg(actor_limit) + .arg(peer_limit) + .invoke_async(&mut *connection) + .await + .map_err(|error| { + AuthError::Internal(format!("Redis client status admission script: {error}")) + })?; + if !matches!(decision, 0 | 1) || reset < 0 { + return Err(AuthError::Internal( + "Redis client status admission response is invalid".to_owned(), + )); + } + let reset_in_secs = u64::try_from(reset).map_err(|_| { + AuthError::Internal("Redis client status admission reset is invalid".to_owned()) + })?; + Ok(ClientStatusAdmissionResult { + allowed: decision == 1, + reset_in_secs, + }) + } +} + +fn client_status_positive_i64(value: u64, coordinate: &str) -> Result { + let value = i64::try_from(value).map_err(|_| { + AuthError::Internal(format!("client status admission {coordinate} is invalid")) + })?; + if value < 1 { + return Err(AuthError::Internal(format!( + "client status admission {coordinate} is invalid" + ))); + } + Ok(value) +} + +fn client_status_admission_keys( + tenant: &TenantContext, + actor: &PublicKey, + authenticated_peer_key: &[u8; 32], +) -> (String, String, String) { + // Literal braces create one Redis Cluster hash tag for the atomic script. + let prefix = format!("buzz:{{{}}}:ratelimit:client-status", tenant.community()); + ( + format!("{prefix}:domain"), + format!("{prefix}:actor:{}", actor.to_hex()), + format!("{prefix}:peer:{}", lower_hex(authenticated_peer_key)), + ) +} + +fn lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[usize::from(byte >> 4)] as char); + encoded.push(HEX[usize::from(byte & 0x0f)] as char); + } + encoded } impl RateLimiter for RedisRateLimiter { @@ -119,3 +301,126 @@ impl RateLimiter for RedisRateLimiter { run_rate_limit(&self.pool, &key, window_secs, limit).await } } + +#[cfg(test)] +mod tests { + use buzz_core::{CommunityId, TenantContext}; + use nostr::Keys; + use uuid::Uuid; + + use super::*; + + fn tenant(id: u128) -> TenantContext { + TenantContext::resolved( + CommunityId::from_uuid(Uuid::from_u128(id)), + "status-admission.example", + ) + } + + #[test] + fn authenticated_peer_keys_preserve_proxy_fan_in_isolation() { + let tenant = tenant(1); + let actor_a = Keys::generate().public_key(); + let actor_b = Keys::generate().public_key(); + let peer_a = [0x11; 32]; + let peer_b = [0x22; 32]; + + // The ingress socket is deliberately absent: authenticated clients + // sharing one proxy retain independent peer coordinates. + let a = client_status_admission_keys(&tenant, &actor_a, &peer_a); + let b = client_status_admission_keys(&tenant, &actor_b, &peer_b); + assert_eq!(a.0, b.0); + assert_ne!(a.1, b.1); + assert_ne!(a.2, b.2); + + let same_peer_other_actor = client_status_admission_keys(&tenant, &actor_b, &peer_a); + assert_eq!(a.2, same_peer_other_actor.2); + assert_ne!(a.1, same_peer_other_actor.1); + } + + #[test] + fn status_keys_share_cluster_slot_and_isolate_domains() { + let actor = Keys::generate().public_key(); + let peer = [0x33; 32]; + let tenant_a = tenant(1); + let tenant_b = tenant(2); + let keys_a = client_status_admission_keys(&tenant_a, &actor, &peer); + let keys_b = client_status_admission_keys(&tenant_b, &actor, &peer); + let cluster_tag = format!("{{{}}}", tenant_a.community()); + for key in [&keys_a.0, &keys_a.1, &keys_a.2] { + assert!(key.contains(&cluster_tag)); + } + assert_ne!(keys_a.0, keys_b.0); + assert_ne!(keys_a.1, keys_b.1); + assert_ne!(keys_a.2, keys_b.2); + } + + #[test] + fn status_limits_reject_zero_and_overflow() { + assert!(client_status_positive_i64(0, "test").is_err()); + assert!(client_status_positive_i64(u64::MAX, "test").is_err()); + assert_eq!(client_status_positive_i64(1, "test").ok(), Some(1)); + } + + #[tokio::test] + #[ignore = "requires disposable Redis via REDIS_URL"] + async fn live_redis_status_admission_is_atomic_across_domain_actor_and_peer() { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); + let pool = deadpool_redis::Config::from_url(redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("create disposable Redis pool"); + let limiter = RedisRateLimiter::new(pool.clone()); + let tenant = tenant(Uuid::new_v4().as_u128()); + let actor_a = Keys::generate().public_key(); + let actor_b = Keys::generate().public_key(); + let peer_a = AuthenticatedClientPeer::for_test([0x41; 32]); + let peer_b = AuthenticatedClientPeer::for_test([0x42; 32]); + let limits = ClientStatusAdmissionLimits::new(10, 1, 1); + + assert!( + limiter + .check_client_status_admission(&tenant, &actor_a, &peer_a, limits) + .await + .expect("admit first client") + .allowed + ); + assert!( + !limiter + .check_client_status_admission(&tenant, &actor_a, &peer_b, limits) + .await + .expect("deny repeated actor") + .allowed + ); + assert!( + !limiter + .check_client_status_admission(&tenant, &actor_b, &peer_a, limits) + .await + .expect("deny repeated peer") + .allowed + ); + assert!( + limiter + .check_client_status_admission(&tenant, &actor_b, &peer_b, limits) + .await + .expect("admit independent fan-in client") + .allowed + ); + + let keys_a = client_status_admission_keys(&tenant, &actor_a, peer_a.admission_key()); + let keys_b = client_status_admission_keys(&tenant, &actor_b, peer_b.admission_key()); + let keys = [&keys_a.0, &keys_a.1, &keys_a.2, &keys_b.1, &keys_b.2]; + let mut connection = pool.get().await.expect("borrow disposable Redis"); + let counts: Vec = redis::cmd("MGET") + .arg(&keys) + .query_async(&mut *connection) + .await + .expect("read status counters"); + assert_eq!(counts, vec![2, 1, 1, 1, 1]); + let _: usize = redis::cmd("DEL") + .arg(&keys) + .query_async(&mut *connection) + .await + .expect("remove exact disposable status counters"); + } +} diff --git a/crates/buzz-relay/src/admission.rs b/crates/buzz-relay/src/admission.rs index e8e9f627a72..175a7337020 100644 --- a/crates/buzz-relay/src/admission.rs +++ b/crates/buzz-relay/src/admission.rs @@ -1,7 +1,12 @@ -use buzz_auth::{LimitType, RateLimiter}; +use buzz_auth::{AuthError, AuthenticatedClientPeer, LimitType, RateLimiter}; use buzz_core::TenantContext; +use buzz_pubsub::rate_limiter::{ + ClientStatusAdmissionLimits, ClientStatusAdmissionResult, RedisRateLimiter, +}; use nostr::PublicKey; +use crate::authorization_runtime::ClientStatusAdmissionPolicy; + // Desktop startup establishes several independent live subscriptions at once. // Preserve the configured average rate while allowing that bounded burst. This // is still a fixed-window limiter, so a Redis-backed token bucket would be a @@ -14,6 +19,28 @@ pub(crate) enum AdmissionError { Unavailable, } +pub(crate) trait ClientStatusAdmissionLimiter: Send + Sync { + fn check_client_status_admission( + &self, + tenant: &TenantContext, + actor: &PublicKey, + peer: &AuthenticatedClientPeer, + limits: ClientStatusAdmissionLimits, + ) -> impl std::future::Future> + Send; +} + +impl ClientStatusAdmissionLimiter for RedisRateLimiter { + async fn check_client_status_admission( + &self, + tenant: &TenantContext, + actor: &PublicKey, + peer: &AuthenticatedClientPeer, + limits: ClientStatusAdmissionLimits, + ) -> Result { + RedisRateLimiter::check_client_status_admission(self, tenant, actor, peer, limits).await + } +} + pub(crate) async fn check_principal( limiter: &L, tenant: &TenantContext, @@ -37,6 +64,41 @@ pub(crate) async fn check_principal( } } +/// Admit optional client status without changing the completed AUTH decision. +/// +/// A denial or Redis failure withholds only the presentation. The caller must +/// return before any status evidence or durable audit allocation occurs. +pub(crate) async fn check_client_status_presentation( + limiter: &L, + tenant: &TenantContext, + actor: &PublicKey, + peer: &AuthenticatedClientPeer, + policy: ClientStatusAdmissionPolicy, +) -> Result<(), AdmissionError> { + match limiter + .check_client_status_admission( + tenant, + actor, + peer, + ClientStatusAdmissionLimits::new( + policy.max_presentations_per_domain(), + policy.max_presentations_per_actor(), + policy.max_presentations_per_peer(), + ), + ) + .await + { + Ok(result) if result.allowed => Ok(()), + Ok(result) => Err(AdmissionError::Exceeded { + reset_in_secs: result.reset_in_secs, + }), + Err(error) => { + tracing::warn!(error = %error, "client status admission unavailable"); + Err(AdmissionError::Unavailable) + } + } +} + pub(crate) fn ws_admission_budget(per_second_limit: u64) -> (u64, u64) { ( WS_BURST_WINDOW_SECS, @@ -48,8 +110,9 @@ pub(crate) fn ws_admission_budget(per_second_limit: u64) -> (u64, u64) { mod tests { use std::net::IpAddr; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; - use buzz_auth::{AuthError, RateLimitResult, RateLimiter}; + use buzz_auth::{AuthorizationEventCapacityPolicy, RateLimitResult}; use buzz_core::CommunityId; use nostr::Keys; use uuid::Uuid; @@ -66,6 +129,30 @@ mod tests { calls: AtomicUsize, } + struct StubStatusLimiter { + allowed: bool, + seen_peers: Mutex>, + } + + impl ClientStatusAdmissionLimiter for StubStatusLimiter { + async fn check_client_status_admission( + &self, + _tenant: &TenantContext, + _actor: &PublicKey, + peer: &AuthenticatedClientPeer, + _limits: ClientStatusAdmissionLimits, + ) -> Result { + self.seen_peers + .lock() + .map_err(|_| AuthError::Internal("status test lock unavailable".to_owned()))? + .push(*peer.admission_key()); + Ok(ClientStatusAdmissionResult { + allowed: self.allowed, + reset_in_secs: 11, + }) + } + } + impl RateLimiter for StubLimiter { async fn check_and_increment( &self, @@ -102,6 +189,16 @@ mod tests { ) } + fn status_policy() -> ClientStatusAdmissionPolicy { + ClientStatusAdmissionPolicy::new( + AuthorizationEventCapacityPolicy::new(10, 1 << 20, 16 << 10).expect("capacity"), + 10, + 2, + 3, + ) + .expect("status policy") + } + #[test] fn websocket_budget_preserves_rate_with_a_bounded_burst() { assert_eq!(ws_admission_budget(10), (5, 50)); @@ -155,4 +252,59 @@ mod tests { assert_eq!(result, Err(AdmissionError::Unavailable)); assert_eq!(limiter.calls.load(Ordering::Relaxed), 1); } + + #[tokio::test] + async fn status_consumer_preserves_authenticated_clients_behind_proxy_fan_in() { + let limiter = StubStatusLimiter { + allowed: true, + seen_peers: Mutex::new(Vec::new()), + }; + let actor_a = Keys::generate().public_key(); + let actor_b = Keys::generate().public_key(); + let peer_a = AuthenticatedClientPeer::for_test([0x11; 32]); + let peer_b = AuthenticatedClientPeer::for_test([0x22; 32]); + + assert_eq!( + check_client_status_presentation( + &limiter, + &tenant(), + &actor_a, + &peer_a, + status_policy(), + ) + .await, + Ok(()) + ); + assert_eq!( + check_client_status_presentation( + &limiter, + &tenant(), + &actor_b, + &peer_b, + status_policy(), + ) + .await, + Ok(()) + ); + assert_eq!( + *limiter.seen_peers.lock().expect("status peer log"), + vec![[0x11; 32], [0x22; 32]] + ); + } + + #[tokio::test] + async fn status_denial_withholds_presentation() { + let limiter = StubStatusLimiter { + allowed: false, + seen_peers: Mutex::new(Vec::new()), + }; + let actor = Keys::generate().public_key(); + let peer = AuthenticatedClientPeer::for_test([0x33; 32]); + + assert_eq!( + check_client_status_presentation(&limiter, &tenant(), &actor, &peer, status_policy(),) + .await, + Err(AdmissionError::Exceeded { reset_in_secs: 11 }) + ); + } } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 11e49a2e8d4..c8e7d420798 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -13,7 +13,9 @@ use axum::{ use base64::Engine; use serde_json::Value; -use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; +use buzz_auth::{ + LimitType, Nip98ReplayGuard, ProofTransport, RouteCapability, DEFAULT_REPLAY_TTL_SECS, +}; use buzz_core::TenantContext; use crate::handlers::ingest::{IngestAuth, IngestError}; @@ -127,7 +129,7 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } -fn exact_nip98_authorization_event(headers: &HeaderMap) -> Option> { +pub(crate) fn exact_nip98_authorization_event(headers: &HeaderMap) -> Option> { let mut values = headers.get_all("authorization").iter(); let value = values.next()?.to_str().ok()?; if values.next().is_some() { @@ -229,6 +231,364 @@ async fn finalize_bridge_corporate_identity( .map_err(|e| e.into_api_error()) } +async fn enforce_bridge_membership( + state: &AppState, + tenant: &TenantContext, + pubkey_bytes: &[u8], + auth_tag: Option<&str>, +) -> Result, (StatusCode, Json)> { + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + pubkey_bytes, + auth_tag, + ) + .await + .map(|owner| { + owner.or_else(|| { + if state.config.require_relay_membership { + None + } else { + super::relay_members::extract_nip_oa_owner(pubkey_bytes, auth_tag) + } + }) + }) +} + +async fn verify_bridge_identity_for_mode( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result, (StatusCode, Json)> { + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag) + .await + .map(Some) + } + buzz_auth::NipFiMode::Enforce => Ok(None), + buzz_auth::NipFiMode::DenyProtected => Err(api_error( + StatusCode::FORBIDDEN, + "restricted: protected ingress denied", + )), + } +} + +#[allow(clippy::too_many_arguments)] +async fn finalize_bridge_identity_for_mode( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + legacy_proof: Option, + method: &str, + expected_url: &str, + body: Option<&[u8]>, + capability: RouteCapability, + ingress: crate::authorization_runtime::ProtectedIngress, +) -> Result<(), (StatusCode, Json)> { + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + let proof = legacy_proof.ok_or_else(|| { + api_error(StatusCode::UNAUTHORIZED, "identity verification required") + })?; + finalize_bridge_corporate_identity(state, tenant, pubkey, proof).await + } + buzz_auth::NipFiMode::Enforce => { + authorize_canonical_bridge( + state, + tenant, + headers, + pubkey, + method, + expected_url, + body, + capability, + ingress, + ) + .await + } + buzz_auth::NipFiMode::DenyProtected => Err(api_error( + StatusCode::FORBIDDEN, + "restricted: protected ingress denied", + )), + } +} + +#[allow(clippy::too_many_arguments)] +async fn authorize_canonical_bridge( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + method: &str, + expected_url: &str, + body: Option<&[u8]>, + capability: RouteCapability, + ingress: crate::authorization_runtime::ProtectedIngress, +) -> Result<(), (StatusCode, Json)> { + let domain = tenant.community(); + let object = + crate::protected_ingress::domain_object(domain).map_err(map_protected_bridge_error)?; + let body_bytes = match body { + Some(body) => body, + None => &[], + }; + let body_digest = + crate::protected_ingress::fingerprint(b"buzz:nip-fi:bridge-body:v1", &[body_bytes]); + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-request:v1", + &[ + domain.as_uuid().as_bytes(), + pubkey.as_bytes(), + method.as_bytes(), + expected_url.as_bytes(), + &body_digest, + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + method.as_bytes(), + expected_url.as_bytes(), + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + ingress, + domain, + capability, + object, + ProofTransport::Nip98, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_bridge_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + &state.config.corporate_identity.jwt_header, + ) + .map_err(map_protected_bridge_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_protected_bridge_error)?; + let event_json = exact_nip98_authorization_event(headers).ok_or_else(|| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + let proof = buzz_auth::verify_nip98_authorization_proof( + &event_json, + expected_url, + method, + body, + &assertion, + ProofTransport::Nip98, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + ) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + crate::protected_ingress::authorize_read(state, coordinates, assertion, proof) + .await + .map(|_| ()) + .map_err(map_protected_bridge_error) +} + +async fn prepare_canonical_bridge_mutation( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + expected_url: &str, + body: &[u8], + target: ( + crate::authorization_runtime::ProtectedIngress, + RouteCapability, + buzz_db::authorization_admission::AdmissionObject, + ), +) -> Result)> { + let domain = tenant.community(); + let (ingress, capability, object) = target; + let body_digest = crate::protected_ingress::fingerprint(b"buzz:nip-fi:bridge-body:v1", &[body]); + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-request:v1", + &[ + domain.as_uuid().as_bytes(), + pubkey.as_bytes(), + b"POST", + expected_url.as_bytes(), + &body_digest, + object.key(), + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + b"POST", + expected_url.as_bytes(), + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + ingress, + domain, + capability, + object, + ProofTransport::Nip98, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_bridge_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + &state.config.corporate_identity.jwt_header, + ) + .map_err(map_protected_bridge_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_protected_bridge_error)?; + let event_json = exact_nip98_authorization_event(headers).ok_or_else(|| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + let proof = buzz_auth::verify_nip98_authorization_proof( + &event_json, + expected_url, + "POST", + Some(body), + &assertion, + ProofTransport::Nip98, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + ) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + crate::protected_ingress::prepare_mutation(state, coordinates, assertion, proof) + .await + .map_err(map_protected_bridge_error) +} + +fn map_protected_bridge_error( + error: crate::protected_ingress::ProtectedIngressError, +) -> (StatusCode, Json) { + match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + api_error(StatusCode::FORBIDDEN, "restricted: authorization denied") + } + crate::protected_ingress::ProtectedIngressError::Expired => { + api_error(StatusCode::UNAUTHORIZED, error.code()) + } + crate::protected_ingress::ProtectedIngressError::Unavailable => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "restricted: authorization unavailable", + ), + } +} + +async fn authorize_canonical_moderation_read( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + path: &str, + expected_url: &str, +) -> Result<(), (StatusCode, Json)> { + let domain = tenant.community(); + let target_key = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:moderation-read-target:v1", + &[domain.as_uuid().as_bytes(), path.as_bytes()], + ); + let object = buzz_db::authorization_admission::AdmissionObject::new( + buzz_db::authorization_admission::AdmissionObjectKind::ModerationTarget, + target_key, + ) + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "restricted: authorization denied"))?; + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:moderation-read-request:v1", + &[ + domain.as_uuid().as_bytes(), + pubkey.as_bytes(), + path.as_bytes(), + expected_url.as_bytes(), + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:moderation-read-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + expected_url.as_bytes(), + b"GET", + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::ModerationRead, + domain, + RouteCapability::Moderation, + object, + ProofTransport::Nip98, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_bridge_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + &state.config.corporate_identity.jwt_header, + ) + .map_err(map_protected_bridge_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_protected_bridge_error)?; + let event_json = exact_nip98_authorization_event(headers).ok_or_else(|| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + let proof = buzz_auth::verify_nip98_authorization_proof( + &event_json, + expected_url, + "GET", + None, + &assertion, + ProofTransport::Nip98, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + ) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "invalid canonical NIP-98 authorization", + ) + })?; + crate::protected_ingress::authorize_read(state, coordinates, assertion, proof) + .await + .map(|_| ()) + .map_err(map_protected_bridge_error) +} + /// Construct the NIP-98 `u`-tag expected URL for a request bound to `tenant`. /// /// Conformance row 44 obligation: "NIP-98 `u` URL host must match @@ -818,19 +1178,22 @@ async fn submit_event_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], ) -> SubmitOutcome { - // Admission and replay checks fire before body parse — a 429 or replay - // reject on a malformed body must still be attributed. - if let Err(e) = enforce_http_admission(state, tenant, &pubkey).await { - return SubmitOutcome::Err { - status: e.0, - response: e, - }; - } - if let Err(e) = check_nip98_replay(state, tenant, event_id_bytes).await { - return SubmitOutcome::Err { - status: e.0, - response: e, - }; + // Off retains its exact legacy quota/replay behavior. Enforce uses the + // canonical PostgreSQL receipt and typed result, so no Redis mutation can + // precede final admission or turn a committed event into a 429 response. + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + if let Err(e) = enforce_http_admission(state, tenant, &pubkey).await { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + if let Err(e) = check_nip98_replay(state, tenant, event_id_bytes).await { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } } let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -861,7 +1224,7 @@ async fn submit_event_authed( // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); let identity_proof = - match verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await { + match verify_bridge_identity_for_mode(state, tenant, headers, pubkey, auth_tag).await { Ok(proof) => proof, Err(e) => { return SubmitOutcome::Err { @@ -870,40 +1233,138 @@ async fn submit_event_authed( }; } }; - let nip_oa_owner = match super::relay_members::enforce_relay_membership( - state, - tenant.community(), - &pubkey_bytes, - auth_tag, - ) - .await + let mut nip_oa_owner = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + match enforce_bridge_membership(state, tenant, &pubkey_bytes, auth_tag).await { + Ok(owner) => owner, + Err(e) => { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + } + } else { + None + }; + let expected_url = nip98_expected_url(&state.config.relay_url, tenant, "/events"); + let kind_u32 = buzz_core::kind::event_kind_u32(&event); + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce + && !buzz_core::kind::is_moderation_command_kind(kind_u32) + && !crate::handlers::ingest::canonical_bridge_kind_supported(kind_u32) { - Ok(owner) => owner.or_else(|| { - if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag) + let e = api_error( + StatusCode::BAD_REQUEST, + "restricted: event kind requires a dedicated canonical mutation owner", + ); + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + let canonical_admission = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + if let Err(e) = finalize_bridge_identity_for_mode( + state, + tenant, + headers, + pubkey, + identity_proof, + "POST", + &expected_url, + Some(body), + RouteCapability::MessagesWrite, + crate::authorization_runtime::ProtectedIngress::BridgeEvent, + ) + .await + { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + None + } + buzz_auth::NipFiMode::Enforce => { + let target = if buzz_core::kind::is_moderation_command_kind(kind_u32) { + match crate::handlers::moderation_commands::prepare_moderation_application_effect( + tenant, &event, + ) { + Ok(effect) => ( + crate::authorization_runtime::ProtectedIngress::ModerationWrite, + RouteCapability::Moderation, + effect.admission_object(), + ), + Err(message) => { + let e = api_error(StatusCode::BAD_REQUEST, &message); + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + } } else { - None + let object = match buzz_db::authorization_admission::AdmissionObject::event( + event.id.to_bytes(), + ) { + Some(object) => object, + None => { + let e = api_error(StatusCode::BAD_REQUEST, "invalid event identifier"); + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + }; + ( + crate::authorization_runtime::ProtectedIngress::BridgeEvent, + RouteCapability::MessagesWrite, + object, + ) + }; + match prepare_canonical_bridge_mutation( + state, + tenant, + headers, + pubkey, + &expected_url, + body, + target, + ) + .await + { + Ok(request) => Some(request), + Err(e) => { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } } - }), - Err(e) => { + } + buzz_auth::NipFiMode::DenyProtected => { + let e = api_error( + StatusCode::FORBIDDEN, + "restricted: protected ingress denied", + ); return SubmitOutcome::Err { status: e.0, response: e, }; } }; - if let Err(e) = finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await - { - return SubmitOutcome::Err { - status: e.0, - response: e, + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + nip_oa_owner = match enforce_bridge_membership(state, tenant, &pubkey_bytes, auth_tag).await + { + Ok(owner) => owner, + Err(e) => { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } }; } - if let Some(owner) = nip_oa_owner { - super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; - } - let kind_u32 = buzz_core::kind::event_kind_u32(&event); let moderation_evidence = buzz_core::kind::is_moderation_command_kind(kind_u32) .then(|| { exact_nip98_authorization_event(headers).map(|authorization_event| { @@ -921,8 +1382,34 @@ async fn submit_event_authed( moderation_evidence, }; - match crate::handlers::ingest::ingest_event(state, tenant, event, auth).await { - Ok(result) => { + let ingested = match canonical_admission { + Some(request) => { + crate::handlers::ingest::ingest_event_with_canonical_admission( + state, tenant, event, auth, request, + ) + .await + } + None => crate::handlers::ingest::ingest_event(state, tenant, event, auth) + .await + .map(|result| { + ( + result, + crate::handlers::ingest::CanonicalIngestDisposition::Legacy, + ) + }), + }; + match ingested { + Ok((result, disposition)) => { + if matches!( + disposition, + crate::handlers::ingest::CanonicalIngestDisposition::Committed + | crate::handlers::ingest::CanonicalIngestDisposition::Legacy + ) { + if let Some(owner) = nip_oa_owner { + super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner) + .await; + } + } let response = Json(serde_json::json!({ "event_id": result.event_id, "accepted": result.accepted, @@ -1047,13 +1534,29 @@ async fn query_events_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], ) -> Result, (StatusCode, Json)> { - enforce_http_admission(state, tenant, &pubkey).await?; - check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); let identity_proof = - verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await?; + verify_bridge_identity_for_mode(state, tenant, headers, pubkey, auth_tag).await?; + let expected_url = nip98_expected_url(&state.config.relay_url, tenant, "/query"); + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + finalize_bridge_identity_for_mode( + state, + tenant, + headers, + pubkey, + None, + "POST", + &expected_url, + Some(body), + RouteCapability::MessagesRead, + crate::authorization_runtime::ProtectedIngress::BridgeQuery, + ) + .await?; + } + enforce_http_admission(state, tenant, &pubkey).await?; + check_nip98_replay(state, tenant, event_id_bytes).await?; super::relay_members::enforce_relay_membership( state, tenant.community(), @@ -1098,7 +1601,21 @@ async fn query_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; - finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + finalize_bridge_identity_for_mode( + state, + tenant, + headers, + pubkey, + identity_proof, + "POST", + &expected_url, + Some(body), + RouteCapability::MessagesRead, + crate::authorization_runtime::ProtectedIngress::BridgeQuery, + ) + .await?; + } if filters.iter().any(|f| f.search.is_some()) { if has_mixed_search_filters(&filters) { @@ -1493,13 +2010,29 @@ async fn count_events_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], ) -> Result, (StatusCode, Json)> { - enforce_http_admission(state, tenant, &pubkey).await?; - check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); let identity_proof = - verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await?; + verify_bridge_identity_for_mode(state, tenant, headers, pubkey, auth_tag).await?; + let expected_url = nip98_expected_url(&state.config.relay_url, tenant, "/count"); + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + finalize_bridge_identity_for_mode( + state, + tenant, + headers, + pubkey, + None, + "POST", + &expected_url, + Some(body), + RouteCapability::MessagesRead, + crate::authorization_runtime::ProtectedIngress::BridgeCount, + ) + .await?; + } + enforce_http_admission(state, tenant, &pubkey).await?; + check_nip98_replay(state, tenant, event_id_bytes).await?; super::relay_members::enforce_relay_membership( state, tenant.community(), @@ -1536,7 +2069,21 @@ async fn count_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; - finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + finalize_bridge_identity_for_mode( + state, + tenant, + headers, + pubkey, + identity_proof, + "POST", + &expected_url, + Some(body), + RouteCapability::MessagesRead, + crate::authorization_runtime::ProtectedIngress::BridgeCount, + ) + .await?; + } let mut total: u64 = 0; for filter in &filters { @@ -2195,12 +2742,18 @@ async fn authorize_moderation_read( state.config.corporate_identity.require, ), )?; - check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); let identity_proof = - verify_bridge_corporate_identity(state, &tenant, headers, pubkey, auth_tag).await?; + verify_bridge_identity_for_mode(state, &tenant, headers, pubkey, auth_tag).await?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + authorize_canonical_moderation_read(state, &tenant, headers, pubkey, path, &url).await?; + } + check_nip98_replay(state, &tenant, event_id_bytes).await?; + // Canonical Enforce admission precedes this distributed legacy quota. + // Fresh admitted signatures still bound queue work before any queue query. + enforce_http_admission(state, &tenant, &pubkey).await?; crate::handlers::moderation_authz::authorize_moderation_action( &tenant, @@ -2217,7 +2770,21 @@ async fn authorize_moderation_read( "restricted: moderator access required", ) })?; - finalize_bridge_corporate_identity(state, &tenant, pubkey, identity_proof).await?; + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + let proof = identity_proof.ok_or_else(|| { + api_error(StatusCode::UNAUTHORIZED, "identity verification required") + })?; + finalize_bridge_corporate_identity(state, &tenant, pubkey, proof).await?; + } + buzz_auth::NipFiMode::Enforce => {} + buzz_auth::NipFiMode::DenyProtected => { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: protected ingress denied", + )); + } + } Ok(tenant) } @@ -2353,8 +2920,33 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { #[cfg(test)] mod tests { use super::*; + use async_trait::async_trait; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; - use std::sync::Mutex; + use std::sync::{Arc, Mutex}; + + fn test_status_writer( + send_tx: tokio::sync::mpsc::Sender, + ) -> crate::connection::StatusWriter { + let (status_tx, mut status_rx) = + tokio::sync::mpsc::channel::(8); + tokio::spawn(async move { + while let Some(status) = status_rx.recv().await { + let acknowledgement = send_tx + .send(axum::extract::ws::Message::Text(status.text.into())) + .await + .map(|()| crate::connection::StatusWriteAck { + identity: status.identity, + }) + .map_err(|_| ()); + let succeeded = acknowledgement.is_ok(); + let _ = status.flushed.send(acknowledgement); + if !succeeded { + break; + } + } + }); + crate::connection::StatusWriter::new(status_tx) + } fn redis_pool() -> deadpool_redis::Pool { let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); @@ -3514,14 +4106,47 @@ mod tests { async fn bridge_handler_test_state_with_corporate_identity( require_corporate_identity: bool, ) -> Option> { + bridge_handler_test_state_with_rate_limit(require_corporate_identity, None).await + } + + async fn bridge_handler_test_state_with_rate_limit( + require_corporate_identity: bool, + human_api_calls_per_min: Option, + ) -> Option> { + build_bridge_handler_test_state( + require_corporate_identity, + human_api_calls_per_min, + None, + None, + ) + .await + .map(|(state, _)| state) + } + + async fn build_bridge_handler_test_state( + require_corporate_identity: bool, + human_api_calls_per_min: Option, + canonical_runtime: Option<( + crate::authorization_runtime::ProviderFreeRuntimeConfig, + crate::authorization_runtime::InstalledAuthorizationRuntime, + )>, + database_url: Option<&str>, + ) -> Option<(Arc, sqlx::PgPool)> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = database_url.unwrap_or(TEST_DB_URL).to_owned(); // 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()); config.relay_url = "wss://bridge-test.local".to_string(); config.require_auth_token = false; config.require_relay_membership = false; + if let Some((runtime_config, _)) = canonical_runtime.as_ref() { + config.nip_fi_mode = buzz_auth::NipFiMode::Enforce; + config.nip_fi = runtime_config.clone(); + } + if let Some(limit) = human_api_calls_per_min { + config.auth.rate_limits.human_api_calls_per_min = limit; + } config.corporate_identity.require = require_corporate_identity; if require_corporate_identity { config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); @@ -3529,7 +4154,7 @@ mod tests { config.corporate_identity.audience = "buzz-relay".to_string(); } - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&config.database_url).await.ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) @@ -3548,20 +4173,831 @@ mod tests { )); let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; - let (mut state, _audit_shutdown) = crate::state::AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); + let (mut state, _audit_shutdown) = match canonical_runtime { + Some((_, runtime)) => crate::state::AppState::new_with_authorization_runtime( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + runtime, + ), + None => crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ), + }; state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); - Some(Arc::new(state)) + Some((Arc::new(state), pool)) + } + + struct StaticBridgeJwksLoader(Vec); + + #[async_trait] + impl crate::authorization_runtime::JwksDocumentLoader for StaticBridgeJwksLoader { + async fn load( + &self, + _source: &crate::authorization_runtime::JwksSourceConfig, + _expected_issuer: &str, + _policy: crate::authorization_runtime::JwksRefreshPolicy, + ) -> Result, crate::authorization_runtime::RuntimeAuthorizationError> { + Ok(self.0.clone()) + } + } + + struct UnavailableStatusEvidence; + + #[async_trait] + impl crate::authorization_runtime::CurrentStatusEvidenceSource for UnavailableStatusEvidence { + async fn current( + &self, + _request: &buzz_auth::CurrentBindingStatusEvidenceRequest, + ) -> Result< + buzz_core::CanonicalCurrentBindingEvidence, + crate::authorization_runtime::StatusSessionError, + > { + Err(crate::authorization_runtime::StatusSessionError::EvidenceUnavailable) + } + + async fn recheck( + &self, + _evidence: &buzz_core::CanonicalCurrentBindingEvidence, + ) -> Result< + ( + buzz_core::CanonicalCurrentBindingEvidence, + chrono::DateTime, + ), + crate::authorization_runtime::StatusSessionError, + > { + Err(crate::authorization_runtime::StatusSessionError::EvidenceUnavailable) + } + } + + async fn install_bridge_binding( + pool: &sqlx::PgPool, + community: buzz_core::CommunityId, + actor: nostr::PublicKey, + issuer: &str, + subject: &str, + ) { + let operation_id = uuid::Uuid::new_v4(); + let history_id = uuid::Uuid::new_v4(); + let binding_id = uuid::Uuid::new_v4(); + let request_fingerprint = [71_u8; 32]; + let actor_bytes = actor.to_bytes(); + let mut transaction = pool.begin().await.expect("begin bridge binding fixture"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,1,2,$2,transaction_timestamp()-interval '1 second') \ + ON CONFLICT (community_id,policy_revision) DO NOTHING", + ) + .bind(community.as_uuid()) + .bind([72_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert bridge policy"); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id,current_generation) \ + VALUES ($1,0) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community.as_uuid()) + .execute(&mut *transaction) + .await + .expect("insert bridge invalidation domain"); + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id,max_events_per_domain,max_bytes_per_domain,max_envelope_bytes) \ + VALUES ($1,32,2097152,16384) ON CONFLICT (community_id) DO NOTHING", + ) + .bind(community.as_uuid()) + .execute(&mut *transaction) + .await + .expect("insert bridge audit capacity"); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id,operation_id,request_fingerprint,operation_kind,actor_fingerprint, \ + outcome_code,result_digest) VALUES ($1,$2,$3,1,$4,1,$5)", + ) + .bind(community.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .bind([73_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert bridge binding receipt"); + sqlx::query( + "INSERT INTO authorization_events \ + (community_id,event_id,event_kind,outcome_code,reason_code,actor_kind, \ + actor_fingerprint,operation_id,request_fingerprint,correlation_id,attempt_id, \ + occurred_at,canonical_envelope,envelope_digest) \ + VALUES ($1,$2,1,1,1,1,$3,$4,$5,$6,$7,transaction_timestamp(),$8,$9)", + ) + .bind(community.as_uuid()) + .bind(uuid::Uuid::new_v4()) + .bind(actor_bytes.as_slice()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::new_v4()) + .bind([1_u8].as_slice()) + .bind([74_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert bridge binding event"); + let binding_version: i64 = sqlx::query_scalar( + "INSERT INTO identity_bindings \ + (community_id,binding_id,issuer,subject,principal_fingerprint,event_author_pubkey, \ + binding_state,lifecycle_revision,binding_provenance,policy_revision, \ + enrollment_evidence_digest,birth_history_id,creation_operation_id, \ + creation_request_fingerprint) \ + VALUES ($1,$2,$3,$4,$5,$6,1,1,2,1,$7,$8,$9,$10) RETURNING binding_version", + ) + .bind(community.as_uuid()) + .bind(binding_id) + .bind(issuer) + .bind(subject) + .bind([75_u8; 32].as_slice()) + .bind(actor_bytes.as_slice()) + .bind([76_u8; 32].as_slice()) + .bind(history_id) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .fetch_one(&mut *transaction) + .await + .expect("insert bridge binding"); + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id,history_id,transition_kind,outcome_code,successor_binding_id, \ + successor_binding_version,successor_lifecycle_revision,successor_state, \ + operation_id,request_fingerprint,transition_digest) \ + VALUES ($1,$2,1,1,$3,$4,1,1,$5,$6,$7)", + ) + .bind(community.as_uuid()) + .bind(history_id) + .bind(binding_id) + .bind(binding_version) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind([77_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert bridge binding history"); + sqlx::query( + "INSERT INTO relay_members (community_id,pubkey,role,added_by) \ + VALUES ($1,$2,'owner',NULL)", + ) + .bind(community.as_uuid()) + .bind(actor.to_hex()) + .execute(&mut *transaction) + .await + .expect("insert bridge moderation owner"); + transaction + .commit() + .await + .expect("commit bridge binding fixture"); + } + + #[tokio::test] + #[ignore = "requires disposable PostgreSQL and Redis"] + async fn live_enforce_bridge_event_co_commits_and_replays_without_legacy_redis_mutation() { + use axum::body::{to_bytes, Body}; + use axum::http::{header, Request}; + use base64::Engine 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 + .rsplit_once('/') + .map(|(prefix, _)| format!("{prefix}/postgres")) + .expect("test database URL has a database name"); + let admin = sqlx::PgPool::connect(&admin_url) + .await + .expect("connect PostgreSQL admin database"); + let stale_databases: Vec = sqlx::query_scalar( + "SELECT datname FROM pg_database WHERE datname LIKE 'buzz_reachability_%'", + ) + .fetch_all(&admin) + .await + .expect("list stale disposable bridge databases"); + for stale_database in stale_databases { + let suffix = stale_database + .strip_prefix("buzz_reachability_") + .expect("query prefix is exact"); + if suffix.len() != 32 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{stale_database}\" WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop stale disposable bridge database"); + } + let database_name = format!("buzz_reachability_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE DATABASE \"{database_name}\"" + ))) + .execute(&admin) + .await + .expect("create disposable bridge database"); + let database_url = TEST_DB_URL + .rsplit_once('/') + .map(|(prefix, _)| format!("{prefix}/{database_name}")) + .expect("derive disposable bridge database URL"); + + let actor = Keys::generate(); + let subject = format!("bridge-user-{}", uuid::Uuid::new_v4()); + let jwk = crate::corporate_identity::canonical_test_support::jwk(0, KID); + let jwks = serde_json::to_vec(&jsonwebtoken::jwk::JwkSet { keys: vec![jwk] }) + .expect("serialize bridge JWKS"); + let runtime_config = crate::authorization_runtime::ProviderFreeRuntimeConfig::from_optional_json(Some( + r#"{ + "issuer":"https://bridge-issuer.example", + "audience":"buzz-bridge-test", + "subject_claim":"sub", + "event_author_claim":"event_author", + "maximum_token_lifetime_seconds":600, + "jwks":{"jwks_uri":"https://bridge-issuer.example/keys"}, + "lease":{"maximum_seconds":300}, + "policy_revision":1, + "audit":{"max_events_per_domain":32,"max_bytes_per_domain":2097152,"max_envelope_bytes":16384}, + "client_status_admission":{"max_presentations_per_domain":32,"max_presentations_per_actor":8,"max_presentations_per_peer":8}, + "transport":{"kind":"sealed_test_transport"}, + "enrollment":{"kind":"canonical_admission"}, + "restore":{"kind":"operation_manifest"} + }"#, + )) + .expect("parse bridge runtime config"); + let enforce = runtime_config.enforce().expect("enforce config"); + let verifier = Arc::new( + crate::authorization_runtime::DynamicVerifier::new( + enforce.verifier_policy().clone(), + enforce.issuer().to_owned(), + enforce.jwks_source().clone(), + crate::authorization_runtime::JwksRefreshPolicy::new( + 64 * 1024, + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(300), + ) + .expect("bridge refresh policy"), + Arc::new(StaticBridgeJwksLoader(jwks)), + ) + .expect("bridge dynamic verifier"), + ); + let snapshot = verifier + .refresh(chrono::Utc::now()) + .await + .expect("publish bridge JWKS"); + let runtime = + crate::authorization_runtime::InstalledAuthorizationRuntime::for_canonical_assertion_test( + verifier, + snapshot, + ); + let (state, pool) = build_bridge_handler_test_state( + false, + None, + Some((runtime_config, runtime)), + Some(&database_url), + ) + .await + .expect("build live canonical bridge state"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("run bridge migrations"); + let host = format!("bridge-canonical-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create bridge community") + .id; + install_bridge_binding(&pool, community, actor.public_key(), ISSUER, &subject).await; + + let event = EventBuilder::new(Kind::TextNote, "canonical bridge event") + .sign_with_keys(&actor) + .expect("sign bridge event"); + let body = serde_json::to_vec(&event).expect("serialize bridge event"); + let expected_url = format!("https://{host}/events"); + let payload = hex::encode(Sha256::digest(&body)); + let authorization_event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", expected_url.as_str()]).expect("bridge u tag"), + Tag::parse(["method", "POST"]).expect("bridge method tag"), + Tag::parse(["payload", payload.as_str()]).expect("bridge payload tag"), + ]) + .sign_with_keys(&actor) + .expect("sign bridge NIP-98 event"); + let authorization_json = + serde_json::to_string(&authorization_event).expect("serialize NIP-98 event"); + let authorization = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(authorization_json.as_bytes()) + ); + let now = chrono::Utc::now().timestamp(); + let assertion = crate::corporate_identity::canonical_test_support::signed_jwt( + &serde_json::json!({ + "iss": ISSUER, + "aud": AUDIENCE, + "sub": subject, + "event_author": actor.public_key().to_hex(), + "iat": now - 1, + "nbf": now - 1, + "exp": now + 300, + }), + 0, + KID, + ); + let assertion_header = state.config.corporate_identity.jwt_header.clone(); + let request = || { + Request::builder() + .method("POST") + .uri("/events") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, &authorization) + .header(&assertion_header, &assertion) + .body(Body::from(body.clone())) + .expect("build canonical bridge request") + }; + let first = crate::router::build_router(state.clone()) + .oneshot(request()) + .await + .expect("first bridge response"); + let first_status = first.status(); + let first_body = to_bytes(first.into_body(), 64 * 1024) + .await + .expect("read first bridge response"); + assert_eq!( + first_status, + StatusCode::OK, + "unexpected bridge response: {}", + String::from_utf8_lossy(&first_body) + ); + let second = crate::router::build_router(state.clone()) + .oneshot(request()) + .await + .expect("replayed bridge response"); + assert_eq!(second.status(), StatusCode::OK); + let second_body = to_bytes(second.into_body(), 64 * 1024) + .await + .expect("read replayed bridge response"); + assert_eq!(first_body, second_body); + + let stored: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE community_id=$1 AND id=$2") + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count stored bridge event"); + assert_eq!(stored, 1); + let canonical_results: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=7 AND object_key=$2 \ + AND application_code=1", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count canonical bridge result"); + assert_eq!(canonical_results, 1); + + let moderation_target = Keys::generate().public_key(); + let moderation_event = EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_MODERATION_BAN as u16), + "", + ) + .tag(Tag::parse(["p", moderation_target.to_hex().as_str()]).expect("moderation target tag")) + .sign_with_keys(&actor) + .expect("sign bridge moderation command"); + let moderation_body = + serde_json::to_vec(&moderation_event).expect("serialize moderation command"); + let moderation_payload = hex::encode(Sha256::digest(&moderation_body)); + let moderation_authorization_event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", expected_url.as_str()]).expect("moderation u tag"), + Tag::parse(["method", "POST"]).expect("moderation method tag"), + Tag::parse(["payload", moderation_payload.as_str()]) + .expect("moderation payload tag"), + ]) + .sign_with_keys(&actor) + .expect("sign moderation NIP-98 event"); + let moderation_authorization = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode( + serde_json::to_string(&moderation_authorization_event) + .expect("serialize moderation NIP-98 event") + .as_bytes() + ) + ); + let moderation_request = || { + Request::builder() + .method("POST") + .uri("/events") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, &moderation_authorization) + .header(&assertion_header, &assertion) + .body(Body::from(moderation_body.clone())) + .expect("build canonical moderation request") + }; + let first_moderation = crate::router::build_router(state.clone()) + .oneshot(moderation_request()) + .await + .expect("first bridge moderation response"); + assert_eq!(first_moderation.status(), StatusCode::OK); + let first_moderation_body = to_bytes(first_moderation.into_body(), 64 * 1024) + .await + .expect("read first moderation response"); + let replayed_moderation = crate::router::build_router(state.clone()) + .oneshot(moderation_request()) + .await + .expect("replayed bridge moderation response"); + assert_eq!(replayed_moderation.status(), StatusCode::OK); + let replayed_moderation_body = to_bytes(replayed_moderation.into_body(), 64 * 1024) + .await + .expect("read replayed moderation response"); + assert_eq!(first_moderation_body, replayed_moderation_body); + let moderation_actions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM moderation_actions WHERE community_id=$1 \ + AND target_pubkey=$2 AND action='ban'", + ) + .bind(community.as_uuid()) + .bind(moderation_target.to_bytes().as_slice()) + .fetch_one(&pool) + .await + .expect("count co-committed moderation actions"); + assert_eq!(moderation_actions, 1); + let moderation_results: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=5", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count canonical moderation result"); + assert_eq!(moderation_results, 1); + + let invite_body = serde_json::to_vec(&serde_json::json!({ + "ttl_secs": 3600, + "max_uses": 2, + })) + .expect("serialize invite mint request"); + let invite_url = format!("https://{host}/api/invites"); + let invite_payload = hex::encode(Sha256::digest(&invite_body)); + let invite_authorization_event = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", invite_url.as_str()]).expect("invite u tag"), + Tag::parse(["method", "POST"]).expect("invite method tag"), + Tag::parse(["payload", invite_payload.as_str()]).expect("invite payload tag"), + ]) + .sign_with_keys(&actor) + .expect("sign invite NIP-98 event"); + let invite_authorization = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode( + serde_json::to_string(&invite_authorization_event) + .expect("serialize invite NIP-98 event") + .as_bytes() + ) + ); + let invite_request = || { + Request::builder() + .method("POST") + .uri("/api/invites") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, &invite_authorization) + .header(&assertion_header, &assertion) + .body(Body::from(invite_body.clone())) + .expect("build canonical invite request") + }; + let first_invite = crate::router::build_router(state.clone()) + .oneshot(invite_request()) + .await + .expect("first invite mint response"); + assert_eq!(first_invite.status(), StatusCode::OK); + let first_invite_body = to_bytes(first_invite.into_body(), 64 * 1024) + .await + .expect("read first invite response"); + let replayed_invite = crate::router::build_router(state.clone()) + .oneshot(invite_request()) + .await + .expect("replayed invite mint response"); + assert_eq!(replayed_invite.status(), StatusCode::OK); + let replayed_invite_body = to_bytes(replayed_invite.into_body(), 64 * 1024) + .await + .expect("read replayed invite response"); + assert_eq!(first_invite_body, replayed_invite_body); + let invite_rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_invites WHERE community_id=$1 AND created_by=$2", + ) + .bind(community.as_uuid()) + .bind(actor.public_key().to_hex()) + .fetch_one(&pool) + .await + .expect("count co-committed invite rows"); + assert_eq!(invite_rows, 1); + let invite_results: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=9 AND application_code=1", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count canonical invite results"); + assert_eq!(invite_results, 1); + + // Drive the real AUTH owner with an opaque peer and opted-in status + // scope. This proves the transport peer survives canonical AUTH into + // the frozen connection context and that live Enforce AUTH activates + // status rather than merely leaving the helper reachable in tests. + let challenge = format!("bridge-auth-{}", uuid::Uuid::new_v4()); + let relay_url = format!("wss://{host}"); + let auth_event = EventBuilder::auth( + &challenge, + nostr::RelayUrl::parse(&relay_url).expect("parse AUTH 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 binding-status scope"), + ) + .sign_with_keys(&actor) + .expect("sign canonical AUTH event"); + let authenticated_peer = buzz_auth::AuthenticatedClientPeer::for_test([0x92; 32]); + let evidence = buzz_auth::SealedTransportEvidence::for_test( + community, + assertion.clone(), + b"GET", + host.as_bytes(), + b"/", + [0; 32], + buzz_auth::ProofTransport::Nip42, + chrono::Utc::now() + chrono::Duration::seconds(300), + authenticated_peer, + ); + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(16); + let (ctrl_tx, mut ctrl_rx) = tokio::sync::mpsc::channel(8); + let status_writer = test_status_writer(send_tx.clone()); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: TenantContext::resolved(community, &host), + corporate_identity_jwt: None, + 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: challenge.clone(), + }), + status_scope: tokio::sync::RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx, + status_writer, + ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + }); + crate::handlers::auth::handle_auth(auth_event, Arc::clone(&conn), Arc::clone(&state)).await; + let mut frames = Vec::new(); + while let Ok(frame) = send_rx.try_recv() { + frames.push(format!("{frame:?}")); + } + let mut control_frames = Vec::new(); + while let Ok(frame) = ctrl_rx.try_recv() { + control_frames.push(format!("{frame:?}")); + } + let status_results: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_kind=8", + ) + .bind(community.as_uuid()) + .fetch_one(&pool) + .await + .expect("count canonical AUTH status results"); + { + let auth = conn.auth_state.read().await; + let crate::connection::AuthState::Authenticated(context) = &*auth else { + panic!( + "canonical AUTH did not authenticate the connection: status_results={status_results} data={frames:?} control={control_frames:?}" + ); + }; + assert_eq!( + context.authenticated_client_peer(), + Some(&authenticated_peer) + ); + assert_eq!(context.authorization().pubkey, actor.public_key()); + } + assert_eq!(status_results, 1); + assert!(conn.canonical_authorization.read().await.is_some()); + let bootstrap_index = frames + .iter() + .position(|frame| { + frame.contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_BOOTSTRAP_SUB_ID) + }) + .expect("real AUTH must deliver the status bootstrap"); + let current_index = frames + .iter() + .position(|frame| { + frame.contains(buzz_core::client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID) + }) + .expect("real AUTH must deliver authoritative current status"); + let acknowledgement_index = frames + .iter() + .position(|frame| frame.contains("true")) + .expect("real AUTH must acknowledge after activation"); + assert!( + bootstrap_index < current_index && current_index < acknowledgement_index, + "AUTH ordering must be bootstrap, authoritative current, then success: {frames:?}" + ); + assert!(control_frames.is_empty()); + assert!(conn.clear_client_binding_status_task().await); + conn.cancel.cancel(); + + // 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 failed_actor = Keys::generate(); + let failed_subject = format!("bridge-status-failure-{}", uuid::Uuid::new_v4()); + let failed_host = format!( + "bridge-status-failure-{}.local", + uuid::Uuid::new_v4().simple() + ); + let failed_community = state + .db + .ensure_configured_community(&failed_host) + .await + .expect("create failed-status community") + .id; + install_bridge_binding( + &pool, + failed_community, + failed_actor.public_key(), + ISSUER, + &failed_subject, + ) + .await; + let failed_challenge = format!("bridge-auth-failure-{}", uuid::Uuid::new_v4()); + let failed_relay_url = format!("wss://{failed_host}"); + let failed_auth_event = EventBuilder::auth( + &failed_challenge, + nostr::RelayUrl::parse(&failed_relay_url).expect("parse failed AUTH 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 failed binding-status scope"), + ) + .sign_with_keys(&failed_actor) + .expect("sign failing canonical AUTH event"); + let failed_now = chrono::Utc::now().timestamp(); + let failed_assertion = crate::corporate_identity::canonical_test_support::signed_jwt( + &serde_json::json!({ + "iss": ISSUER, + "aud": AUDIENCE, + "sub": failed_subject, + "event_author": failed_actor.public_key().to_hex(), + "iat": failed_now - 1, + "nbf": failed_now - 1, + "exp": failed_now + 300, + "nonce": uuid::Uuid::new_v4().to_string(), + }), + 0, + KID, + ); + let failed_evidence = buzz_auth::SealedTransportEvidence::for_test( + failed_community, + failed_assertion, + b"GET", + failed_host.as_bytes(), + b"/", + [0; 32], + buzz_auth::ProofTransport::Nip42, + chrono::Utc::now() + chrono::Duration::seconds(300), + authenticated_peer, + ); + let (failed_send_tx, mut failed_send_rx) = tokio::sync::mpsc::channel(16); + let (failed_ctrl_tx, mut failed_ctrl_rx) = tokio::sync::mpsc::channel(8); + let failed_status_writer = test_status_writer(failed_send_tx.clone()); + let failed_conn = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant: TenantContext::resolved(failed_community, &failed_host), + corporate_identity_jwt: None, + canonical_transport_evidence: tokio::sync::Mutex::new(Some(failed_evidence)), + canonical_authorization: tokio::sync::RwLock::new(None), + auth_state: tokio::sync::RwLock::new(crate::connection::AuthState::Pending { + challenge: failed_challenge, + }), + status_scope: tokio::sync::RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + send_tx: failed_send_tx, + status_writer: failed_status_writer, + ctrl_tx: failed_ctrl_tx, + cancel: tokio_util::sync::CancellationToken::new(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + }); + crate::handlers::auth::handle_auth( + failed_auth_event, + Arc::clone(&failed_conn), + Arc::clone(&state), + ) + .await; + let mut failed_frames = Vec::new(); + while let Ok(frame) = failed_send_rx.try_recv() { + failed_frames.push(format!("{frame:?}")); + } + let mut failed_control_frames = Vec::new(); + while let Ok(frame) = failed_ctrl_rx.try_recv() { + failed_control_frames.push(format!("{frame:?}")); + } + assert!( + failed_frames.iter().all(|frame| !frame.contains("true")), + "failed PG status evidence must precede and suppress AUTH success: {failed_frames:?}" + ); + assert!( + failed_frames.iter().any(|frame| frame.contains( + buzz_core::client_binding_bootstrap::CLIENT_BINDING_BOOTSTRAP_SUB_ID + )), + "the negative case must reach live status activation before PG evidence fails: {failed_frames:?}" + ); + assert!( + failed_frames + .iter() + .chain(failed_control_frames.iter()) + .any(|frame| frame.contains("false")), + "failed activation must send a negative AUTH result: data={failed_frames:?} control={failed_control_frames:?}" + ); + assert!(matches!( + &*failed_conn.auth_state.read().await, + crate::connection::AuthState::Failed + )); + 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 tenant = TenantContext::resolved(community, &host); + let rate_key = buzz_auth::rate_limit::rate_limit_key( + &tenant, + &actor.public_key(), + &buzz_auth::LimitType::ApiCalls, + ); + let replay_key = buzz_auth::nip98_replay_key(&tenant, &authorization_event.id); + let moderation_replay_key = + buzz_auth::nip98_replay_key(&tenant, &moderation_authorization_event.id); + let invite_replay_key = + buzz_auth::nip98_replay_key(&tenant, &invite_authorization_event.id); + let mut redis = state + .redis_pool + .get() + .await + .expect("borrow live bridge Redis"); + let legacy_keys: i64 = redis::cmd("EXISTS") + .arg(&[ + rate_key, + replay_key, + moderation_replay_key, + invite_replay_key, + ]) + .query_async(&mut *redis) + .await + .expect("read legacy bridge Redis keys"); + assert_eq!(legacy_keys, 0); + + drop(redis); + drop(state); + pool.close().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{database_name}\" WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop exact disposable bridge database"); } #[test] @@ -3610,6 +5046,87 @@ mod tests { ); } + #[test] + fn repeated_moderation_reads_are_bounded_before_identity_binding_work() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let Some(state) = rt.block_on(bridge_handler_test_state_with_rate_limit(false, Some(1))) + else { + return; + }; + let host = format!( + "bridge-moderation-limit-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure moderation limit community") + .id; + let keys = Keys::generate(); + let pubkey = keys.public_key().to_bytes(); + assert!(rt + .block_on( + state + .db + .get_active_identity_binding_by_pubkey(community, &pubkey) + ) + .expect("read initial identity binding") + .is_none()); + + let signed_url = format!("https://{host}/moderation/reports"); + let event_json = build_nip98_event_json(&keys, &signed_url, "GET"); + let auth = nip98_auth_headers(&event_json) + .get(header::AUTHORIZATION) + .cloned() + .expect("authorization header"); + let request = || { + Request::builder() + .method("GET") + .uri("/moderation/reports") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, auth.clone()) + .body(Body::empty()) + .expect("build moderation request") + }; + + let first = rt + .block_on(crate::router::build_router(state.clone()).oneshot(request())) + .expect("first moderation response"); + assert_eq!( + first.status(), + StatusCode::FORBIDDEN, + "the first request must pass admission and reach moderator authorization" + ); + + let second = rt + .block_on(crate::router::build_router(state.clone()).oneshot(request())) + .expect("second moderation response"); + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + second + .headers() + .get("x-buzz-error-code") + .and_then(|value| value.to_str().ok()), + Some("rate_limited") + ); + assert!( + rt.block_on( + state + .db + .get_active_identity_binding_by_pubkey(community, &pubkey) + ) + .expect("read final identity binding") + .is_none(), + "bounded moderation reads must not enroll or persist an identity binding" + ); + } + /// Drive a single POST /events request through the router and return the /// HTTP status code. async fn post_events( diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index cd174e5f7ed..b1fbbe32ab6 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -201,7 +201,6 @@ impl fmt::Debug for StagedGitPublication { } } -#[allow(dead_code)] // Accessed by the pending S5 publication transaction adapter. impl StagedGitPublication { /// Server-resolved authorization domain bound to this publication. pub const fn community_id(&self) -> CommunityId { @@ -242,6 +241,14 @@ impl StagedGitPublication { pub const fn result_digest(&self) -> [u8; 32] { self.result_digest } + + /// Consume a canonically committed publication for downstream event derivation. + pub(crate) fn into_success(self) -> CasSuccess { + CasSuccess { + manifest: self.manifest, + manifest_key: self.manifest_key, + } + } } /// Best-effort result of refreshing the non-authoritative raw pointer cache. @@ -1096,7 +1103,6 @@ pub async fn cas_publish( /// /// The returned digest is suitable for the canonical protected-operation /// receipt. This function never reads or writes the raw repository pointer. -#[allow(dead_code)] // Activated only after S5 freezes the joined publication seam. pub(crate) async fn stage_git_publication( store: &GitStore, ctx: &TenantContext, @@ -1374,7 +1380,6 @@ async fn stage_git_publication_inner( /// domain, owner, and repository target and committed that target-bound digest /// as the canonical PostgreSQL witness. They must tolerate /// [`PointerCacheUpdate::Stale`]. -#[allow(dead_code)] // Activated only after canonical DB publication succeeds. pub(crate) async fn publish_pointer_cache( store: &GitStore, staged: &StagedGitPublication, diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 6e6376e64ee..aad18f72301 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -29,15 +29,25 @@ use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; use super::binding::{resolve_repo_binding, RepoBinding}; -use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits}; +use super::cas_publish::{ + cas_publish, publish_pointer_cache, stage_git_publication, CasError, ParentState, PublishLimits, +}; use super::hook::install_hook; use super::hydrate::{ hydrate_for_read, hydrate_for_write, load_manifest_for_read, HydrateError, HydratedRepo, HydrationOptions, }; use super::manifest_event::{build_ref_state_event, RefStateInputs}; +use crate::api::media::{ + ProtectedPublicationBinding, ProtectedPublicationPlan, ProtectedPublicationReceipt, + ProtectedPublicationTarget, +}; use crate::state::AppState; +use buzz_auth::RouteCapability; use buzz_core::TenantContext; +use buzz_db::authorization_admission::{ + AdmissionCommitError, AdmissionCommitRequest, CanonicalAdmissionCommitter, +}; /// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`). const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); @@ -76,7 +86,12 @@ pub struct GitAuth { pub tenant: TenantContext, /// Cryptographically verified identity staged until repository policy /// authorization succeeds. - identity_proof: crate::corporate_identity::CorporateIdentityProof, + identity_proof: Option, + canonical_assertion: Option, + auth_tag: Option, + event_json: String, + event_method: String, + expected_url: String, } impl axum::extract::FromRequestParts> for GitAuth { @@ -144,6 +159,9 @@ impl axum::extract::FromRequestParts> for GitAuth { .unwrap_or(parts.uri.path()), ) .ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::DenyProtected { + return Err((StatusCode::UNAUTHORIZED, "protected route unavailable").into_response()); + } // Repo-root URL verification. // @@ -214,53 +232,84 @@ impl axum::extract::FromRequestParts> for GitAuth { .get("x-auth-tag") .and_then(|value| value.to_str().ok()); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - &parts.headers, - &state.config.corporate_identity, - ); - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - state, - tenant.community(), - pubkey, - identity_jwt.as_deref(), - auth_tag, - ) - .await - { - Ok(proof) => proof, - Err(e) => { - warn!(pubkey = %pubkey.to_hex(), error = %e, "git: corporate identity denied"); - return Err((e.status_code(), e.public_message()).into_response()); + let (identity_proof, canonical_assertion) = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + &parts.headers, + &state.config.corporate_identity, + ); + let proof = crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + .map_err(|error| { + warn!(pubkey = %pubkey.to_hex(), error = %error, "git: corporate identity denied"); + (error.status_code(), error.public_message()).into_response() + })?; + (Some(proof), None) + } + buzz_auth::NipFiMode::Enforce => { + let assertion = crate::protected_ingress::exact_assertion( + &parts.headers, + state.config.corporate_identity.jwt_header.as_str(), + ) + .map_err(map_protected_git_error)?; + (None, Some(assertion)) + } + buzz_auth::NipFiMode::DenyProtected => { + return Err( + (StatusCode::UNAUTHORIZED, "protected route unavailable").into_response() + ); } }; - if crate::api::relay_members::enforce_relay_membership( - state, - tenant.community(), - pubkey.as_bytes(), - auth_tag, - ) - .await - .is_err() - { - warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); - return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); - } - deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; - Ok(GitAuth { pubkey, tenant, identity_proof, + canonical_assertion, + auth_tag: auth_tag.map(str::to_owned), + event_json, + event_method, + expected_url, }) } } +async fn enforce_git_membership_and_ban(state: &AppState, auth: &GitAuth) -> Result<(), Response> { + if crate::api::relay_members::enforce_relay_membership( + state, + auth.tenant.community(), + auth.pubkey.as_bytes(), + auth.auth_tag.as_deref(), + ) + .await + .is_err() + { + warn!(pubkey = %auth.pubkey.to_hex(), "git: relay membership denied"); + return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); + } + deny_banned_git_principal( + &state.db, + auth.tenant.community(), + &auth.pubkey, + auth.auth_tag.as_deref(), + ) + .await +} + async fn finalize_git_corporate_identity(state: &AppState, auth: &GitAuth) -> Result<(), Response> { + let proof = auth.identity_proof.clone().ok_or_else(|| { + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + })?; crate::corporate_identity::finalize_corporate_identity( state, auth.tenant.community(), auth.pubkey, - auth.identity_proof.clone(), + proof, ) .await .map(|_| ()) @@ -270,6 +319,181 @@ async fn finalize_git_corporate_identity(state: &AppState, auth: &GitAuth) -> Re }) } +async fn authorize_git_read_admission( + state: &AppState, + auth: &GitAuth, + owner: &str, + repo: &str, + repo_name: &str, +) -> Result<(), Response> { + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + enforce_git_membership_and_ban(state, auth).await?; + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + owner, + repo_name, + ) + .await?; + finalize_git_corporate_identity(state, auth).await + } + buzz_auth::NipFiMode::Enforce => { + let (coordinates, assertion, proof) = + canonical_git_authorization(state, auth, owner, repo, RouteCapability::GitRead) + .await?; + crate::protected_ingress::authorize_read(state, coordinates, assertion, proof) + .await + .map_err(map_protected_git_error)?; + enforce_git_membership_and_ban(state, auth).await?; + authorize_git_read( + &state.db, + auth.tenant.community(), + &auth.pubkey, + owner, + repo_name, + ) + .await + } + buzz_auth::NipFiMode::DenyProtected => { + Err((StatusCode::UNAUTHORIZED, "protected route unavailable").into_response()) + } + } +} + +async fn prepare_git_write_admission( + state: &AppState, + auth: &GitAuth, + owner: &str, + repo: &str, +) -> Result, Response> { + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + enforce_git_membership_and_ban(state, auth).await?; + Ok(None) + } + buzz_auth::NipFiMode::Enforce => { + let (coordinates, assertion, proof) = + canonical_git_authorization(state, auth, owner, repo, RouteCapability::GitWrite) + .await?; + let request = + crate::protected_ingress::prepare_mutation(state, coordinates, assertion, proof) + .await + .map_err(map_protected_git_error)?; + enforce_git_membership_and_ban(state, auth).await?; + Ok(Some(request)) + } + buzz_auth::NipFiMode::DenyProtected => { + Err((StatusCode::UNAUTHORIZED, "protected route unavailable").into_response()) + } + } +} + +async fn canonical_git_authorization( + state: &AppState, + auth: &GitAuth, + owner: &str, + repo: &str, + capability: RouteCapability, +) -> Result< + ( + crate::protected_ingress::ProtectedRequestCoordinates, + buzz_auth::VerifiedFederatedAssertion, + buzz_auth::VerifiedNostrProof, + ), + Response, +> { + let target = ProtectedPublicationTarget::repository(auth.tenant.community(), owner, repo) + .map_err(map_canonical_git_error)?; + let event_digest = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:git-session-event:v1", + &[auth.event_json.as_bytes()], + ); + let canonical_repo = match repo.strip_suffix(".git") { + Some(repo) => repo, + None => repo, + }; + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:git-session-request:v1", + &[ + auth.tenant.community().as_uuid().as_bytes(), + owner.as_bytes(), + canonical_repo.as_bytes(), + auth.pubkey.as_bytes(), + &event_digest, + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:git-session-transport:v1", + &[ + auth.tenant.community().as_uuid().as_bytes(), + auth.tenant.host().as_bytes(), + auth.expected_url.as_bytes(), + &event_digest, + ], + ); + let ingress = match capability { + RouteCapability::GitRead => crate::authorization_runtime::ProtectedIngress::GitRead, + RouteCapability::GitWrite => crate::authorization_runtime::ProtectedIngress::GitWrite, + _ => return Err((StatusCode::FORBIDDEN, "authorization denied").into_response()), + }; + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + ingress, + auth.tenant.community(), + capability, + target.admission_object(), + buzz_auth::ProofTransport::GitSmartHttpSession, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_git_error)?; + let assertion_token = auth.canonical_assertion.as_deref().ok_or_else(|| { + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + })?; + let assertion = crate::protected_ingress::verify_assertion(state, assertion_token, coordinates) + .await + .map_err(map_protected_git_error)?; + let proof = buzz_auth::verify_nip98_authorization_proof( + &auth.event_json, + &auth.expected_url, + &auth.event_method, + None, + &assertion, + buzz_auth::ProofTransport::GitSmartHttpSession, + request_fingerprint, + *target.admission_object().key(), + transport_context_fingerprint, + ) + .map_err(|_| (StatusCode::UNAUTHORIZED, "authorization denied").into_response())?; + Ok((coordinates, assertion, proof)) +} + +fn map_protected_git_error(error: crate::protected_ingress::ProtectedIngressError) -> Response { + match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + (StatusCode::UNAUTHORIZED, "authorization denied").into_response() + } + crate::protected_ingress::ProtectedIngressError::Expired => { + (StatusCode::UNAUTHORIZED, error.code()).into_response() + } + crate::protected_ingress::ProtectedIngressError::Unavailable => { + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + } + } +} + +fn map_canonical_git_error(error: AdmissionCommitError) -> Response { + match error { + AdmissionCommitError::DependencyUnavailable + | AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable => { + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + } + _ => (StatusCode::UNAUTHORIZED, "authorization denied").into_response(), + } +} + /// Deny banned principals on every Git HTTP request. /// /// Git runs outside the WebSocket authentication path, so a valid NIP-98 @@ -801,15 +1025,7 @@ pub async fn info_refs( // SEC-005: channel-membership gate before any manifest load, hydration, // or subprocess work. Both services — the receive-pack advertisement // leaks the ref list just like the upload-pack one. - authorize_git_read( - &state.db, - auth.tenant.community(), - &auth.pubkey, - ¶ms.owner, - repo_name, - ) - .await?; - finalize_git_corporate_identity(&state, &auth).await?; + authorize_git_read_admission(&state, &auth, ¶ms.owner, ¶ms.repo, repo_name).await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, @@ -1058,15 +1274,7 @@ pub async fn upload_pack( // authorization cannot stand in for POST-time membership — gate this // door independently, before body decode work is driven or hydration // starts. - authorize_git_read( - &state.db, - auth.tenant.community(), - &auth.pubkey, - ¶ms.owner, - repo_name, - ) - .await?; - finalize_git_corporate_identity(&state, &auth).await?; + authorize_git_read_admission(&state, &auth, ¶ms.owner, ¶ms.repo, repo_name).await?; let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; @@ -1140,6 +1348,8 @@ pub async fn receive_pack( body: Body, ) -> Result { let repo_name = validate_repo_id(¶ms.owner, ¶ms.repo)?; + let canonical_admission = + prepare_git_write_admission(&state, &auth, ¶ms.owner, ¶ms.repo).await?; let body = decode_git_request_body(&headers, body, state.config.git_max_pack_bytes); let pusher_hex = hex::encode(auth.pubkey.to_bytes()); let _permit = acquire_git_permit(&state, "receive_pack")?; @@ -1228,6 +1438,7 @@ pub async fn receive_pack( pusher: auth.pubkey, tenant: auth.tenant, identity_proof: auth.identity_proof, + canonical_admission, repo_handle: repo, }; Ok(finalize_push(&state, ctx).await) @@ -1820,13 +2031,56 @@ pub(crate) struct PushContext { /// any derived kind:30618 event from this push. pub tenant: TenantContext, /// Identity proof finalized only after the pre-receive policy hook accepts. - pub identity_proof: crate::corporate_identity::CorporateIdentityProof, + pub identity_proof: Option, + /// Prepared exact canonical authority, present only in Enforce mode. + pub canonical_admission: Option, /// The hydrated workspace handle. Held until response construction /// (which happens *after* `cas_publish` returns) so the tempdir /// outlives the receive-pack subprocess and the CAS publish. pub repo_handle: HydratedRepo, } +fn git_publication_error_response(owner: &str, repo: &str, error: CasError) -> Response { + match error { + CasError::Conflict { + winner_manifest_key, + .. + } => { + warn!( + owner, + repo, + winner = %winner_manifest_key, + "push lost publication race; returning 409" + ); + ( + StatusCode::CONFLICT, + "push superseded by a concurrent writer; pull and retry", + ) + .into_response() + } + CasError::ManifestInvalid(error) => { + warn!(owner, repo, error = %error, "push rejected: manifest validation failed"); + ( + StatusCode::BAD_REQUEST, + "push produced invalid manifest state", + ) + .into_response() + } + CasError::ResourceLimit(error) => { + warn!(owner, repo, error = %error, "push rejected: repository resource limit"); + ( + StatusCode::PAYLOAD_TOO_LARGE, + "repository exceeds relay resource limits", + ) + .into_response() + } + error => { + error!(owner, repo, error = %error, "push failed before response"); + (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response() + } + } +} + /// Finalize a push request: CAS-commit the new state into the object /// store, derive kind:30618 from the committed manifest, and only then /// build the success response. @@ -1867,96 +2121,105 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { return response; } - if let Err(error) = crate::corporate_identity::finalize_corporate_identity( - state, - ctx.tenant.community(), - ctx.pusher, - ctx.identity_proof.clone(), - ) - .await - { - warn!(pusher = %ctx.pusher.to_hex(), error = %error, "git: post-policy corporate identity finalization denied"); - return (error.status_code(), error.public_message()).into_response(); - } - - // Step 7 (CAS). The PushContext binds `parent_state` (observed at - // hydrate) to the CAS predicate here — no re-reading of the pointer - // between hydrate and CAS. - let success = match cas_publish( - &state.git_store, - &ctx.tenant, - ctx.repo_handle.path(), - &ctx.owner, - &ctx.repo, - &ctx.parent_state, - PublishLimits { - parent_hydrated_bytes: ctx.repo_handle.hydrated_bytes(), - max_pack_bytes: state.config.git_max_pack_bytes, - max_repo_bytes: state.config.git_max_repo_bytes, - }, - ) - .await - { - Ok(s) => s, - Err(CasError::Conflict { - winner_manifest_key, - .. - }) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - winner = %winner_manifest_key, - "push lost CAS race; tempdir dropped, returning 409" - ); - return ( - StatusCode::CONFLICT, - "push superseded by a concurrent writer; pull and retry", - ) - .into_response(); - } - Err(CasError::ManifestInvalid(e)) => { - // 4xx-class: the workspace produced refs/HEAD/oids the - // manifest validator rejects (unsafe refname, malformed oid, - // empty head, malformed parent). Pre-CAS — no pointer was - // written. - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: manifest validation failed" - ); - return ( - StatusCode::BAD_REQUEST, - "push produced invalid manifest state", - ) - .into_response(); + let limits = PublishLimits { + parent_hydrated_bytes: ctx.repo_handle.hydrated_bytes(), + max_pack_bytes: state.config.git_max_pack_bytes, + max_repo_bytes: state.config.git_max_repo_bytes, + }; + let success = if let Some(request) = ctx.canonical_admission { + let staged = match stage_git_publication( + &state.git_store, + &ctx.tenant, + &ctx.owner, + &ctx.repo, + ctx.repo_handle.path(), + &ctx.parent_state, + limits, + ) + .await + { + Ok(staged) => staged, + Err(error) => return git_publication_error_response(&ctx.owner, &ctx.repo, error), + }; + let plan = ProtectedPublicationPlan::from(staged); + let effect = match plan.application_effect() { + Ok(effect) => effect, + Err(error) => return map_canonical_git_error(error), + }; + let request = match request.with_application_effect(Box::new(effect)) { + Ok(request) => request, + Err(error) => return map_canonical_git_error(error), + }; + let committer = match crate::protected_ingress::mutation_committer(state) { + Ok(committer) => committer, + Err(error) => return map_protected_git_error(error), + }; + let outcome = match committer.commit(request).await { + Ok(outcome) => outcome, + Err(error) => return map_canonical_git_error(error), + }; + match ProtectedPublicationReceipt::bind(plan, &outcome) { + Ok(ProtectedPublicationBinding::Committed(receipt)) => match receipt.into_plan() { + ProtectedPublicationPlan::Git(staged) => { + match publish_pointer_cache(&state.git_store, &staged).await { + Ok(update) => { + info!( + owner = %ctx.owner, + repo = %ctx.repo, + cache_update = ?update, + "canonical Git publication committed" + ); + } + Err(error) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo, + error = %error, + "canonical Git pointer projection deferred to outbox" + ); + } + } + Some(staged.into_success()) + } + ProtectedPublicationPlan::Media(_) => { + return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error") + .into_response(); + } + }, + Ok(ProtectedPublicationBinding::ExactReplay(_)) => { + info!(owner = %ctx.owner, repo = %ctx.repo, "canonical Git publication replay"); + None + } + Err(error) => return map_canonical_git_error(error), } - Err(CasError::ResourceLimit(e)) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push rejected: repo exceeds relay resource limits" - ); - return ( - StatusCode::PAYLOAD_TOO_LARGE, - "repository exceeds relay resource limits", - ) - .into_response(); + } else { + let Some(identity_proof) = ctx.identity_proof.clone() else { + return (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response(); + }; + if let Err(error) = crate::corporate_identity::finalize_corporate_identity( + state, + ctx.tenant.community(), + ctx.pusher, + identity_proof, + ) + .await + { + warn!(pusher = %ctx.pusher.to_hex(), error = %error, "git: post-policy corporate identity finalization denied"); + return (error.status_code(), error.public_message()).into_response(); } - Err(e) => { - // 5xx-class: ManifestReadFailed (parent corruption), - // Backend, PackCapture. The tempdir drops on scope exit; no - // pointer was written (or, on rare ManifestReadFailed during - // winner-fetch, the winner is already installed and the - // loser's data is unrelated). - error!( - owner = %ctx.owner, - repo = %ctx.repo, - error = %e, - "push failed pre-response" - ); - return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error").into_response(); + match cas_publish( + &state.git_store, + &ctx.tenant, + ctx.repo_handle.path(), + &ctx.owner, + &ctx.repo, + &ctx.parent_state, + limits, + ) + .await + { + Ok(success) => Some(success), + Err(error) => return git_publication_error_response(&ctx.owner, &ctx.repo, error), } }; @@ -1977,69 +2240,71 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { // round-trip per pack-only push, which clients don't normally // generate. Tightening to refs+head equality is a future // micro-optimization only if that dedup cost becomes visible. - let parent_digest_str: Option<&str> = ctx.parent_state.parent_digest.as_deref(); - let after_digest = success.manifest_key.strip_prefix("manifests/"); - let manifest_changed = match (parent_digest_str, after_digest) { - (Some(before), Some(after)) => before != after, - _ => true, // first push (parent None) or impossible-shape after key → publish - }; - if manifest_changed { - let inputs = RefStateInputs { - repo_id: &ctx.repo_id, - head: &success.manifest.head, - refs: &success.manifest.refs, - actor_pubkey_hex: &hex::encode(ctx.pusher.to_bytes()), + if let Some(success) = success { + let parent_digest_str: Option<&str> = ctx.parent_state.parent_digest.as_deref(); + let after_digest = success.manifest_key.strip_prefix("manifests/"); + let manifest_changed = match (parent_digest_str, after_digest) { + (Some(before), Some(after)) => before != after, + _ => true, }; - match build_ref_state_event(&inputs, &state.relay_keypair) { - Ok(event) => { - // Relay-signed kind:30618 belongs to the same server-resolved - // tenant as the git request that committed the pointer. - match state - .db - .insert_event(ctx.tenant.community(), &event, None) - .await - { - Ok((stored, true)) => { - // Routed through the guarded send path for uniformity; - // the access gate no-ops for this globally-scoped - // (channel_id = None) ref-state event. - crate::handlers::event::fan_out_event_to_local_subscribers( - state, - ctx.tenant.community(), - &stored, - ) - .await; - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - manifest = %success.manifest_key, - "kind:30618 published (derived after CAS)" - ); - } - Ok((_, false)) => { - info!( - owner = %ctx.owner, - repo = %ctx.repo_id, - "kind:30618 deduplicated by relay db" - ); - } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 insert failed; push remains durable in object store" - ); + if manifest_changed { + let inputs = RefStateInputs { + repo_id: &ctx.repo_id, + head: &success.manifest.head, + refs: &success.manifest.refs, + actor_pubkey_hex: &hex::encode(ctx.pusher.to_bytes()), + }; + match build_ref_state_event(&inputs, &state.relay_keypair) { + Ok(event) => { + // Relay-signed kind:30618 belongs to the same server-resolved + // tenant as the git request that committed the pointer. + match state + .db + .insert_event(ctx.tenant.community(), &event, None) + .await + { + Ok((stored, true)) => { + // Routed through the guarded send path for uniformity; + // the access gate no-ops for this globally-scoped + // (channel_id = None) ref-state event. + crate::handlers::event::fan_out_event_to_local_subscribers( + state, + ctx.tenant.community(), + &stored, + ) + .await; + info!( + owner = %ctx.owner, + repo = %ctx.repo_id, + manifest = %success.manifest_key, + "kind:30618 published (derived after CAS)" + ); + } + Ok((_, false)) => { + info!( + owner = %ctx.owner, + repo = %ctx.repo_id, + "kind:30618 deduplicated by relay db" + ); + } + Err(e) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo_id, + error = %e, + "kind:30618 insert failed; push remains durable in object store" + ); + } } } - } - Err(e) => { - warn!( - owner = %ctx.owner, - repo = %ctx.repo_id, - error = %e, - "kind:30618 build failed; push remains durable in object store" - ); + Err(e) => { + warn!( + owner = %ctx.owner, + repo = %ctx.repo_id, + error = %e, + "kind:30618 build failed; push remains durable in object store" + ); + } } } } diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index f22c41eec8d..0a5b7f91fe0 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -21,13 +21,20 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{Html, Json}, }; +use hmac::{Hmac, KeyInit as _, Mac as _}; use serde::Deserialize; use serde_json::Value; +use sha2::Sha256; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; use buzz_core::invite::{ - hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, - MIN_INVITE_TTL_SECS, V2_PREFIX, + encode_v2_code, hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, + MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, V2_SECRET_LEN, +}; +use buzz_db::authorization_admission::{ + AdmissionApplicationContext, AdmissionApplicationEffect, AdmissionApplicationOutcome, + AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, + AdmissionCommitOutcome, AdmissionObjectKind, CanonicalAdmissionCommitter as _, }; use crate::invite_token; @@ -60,6 +67,129 @@ pub struct MintInviteRequest { pub max_uses: Option, } +#[derive(serde::Serialize, serde::Deserialize)] +struct CanonicalInviteMintResult { + invite_id: uuid::Uuid, + expires_at: i64, + max_uses: Option, + uses_remaining: Option, +} + +struct CanonicalInviteMintEffect { + token_hash: [u8; 32], + ttl_secs: u64, + max_uses: Option, + intent_digest: [u8; 32], +} + +impl CanonicalInviteMintEffect { + fn new(token_hash: [u8; 32], ttl_secs: u64, max_uses: Option) -> Self { + let max_uses_bytes = max_uses.unwrap_or_default().to_be_bytes(); + let intent_digest = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:invite-mint-application-intent:v1", + &[&token_hash, &ttl_secs.to_be_bytes(), &max_uses_bytes], + ); + Self { + token_hash, + ttl_secs, + max_uses, + intent_digest, + } + } +} + +impl std::fmt::Debug for CanonicalInviteMintEffect { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CanonicalInviteMintEffect([REDACTED])") + } +} + +impl AdmissionApplicationEffect for CanonicalInviteMintEffect { + fn intent_digest(&self) -> [u8; 32] { + self.intent_digest + } + + fn result_schema(&self) -> AdmissionApplicationResultSchema { + AdmissionApplicationResultSchema::invite_mint() + } + + fn apply<'a, 'transaction>( + &'a mut self, + transaction: &'a mut sqlx::Transaction<'transaction, sqlx::Postgres>, + context: &'a AdmissionApplicationContext<'a>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send + + 'a, + >, + > { + Box::pin(async move { + if context.authorization().capability() != buzz_auth::RouteCapability::InviteMint + || context.object().kind() != AdmissionObjectKind::Invitation + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let actor = context.authorization().actor_pubkey().to_hex(); + let role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id=$1 AND pubkey=$2", + ) + .bind(context.authorization_domain().as_uuid()) + .bind(&actor) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if !matches!(role.as_deref(), Some("owner" | "admin")) { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let ttl = + i64::try_from(self.ttl_secs).map_err(|_| AdmissionCommitError::InvalidRequest)?; + let expires_at = context + .authoritative_now() + .checked_add_signed(chrono::Duration::seconds(ttl)) + .ok_or(AdmissionCommitError::InvalidRequest)?; + let invite_id: uuid::Uuid = sqlx::query_scalar( + "INSERT INTO relay_invites \ + (community_id,token_hash,max_uses,expires_at,created_by) \ + VALUES ($1,$2,$3,$4,$5) RETURNING id", + ) + .bind(context.authorization_domain().as_uuid()) + .bind(self.token_hash.as_slice()) + .bind(self.max_uses) + .bind(expires_at) + .bind(&actor) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let payload = serde_json::to_vec(&CanonicalInviteMintResult { + invite_id, + expires_at: expires_at.timestamp(), + max_uses: self.max_uses, + uses_remaining: self.max_uses, + }) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let result = AdmissionApplicationResult::new(self.result_schema(), 1, payload)?; + let effect_digest = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:invite-mint-application-effect:v1", + &[ + context.authorization_domain().as_uuid().as_bytes(), + context.operation_id().as_bytes(), + context.request_fingerprint(), + &self.intent_digest, + result.payload(), + ], + ); + AdmissionApplicationOutcome::new(result, effect_digest) + }) + } +} + +struct CanonicalInviteMintResponse { + code: String, + result: CanonicalInviteMintResult, +} + fn validate_mint_request( request: &MintInviteRequest, ) -> Result<(u64, Option), (StatusCode, Json)> { @@ -284,6 +414,219 @@ async fn authenticate( Ok((tenant, pubkey, identity_proof)) } +async fn authenticate_mint( + state: &Arc, + headers: &HeaderMap, + body: &[u8], +) -> Result< + ( + buzz_core::TenantContext, + nostr::PublicKey, + Option, + ), + (StatusCode, Json), +> { + if state.config.nip_fi_mode == buzz_auth::NipFiMode::DenyProtected { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + )); + } + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + return authenticate(state, headers, "/api/invites", body) + .await + .map(|(tenant, pubkey, proof)| (tenant, pubkey, Some(proof))); + } + + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, "/api/invites"); + let (pubkey, _) = + bridge::verify_bridge_auth_with_options(headers, "POST", &url, Some(body), true, true)?; + Ok((tenant, pubkey, None)) +} + +fn canonical_invite_code( + relay_keys: &nostr::Keys, + domain: buzz_core::CommunityId, + mint_seed: uuid::Uuid, +) -> Result)> { + let key = invite_token::derive_invite_key(relay_keys); + let mut mac = Hmac::::new_from_slice(&key) + .map_err(|_| internal_error("canonical invite key unavailable"))?; + mac.update(b"buzz:canonical-invite-mint-secret:v1"); + mac.update(domain.as_uuid().as_bytes()); + mac.update(mint_seed.as_bytes()); + let secret: [u8; V2_SECRET_LEN] = mac.finalize().into_bytes().into(); + Ok(encode_v2_code(&secret)) +} + +async fn authorize_canonical_invite_mint( + state: &AppState, + tenant: &buzz_core::TenantContext, + headers: &HeaderMap, + body: &[u8], + pubkey: nostr::PublicKey, + ttl_secs: u64, + max_uses: Option, +) -> Result)> { + let domain = tenant.community(); + let target_key = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:invite-mint-target:v1", + &[domain.as_uuid().as_bytes(), pubkey.as_bytes()], + ); + let object = buzz_db::authorization_admission::AdmissionObject::new( + buzz_db::authorization_admission::AdmissionObjectKind::Invitation, + target_key, + ) + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_authorization_denied"))?; + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, tenant, "/api/invites"); + let body_digest = + crate::protected_ingress::fingerprint(b"buzz:nip-fi:invite-mint-body:v1", &[body]); + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:invite-mint-request:v1", + &[ + domain.as_uuid().as_bytes(), + pubkey.as_bytes(), + expected_url.as_bytes(), + &body_digest, + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:invite-mint-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + expected_url.as_bytes(), + b"POST", + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::InviteMint, + domain, + buzz_auth::RouteCapability::InviteMint, + object, + buzz_auth::ProofTransport::Nip98, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_invite_authorization_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + &state.config.corporate_identity.jwt_header, + ) + .map_err(map_invite_authorization_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_invite_authorization_error)?; + let event_json = bridge::exact_nip98_authorization_event(headers) + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid invite authorization"))?; + let proof = buzz_auth::verify_nip98_authorization_proof( + &event_json, + &expected_url, + "POST", + Some(body), + &assertion, + buzz_auth::ProofTransport::Nip98, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + ) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid invite authorization"))?; + let request = crate::protected_ingress::prepare_mutation(state, coordinates, assertion, proof) + .await + .map_err(map_invite_authorization_error)?; + let code = canonical_invite_code(&state.relay_keypair, domain, request.operation_id())?; + let effect = CanonicalInviteMintEffect::new(hash_v2_code(&code), ttl_secs, max_uses); + let expected_intent = effect.intent_digest(); + let request = request + .with_application_effect(Box::new(effect)) + .map_err(map_canonical_invite_commit_error)?; + let committer = crate::protected_ingress::mutation_committer(state) + .map_err(map_invite_authorization_error)?; + let outcome = committer + .commit(request) + .await + .map_err(map_canonical_invite_commit_error)?; + let (receipt, result) = match outcome { + AdmissionCommitOutcome::Committed { + receipt, + application_result: Some(result), + application_result_binding: Some(binding), + .. + } if binding.authorization_domain() == domain + && binding.object() == object + && binding.application_intent_digest() == &expected_intent => + { + (receipt, result) + } + AdmissionCommitOutcome::ExactReplay { + receipt, + application_result: Some(result), + } => (receipt, result), + _ => return Err(internal_error("canonical invite result unavailable")), + }; + if receipt.authorization_domain() != domain + || receipt.object() != object + || result.schema() != AdmissionApplicationResultSchema::invite_mint() + || result.code() != 1 + { + return Err(internal_error("canonical invite result binding mismatch")); + } + let result: CanonicalInviteMintResult = serde_json::from_slice(result.payload()) + .map_err(|_| internal_error("canonical invite result invalid"))?; + Ok(CanonicalInviteMintResponse { code, result }) +} + +fn map_canonical_invite_commit_error(error: AdmissionCommitError) -> (StatusCode, Json) { + match error { + AdmissionCommitError::InvalidRequest + | AdmissionCommitError::RecordedInvalidRequest + | AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied + | AdmissionCommitError::IntentConflict + | AdmissionCommitError::RecordedIntentConflict + | AdmissionCommitError::ReplayRejected + | AdmissionCommitError::RecordedReplayRejected => { + api_error(StatusCode::FORBIDDEN, "invite_authorization_denied") + } + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + ), + } +} + +fn map_invite_authorization_error( + error: crate::protected_ingress::ProtectedIngressError, +) -> (StatusCode, Json) { + match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + api_error(StatusCode::FORBIDDEN, "invite_authorization_denied") + } + crate::protected_ingress::ProtectedIngressError::Expired => { + api_error(StatusCode::UNAUTHORIZED, error.code()) + } + crate::protected_ingress::ProtectedIngressError::Unavailable => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + ), + } +} + async fn record_atomic_identity_rejection( state: &AppState, community_id: buzz_core::CommunityId, @@ -314,24 +657,9 @@ pub async fn mint_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey, identity_proof) = - authenticate(&state, &headers, "/api/invites", &body).await?; + let (tenant, pubkey, identity_proof) = authenticate_mint(&state, &headers, &body).await?; - // Authz mirrors kind:9030 (add member): owner or admin only. let sender_hex = pubkey.to_hex(); - let member = state - .db - .get_relay_member(tenant.community(), &sender_hex) - .await - .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; - let role = member.map(|m| m.role).unwrap_or_default(); - if role != "owner" && role != "admin" { - return Err(api_error( - StatusCode::FORBIDDEN, - "only relay owners and admins can create invites", - )); - } - let request: MintInviteRequest = if body.is_empty() { MintInviteRequest::default() } else { @@ -344,14 +672,67 @@ pub async fn mint_invite( }; let (ttl, max_uses) = validate_mint_request(&request)?; - crate::corporate_identity::finalize_corporate_identity( - &state, - tenant.community(), - pubkey, - identity_proof, - ) - .await - .map_err(|error| error.into_api_error())?; + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + // Authz mirrors kind:9030 (add member): owner or admin only. + let member = state + .db + .get_relay_member(tenant.community(), &sender_hex) + .await + .map_err(|e| internal_error(&format!("invite mint role lookup: {e}")))?; + let role = member.map(|m| m.role).unwrap_or_default(); + if role != "owner" && role != "admin" { + return Err(api_error( + StatusCode::FORBIDDEN, + "only relay owners and admins can create invites", + )); + } + let identity_proof = identity_proof.ok_or_else(|| { + api_error(StatusCode::UNAUTHORIZED, "identity verification required") + })?; + crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + .map_err(|error| error.into_api_error())?; + } + buzz_auth::NipFiMode::Enforce => { + let minted = authorize_canonical_invite_mint( + &state, &tenant, &headers, &body, pubkey, ttl, max_uses, + ) + .await?; + tracing::info!( + community = %tenant.community(), + minted_by = %sender_hex, + invite_id = %minted.result.invite_id, + expires_at = minted.result.expires_at, + max_uses = ?minted.result.max_uses, + "canonical relay invite minted or replayed" + ); + let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { + "https" + } else { + "http" + }; + return Ok(Json(serde_json::json!({ + "code": minted.code, + "expires_at": u64::try_from(minted.result.expires_at) + .map_err(|_| internal_error("canonical invite expiry invalid"))?, + "max_uses": minted.result.max_uses, + "uses_remaining": minted.result.uses_remaining, + "url": format!("{scheme}://{}/invite/{}", tenant.host(), minted.code), + }))); + } + buzz_auth::NipFiMode::DenyProtected => { + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + )); + } + } // Mint a v2 opaque, database-backed invite. let invite = state @@ -843,10 +1224,13 @@ async fn claim_invite_enforced( Ok(assertion) => assertion, Err(assertion_error) => { let reason = match assertion_error { - crate::state::InviteAssertionError::Unavailable => { + crate::state::CanonicalAssertionError::Unavailable => { buzz_db::authorization_events::ProtectedDenialReason::DependencyUnavailable } - crate::state::InviteAssertionError::Denied => { + crate::state::CanonicalAssertionError::Denied => { + buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied + } + crate::state::CanonicalAssertionError::Expired => { buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied } }; @@ -1004,16 +1388,19 @@ fn exact_federated_assertion(headers: &HeaderMap) -> Result (StatusCode, Json) { match error { - crate::state::InviteAssertionError::Unavailable => api_error( + crate::state::CanonicalAssertionError::Unavailable => api_error( StatusCode::SERVICE_UNAVAILABLE, "invite_authorization_unavailable", ), - crate::state::InviteAssertionError::Denied => { + crate::state::CanonicalAssertionError::Denied => { api_error(StatusCode::FORBIDDEN, "invite_authorization_denied") } + crate::state::CanonicalAssertionError::Expired => { + api_error(StatusCode::UNAUTHORIZED, "nip_fi_auth_expired") + } } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index e25540bcd16..a349ed5ef88 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -18,13 +18,14 @@ use axum::{ }; use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; -use buzz_auth::SealedTransportEvidence; +use buzz_auth::{BlossomAuthorizationVerb, RouteCapability, SealedTransportEvidence}; use buzz_core::tenant::TenantContext; use buzz_db::authorization_admission::{ AdmissionApplicationContext, AdmissionApplicationEffect, AdmissionApplicationOutcome, AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, AdmissionCommitOutcome, AdmissionCommitReceipt, AdmissionCommitRequest, AdmissionObject, AdmissionObjectKind, AdmissionReplayClaim, AdmissionReplayClaimKind, + CanonicalAdmissionCommitter, }; use buzz_media::{ BlobDescriptor, MediaError, MediaStorage, StagedMediaUpload, UploadAttribution, @@ -739,6 +740,7 @@ pub(crate) struct AuthenticatedUpload { tenant: TenantContext, route_mode: UploadRouteMode, attribution: UploadAttributionSnapshot, + canonical_admission: Option, _upload_permit: UploadPermit, } @@ -786,6 +788,15 @@ enum UploadRouteMode { LegacyMedia, } +impl UploadRouteMode { + const fn path(self) -> &'static [u8] { + match self { + Self::Upload => b"/upload", + Self::LegacyMedia => b"/media/upload", + } + } +} + fn should_stream_as_video(sniff: &[u8]) -> bool { infer::get(sniff).is_some_and(|kind| kind.mime_type() == "video/mp4") || buzz_media::looks_like_iso_bmff(sniff) @@ -803,6 +814,173 @@ struct MediaReadAuth { tenant: TenantContext, } +#[derive(Clone, Copy)] +enum MediaReadMethod { + Get, + Head, +} + +impl MediaReadMethod { + const fn code(self) -> &'static [u8] { + match self { + Self::Get => b"GET", + Self::Head => b"HEAD", + } + } +} + +fn media_read_fingerprint(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((domain.len() as u64).to_be_bytes()); + digest.update(domain); + for field in fields { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + digest.finalize().into() +} + +async fn authorize_canonical_media_read( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + auth_event: &nostr::Event, + sha256: &str, + method: MediaReadMethod, +) -> Result<(), MediaError> { + let domain = tenant.community(); + let event_id = auth_event.id.to_bytes(); + let target = + ProtectedPublicationTarget::media(domain, sha256).map_err(|_| MediaError::Unauthorized)?; + let request_fingerprint = media_read_fingerprint( + b"buzz:nip-fi:media-read-request:v1", + &[ + domain.as_uuid().as_bytes(), + method.code(), + sha256.as_bytes(), + auth_event.pubkey.as_bytes(), + &event_id, + ], + ); + let transport_context_fingerprint = media_read_fingerprint( + b"buzz:nip-fi:media-read-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + &event_id, + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::MediaRead, + domain, + RouteCapability::MediaRead, + target.admission_object(), + buzz_auth::ProofTransport::Blossom, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_media_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + state.config.corporate_identity.jwt_header.as_str(), + ) + .map_err(map_protected_media_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_protected_media_error)?; + let proof = buzz_auth::verify_blossom_authorization_proof( + auth_event, + BlossomAuthorizationVerb::Get, + tenant.host(), + sha256, + 600, + &assertion, + request_fingerprint, + *target.admission_object().key(), + transport_context_fingerprint, + ) + .map_err(|_| MediaError::Unauthorized)?; + crate::protected_ingress::authorize_read(state, coordinates, assertion, proof) + .await + .map(|_| ()) + .map_err(map_protected_media_error) +} + +async fn prepare_canonical_media_upload( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + auth_event: &nostr::Event, + sha256: &str, + route_mode: UploadRouteMode, +) -> Result { + let domain = tenant.community(); + let target = + ProtectedPublicationTarget::media(domain, sha256).map_err(|_| MediaError::Unauthorized)?; + let request_fingerprint = media_read_fingerprint( + b"buzz:nip-fi:media-upload-request:v1", + &[ + domain.as_uuid().as_bytes(), + b"PUT", + route_mode.path(), + sha256.as_bytes(), + auth_event.pubkey.as_bytes(), + ], + ); + let transport_context_fingerprint = media_read_fingerprint( + b"buzz:nip-fi:media-upload-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + b"PUT", + route_mode.path(), + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::MediaWrite, + domain, + RouteCapability::MediaWrite, + target.admission_object(), + buzz_auth::ProofTransport::Blossom, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(map_protected_media_error)?; + let assertion_token = crate::protected_ingress::exact_assertion( + headers, + state.config.corporate_identity.jwt_header.as_str(), + ) + .map_err(map_protected_media_error)?; + let assertion = + crate::protected_ingress::verify_assertion(state, &assertion_token, coordinates) + .await + .map_err(map_protected_media_error)?; + let proof = buzz_auth::verify_blossom_authorization_proof( + auth_event, + BlossomAuthorizationVerb::Upload, + tenant.host(), + sha256, + 3600, + &assertion, + request_fingerprint, + *target.admission_object().key(), + transport_context_fingerprint, + ) + .map_err(|_| MediaError::Unauthorized)?; + crate::protected_ingress::prepare_mutation(state, coordinates, assertion, proof) + .await + .map_err(map_protected_media_error) +} + +fn map_protected_media_error(error: crate::protected_ingress::ProtectedIngressError) -> MediaError { + match error { + crate::protected_ingress::ProtectedIngressError::Denied => MediaError::Unauthorized, + crate::protected_ingress::ProtectedIngressError::Expired => MediaError::TokenExpired, + crate::protected_ingress::ProtectedIngressError::Unavailable => MediaError::Internal, + } +} + async fn verify_media_corporate_identity( state: &AppState, tenant: &TenantContext, @@ -856,6 +1034,35 @@ async fn finalize_media_corporate_identity( }) } +async fn deny_banned_media_principal( + state: &AppState, + tenant: &TenantContext, + pubkey: &nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result<(), MediaError> { + let restriction = state + .db + .moderation_restriction_state(tenant.community(), pubkey.as_bytes()) + .await + .map_err(|_| MediaError::Internal)?; + if restriction.banned { + return Err(MediaError::Unauthorized); + } + if let Some(owner) = + crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + { + let owner_restriction = state + .db + .moderation_restriction_state(tenant.community(), owner.as_bytes()) + .await + .map_err(|_| MediaError::Internal)?; + if owner_restriction.banned { + return Err(MediaError::Unauthorized); + } + } + Ok(()) +} + const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { @@ -960,6 +1167,9 @@ impl FromRequestParts> for AuthenticatedUpload { .map_err(|_| MediaError::NotFound)?; let route_mode = upload_route_mode(parts.uri.path())?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::DenyProtected { + return Err(MediaError::Unauthorized); + } // 2. Extract and validate Blossom auth event against the bound host. let auth_event = extract_blossom_auth(headers)?; @@ -1001,8 +1211,26 @@ impl FromRequestParts> for AuthenticatedUpload { // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - let identity_proof = - verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; + let legacy_identity_proof = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + Some(verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?) + } else { + None + }; + let canonical_admission = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => None, + buzz_auth::NipFiMode::Enforce => Some( + prepare_canonical_media_upload( + state, + &tenant, + headers, + &auth_event, + claimed_hash, + route_mode, + ) + .await?, + ), + buzz_auth::NipFiMode::DenyProtected => return Err(MediaError::Unauthorized), + }; crate::api::relay_members::enforce_relay_membership( state, @@ -1012,6 +1240,7 @@ impl FromRequestParts> for AuthenticatedUpload { ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; + deny_banned_media_principal(state, &tenant, &auth_event.pubkey, auth_tag).await?; if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") .increment(1); @@ -1022,8 +1251,11 @@ impl FromRequestParts> for AuthenticatedUpload { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") .increment(1); })?; - finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) - .await?; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + let identity_proof = legacy_identity_proof.ok_or(MediaError::Internal)?; + finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) + .await?; + } let attribution = snapshot_upload_attribution(state, &tenant, &auth_event.pubkey, headers).await; @@ -1032,6 +1264,7 @@ impl FromRequestParts> for AuthenticatedUpload { tenant, route_mode, attribution, + canonical_admission, _upload_permit: upload_permit, }) } @@ -1131,13 +1364,13 @@ pub async fn upload_blob( } let replay = futures_util::stream::iter(replay_chunks.into_iter().map(Ok)).chain(source); - let mut descriptor = if should_stream_as_video(&sniff) { + let staged = if should_stream_as_video(&sniff) { // Video path: stream body directly to disk — never fully buffered in RAM. let content_length = headers .get("content-length") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); - buzz_media::process_video_upload( + buzz_media::stage_video_upload( &state.media_storage, &state.config.media, &auth.tenant, @@ -1168,7 +1401,7 @@ pub async fn upload_blob( ); if is_image { - buzz_media::process_upload( + buzz_media::stage_upload( &state.media_storage, &state.config.media, &auth.tenant, @@ -1183,7 +1416,7 @@ pub async fn upload_blob( .unwrap_or_else(|| "application/octet-stream".to_string()); return Err(MediaError::DisallowedContentType(mime)); } else { - buzz_media::process_file_upload( + buzz_media::stage_file_upload( &state.media_storage, &state.config.media, &auth.tenant, @@ -1195,6 +1428,10 @@ pub async fn upload_blob( } }; + let mut descriptor = + finalize_staged_media_upload(&state, &auth.tenant, staged, auth.canonical_admission) + .await?; + rewrite_descriptor_urls_for_tenant( &mut descriptor, &state.config.relay_url, @@ -1240,6 +1477,66 @@ pub async fn upload_blob( Ok(Json(descriptor)) } +async fn finalize_staged_media_upload( + state: &AppState, + tenant: &TenantContext, + staged: StagedMediaUpload, + canonical_admission: Option, +) -> Result { + let Some(request) = canonical_admission else { + staged + .publish_post_commit_projections( + &state.media_storage, + tenant, + &state.config.media.public_base_url, + ) + .await?; + return Ok(staged.into_descriptor()); + }; + + let replay_descriptor = staged.descriptor().clone(); + let plan = ProtectedPublicationPlan::from(staged); + let effect = plan + .application_effect() + .map_err(map_canonical_commit_media_error)?; + let request = request + .with_application_effect(Box::new(effect)) + .map_err(map_canonical_commit_media_error)?; + let committer = + crate::protected_ingress::mutation_committer(state).map_err(map_protected_media_error)?; + let outcome = committer + .commit(request) + .await + .map_err(map_canonical_commit_media_error)?; + match ProtectedPublicationReceipt::bind(plan, &outcome) + .map_err(map_canonical_commit_media_error)? + { + ProtectedPublicationBinding::Committed(receipt) => match receipt.into_plan() { + ProtectedPublicationPlan::Media(staged) => { + staged + .publish_post_commit_projections( + &state.media_storage, + tenant, + &state.config.media.public_base_url, + ) + .await?; + Ok(staged.into_descriptor()) + } + ProtectedPublicationPlan::Git(_) => Err(MediaError::Internal), + }, + ProtectedPublicationBinding::ExactReplay(_) => Ok(replay_descriptor), + } +} + +fn map_canonical_commit_media_error(error: AdmissionCommitError) -> MediaError { + match error { + AdmissionCommitError::DependencyUnavailable + | AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable => MediaError::Internal, + _ => MediaError::Unauthorized, + } +} + pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") || config_relay_url.trim_start().starts_with("https://") @@ -1286,6 +1583,7 @@ async fn authenticate_media_read( state: &AppState, headers: &HeaderMap, sha256_ext: &str, + method: MediaReadMethod, ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; @@ -1294,8 +1592,19 @@ async fn authenticate_media_read( buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); - let identity_proof = - verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; + let legacy_identity_proof = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + Some(verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?) + } else { + None + }; + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => {} + buzz_auth::NipFiMode::Enforce => { + authorize_canonical_media_read(state, &tenant, headers, &auth_event, sha256, method) + .await?; + } + buzz_auth::NipFiMode::DenyProtected => return Err(MediaError::Unauthorized), + } crate::api::relay_members::enforce_relay_membership( state, tenant.community(), @@ -1304,7 +1613,10 @@ async fn authenticate_media_read( ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; - finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof).await?; + if let Some(identity_proof) = legacy_identity_proof { + finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) + .await?; + } Ok(MediaReadAuth { tenant }) } @@ -1398,7 +1710,8 @@ pub async fn get_blob( req_headers: HeaderMap, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; + let media_auth = + authenticate_media_read(&state, &req_headers, &sha256_ext, MediaReadMethod::Get).await?; serve_blob_for_tenant(&state, &media_auth.tenant, &sha256_ext, &req_headers).await } @@ -1592,7 +1905,8 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; + let media_auth = + authenticate_media_read(&state, &headers, &sha256_ext, MediaReadMethod::Head).await?; let tenant = media_auth.tenant; let cache_control = blob_cache_control(); @@ -1703,9 +2017,13 @@ mod tests { use std::{ future::Future, pin::Pin, - sync::{Arc, OnceLock}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, OnceLock, + }, }; + use async_trait::async_trait; use axum::{ body::Body, http::{header, Request, StatusCode}, @@ -1718,6 +2036,8 @@ mod tests { use chrono::TimeZone; use hmac::{Hmac, KeyInit, Mac}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; use tower::ServiceExt; use uuid::Uuid; @@ -1737,6 +2057,75 @@ mod tests { }) } + struct CountingJwksLoader { + document: Vec, + requests: Arc, + } + + #[async_trait] + impl crate::authorization_runtime::JwksDocumentLoader for CountingJwksLoader { + async fn load( + &self, + _source: &crate::authorization_runtime::JwksSourceConfig, + _expected_issuer: &str, + _policy: crate::authorization_runtime::JwksRefreshPolicy, + ) -> Result, crate::authorization_runtime::RuntimeAuthorizationError> { + self.requests.fetch_add(1, Ordering::SeqCst); + Ok(self.document.clone()) + } + } + + #[test] + fn canonical_media_read_coordinates_separate_method_and_target() { + let domain = Uuid::from_u128(1); + let get = media_read_fingerprint( + b"buzz:nip-fi:media-read-request:v1", + &[ + domain.as_bytes(), + MediaReadMethod::Get.code(), + VALID_HASH.as_bytes(), + ], + ); + let head = media_read_fingerprint( + b"buzz:nip-fi:media-read-request:v1", + &[ + domain.as_bytes(), + MediaReadMethod::Head.code(), + VALID_HASH.as_bytes(), + ], + ); + let other = media_read_fingerprint( + b"buzz:nip-fi:media-read-request:v1", + &[ + domain.as_bytes(), + MediaReadMethod::Get.code(), + "1".repeat(64).as_bytes(), + ], + ); + + assert_ne!(get, head); + assert_ne!(get, other); + assert_ne!(get, [0; 32]); + } + + #[test] + fn canonical_media_assertion_rejects_ambiguous_headers() { + let mut headers = HeaderMap::new(); + headers.append( + ASSERTION_HEADER_NAME, + proxy_assertion().parse().expect("valid proxy assertion"), + ); + headers.append( + ASSERTION_HEADER_NAME, + proxy_assertion().parse().expect("valid proxy assertion"), + ); + + assert!(matches!( + crate::protected_ingress::exact_assertion(&headers, ASSERTION_HEADER_NAME), + Err(crate::protected_ingress::ProtectedIngressError::Denied) + )); + } + #[derive(Default)] struct EmptyReplayReader; @@ -2102,12 +2491,72 @@ mod tests { async fn test_state_with_corporate_identity(require_corporate_identity: bool) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; + config.nip_fi_mode = buzz_auth::NipFiMode::Off; config.corporate_identity.require = require_corporate_identity; if require_corporate_identity { config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); config.corporate_identity.issuer = "https://idp.example".to_string(); config.corporate_identity.audience = "buzz-relay".to_string(); } + build_media_test_state(config, None).await + } + + async fn enforced_canonical_media_state( + jwks: Vec, + jwks_requests: Arc, + storage_endpoint: String, + ) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.nip_fi_mode = buzz_auth::NipFiMode::Enforce; + config.corporate_identity.require = true; + config.corporate_identity.jwt_header = "x-buzz-identity-token".to_owned(); + config.media.s3_endpoint = storage_endpoint; + let policy = buzz_auth::CanonicalVerifierPolicy::new( + "https://idp.example".to_owned(), + "buzz-relay".to_owned(), + "sub".to_owned(), + Some("buzz_npub".to_owned()), + 0, + 3_600, + ) + .expect("canonical media verifier policy"); + let verifier = Arc::new( + crate::authorization_runtime::DynamicVerifier::new( + policy, + "https://idp.example".to_owned(), + crate::authorization_runtime::JwksSourceConfig::JwksUri( + url::Url::parse("https://idp.example/jwks").expect("canonical test JWKS URL"), + ), + crate::authorization_runtime::JwksRefreshPolicy::new( + 64 * 1024, + Duration::from_secs(2), + Duration::from_secs(300), + ) + .expect("canonical test refresh policy"), + Arc::new(CountingJwksLoader { + document: jwks, + requests: jwks_requests, + }), + ) + .expect("canonical media verifier"), + ); + let snapshot = verifier + .refresh(chrono::Utc::now()) + .await + .expect("load canonical media keys"); + let runtime = + crate::authorization_runtime::InstalledAuthorizationRuntime::for_canonical_assertion_test( + verifier, + snapshot, + ); + build_media_test_state(config, Some(runtime)).await + } + + async fn build_media_test_state( + mut config: crate::config::Config, + authorization_runtime: Option, + ) -> Arc { config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -2134,18 +2583,33 @@ mod tests { buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); + let (state, _audit_shutdown) = match authorization_runtime { + Some(authorization_runtime) => AppState::new_with_authorization_runtime( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + authorization_runtime, + ), + None => AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ), + }; Arc::new(state) } @@ -2159,16 +2623,6 @@ mod tests { .with_state(state) } - async fn media_get_auth_router_with_corporate_identity() -> axum::Router { - let state = test_state_with_corporate_identity(true).await; - axum::Router::new() - .route( - "/media/{sha256_ext}", - axum::routing::get(get_blob).head(head_blob), - ) - .with_state(state) - } - fn media_get_auth_header(keys: &Keys, tags: Vec) -> String { let event = EventBuilder::new(Kind::from(24242), "Get media") .tags(tags) @@ -2205,6 +2659,57 @@ mod tests { builder.body(Body::empty()).expect("request") } + fn test_http_response(status: &str, content_type: &str, body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + async fn spawn_counting_http_server( + response: String, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind media test HTTP server"); + let address = listener.local_addr().expect("media test server address"); + let requests = Arc::new(AtomicUsize::new(0)); + let request_count = Arc::clone(&requests); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + request_count.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 2_048]; + let Ok(bytes_read) = stream.read(&mut request).await else { + return; + }; + if bytes_read == 0 || stream.write_all(response.as_bytes()).await.is_err() { + return; + } + } + }); + (format!("http://{address}"), requests, server) + } + + fn media_identity_claims( + subject: &str, + pubkey: nostr::PublicKey, + issued_at: i64, + expires_at: i64, + ) -> serde_json::Value { + serde_json::json!({ + "iss": "https://idp.example", + "aud": "buzz-relay", + "sub": subject, + "buzz_npub": pubkey.to_hex(), + "iat": issued_at, + "nbf": issued_at, + "exp": expires_at, + }) + } + #[tokio::test] async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() { for method in ["GET", "HEAD"] { @@ -2232,20 +2737,90 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] - async fn protected_media_reads_require_corporate_identity_for_get_and_head() { + async fn protected_media_get_and_head_reject_invalid_assertions_before_storage() { + const KID: &str = "media-canonical-test"; let keys = Keys::generate(); + let other_keys = Keys::generate(); + let now = chrono::Utc::now().timestamp(); + let valid_claims = + media_identity_claims("media-user", keys.public_key(), now - 1, now + 600); + let expired_claims = + media_identity_claims("media-user", keys.public_key(), now - 600, now - 120); + let mismatched_claims = + media_identity_claims("media-user", other_keys.public_key(), now - 1, now + 600); + let current_jwk = crate::corporate_identity::canonical_test_support::jwk(0, KID); + let jwks = serde_json::to_string(&jsonwebtoken::jwk::JwkSet { + keys: vec![current_jwk], + }) + .expect("serialize media test JWKS"); + let jwks_requests = Arc::new(AtomicUsize::new(0)); + let (storage_endpoint, storage_requests, storage_server) = + spawn_counting_http_server(test_http_response("404 Not Found", "text/plain", "")).await; + let state = enforced_canonical_media_state( + jwks.into_bytes(), + Arc::clone(&jwks_requests), + storage_endpoint, + ) + .await; + assert_eq!(storage_requests.load(Ordering::SeqCst), 0); + let app = axum::Router::new() + .route( + "/media/{sha256_ext}", + axum::routing::get(get_blob).head(head_blob), + ) + .with_state(state); + let expired = + crate::corporate_identity::canonical_test_support::signed_jwt(&expired_claims, 0, KID); + let rotated_key = + crate::corporate_identity::canonical_test_support::signed_jwt(&valid_claims, 1, KID); + let mismatched = crate::corporate_identity::canonical_test_support::signed_jwt( + &mismatched_claims, + 0, + KID, + ); + let cases = [ + ("absent", None), + ("expired", Some(expired)), + ("revoked-key", Some(rotated_key)), + ("mismatched", Some(mismatched)), + ]; for method in ["GET", "HEAD"] { - let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); - let response = media_get_auth_router_with_corporate_identity() - .await - .oneshot(media_request(method, Some(auth))) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{method}"); + for (case, assertion) in &cases { + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + let mut request = media_request(method, Some(auth)); + if let Some(assertion) = assertion { + request.headers_mut().insert( + "x-buzz-identity-token", + assertion.parse().expect("identity assertion header"), + ); + } + let response = app + .clone() + .oneshot(request) + .await + .expect("protected media response"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method}/{case}" + ); + assert_eq!( + storage_requests.load(Ordering::SeqCst), + 0, + "{method}/{case} reached object storage before canonical admission" + ); + } } + + assert_eq!( + jwks_requests.load(Ordering::SeqCst), + 1, + "one cached current-key fetch must cover every presented assertion" + ); + assert_eq!(storage_requests.load(Ordering::SeqCst), 0); + storage_server.abort(); } #[tokio::test] diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index d9f829433b1..0a93bd8df15 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -15,9 +15,64 @@ pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; use axum::{http::StatusCode, response::Json}; -/// Standard error envelope. +/// Stable, bounded HTTP error taxonomy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ApiErrorCode { + InvalidRequest, + AuthenticationRequired, + AuthorizationDenied, + ResourceNotFound, + Conflict, + RateLimited, + DependencyUnavailable, + InternalError, +} + +impl ApiErrorCode { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::AuthenticationRequired => "authentication_required", + Self::AuthorizationDenied => "authorization_denied", + Self::ResourceNotFound => "resource_not_found", + Self::Conflict => "conflict", + Self::RateLimited => "rate_limited", + Self::DependencyUnavailable => "dependency_unavailable", + Self::InternalError => "internal_error", + } + } + + pub(crate) fn for_status(status: StatusCode) -> Self { + match status { + StatusCode::UNAUTHORIZED => Self::AuthenticationRequired, + StatusCode::FORBIDDEN => Self::AuthorizationDenied, + StatusCode::NOT_FOUND => Self::ResourceNotFound, + StatusCode::CONFLICT => Self::Conflict, + StatusCode::TOO_MANY_REQUESTS => Self::RateLimited, + StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT => { + Self::DependencyUnavailable + } + status if status.is_server_error() => Self::InternalError, + _ => Self::InvalidRequest, + } + } +} + +/// Standard error envelope with an additive machine-readable code. pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json) { - (status, Json(serde_json::json!({ "error": msg }))) + coded_api_error(status, ApiErrorCode::for_status(status), msg) +} + +/// Standard error envelope with a specific stable machine-readable code. +pub(crate) fn coded_api_error( + status: StatusCode, + code: ApiErrorCode, + msg: &str, +) -> (StatusCode, Json) { + ( + status, + Json(serde_json::json!({ "error": msg, "code": code.as_str() })), + ) } pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json) { @@ -30,6 +85,31 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) } +#[cfg(test)] +mod error_tests { + use super::*; + + #[test] + fn default_http_errors_expose_stable_codes_without_removing_message() { + let (_, Json(body)) = api_error(StatusCode::FORBIDDEN, "access denied"); + assert_eq!(body["error"], "access denied"); + assert_eq!(body["code"], "authorization_denied"); + + let (_, Json(body)) = api_error(StatusCode::SERVICE_UNAVAILABLE, "try later"); + assert_eq!(body["code"], "dependency_unavailable"); + } + + #[test] + fn specific_http_error_codes_are_bounded_strings() { + let (_, Json(body)) = coded_api_error( + StatusCode::FORBIDDEN, + ApiErrorCode::AuthenticationRequired, + "authentication failed", + ); + assert_eq!(body["code"], "authentication_required"); + } +} + /// Relay membership enforcement — single gate for all authenticated entry points. /// /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 692b2ac9b7e..1c91a59e123 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -97,10 +97,18 @@ pub async fn ws_audio_handler( .into_response(); } }; - let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - &headers, - &state.config.corporate_identity, - ); + let corporate_identity_jwt = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + crate::protected_ingress::exact_assertion( + &headers, + &state.config.corporate_identity.jwt_header, + ) + .ok() + } else { + crate::corporate_identity::identity_jwt_from_headers( + &headers, + &state.config.corporate_identity, + ) + }; // Keep the parser boundary at the largest message this route accepts. The // checks in the receive loop still distinguish text from binary policy, but @@ -234,8 +242,20 @@ async fn handle_active_audio_connection( } }; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::DenyProtected { + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type":"error","message":"protected ingress denied"}) + .to_string() + .into(), + )) + .await; + return; + } + // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let canonical_auth_event = auth_msg.event.clone(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); let auth_ctx = match state @@ -262,28 +282,65 @@ async fn handle_active_audio_connection( let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - &state, - tenant.community(), - pubkey, - corporate_identity_jwt.as_deref(), - auth_tag_json.as_deref(), - ) - .await - { - Ok(proof) => proof, - Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity denied"); + let identity_proof = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + match crate::corporate_identity::verify_corporate_identity( + &state, + tenant.community(), + pubkey, + corporate_identity_jwt.as_deref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => Some(proof), + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } + } + } + buzz_auth::NipFiMode::Enforce => None, + buzz_auth::NipFiMode::DenyProtected => return, + }; + + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + if let Err(error) = authorize_canonical_audio_join( + &state, + &tenant, + channel_id, + &canonical_auth_event, + &challenge, + &relay_url, + corporate_identity_jwt.as_deref(), + ) + .await + { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, ?error, "audio: canonical authorization denied"); + let message = match error { + crate::protected_ingress::ProtectedIngressError::Denied => "authorization denied", + crate::protected_ingress::ProtectedIngressError::Expired => "nip_fi_auth_expired", + crate::protected_ingress::ProtectedIngressError::Unavailable => { + "authorization unavailable" + } + }; let _ = ws_send .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) + serde_json::json!({"type":"error","message":message}) .to_string() .into(), )) .await; return; } - }; + } if crate::api::relay_members::enforce_relay_membership( &state, @@ -329,41 +386,64 @@ async fn handle_active_audio_connection( } }; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce && auto_add_member_by.is_some() { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: canonical admission requires existing membership"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type":"error","message":"not a member"}) + .to_string() + .into(), + )) + .await; + return; + } + // Existing members and open channels retain the established identity path. // Private-huddle auto-add is deferred until room admission succeeds, then // membership and direct identity binding commit in one database transaction. - let deferred_private_admission = if let Some(added_by) = auto_add_member_by { - Some((added_by, identity_proof)) - } else { - let identity_decision = match crate::corporate_identity::finalize_corporate_identity( - &state, - tenant.community(), - pubkey, - identity_proof, - ) - .await - { - Ok(decision) => decision, - Err(e) => { - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); - let _ = ws_send - .send(WsMessage::Text( - serde_json::json!({"type": "error", "message": e.public_message()}) - .to_string() - .into(), - )) - .await; - return; + let deferred_private_admission = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + let identity_proof = match identity_proof { + Some(proof) => proof, + None => return, + }; + if let Some(added_by) = auto_add_member_by { + Some((added_by, identity_proof)) + } else { + let identity_decision = + match crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } + }; + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + tenant.community(), + pubkey, + identity_decision, + cancel.clone(), + ); + None } - }; - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - tenant.community(), - pubkey, - identity_decision, - cancel.clone(), - ); - None + } + buzz_auth::NipFiMode::Enforce => None, + buzz_auth::NipFiMode::DenyProtected => return, }; // Huddle cross-pod routing (mesh) OR single-pod guardrail. @@ -1062,6 +1142,78 @@ async fn handle_active_audio_connection( ); } +#[allow(clippy::too_many_arguments)] +async fn authorize_canonical_audio_join( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + event: &nostr::Event, + challenge: &str, + relay_url: &str, + assertion_token: Option<&str>, +) -> Result<(), crate::protected_ingress::ProtectedIngressError> { + let domain = tenant.community(); + let target_key = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:audio-session-target:v1", + &[domain.as_uuid().as_bytes(), channel_id.as_bytes()], + ); + let object = buzz_db::authorization_admission::AdmissionObject::new( + buzz_db::authorization_admission::AdmissionObjectKind::AudioSession, + target_key, + ) + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let event_id = event.id.to_bytes(); + let request_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:audio-join-request:v1", + &[ + domain.as_uuid().as_bytes(), + channel_id.as_bytes(), + event.pubkey.as_bytes(), + &event_id, + challenge.as_bytes(), + ], + ); + let transport_context_fingerprint = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:audio-join-transport:v1", + &[ + domain.as_uuid().as_bytes(), + tenant.host().as_bytes(), + relay_url.as_bytes(), + challenge.as_bytes(), + ], + ); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::AudioJoin, + domain, + buzz_auth::RouteCapability::AudioJoin, + object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let assertion_token = + assertion_token.ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let assertion = + crate::protected_ingress::verify_assertion(state, assertion_token, coordinates).await?; + let (_, assertion_expires_at) = assertion.time_bounds(); + let proof = buzz_auth::verify_nip42_authorization_proof( + event, + challenge, + relay_url, + domain, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + Some(*assertion.assertion_fingerprint()), + None, + assertion_expires_at, + ) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + crate::protected_ingress::authorize_read(state, coordinates, assertion, proof) + .await + .map(|_| ()) +} + /// React to a non-owner huddle teardown signal read off the owner's control /// stream: cancel the connection (which drives the client's WS to close so it /// rejoins) and forget the local generation floor for this session. diff --git a/crates/buzz-relay/src/authorization_runtime/authority.rs b/crates/buzz-relay/src/authorization_runtime/authority.rs new file mode 100644 index 00000000000..45e07925afa --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/authority.rs @@ -0,0 +1,586 @@ +//! Canonical relay-side admission and resource-witness composition. + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use buzz_auth::{ + AuthorizationLeaseDependencySnapshot, FinalizedAuthContext, PreparedAuthorization, + ProofTransport, RouteCapability, VerifiedNostrProof, +}; +use buzz_core::CommunityId; +use buzz_db::authorization_admission::AdmissionEnrollmentEvidence; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::{ + InvalidationRegistration, InvalidationRegistry, ProtectedResourceKind, ResolvedProtectedRoute, + RouteAuthority, RuntimeAuthorizationError, +}; + +/// Origin-sealed server resource used for pre-effect and streaming rechecks. +/// +/// Production adapters own authoritative implementations. Raw handler paths, headers, and body +/// fields cannot implement authority by themselves; the adapter must first +/// verify transport provenance and resolve the server-owned object. +#[async_trait] +pub trait ProtectedResourceWitness: Send + Sync { + /// Server-resolved authorization domain. + fn authorization_domain(&self) -> CommunityId; + /// Exact sealed route capability. + fn capability(&self) -> RouteCapability; + /// Closed resource namespace. + fn resource_kind(&self) -> ProtectedResourceKind; + /// Privacy-safe server-owned resource key. + fn resource_key(&self) -> &[u8; 32]; + /// Independently verified proof transport. + fn transport(&self) -> ProofTransport; + /// Canonical request fingerprint. + fn request_fingerprint(&self) -> &[u8; 32]; + /// Canonical target fingerprint. + fn target_fingerprint(&self) -> &[u8; 32]; + /// Canonical transport-context fingerprint. + fn transport_context_fingerprint(&self) -> &[u8; 32]; + /// Protected-object authority epoch captured during preparation. + fn authority_epoch(&self) -> u64; + /// Exclusive witness expiry. + fn expires_at(&self) -> DateTime; + /// Optional opaque replay-claim key supplied only by the sealed transport. + fn replay_claim_key(&self) -> Option<&[u8; 32]> { + None + } + /// Exclusive replay-claim retention bound, when a claim is present. + fn replay_claim_retain_until(&self) -> Option> { + None + } + + /// Re-read the authoritative resource after canonical admission and before + /// application effect. Streaming callers invoke this periodically too. + async fn recheck( + &self, + lease: &AuthorizationLeaseDependencySnapshot, + ) -> Result; +} + +/// Fresh authoritative observation of one sealed resource witness. +#[derive(Clone, PartialEq, Eq)] +pub struct ResourceRecheck { + authorization_domain: CommunityId, + resource_kind: ProtectedResourceKind, + resource_key: [u8; 32], + authority_epoch: u64, + authoritative_now: DateTime, +} + +impl ResourceRecheck { + /// Adapt one authoritative transport observation. + pub fn from_authoritative_parts( + authorization_domain: CommunityId, + resource_kind: ProtectedResourceKind, + resource_key: [u8; 32], + authority_epoch: u64, + authoritative_now: DateTime, + ) -> Result { + if authorization_domain.as_uuid().is_nil() + || resource_key == [0; 32] + || authority_epoch == 0 + { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + Ok(Self { + authorization_domain, + resource_kind, + resource_key, + authority_epoch, + authoritative_now, + }) + } +} + +impl fmt::Debug for ResourceRecheck { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ResourceRecheck([REDACTED])") + } +} + +/// Complete relay-side request for the corrected canonical admission adapter. +/// +/// The adapter converts this value to the exact canonical request at the +/// application boundary. This module never performs enrollment, replay, audit, +/// or receipt writes itself. +pub struct RuntimeAdmissionRequest { + operation_id: Uuid, + attempt_id: Uuid, + route: ResolvedProtectedRoute, + preparation: RuntimeAdmissionPreparation, + witness: Arc, +} + +pub(super) enum RuntimeAdmissionPreparation { + Existing(Box), + Enrollment(Box), +} + +pub(super) struct RuntimeEnrollmentPreparation { + pub(super) correlation_id: Uuid, + pub(super) evidence: AdmissionEnrollmentEvidence, +} + +impl RuntimeAdmissionRequest { + /// Bind a read-only authorization preparation to the independently sealed route and + /// resource coordinates that the final transaction must consume. + pub fn existing( + operation_id: Uuid, + attempt_id: Uuid, + route: ResolvedProtectedRoute, + prepared: PreparedAuthorization, + witness: Arc, + ) -> Result { + if operation_id.is_nil() || attempt_id.is_nil() { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + validate_prepared_coordinates(route, &prepared, witness.as_ref())?; + validate_replay_claim(witness.as_ref())?; + Ok(Self { + operation_id, + attempt_id, + route, + preparation: RuntimeAdmissionPreparation::Existing(Box::new(prepared)), + witness, + }) + } + + /// Bind a transactionally committed enrollment to the same sealed route + /// and protected resource coordinates. + #[allow(clippy::too_many_arguments)] + pub fn enrollment( + operation_id: Uuid, + attempt_id: Uuid, + correlation_id: Uuid, + route: ResolvedProtectedRoute, + evidence: AdmissionEnrollmentEvidence, + proof: VerifiedNostrProof, + witness: Arc, + ) -> Result { + if operation_id.is_nil() || attempt_id.is_nil() || correlation_id.is_nil() { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + validate_enrollment_coordinates(route, &proof, witness.as_ref())?; + validate_replay_claim(witness.as_ref())?; + Ok(Self { + operation_id, + attempt_id, + route, + preparation: RuntimeAdmissionPreparation::Enrollment(Box::new( + RuntimeEnrollmentPreparation { + correlation_id, + evidence, + }, + )), + witness, + }) + } + + /// Server-generated idempotency identifier. + pub const fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Server-generated bounded-attempt identifier. + pub const fn attempt_id(&self) -> Uuid { + self.attempt_id + } + + /// Exact typed route. + pub const fn route(&self) -> ResolvedProtectedRoute { + self.route + } + + /// Borrow the sealed protected object. + pub fn witness(&self) -> &dyn ProtectedResourceWitness { + self.witness.as_ref() + } + + /// Consume the request into the exact pieces needed by canonical admission. + pub(super) fn into_parts( + self, + ) -> ( + Uuid, + Uuid, + ResolvedProtectedRoute, + RuntimeAdmissionPreparation, + Arc, + ) { + ( + self.operation_id, + self.attempt_id, + self.route, + self.preparation, + self.witness, + ) + } +} + +impl fmt::Debug for RuntimeAdmissionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RuntimeAdmissionRequest([REDACTED])") + } +} + +/// Object-safe adapter to the sole corrected atomic admission committer. +#[async_trait] +pub trait AdmissionCommitPort: Send + Sync { + /// Atomically consume replay claim, enrollment, receipt, audit, and + /// authority state, returning only finalized credential-free state. + /// Mutation routes remain unavailable until the committer exposes its + /// typed bounded application-effect input on this same transaction. + async fn commit( + &self, + request: RuntimeAdmissionRequest, + ) -> Result; +} + +/// Canonical route authority composed from immutable route and admission ports. +pub struct RuntimeAuthority { + routes: Arc, + committer: Arc, + invalidation: Arc, +} + +impl RuntimeAuthority { + /// Install one complete route table and one canonical admission port. + pub fn new( + routes: Arc, + committer: Arc, + invalidation: Arc, + ) -> Self { + Self { + routes, + committer, + invalidation, + } + } + + /// Immutable closed route inventory. + pub const fn routes(&self) -> &Arc { + &self.routes + } + + /// Exact live invalidation registry bound to every admitted lease. + pub const fn invalidation(&self) -> &Arc { + &self.invalidation + } + + /// Commit admission, then recheck the independently owned resource before + /// returning permission to execute the application effect. + pub async fn authorize( + &self, + request: RuntimeAdmissionRequest, + ) -> Result { + let route = request.route; + if route.effect() == super::ProtectedEffect::Mutate { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + let witness = Arc::clone(&request.witness); + let authorization = self.committer.commit(request).await?; + validate_finalized_coordinates(route, &authorization, witness.as_ref())?; + let invalidation = self.invalidation.register(authorization.lease())?; + let lease_snapshot = authorization.lease().dependency_snapshot(); + let rechecked = witness.recheck(&lease_snapshot).await?; + validate_resource_recheck(&authorization, witness.as_ref(), &rechecked)?; + if invalidation.is_cancelled() { + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + Ok(AuthorizedRoute { + route, + authorization, + witness, + invalidation, + }) + } +} + +impl fmt::Debug for RuntimeAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RuntimeAuthority([REDACTED])") + } +} + +/// Finalized route whose resource was rechecked immediately before effect. +pub struct AuthorizedRoute { + route: ResolvedProtectedRoute, + authorization: FinalizedAuthContext, + witness: Arc, + invalidation: InvalidationRegistration, +} + +impl AuthorizedRoute { + /// Exact admitted route. + pub const fn route(&self) -> ResolvedProtectedRoute { + self.route + } + + /// Credential-free finalized context. + pub const fn authorization(&self) -> &FinalizedAuthContext { + &self.authorization + } + + /// Re-fence a long-running operation against current authorization and + /// resource state. Drift or expiry terminates the operation. + pub async fn recheck_stream(&self) -> Result<(), RuntimeAuthorizationError> { + if self.invalidation.is_cancelled() { + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + let lease_snapshot = self.authorization.lease().dependency_snapshot(); + let rechecked = self.witness.recheck(&lease_snapshot).await?; + validate_resource_recheck(&self.authorization, self.witness.as_ref(), &rechecked) + } +} + +impl fmt::Debug for AuthorizedRoute { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizedRoute([REDACTED])") + } +} + +fn validate_prepared_coordinates( + route: ResolvedProtectedRoute, + prepared: &PreparedAuthorization, + witness: &dyn ProtectedResourceWitness, +) -> Result<(), RuntimeAuthorizationError> { + let snapshot = prepared.recheck_request().lease_dependencies(); + let (_, domain) = snapshot.identity(); + let (capability, _, _) = snapshot.authority(); + let (request, target, transport, transport_context) = snapshot.request_binding(); + let (_, _, authority_epoch) = snapshot.dependency_versions(); + if domain != witness.authorization_domain() + || capability != route.capability() + || capability != witness.capability() + || route.resource() != witness.resource_kind() + || route.transport() != transport + || transport != witness.transport() + || request != witness.request_fingerprint() + || target != witness.target_fingerprint() + || transport_context != witness.transport_context_fingerprint() + || authority_epoch != witness.authority_epoch() + || witness.resource_key() == &[0; 32] + || witness.expires_at() > prepared.expires_at() + { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + Ok(()) +} + +fn validate_enrollment_coordinates( + route: ResolvedProtectedRoute, + proof: &VerifiedNostrProof, + witness: &dyn ProtectedResourceWitness, +) -> Result<(), RuntimeAuthorizationError> { + if proof.authorization_domain() != witness.authorization_domain() + || route.capability() != witness.capability() + || route.resource() != witness.resource_kind() + || route.transport() != witness.transport() + || proof.transport() != witness.transport() + || proof.request_fingerprint() != witness.request_fingerprint() + || proof.target_fingerprint() != witness.target_fingerprint() + || proof.transport_context_fingerprint() != witness.transport_context_fingerprint() + || witness.resource_key() == &[0; 32] + || witness.expires_at() > proof.expires_at() + { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + Ok(()) +} + +fn validate_replay_claim( + witness: &dyn ProtectedResourceWitness, +) -> Result<(), RuntimeAuthorizationError> { + match ( + witness.replay_claim_key(), + witness.replay_claim_retain_until(), + ) { + (None, None) => Ok(()), + (Some(key), Some(retain_until)) + if key != &[0; 32] && retain_until == witness.expires_at() => + { + Ok(()) + } + _ => Err(RuntimeAuthorizationError::ResourceWitnessMismatch), + } +} + +fn validate_finalized_coordinates( + route: ResolvedProtectedRoute, + authorization: &FinalizedAuthContext, + witness: &dyn ProtectedResourceWitness, +) -> Result<(), RuntimeAuthorizationError> { + let lease = authorization.lease(); + let (request, target, transport_context) = lease.request_binding(); + let (_, _, authority_epoch) = lease.dependency_versions(); + if authorization.authorization_domain() != witness.authorization_domain() + || authorization.capability() != route.capability() + || authorization.capability() != witness.capability() + || authorization.transport() != route.transport() + || authorization.transport() != witness.transport() + || authorization.request_fingerprint() != witness.request_fingerprint() + || request != witness.request_fingerprint() + || target != witness.target_fingerprint() + || transport_context != witness.transport_context_fingerprint() + || authority_epoch != witness.authority_epoch() + { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + Ok(()) +} + +fn validate_resource_recheck( + authorization: &FinalizedAuthContext, + witness: &dyn ProtectedResourceWitness, + rechecked: &ResourceRecheck, +) -> Result<(), RuntimeAuthorizationError> { + if rechecked.authorization_domain != witness.authorization_domain() + || rechecked.resource_kind != witness.resource_kind() + || rechecked.resource_key.as_slice() != witness.resource_key() + || rechecked.authority_epoch != witness.authority_epoch() + || rechecked.authoritative_now >= witness.expires_at() + || !authorization + .lease() + .is_valid_at(rechecked.authoritative_now) + { + return Err(RuntimeAuthorizationError::ResourceWitnessMismatch); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Witness { + expires_at: DateTime, + claim_key: Option<[u8; 32]>, + retain_until: Option>, + } + + #[async_trait] + impl ProtectedResourceWitness for Witness { + fn authorization_domain(&self) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(1)) + } + + fn capability(&self) -> RouteCapability { + RouteCapability::MessagesRead + } + + fn resource_kind(&self) -> ProtectedResourceKind { + ProtectedResourceKind::Domain + } + + fn resource_key(&self) -> &[u8; 32] { + static KEY: [u8; 32] = [1; 32]; + &KEY + } + + fn transport(&self) -> ProofTransport { + ProofTransport::Nip98 + } + + fn request_fingerprint(&self) -> &[u8; 32] { + static FINGERPRINT: [u8; 32] = [2; 32]; + &FINGERPRINT + } + + fn target_fingerprint(&self) -> &[u8; 32] { + static FINGERPRINT: [u8; 32] = [3; 32]; + &FINGERPRINT + } + + fn transport_context_fingerprint(&self) -> &[u8; 32] { + static FINGERPRINT: [u8; 32] = [4; 32]; + &FINGERPRINT + } + + fn authority_epoch(&self) -> u64 { + 1 + } + + fn expires_at(&self) -> DateTime { + self.expires_at + } + + fn replay_claim_key(&self) -> Option<&[u8; 32]> { + self.claim_key.as_ref() + } + + fn replay_claim_retain_until(&self) -> Option> { + self.retain_until + } + + async fn recheck( + &self, + _lease: &AuthorizationLeaseDependencySnapshot, + ) -> Result { + std::future::pending().await + } + } + + #[test] + fn replay_claim_is_absent_or_exactly_paired_and_expiry_bound() { + let expires_at = Utc::now() + chrono::Duration::seconds(60); + let witness = |claim_key, retain_until| Witness { + expires_at, + claim_key, + retain_until, + }; + assert!(validate_replay_claim(&witness(None, None)).is_ok()); + assert!(validate_replay_claim(&witness(Some([1; 32]), Some(expires_at))).is_ok()); + assert_eq!( + validate_replay_claim(&witness(Some([0; 32]), Some(expires_at))), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + assert_eq!( + validate_replay_claim(&witness(Some([1; 32]), None)), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + assert_eq!( + validate_replay_claim(&witness( + Some([1; 32]), + Some(expires_at + chrono::Duration::seconds(1)), + )), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + } + + #[test] + fn resource_recheck_rejects_unallocated_server_coordinates() { + let now = Utc::now(); + assert!(ResourceRecheck::from_authoritative_parts( + CommunityId::from_uuid(Uuid::from_u128(1)), + ProtectedResourceKind::Domain, + [1; 32], + 1, + now, + ) + .is_ok()); + assert_eq!( + ResourceRecheck::from_authoritative_parts( + CommunityId::from_uuid(Uuid::nil()), + ProtectedResourceKind::Domain, + [1; 32], + 1, + now, + ), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + assert_eq!( + ResourceRecheck::from_authoritative_parts( + CommunityId::from_uuid(Uuid::from_u128(1)), + ProtectedResourceKind::Domain, + [0; 32], + 0, + now, + ), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/canonical_admission.rs b/crates/buzz-relay/src/authorization_runtime/canonical_admission.rs new file mode 100644 index 00000000000..d8bce05a068 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/canonical_admission.rs @@ -0,0 +1,185 @@ +//! Adapter to the canonical atomic admission contract. + +use async_trait::async_trait; +use buzz_db::authorization_admission::{ + AdmissionCommitError, AdmissionCommitOutcome, AdmissionCommitRequest, AdmissionObject, + AdmissionObjectKind, AdmissionReplayClaim, AdmissionReplayClaimKind, + CanonicalAdmissionCommitter, +}; + +use super::authority::RuntimeAdmissionPreparation; +use super::{ + AdmissionCommitPort, ProtectedResourceKind, RuntimeAdmissionRequest, RuntimeAuthorizationError, +}; + +/// Exact relay adapter to the sole canonical admission committer. +pub struct CanonicalAdmissionAdapter { + committer: C, +} + +impl CanonicalAdmissionAdapter { + /// Bind one concrete canonical committer. + pub const fn new(committer: C) -> Self { + Self { committer } + } +} + +impl std::fmt::Debug for CanonicalAdmissionAdapter { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("CanonicalAdmissionAdapter([REDACTED])") + } +} + +#[async_trait] +impl AdmissionCommitPort for CanonicalAdmissionAdapter +where + C: CanonicalAdmissionCommitter, +{ + async fn commit( + &self, + request: RuntimeAdmissionRequest, + ) -> Result { + let (_operation_id, attempt_id, route, preparation, witness) = request.into_parts(); + let object_kind = admission_object_kind(witness.resource_kind())?; + let object = AdmissionObject::new(object_kind, *witness.resource_key()) + .ok_or(RuntimeAuthorizationError::ResourceWitnessMismatch)?; + let mut request = match preparation { + RuntimeAdmissionPreparation::Existing(prepared) => { + AdmissionCommitRequest::existing(attempt_id, object, *prepared) + } + RuntimeAdmissionPreparation::Enrollment(enrollment) => { + AdmissionCommitRequest::enrollment( + attempt_id, + enrollment.correlation_id, + object, + route.capability(), + enrollment.evidence, + ) + } + } + .map_err(map_admission_error)?; + + match ( + witness.replay_claim_key(), + witness.replay_claim_retain_until(), + ) { + (Some(key), Some(retain_until)) => { + let claim = AdmissionReplayClaim::new( + AdmissionReplayClaimKind::TrustedProxyNonce, + *key, + retain_until, + ) + .map_err(map_admission_error)?; + request = request.with_replay_claim(claim); + } + (None, None) => {} + _ => return Err(RuntimeAuthorizationError::ResourceWitnessMismatch), + } + + match self + .committer + .commit(request) + .await + .map_err(map_admission_error)? + { + AdmissionCommitOutcome::Committed { authorization, .. } => Ok(*authorization), + AdmissionCommitOutcome::ExactReplay { .. } => { + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + } + } + } +} + +fn admission_object_kind( + resource: ProtectedResourceKind, +) -> Result { + match resource { + ProtectedResourceKind::Domain => Ok(AdmissionObjectKind::Domain), + ProtectedResourceKind::Channel => Ok(AdmissionObjectKind::Channel), + ProtectedResourceKind::Repository => Ok(AdmissionObjectKind::Repository), + ProtectedResourceKind::Media => Ok(AdmissionObjectKind::Media), + ProtectedResourceKind::ModerationTarget => Ok(AdmissionObjectKind::ModerationTarget), + ProtectedResourceKind::AudioSession => Ok(AdmissionObjectKind::AudioSession), + ProtectedResourceKind::Event => Ok(AdmissionObjectKind::Event), + ProtectedResourceKind::Invitation => Ok(AdmissionObjectKind::Invitation), + ProtectedResourceKind::BindingStatus => Ok(AdmissionObjectKind::BindingStatus), + ProtectedResourceKind::DelegatedAgent => { + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + } + } +} + +const fn map_admission_error(error: AdmissionCommitError) -> RuntimeAuthorizationError { + match error { + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => { + RuntimeAuthorizationError::DependencyUnavailable + } + AdmissionCommitError::InvalidRequest + | AdmissionCommitError::RecordedInvalidRequest + | AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied + | AdmissionCommitError::IntentConflict + | AdmissionCommitError::RecordedIntentConflict + | AdmissionCommitError::ReplayRejected + | AdmissionCommitError::RecordedReplayRejected => { + RuntimeAuthorizationError::ResourceWitnessMismatch + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn admission_object_mapping_rejects_unsealed_resource_namespaces() { + for supported in [ + ProtectedResourceKind::Domain, + ProtectedResourceKind::Channel, + ProtectedResourceKind::Repository, + ProtectedResourceKind::Media, + ProtectedResourceKind::ModerationTarget, + ProtectedResourceKind::AudioSession, + ProtectedResourceKind::Event, + ProtectedResourceKind::Invitation, + ProtectedResourceKind::BindingStatus, + ] { + assert!(admission_object_kind(supported).is_ok()); + } + assert_eq!( + admission_object_kind(ProtectedResourceKind::DelegatedAgent), + Err(RuntimeAuthorizationError::ResourceWitnessMismatch) + ); + } + + #[test] + fn admission_errors_map_dependency_failures_separately_from_denials() { + for dependency in [ + AdmissionCommitError::AuditUnavailable, + AdmissionCommitError::RecordedAuditUnavailable, + AdmissionCommitError::DependencyUnavailable, + ] { + assert_eq!( + map_admission_error(dependency), + RuntimeAuthorizationError::DependencyUnavailable + ); + } + for denial in [ + AdmissionCommitError::InvalidRequest, + AdmissionCommitError::RecordedInvalidRequest, + AdmissionCommitError::AuthorizationDenied, + AdmissionCommitError::RecordedAuthorizationDenied, + AdmissionCommitError::IntentConflict, + AdmissionCommitError::RecordedIntentConflict, + AdmissionCommitError::ReplayRejected, + AdmissionCommitError::RecordedReplayRejected, + ] { + assert_eq!( + map_admission_error(denial), + RuntimeAuthorizationError::ResourceWitnessMismatch + ); + } + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/config.rs b/crates/buzz-relay/src/authorization_runtime/config.rs new file mode 100644 index 00000000000..16e5c456ea6 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/config.rs @@ -0,0 +1,735 @@ +//! Sole provider-free runtime configuration document. + +use std::collections::BTreeSet; +use std::time::Duration; + +use base64::Engine as _; +use buzz_auth::{ + AuthorizationEventCapacityPolicy, CanonicalVerifierPolicy, NipFiMode, RouteCapability, + TrustedProxyProvenanceVerifier, +}; +use serde::Deserialize; +use url::Url; + +use super::RuntimeAuthorizationError; + +/// Sole environment variable carrying the NIP-FI V1 configuration document. +pub const CONFIG_ENV: &str = "BUZZ_NIP_FI_V1_CONFIG_JSON"; + +const MAX_LEASE_SECONDS: u64 = 3_600; +const MAX_DELEGATION_SECONDS: u64 = 3_600; + +/// Closed runtime mode after emergency-denial precedence is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderFreeRuntimeMode { + /// Configuration is absent and protected composition is not installed. + Off, + /// Complete provider-free protected composition is required. + Enforce, + /// Emergency denial overrides every configured protected capability. + DenyProtected, +} + +impl From for NipFiMode { + fn from(mode: ProviderFreeRuntimeMode) -> Self { + match mode { + ProviderFreeRuntimeMode::Off => Self::Off, + ProviderFreeRuntimeMode::Enforce => Self::Enforce, + ProviderFreeRuntimeMode::DenyProtected => Self::DenyProtected, + } + } +} + +/// HTTPS source for the canonical verifier's rotating keys. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum JwksSourceConfig { + /// Fetch a JWKS document directly. + JwksUri(Url), + /// Resolve `jwks_uri` from an issuer-matched discovery document. + DiscoveryUri(Url), +} + +impl JwksSourceConfig { + /// Configured HTTPS endpoint. + pub const fn endpoint(&self) -> &Url { + match self { + Self::JwksUri(uri) | Self::DiscoveryUri(uri) => uri, + } + } +} + +/// Explicit delegation policy. Disabled is the default and has no authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DelegationConfig { + /// Delegated authorization is unavailable. + Disabled, + /// Only the listed capabilities may be delegated for the finite bound. + Enabled { + /// Closed capability allow-list. + capabilities: BTreeSet, + /// Maximum delegated lifetime, further bounded by every lease edge. + maximum_lifetime: Duration, + }, +} + +/// Complete enabled-mode configuration independent of storage adapters. +#[derive(Clone)] +pub struct EnforceRuntimeConfig { + verifier_policy: CanonicalVerifierPolicy, + issuer: String, + jwks_source: JwksSourceConfig, + lease_maximum: Duration, + policy_revision: u64, + audit_capacity: AuthorizationEventCapacityPolicy, + client_status_admission: ClientStatusAdmissionPolicy, + delegation: DelegationConfig, + transport: serde_json::Map, + enrollment: serde_json::Map, + restore: serde_json::Map, +} + +impl EnforceRuntimeConfig { + /// Stable verifier policy constructed from the sole document. + pub const fn verifier_policy(&self) -> &CanonicalVerifierPolicy { + &self.verifier_policy + } + + /// Expected issuer used to bind discovery metadata. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Rotating key source. + pub const fn jwks_source(&self) -> &JwksSourceConfig { + &self.jwks_source + } + + /// Installation lease ceiling. + pub const fn lease_maximum(&self) -> Duration { + self.lease_maximum + } + + /// Positive local policy revision. + pub const fn policy_revision(&self) -> u64 { + self.policy_revision + } + + /// Immutable authorization-event capacity. + pub const fn audit_capacity(&self) -> AuthorizationEventCapacityPolicy { + self.audit_capacity + } + + /// Explicit bounded admission policy for optional client status. + pub const fn client_status_admission(&self) -> ClientStatusAdmissionPolicy { + self.client_status_admission + } + + /// Explicit delegation policy. + pub const fn delegation(&self) -> &DelegationConfig { + &self.delegation + } + + /// Opaque transport configuration reserved for the transport authority adapter. + pub const fn transport_config(&self) -> &serde_json::Map { + &self.transport + } + + /// Build the sole trusted-proxy transport verifier for production ingress. + pub(crate) fn trusted_proxy_verifier( + &self, + ) -> Result { + let document: TrustedProxyDocument = + serde_json::from_value(serde_json::Value::Object(self.transport.clone())) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?; + if document.kind != "trusted_proxy_hmac_v2" { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + let secrets = document + .active_secrets_base64url + .into_iter() + .map(|encoded| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration) + }) + .collect::, _>>()?; + TrustedProxyProvenanceVerifier::new( + secrets, + Duration::from_secs(document.maximum_provenance_age_seconds), + Duration::from_secs(document.future_skew_seconds), + ) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration) + } + + /// Opaque enrollment configuration reserved for canonical admission. + pub const fn enrollment_config(&self) -> &serde_json::Map { + &self.enrollment + } + + /// Opaque restore backend configuration reserved for the restore adapter. + pub const fn restore_config(&self) -> &serde_json::Map { + &self.restore + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TrustedProxyDocument { + kind: String, + active_secrets_base64url: Vec, + maximum_provenance_age_seconds: u64, + #[serde(default)] + future_skew_seconds: u64, +} + +/// Explicit three-coordinate limits for optional client-status presentation. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ClientStatusAdmissionPolicy { + max_presentations_per_domain: u64, + max_presentations_per_actor: u64, + max_presentations_per_peer: u64, +} + +impl ClientStatusAdmissionPolicy { + pub(crate) fn new( + capacity: AuthorizationEventCapacityPolicy, + max_presentations_per_domain: u64, + max_presentations_per_actor: u64, + max_presentations_per_peer: u64, + ) -> Result { + if max_presentations_per_domain == 0 + || max_presentations_per_actor == 0 + || max_presentations_per_peer == 0 + || max_presentations_per_domain > capacity.max_events_per_domain() + || max_presentations_per_actor > max_presentations_per_domain + || max_presentations_per_peer > max_presentations_per_domain + { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + Ok(Self { + max_presentations_per_domain, + max_presentations_per_actor, + max_presentations_per_peer, + }) + } + + /// Fixed-window domain limit. + pub const fn max_presentations_per_domain(self) -> u64 { + self.max_presentations_per_domain + } + + /// Fixed-window authenticated-actor limit. + pub const fn max_presentations_per_actor(self) -> u64 { + self.max_presentations_per_actor + } + + /// Fixed-window authenticated-peer limit. + pub const fn max_presentations_per_peer(self) -> u64 { + self.max_presentations_per_peer + } +} + +impl std::fmt::Debug for ClientStatusAdmissionPolicy { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ClientStatusAdmissionPolicy([REDACTED])") + } +} + +impl std::fmt::Debug for EnforceRuntimeConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("EnforceRuntimeConfig([REDACTED])") + } +} + +/// Validated sole runtime configuration. +#[derive(Clone)] +pub struct ProviderFreeRuntimeConfig { + mode: ProviderFreeRuntimeMode, + enforce: Option, +} + +impl ProviderFreeRuntimeConfig { + /// Parse an optional JSON document. Absence is exactly Off. + pub fn from_optional_json(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(Self::off()); + }; + if raw.trim().is_empty() { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + let document: RuntimeDocument = serde_json::from_str(raw) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?; + if document.deny_protected { + return Ok(Self { + mode: ProviderFreeRuntimeMode::DenyProtected, + enforce: None, + }); + } + let enforce = document.validate_enforce()?; + Ok(Self { + mode: ProviderFreeRuntimeMode::Enforce, + enforce: Some(enforce), + }) + } + + /// Stock provider-free runtime default. + pub const fn off() -> Self { + Self { + mode: ProviderFreeRuntimeMode::Off, + enforce: None, + } + } + + /// Closed operating mode. + pub const fn mode(&self) -> ProviderFreeRuntimeMode { + self.mode + } + + /// Complete enabled configuration, present only in Enforce mode. + pub const fn enforce(&self) -> Option<&EnforceRuntimeConfig> { + self.enforce.as_ref() + } +} + +impl std::fmt::Debug for ProviderFreeRuntimeConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProviderFreeRuntimeConfig") + .field("mode", &self.mode) + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeDocument { + #[serde(default)] + deny_protected: bool, + issuer: Option, + audience: Option, + #[serde(default = "default_subject_claim")] + subject_claim: String, + event_author_claim: Option, + #[serde(default)] + clock_skew_seconds: u64, + maximum_token_lifetime_seconds: Option, + jwks: Option, + lease: Option, + policy_revision: Option, + audit: Option, + client_status_admission: Option, + transport: Option>, + enrollment: Option>, + restore: Option>, + #[serde(default)] + delegation: DelegationDocument, +} + +impl RuntimeDocument { + fn validate_enforce(self) -> Result { + let issuer = required_text(self.issuer)?; + let audience = required_text(self.audience)?; + let maximum_token_lifetime_seconds = self + .maximum_token_lifetime_seconds + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let verifier_policy = CanonicalVerifierPolicy::new( + issuer.clone(), + audience, + self.subject_claim, + self.event_author_claim, + self.clock_skew_seconds, + maximum_token_lifetime_seconds, + ) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?; + let jwks_source = self + .jwks + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)? + .validate()?; + let lease_maximum_seconds = self + .lease + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)? + .maximum_seconds; + if lease_maximum_seconds == 0 || lease_maximum_seconds > MAX_LEASE_SECONDS { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + let policy_revision = self + .policy_revision + .filter(|revision| *revision > 0) + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let audit = self + .audit + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let audit_capacity = AuthorizationEventCapacityPolicy::new( + audit.max_events_per_domain, + audit.max_bytes_per_domain, + audit.max_envelope_bytes, + ) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?; + let client_status_admission = self + .client_status_admission + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let client_status_admission = ClientStatusAdmissionPolicy::new( + audit_capacity, + client_status_admission.max_presentations_per_domain, + client_status_admission.max_presentations_per_actor, + client_status_admission.max_presentations_per_peer, + )?; + let transport = required_nonempty_map(self.transport)?; + let enrollment = required_nonempty_map(self.enrollment)?; + let restore = required_nonempty_map(self.restore)?; + let delegation = self.delegation.validate(lease_maximum_seconds)?; + + Ok(EnforceRuntimeConfig { + verifier_policy, + issuer, + jwks_source, + lease_maximum: Duration::from_secs(lease_maximum_seconds), + policy_revision, + audit_capacity, + client_status_admission, + delegation, + transport, + enrollment, + restore, + }) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JwksDocument { + jwks_uri: Option, + discovery_uri: Option, +} + +impl JwksDocument { + fn validate(self) -> Result { + match (self.jwks_uri, self.discovery_uri) { + (Some(uri), None) => Ok(JwksSourceConfig::JwksUri(valid_https_url(&uri)?)), + (None, Some(uri)) => Ok(JwksSourceConfig::DiscoveryUri(valid_https_url(&uri)?)), + _ => Err(RuntimeAuthorizationError::InvalidConfiguration), + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LeaseDocument { + maximum_seconds: u64, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AuditDocument { + max_events_per_domain: u64, + max_bytes_per_domain: u64, + max_envelope_bytes: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ClientStatusAdmissionDocument { + max_presentations_per_domain: u64, + max_presentations_per_actor: u64, + max_presentations_per_peer: u64, +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct DelegationDocument { + #[serde(default)] + enabled: bool, + #[serde(default)] + capabilities: Vec, + maximum_seconds: Option, +} + +impl DelegationDocument { + fn validate( + self, + lease_maximum_seconds: u64, + ) -> Result { + if !self.enabled { + if !self.capabilities.is_empty() || self.maximum_seconds.is_some() { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + return Ok(DelegationConfig::Disabled); + } + let maximum_seconds = self + .maximum_seconds + .filter(|seconds| { + *seconds > 0 + && *seconds <= MAX_DELEGATION_SECONDS + && *seconds <= lease_maximum_seconds + }) + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let capability_count = self.capabilities.len(); + let capabilities: BTreeSet<_> = self + .capabilities + .into_iter() + .map(CapabilityDocument::into_capability) + .collect(); + if capabilities.is_empty() || capabilities.len() != capability_count { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + Ok(DelegationConfig::Enabled { + capabilities, + maximum_lifetime: Duration::from_secs(maximum_seconds), + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum CapabilityDocument { + MessagesRead, + MessagesWrite, + ChannelsRead, + ChannelsWrite, + AdminChannels, + UsersRead, + UsersWrite, + AdminUsers, + JobsRead, + JobsWrite, + SubscriptionsRead, + SubscriptionsWrite, + FilesRead, + FilesWrite, + ReposRead, + ReposWrite, + GitRead, + GitWrite, + GitStream, + MediaRead, + MediaWrite, + Moderation, + AudioJoin, + AudioMedia, + Discovery, + BindingStatus, + Enrollment, + InviteMint, + InviteClaim, +} + +impl CapabilityDocument { + const fn into_capability(self) -> RouteCapability { + match self { + Self::MessagesRead => RouteCapability::MessagesRead, + Self::MessagesWrite => RouteCapability::MessagesWrite, + Self::ChannelsRead => RouteCapability::ChannelsRead, + Self::ChannelsWrite => RouteCapability::ChannelsWrite, + Self::AdminChannels => RouteCapability::AdminChannels, + Self::UsersRead => RouteCapability::UsersRead, + Self::UsersWrite => RouteCapability::UsersWrite, + Self::AdminUsers => RouteCapability::AdminUsers, + Self::JobsRead => RouteCapability::JobsRead, + Self::JobsWrite => RouteCapability::JobsWrite, + Self::SubscriptionsRead => RouteCapability::SubscriptionsRead, + Self::SubscriptionsWrite => RouteCapability::SubscriptionsWrite, + Self::FilesRead => RouteCapability::FilesRead, + Self::FilesWrite => RouteCapability::FilesWrite, + Self::ReposRead => RouteCapability::ReposRead, + Self::ReposWrite => RouteCapability::ReposWrite, + Self::GitRead => RouteCapability::GitRead, + Self::GitWrite => RouteCapability::GitWrite, + Self::GitStream => RouteCapability::GitStream, + Self::MediaRead => RouteCapability::MediaRead, + Self::MediaWrite => RouteCapability::MediaWrite, + Self::Moderation => RouteCapability::Moderation, + Self::AudioJoin => RouteCapability::AudioJoin, + Self::AudioMedia => RouteCapability::AudioMedia, + Self::Discovery => RouteCapability::Discovery, + Self::BindingStatus => RouteCapability::BindingStatus, + Self::Enrollment => RouteCapability::Enrollment, + Self::InviteMint => RouteCapability::InviteMint, + Self::InviteClaim => RouteCapability::InviteClaim, + } + } +} + +fn default_subject_claim() -> String { + "sub".to_owned() +} + +fn required_text(value: Option) -> Result { + value + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .ok_or(RuntimeAuthorizationError::InvalidConfiguration) +} + +fn required_nonempty_map( + value: Option>, +) -> Result, RuntimeAuthorizationError> { + value + .filter(|value| !value.is_empty()) + .ok_or(RuntimeAuthorizationError::InvalidConfiguration) +} + +fn valid_https_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn enforce_json(extra: &str) -> String { + format!( + r#"{{ + "issuer":"https://issuer.example", + "audience":"buzz", + "maximum_token_lifetime_seconds":300, + "jwks":{{"jwks_uri":"https://issuer.example/keys"}}, + "lease":{{"maximum_seconds":120}}, + "policy_revision":1, + "audit":{{"max_events_per_domain":100,"max_bytes_per_domain":65536,"max_envelope_bytes":4096}}, + "client_status_admission":{{"max_presentations_per_domain":100,"max_presentations_per_actor":5,"max_presentations_per_peer":20}}, + "transport":{{"kind":"sealed_nostr_proof"}}, + "enrollment":{{"kind":"canonical_admission"}}, + "restore":{{"kind":"operation_manifest"}} + {extra} + }}"# + ) + } + + #[test] + fn absence_is_off_and_deny_overrides_incomplete_document() { + assert_eq!( + ProviderFreeRuntimeConfig::from_optional_json(None) + .unwrap() + .mode(), + ProviderFreeRuntimeMode::Off + ); + assert_eq!( + ProviderFreeRuntimeConfig::from_optional_json(Some(r#"{"deny_protected":true}"#)) + .unwrap() + .mode(), + ProviderFreeRuntimeMode::DenyProtected + ); + } + + #[test] + fn enforce_requires_every_dependency_family() { + let config = + ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json(""))).unwrap(); + assert_eq!(config.mode(), ProviderFreeRuntimeMode::Enforce); + assert!(config.enforce().is_some()); + + let mut missing_restore: serde_json::Value = + serde_json::from_str(&enforce_json("")).unwrap(); + missing_restore.as_object_mut().unwrap().remove("restore"); + let missing_restore = serde_json::to_string(&missing_restore).unwrap(); + assert!(ProviderFreeRuntimeConfig::from_optional_json(Some(&missing_restore)).is_err()); + } + + #[test] + fn production_transport_requires_exact_v2_provenance_configuration() { + let mut configured: serde_json::Value = serde_json::from_str(&enforce_json("")).unwrap(); + configured["transport"] = serde_json::json!({ + "kind": "trusted_proxy_hmac_v2", + "active_secrets_base64url": [ + "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" + ], + "maximum_provenance_age_seconds": 60, + "future_skew_seconds": 5 + }); + let configured = serde_json::to_string(&configured).unwrap(); + let parsed = ProviderFreeRuntimeConfig::from_optional_json(Some(&configured)).unwrap(); + assert!(parsed.enforce().unwrap().trusted_proxy_verifier().is_ok()); + + let legacy = + ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json(""))).unwrap(); + assert!(legacy.enforce().unwrap().trusted_proxy_verifier().is_err()); + } + + #[test] + fn status_admission_is_explicit_bounded_and_redacted() { + let configured = + ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json(""))).unwrap(); + let policy = configured.enforce().unwrap().client_status_admission(); + assert_eq!(policy.max_presentations_per_domain(), 100); + assert_eq!(policy.max_presentations_per_actor(), 5); + assert_eq!(policy.max_presentations_per_peer(), 20); + assert_eq!( + format!("{policy:?}"), + "ClientStatusAdmissionPolicy([REDACTED])" + ); + + let base: serde_json::Value = serde_json::from_str(&enforce_json("")).unwrap(); + let mut rejected = Vec::new(); + + let mut missing = base.clone(); + missing + .as_object_mut() + .unwrap() + .remove("client_status_admission"); + rejected.push(missing); + + for (coordinate, value) in [ + ("max_presentations_per_domain", 0), + ("max_presentations_per_domain", 101), + ("max_presentations_per_actor", 101), + ("max_presentations_per_peer", 101), + ] { + let mut invalid = base.clone(); + invalid["client_status_admission"][coordinate] = serde_json::json!(value); + rejected.push(invalid); + } + + for invalid in rejected { + let invalid = serde_json::to_string(&invalid).unwrap(); + assert!(ProviderFreeRuntimeConfig::from_optional_json(Some(&invalid)).is_err()); + } + } + + #[test] + fn delegation_is_disabled_by_default_and_finite_when_enabled() { + let disabled = + ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json(""))).unwrap(); + assert_eq!( + disabled.enforce().unwrap().delegation(), + &DelegationConfig::Disabled + ); + + let enabled = ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json( + r#", "delegation":{"enabled":true,"capabilities":["git_read"],"maximum_seconds":60}"#, + ))) + .unwrap(); + assert!(matches!( + enabled.enforce().unwrap().delegation(), + DelegationConfig::Enabled { .. } + )); + + let unbounded = + enforce_json(r#", "delegation":{"enabled":true,"capabilities":["git_read"]}"#); + assert!(ProviderFreeRuntimeConfig::from_optional_json(Some(&unbounded)).is_err()); + } + + #[test] + fn verifier_policy_identity_does_not_depend_on_key_source() { + let direct = + ProviderFreeRuntimeConfig::from_optional_json(Some(&enforce_json(""))).unwrap(); + let discovery_json = enforce_json("").replace( + r#""jwks_uri":"https://issuer.example/keys""#, + r#""discovery_uri":"https://issuer.example/.well-known/openid-configuration""#, + ); + let discovery = + ProviderFreeRuntimeConfig::from_optional_json(Some(&discovery_json)).unwrap(); + assert_eq!( + direct.enforce().unwrap().verifier_policy().id(), + discovery.enforce().unwrap().verifier_policy().id() + ); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/error.rs b/crates/buzz-relay/src/authorization_runtime/error.rs new file mode 100644 index 00000000000..32368716cdb --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/error.rs @@ -0,0 +1,72 @@ +//! Stable, credential-free runtime failures. + +use thiserror::Error; + +/// Fail-closed errors returned by the provider-free relay runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum RuntimeAuthorizationError { + /// The sole runtime configuration was absent or internally inconsistent. + #[error("invalid provider-free authorization configuration")] + InvalidConfiguration, + /// The closed protected-route inventory was incomplete or ambiguous. + #[error("protected route inventory is incomplete")] + IncompleteRouteInventory, + /// No exact protected route matched the request. + #[error("protected route is not classified")] + UnknownProtectedRoute, + /// The verified transport did not match the registered route. + #[error("protected route transport mismatch")] + TransportMismatch, + /// A protected resource witness did not match route or authorization state. + #[error("protected resource witness mismatch")] + ResourceWitnessMismatch, + /// A required storage, lifecycle, transport, or status dependency is absent. + #[error("provider-free authorization dependency unavailable")] + DependencyUnavailable, + /// Verifier policy, keys, or freshness changed before final use. + #[error("provider-free verifier state is stale")] + StaleVerifier, + /// The current verifier rejected the presented assertion. + #[error("provider-free verifier rejected assertion")] + AssertionDenied, + /// The assertion's exact half-open validity interval has elapsed. + #[error("provider-free assertion expired")] + AssertionExpired, + /// Invalidation delivery or reconciliation is not healthy. + #[error("authorization invalidation state is unavailable")] + InvalidationUnavailable, + /// Restore evidence was missing, ambiguous, stale, or inconsistent. + #[error("authorization restore was rejected")] + RestoreRejected, + /// Current status could not be safely produced or withdrawn. + #[error("current authorization status is unavailable")] + StatusUnavailable, + /// Startup attempted to install a partial or mixed-lineage runtime. + #[error("provider-free runtime state is incomplete")] + PartialState, + /// The installed runtime is not ready for protected work. + #[error("provider-free runtime is not ready")] + NotReady, +} + +impl RuntimeAuthorizationError { + /// Stable machine code safe for logs, metrics, and protocol denials. + pub const fn code(self) -> &'static str { + match self { + Self::InvalidConfiguration => "nip_fi_runtime_invalid_config", + Self::IncompleteRouteInventory => "nip_fi_runtime_incomplete_routes", + Self::UnknownProtectedRoute => "nip_fi_runtime_unknown_route", + Self::TransportMismatch => "nip_fi_runtime_transport_mismatch", + Self::ResourceWitnessMismatch => "nip_fi_runtime_resource_witness_mismatch", + Self::DependencyUnavailable => "nip_fi_runtime_dependency_unavailable", + Self::StaleVerifier => "nip_fi_runtime_stale_verifier", + Self::AssertionDenied => "nip_fi_runtime_assertion_denied", + Self::AssertionExpired => "nip_fi_auth_expired", + Self::InvalidationUnavailable => "nip_fi_runtime_invalidation_unavailable", + Self::RestoreRejected => "nip_fi_runtime_restore_rejected", + Self::StatusUnavailable => "nip_fi_runtime_status_unavailable", + Self::PartialState => "nip_fi_runtime_partial_state", + Self::NotReady => "nip_fi_runtime_not_ready", + } + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/invalidation.rs b/crates/buzz-relay/src/authorization_runtime/invalidation.rs new file mode 100644 index 00000000000..cec3931d734 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/invalidation.rs @@ -0,0 +1,610 @@ +//! Recoverable, fail-closed live invalidation registry. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use buzz_auth::BoundedAuthorizationLease; +use buzz_core::CommunityId; +use buzz_db::lifecycle_invalidation::{AdmissionLossNotice, LifecycleInvalidationNotice}; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::RuntimeAuthorizationError; + +/// Typed durable change observed from lifecycle or protected-object authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvalidationNotice { + /// Entire domain generation advanced. + Domain { + /// Server-resolved authorization domain. + domain: CommunityId, + /// New durable invalidation generation. + generation: u64, + }, + /// One exact lease lost admission. + Lease { + /// Server-resolved authorization domain. + domain: CommunityId, + /// Exact lease identifier. + lease_id: Uuid, + /// New durable invalidation generation. + generation: u64, + }, + /// A binding generation became invalid. + Binding { + /// Server-resolved authorization domain. + domain: CommunityId, + /// Exact binding identifier. + binding_id: Uuid, + /// First invalid binding version. + version_floor: u64, + /// New durable invalidation generation. + generation: u64, + }, + /// A delegated relationship generation became invalid. + Relationship { + /// Server-resolved authorization domain. + domain: CommunityId, + /// Exact relationship identifier. + relationship_id: Uuid, + /// First invalid relationship revision. + revision_floor: u64, + /// New durable invalidation generation. + generation: u64, + }, + /// Protected-object authority advanced. + AuthorityEpoch { + /// Server-resolved authorization domain. + domain: CommunityId, + /// First invalid authority epoch. + epoch_floor: u64, + /// New durable invalidation generation. + generation: u64, + }, + /// Local authorization policy or configuration advanced. + Policy { + /// Server-resolved authorization domain. + domain: CommunityId, + /// First invalid policy revision. + revision_floor: u64, + /// New durable invalidation generation. + generation: u64, + }, +} + +impl InvalidationNotice { + const fn domain(self) -> CommunityId { + match self { + Self::Domain { domain, .. } + | Self::Lease { domain, .. } + | Self::Binding { domain, .. } + | Self::Relationship { domain, .. } + | Self::AuthorityEpoch { domain, .. } + | Self::Policy { domain, .. } => domain, + } + } + + const fn generation(self) -> u64 { + match self { + Self::Domain { generation, .. } + | Self::Lease { generation, .. } + | Self::Binding { generation, .. } + | Self::Relationship { generation, .. } + | Self::AuthorityEpoch { generation, .. } + | Self::Policy { generation, .. } => generation, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LiveCoordinate { + lease_id: Uuid, + domain: CommunityId, + binding_id: Uuid, + binding_version: u64, + relationship: Option<(Uuid, u64)>, + policy_revision: u64, + invalidation_generation: u64, + authority_epoch: u64, + expires_at: DateTime, +} + +impl LiveCoordinate { + fn from_lease(lease: &BoundedAuthorizationLease) -> Self { + let (_, domain) = lease.dependency_snapshot().identity(); + let (binding_id, binding_version) = lease.binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + lease.dependency_versions(); + Self { + lease_id: lease.lease_id(), + domain, + binding_id, + binding_version, + relationship: lease.delegated_relationship(), + policy_revision, + invalidation_generation, + authority_epoch, + expires_at: lease.expires_at(), + } + } +} + +struct RegistryEntry { + coordinate: LiveCoordinate, + cancel: CancellationToken, +} + +struct RegistryInner { + entries: DashMap, + generations: DashMap, + ready: AtomicBool, + mutation_lock: std::sync::Mutex<()>, +} + +/// In-memory lease registry driven by durable monotonic notices. +#[derive(Clone)] +pub struct InvalidationRegistry { + inner: Arc, +} + +impl InvalidationRegistry { + /// Construct unready. Production must reconcile authoritative generations + /// before any registration can succeed. + pub fn new() -> Self { + Self { + inner: Arc::new(RegistryInner { + entries: DashMap::new(), + generations: DashMap::new(), + ready: AtomicBool::new(false), + mutation_lock: std::sync::Mutex::new(()), + }), + } + } + + /// Whether the feed has completed authoritative reconciliation and has not + /// subsequently observed a gap or loss. + pub fn is_ready(&self) -> bool { + self.inner.ready.load(Ordering::Acquire) + } + + /// Register one finalized lease. Generation is checked before and after + /// insertion to close the notice-before-registration race. + pub fn register( + &self, + lease: &BoundedAuthorizationLease, + ) -> Result { + self.register_coordinate(LiveCoordinate::from_lease(lease)) + } + + fn register_coordinate( + &self, + coordinate: LiveCoordinate, + ) -> Result { + if !self.is_ready() + || self.current_generation(coordinate.domain) + != Some(coordinate.invalidation_generation) + { + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + let registration_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + self.inner.entries.insert( + registration_id, + RegistryEntry { + coordinate: coordinate.clone(), + cancel: cancel.clone(), + }, + ); + if !self.is_ready() + || self.current_generation(coordinate.domain) + != Some(coordinate.invalidation_generation) + { + cancel.cancel(); + self.inner.entries.remove(®istration_id); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + Ok(InvalidationRegistration { + registration_id, + cancel, + inner: Arc::clone(&self.inner), + }) + } + + /// Apply one typed post-commit notice. A generation gap withdraws readiness + /// and cancels every live authorization in that domain until reconciliation. + pub fn apply(&self, notice: InvalidationNotice) -> Result { + let _mutation = self + .inner + .mutation_lock + .lock() + .map_err(|_| RuntimeAuthorizationError::InvalidationUnavailable)?; + let domain = notice.domain(); + let generation = notice.generation(); + if domain.as_uuid().is_nil() || generation == 0 { + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + let current = self.current_generation(domain).unwrap_or(0); + if self.is_ready() && current != 0 && generation > current.saturating_add(1) { + self.inner.ready.store(false, Ordering::Release); + self.cancel_domain(domain); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + if generation > current { + self.inner.generations.insert(domain, generation); + } + + let mut cancelled = 0; + for entry in &self.inner.entries { + if entry.coordinate.domain == domain && notice_matches(¬ice, &entry.coordinate) { + entry.cancel.cancel(); + cancelled += 1; + } + } + Ok(cancelled) + } + + /// Consume the database authority's credential-free lifecycle notice. It exports + /// selector fingerprints rather than the raw coordinates held by this + /// registry, so the relay conservatively invalidates the complete domain. + pub fn apply_lifecycle( + &self, + notice: &LifecycleInvalidationNotice, + ) -> Result { + self.apply(InvalidationNotice::Domain { + domain: notice.authorization_domain(), + generation: notice.generation(), + }) + } + + /// Consume the database authority's exact admission-loss notice without widening its lease + /// selector. + pub fn apply_admission_loss( + &self, + notice: AdmissionLossNotice, + ) -> Result { + self.apply(InvalidationNotice::Lease { + domain: notice.authorization_domain(), + lease_id: notice.lease_id(), + generation: notice.invalidation_generation(), + }) + } + + /// Mark feed loss, withdraw readiness, and cancel every protected operation. + pub fn feed_lost(&self) -> usize { + let _mutation = match self.inner.mutation_lock.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + self.inner.ready.store(false, Ordering::Release); + self.cancel_all() + } + + /// Recover only from one complete authoritative domain-generation snapshot. + pub fn reconcile( + &self, + state: ReconciledInvalidationState, + ) -> Result { + let _mutation = self + .inner + .mutation_lock + .lock() + .map_err(|_| RuntimeAuthorizationError::InvalidationUnavailable)?; + self.inner.ready.store(false, Ordering::Release); + if !state.complete { + self.cancel_all(); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + let mut seen = HashSet::new(); + let mut generations = BTreeMap::new(); + for (domain, generation) in state.domain_generations { + if domain.as_uuid().is_nil() || generation == 0 || !seen.insert(domain) { + self.cancel_all(); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + generations.insert(domain, generation); + } + let local_now = Utc::now(); + if state.observed_at > local_now + chrono::Duration::seconds(30) + || state.observed_at < local_now - chrono::Duration::seconds(300) + { + self.cancel_all(); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + + // A complete authoritative snapshot cannot forget a previously known + // domain or move its durable floor backwards. + for previous in &self.inner.generations { + if generations + .get(previous.key()) + .is_none_or(|generation| *generation < *previous.value()) + { + self.cancel_all(); + return Err(RuntimeAuthorizationError::InvalidationUnavailable); + } + } + + self.inner.generations.clear(); + for (domain, generation) in &generations { + self.inner.generations.insert(*domain, *generation); + } + let mut cancelled = 0; + for entry in &self.inner.entries { + let current = generations + .get(&entry.coordinate.domain) + .copied() + .unwrap_or(0); + if current != entry.coordinate.invalidation_generation + || state.observed_at >= entry.coordinate.expires_at + { + entry.cancel.cancel(); + cancelled += 1; + } + } + self.inner.ready.store(true, Ordering::Release); + Ok(cancelled) + } + + fn current_generation(&self, domain: CommunityId) -> Option { + self.inner.generations.get(&domain).map(|value| *value) + } + + fn cancel_domain(&self, domain: CommunityId) -> usize { + let mut cancelled = 0; + for entry in &self.inner.entries { + if entry.coordinate.domain == domain { + entry.cancel.cancel(); + cancelled += 1; + } + } + cancelled + } + + fn cancel_all(&self) -> usize { + let mut cancelled = 0; + for entry in &self.inner.entries { + entry.cancel.cancel(); + cancelled += 1; + } + cancelled + } +} + +impl Default for InvalidationRegistry { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Debug for InvalidationRegistry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("InvalidationRegistry([REDACTED])") + } +} + +/// Complete authoritative generation snapshot used after startup or feed loss. +#[derive(Debug, Clone)] +pub struct ReconciledInvalidationState { + /// The authoritative adapter proved that every installed domain was read. + pub complete: bool, + /// One current generation for every installed authorization domain. + pub domain_generations: Vec<(CommunityId, u64)>, + /// Authoritative database observation time. + pub observed_at: DateTime, +} + +/// Owned cancellation guard for one live authorization. +pub struct InvalidationRegistration { + registration_id: Uuid, + cancel: CancellationToken, + inner: Arc, +} + +impl InvalidationRegistration { + /// Token cancelled by matching notice, feed loss, or stale reconciliation. + pub fn cancellation_token(&self) -> CancellationToken { + self.cancel.clone() + } + + /// Whether this live authorization has been invalidated. + pub fn is_cancelled(&self) -> bool { + self.cancel.is_cancelled() + } +} + +impl Drop for InvalidationRegistration { + fn drop(&mut self) { + self.inner.entries.remove(&self.registration_id); + } +} + +impl std::fmt::Debug for InvalidationRegistration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("InvalidationRegistration([REDACTED])") + } +} + +fn notice_matches(notice: &InvalidationNotice, coordinate: &LiveCoordinate) -> bool { + match *notice { + InvalidationNotice::Domain { .. } => true, + InvalidationNotice::Lease { lease_id, .. } => coordinate.lease_id == lease_id, + InvalidationNotice::Binding { + binding_id, + version_floor, + .. + } => coordinate.binding_id == binding_id && coordinate.binding_version < version_floor, + InvalidationNotice::Relationship { + relationship_id, + revision_floor, + .. + } => coordinate + .relationship + .is_some_and(|(id, revision)| id == relationship_id && revision < revision_floor), + InvalidationNotice::AuthorityEpoch { epoch_floor, .. } => { + coordinate.authority_epoch < epoch_floor + } + InvalidationNotice::Policy { revision_floor, .. } => { + coordinate.policy_revision < revision_floor + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::*; + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn coordinate(domain: CommunityId, lease: u128, generation: u64) -> LiveCoordinate { + LiveCoordinate { + lease_id: Uuid::from_u128(lease), + domain, + binding_id: Uuid::from_u128(10), + binding_version: 3, + relationship: Some((Uuid::from_u128(20), 4)), + policy_revision: 5, + invalidation_generation: generation, + authority_epoch: 6, + expires_at: Utc::now() + Duration::minutes(5), + } + } + + #[test] + fn registration_requires_reconciliation_and_closes_notice_races() { + let registry = InvalidationRegistry::new(); + let domain = domain(1); + assert!(registry + .register_coordinate(coordinate(domain, 1, 1)) + .is_err()); + registry + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(domain, 2)], + observed_at: Utc::now(), + }) + .unwrap(); + assert!(registry + .register_coordinate(coordinate(domain, 1, 1)) + .is_err()); + assert!(registry + .register_coordinate(coordinate(CommunityId::from_uuid(Uuid::from_u128(2)), 2, 2,)) + .is_err()); + assert!(registry + .register_coordinate(coordinate(domain, 1, 2)) + .is_ok()); + } + + #[test] + fn exact_notice_cancels_related_and_preserves_unrelated() { + let registry = InvalidationRegistry::new(); + let first_domain = domain(1); + let second_domain = domain(2); + registry + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(first_domain, 1), (second_domain, 1)], + observed_at: Utc::now(), + }) + .unwrap(); + let first = registry + .register_coordinate(coordinate(first_domain, 1, 1)) + .unwrap(); + let second = registry + .register_coordinate(coordinate(second_domain, 2, 1)) + .unwrap(); + assert_eq!( + registry + .apply(InvalidationNotice::Binding { + domain: first_domain, + binding_id: Uuid::from_u128(10), + version_floor: 4, + generation: 2, + }) + .unwrap(), + 1 + ); + assert!(first.is_cancelled()); + assert!(!second.is_cancelled()); + + let admission_loss = AdmissionLossNotice::new( + second_domain, + Uuid::from_u128(30), + Uuid::from_u128(2), + Uuid::from_u128(10), + 3, + 2, + ) + .unwrap(); + assert_eq!(registry.apply_admission_loss(admission_loss).unwrap(), 1); + assert!(second.is_cancelled()); + } + + #[test] + fn feed_loss_and_generation_gap_withdraw_readiness_until_reconciled() { + let registry = InvalidationRegistry::new(); + let domain = domain(1); + registry + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(domain, 1)], + observed_at: Utc::now(), + }) + .unwrap(); + let registration = registry + .register_coordinate(coordinate(domain, 1, 1)) + .unwrap(); + assert_eq!( + registry.apply(InvalidationNotice::Domain { + domain, + generation: 3, + }), + Err(RuntimeAuthorizationError::InvalidationUnavailable) + ); + assert!(!registry.is_ready()); + assert!(registration.is_cancelled()); + + registry + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(domain, 3)], + observed_at: Utc::now(), + }) + .unwrap(); + assert!(registry.is_ready()); + assert_eq!(registry.feed_lost(), 1); + assert!(!registry.is_ready()); + } + + #[test] + fn reconciliation_rejects_omitted_or_regressed_domains_and_cancels_live_work() { + for replacement in [vec![], vec![(domain(1), 1)]] { + let registry = InvalidationRegistry::new(); + registry + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(domain(1), 2)], + observed_at: Utc::now(), + }) + .unwrap(); + let registration = registry + .register_coordinate(coordinate(domain(1), 1, 2)) + .unwrap(); + assert_eq!( + registry.reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: replacement, + observed_at: Utc::now(), + }), + Err(RuntimeAuthorizationError::InvalidationUnavailable) + ); + assert!(!registry.is_ready()); + assert!(registration.is_cancelled()); + } + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/jwks.rs b/crates/buzz-relay/src/authorization_runtime/jwks.rs new file mode 100644 index 00000000000..4d51518882e --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/jwks.rs @@ -0,0 +1,574 @@ +//! Dynamic canonical JWKS and discovery runtime. + +use std::collections::HashSet; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + CanonicalFederatedAssertionVerifier, CanonicalVerifierKeySet, CanonicalVerifierPolicy, + CanonicalVerifierPolicyId, ProofTransport, VerifiedFederatedAssertion, VerifierKeyGeneration, + VerifierPolicyStamp, +}; +use buzz_core::CommunityId; +use chrono::{DateTime, Utc}; +use futures_util::StreamExt; +use jsonwebtoken::jwk::JwkSet; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use tokio::sync::{Mutex, RwLock}; +use url::Url; + +use super::{JwksSourceConfig, RuntimeAuthorizationError}; + +const MAX_JWKS_KEYS: usize = 128; + +/// Bounds for one refresh attempt and admitted snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JwksRefreshPolicy { + /// Maximum discovery or JWKS response body. + pub max_document_bytes: usize, + /// Network deadline for each document. + pub request_timeout: Duration, + /// Fresh lifetime of one successfully fetched snapshot. + pub fresh_lifetime: Duration, +} + +impl JwksRefreshPolicy { + /// Validate finite nonzero refresh bounds. + pub fn new( + max_document_bytes: usize, + request_timeout: Duration, + fresh_lifetime: Duration, + ) -> Result { + if max_document_bytes == 0 + || max_document_bytes > 4 * 1024 * 1024 + || request_timeout.is_zero() + || request_timeout > Duration::from_secs(30) + || fresh_lifetime.is_zero() + || fresh_lifetime > Duration::from_secs(24 * 60 * 60) + { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + Ok(Self { + max_document_bytes, + request_timeout, + fresh_lifetime, + }) + } +} + +/// Bounded loader for a direct JWKS or issuer-matched discovery source. +#[async_trait] +pub trait JwksDocumentLoader: Send + Sync { + /// Return exact JWKS bytes. Redirect, address, size, and discovery issuer + /// checks belong to the loader and fail closed. + async fn load( + &self, + source: &JwksSourceConfig, + expected_issuer: &str, + policy: JwksRefreshPolicy, + ) -> Result, RuntimeAuthorizationError>; +} + +/// Production HTTPS loader with redirects disabled and bounded streaming. +#[derive(Clone, Copy, Default)] +pub struct ReqwestJwksDocumentLoader; + +impl ReqwestJwksDocumentLoader { + /// Construct an HTTPS-only loader. Each request is additionally bounded by + /// the refresh policy supplied to [`JwksDocumentLoader::load`]. + pub fn new() -> Result { + Ok(Self) + } + + async fn fetch( + &self, + endpoint: &Url, + policy: JwksRefreshPolicy, + ) -> Result, RuntimeAuthorizationError> { + let host = endpoint + .host_str() + .ok_or(RuntimeAuthorizationError::DependencyUnavailable)?; + let addresses = resolve_public_https_endpoint(endpoint).await?; + // Bind this request's connector to the exact addresses that passed the + // public-address check. Reqwest must not perform a second DNS lookup. + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .resolve_to_addrs(host, &addresses) + .build() + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + let response = + tokio::time::timeout(policy.request_timeout, client.get(endpoint.clone()).send()) + .await + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)? + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + if response.status().is_redirection() || !response.status().is_success() { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + if response + .content_length() + .is_some_and(|length| length > policy.max_document_bytes as u64) + { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + if body.len().saturating_add(chunk.len()) > policy.max_document_bytes { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + body.extend_from_slice(&chunk); + } + Ok(body) + } +} + +#[derive(Deserialize)] +struct DiscoveryDocument { + issuer: String, + jwks_uri: String, +} + +#[async_trait] +impl JwksDocumentLoader for ReqwestJwksDocumentLoader { + async fn load( + &self, + source: &JwksSourceConfig, + expected_issuer: &str, + policy: JwksRefreshPolicy, + ) -> Result, RuntimeAuthorizationError> { + match source { + JwksSourceConfig::JwksUri(endpoint) => self.fetch(endpoint, policy).await, + JwksSourceConfig::DiscoveryUri(endpoint) => { + let bytes = self.fetch(endpoint, policy).await?; + let discovery: DiscoveryDocument = serde_json::from_slice(&bytes) + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + if discovery.issuer != expected_issuer { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + let jwks_uri = validated_https_url(&discovery.jwks_uri)?; + self.fetch(&jwks_uri, policy).await + } + } + } +} + +/// Immutable current key snapshot and its hard freshness deadline. +#[derive(Clone)] +pub struct JwksSnapshot { + keys: Arc, + stamp: VerifierPolicyStamp, + fresh_until: DateTime, + discovery_fresh_until: Option>, +} + +impl JwksSnapshot { + /// Stable verifier policy plus separate rotating generation. + pub const fn stamp(&self) -> VerifierPolicyStamp { + self.stamp + } + + /// Exclusive hard freshness deadline. + pub const fn fresh_until(&self) -> DateTime { + self.fresh_until + } + + /// Independent discovery deadline when the key URI was discovered. + pub const fn discovery_fresh_until(&self) -> Option> { + self.discovery_fresh_until + } +} + +impl std::fmt::Debug for JwksSnapshot { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("JwksSnapshot([REDACTED])") + } +} + +struct SnapshotState { + snapshot: JwksSnapshot, + document_digest: [u8; 32], +} + +/// Single canonical verifier with a single-flight rotating key cache. +pub struct DynamicVerifier { + verifier: CanonicalFederatedAssertionVerifier, + policy_id: CanonicalVerifierPolicyId, + issuer: String, + source: JwksSourceConfig, + refresh_policy: JwksRefreshPolicy, + loader: Arc, + state: RwLock>, + published_generation: AtomicU64, + published_fresh_until_millis: AtomicI64, + refresh_lock: Mutex<()>, +} + +impl DynamicVerifier { + /// Compose stable policy and dynamic-key ownership. No network request is + /// issued until [`Self::refresh`] is called by startup or recovery. + pub fn new( + policy: CanonicalVerifierPolicy, + issuer: String, + source: JwksSourceConfig, + refresh_policy: JwksRefreshPolicy, + loader: Arc, + ) -> Result { + if issuer.trim().is_empty() { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + let verifier = CanonicalFederatedAssertionVerifier::new(policy); + let policy_id = verifier.policy_id(); + Ok(Self { + verifier, + policy_id, + issuer, + source, + refresh_policy, + loader, + state: RwLock::new(None), + published_generation: AtomicU64::new(0), + published_fresh_until_millis: AtomicI64::new(0), + refresh_lock: Mutex::new(()), + }) + } + + /// Stable policy identity, unaffected by every key refresh. + pub const fn policy_id(&self) -> CanonicalVerifierPolicyId { + self.policy_id + } + + /// Current policy/generation identity available to synchronous aggregate + /// readiness checks. A zero generation means no snapshot is published. + pub fn current_stamp(&self) -> Option { + let generation = + VerifierKeyGeneration::new(self.published_generation.load(Ordering::Acquire))?; + Some(VerifierPolicyStamp::new(self.policy_id, generation)) + } + + /// Synchronous readiness view paired with the published key generation. + pub fn has_current_snapshot(&self, now: DateTime) -> bool { + self.current_stamp().is_some() + && now.timestamp_millis() < self.published_fresh_until_millis.load(Ordering::Acquire) + } + + /// Fetch, bound, parse, and atomically publish one key snapshot. + /// Byte-identical documents retain their generation; any changed admitted + /// document advances it and forces prepared evidence to reverify. + pub async fn refresh( + &self, + now: DateTime, + ) -> Result { + let _singleflight = self.refresh_lock.lock().await; + let bytes = self + .loader + .load(&self.source, &self.issuer, self.refresh_policy) + .await?; + if bytes.is_empty() || bytes.len() > self.refresh_policy.max_document_bytes { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + let parsed: JwkSet = serde_json::from_slice(&bytes) + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + validate_key_set(&parsed)?; + let document_digest: [u8; 32] = Sha256::digest(&bytes).into(); + let fresh_until = now + .checked_add_signed( + chrono::Duration::from_std(self.refresh_policy.fresh_lifetime) + .map_err(|_| RuntimeAuthorizationError::InvalidConfiguration)?, + ) + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + + let current = self.state.read().await; + let generation = match current.as_ref() { + Some(state) if state.document_digest == document_digest => { + state.snapshot.stamp.key_generation() + } + Some(state) => VerifierKeyGeneration::new( + state + .snapshot + .stamp + .key_generation() + .get() + .checked_add(1) + .ok_or(RuntimeAuthorizationError::StaleVerifier)?, + ) + .ok_or(RuntimeAuthorizationError::StaleVerifier)?, + None => { + VerifierKeyGeneration::new(1).ok_or(RuntimeAuthorizationError::StaleVerifier)? + } + }; + drop(current); + + let keys = Arc::new(CanonicalVerifierKeySet::new(generation, parsed)); + let snapshot = JwksSnapshot { + keys, + stamp: VerifierPolicyStamp::new(self.policy_id, generation), + fresh_until, + discovery_fresh_until: matches!(&self.source, JwksSourceConfig::DiscoveryUri(_)) + .then_some(fresh_until), + }; + // Withdraw any installed old-generation readiness before the new key + // snapshot can be observed. A new immutable runtime must bind the new + // stamp explicitly. + *self.state.write().await = Some(SnapshotState { + snapshot: snapshot.clone(), + document_digest, + }); + self.published_fresh_until_millis + .store(fresh_until.timestamp_millis(), Ordering::Release); + self.published_generation + .store(generation.get(), Ordering::Release); + Ok(snapshot) + } + + /// Return a current hard-fresh snapshot. Missing or exact-deadline state is + /// unavailable; stale keys are never used as a silent fallback. + pub async fn current( + &self, + now: DateTime, + ) -> Result { + let state = self.state.read().await; + let snapshot = state + .as_ref() + .map(|state| state.snapshot.clone()) + .ok_or(RuntimeAuthorizationError::StaleVerifier)?; + if now >= snapshot.fresh_until { + return Err(RuntimeAuthorizationError::StaleVerifier); + } + if snapshot + .discovery_fresh_until + .is_some_and(|deadline| now >= deadline) + { + return Err(RuntimeAuthorizationError::StaleVerifier); + } + Ok(snapshot) + } + + /// Whether prepared verifier evidence still names the exact current policy + /// and generation at an authoritative time. + pub async fn accepts_stamp(&self, stamp: VerifierPolicyStamp, now: DateTime) -> bool { + self.current(now) + .await + .is_ok_and(|snapshot| snapshot.stamp == stamp) + } + + /// Verify one request against exactly one current generation snapshot. + /// The token is borrowed for this call and is never retained by the cache. + #[allow(clippy::too_many_arguments)] + pub async fn verify( + &self, + token: &str, + authorization_domain: CommunityId, + transport: ProofTransport, + target_fingerprint: [u8; 32], + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + now: DateTime, + ) -> Result { + let snapshot = self.current(now).await?; + self.verifier + .verify( + token, + snapshot.keys.as_ref(), + authorization_domain, + transport, + target_fingerprint, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(|error| match error { + buzz_auth::CanonicalVerifierError::Expired => { + RuntimeAuthorizationError::AssertionExpired + } + _ => RuntimeAuthorizationError::AssertionDenied, + }) + } +} + +impl std::fmt::Debug for DynamicVerifier { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("DynamicVerifier([REDACTED])") + } +} + +fn validate_key_set(keys: &JwkSet) -> Result<(), RuntimeAuthorizationError> { + if keys.keys.is_empty() || keys.keys.len() > MAX_JWKS_KEYS { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + let mut key_ids = HashSet::new(); + for key in &keys.keys { + let key_id = key + .common + .key_id + .as_deref() + .filter(|key_id| !key_id.is_empty() && key_id.len() <= 512) + .ok_or(RuntimeAuthorizationError::DependencyUnavailable)?; + if !key_ids.insert(key_id) { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + } + Ok(()) +} + +async fn resolve_public_https_endpoint( + endpoint: &Url, +) -> Result, RuntimeAuthorizationError> { + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.fragment().is_some() + { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + let host = endpoint + .host_str() + .ok_or(RuntimeAuthorizationError::DependencyUnavailable)?; + let port = endpoint + .port_or_known_default() + .ok_or(RuntimeAuthorizationError::DependencyUnavailable)?; + let addresses = tokio::net::lookup_host((host, port)) + .await + .map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + let mut public = Vec::new(); + let mut unique = HashSet::new(); + for address in addresses { + if buzz_core::network::is_private_ip(&address.ip()) { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + if unique.insert(address) { + public.push(address); + } + } + if public.is_empty() { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + Ok(public) +} + +fn validated_https_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| RuntimeAuthorizationError::DependencyUnavailable)?; + if let Some(host) = url.host() { + if let url::Host::Ipv4(ip) = host { + if buzz_core::network::is_private_ip(&IpAddr::V4(ip)) { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + } + if let url::Host::Ipv6(ip) = host { + if buzz_core::network::is_private_ip(&IpAddr::V6(ip)) { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + } + } + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(RuntimeAuthorizationError::DependencyUnavailable); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use super::*; + + struct FakeLoader { + documents: Mutex>>, + } + + #[async_trait] + impl JwksDocumentLoader for FakeLoader { + async fn load( + &self, + _source: &JwksSourceConfig, + _expected_issuer: &str, + _policy: JwksRefreshPolicy, + ) -> Result, RuntimeAuthorizationError> { + self.documents + .lock() + .await + .pop_front() + .ok_or(RuntimeAuthorizationError::DependencyUnavailable) + } + } + + fn verifier(documents: Vec>) -> DynamicVerifier { + let policy = CanonicalVerifierPolicy::new( + "https://issuer.example".to_owned(), + "buzz".to_owned(), + "sub".to_owned(), + None, + 0, + 300, + ) + .unwrap(); + DynamicVerifier::new( + policy, + "https://issuer.example".to_owned(), + JwksSourceConfig::JwksUri(Url::parse("https://issuer.example/keys").unwrap()), + JwksRefreshPolicy::new(64 * 1024, Duration::from_secs(2), Duration::from_secs(300)) + .unwrap(), + Arc::new(FakeLoader { + documents: Mutex::new(documents.into()), + }), + ) + .unwrap() + } + + fn jwks(kid: &str) -> Vec { + format!(r#"{{"keys":[{{"kty":"RSA","kid":"{kid}","n":"AQAB","e":"AQAB"}}]}}"#).into_bytes() + } + + #[tokio::test] + async fn identical_document_retains_generation_and_rotation_advances_it() { + let verifier = verifier(vec![jwks("first"), jwks("first"), jwks("second")]); + let now = Utc::now(); + let first = verifier.refresh(now).await.unwrap(); + let same = verifier.refresh(now).await.unwrap(); + let changed = verifier.refresh(now).await.unwrap(); + assert_eq!( + first.stamp().key_generation(), + same.stamp().key_generation() + ); + assert_eq!( + changed.stamp().key_generation().get(), + first.stamp().key_generation().get() + 1 + ); + assert_eq!(first.stamp().policy_id(), changed.stamp().policy_id()); + } + + #[tokio::test] + async fn stale_deadline_and_old_generation_fail_closed() { + let verifier = verifier(vec![jwks("first"), jwks("second")]); + let now = Utc::now(); + let first = verifier.refresh(now).await.unwrap(); + assert!(verifier.has_current_snapshot(now)); + assert!(verifier.accepts_stamp(first.stamp(), now).await); + let second = verifier.refresh(now).await.unwrap(); + assert!(!verifier.accepts_stamp(first.stamp(), now).await); + assert!(verifier.accepts_stamp(second.stamp(), now).await); + assert!(!verifier.has_current_snapshot(second.fresh_until())); + assert!(verifier.current(second.fresh_until()).await.is_err()); + } + + #[tokio::test] + async fn duplicate_or_missing_key_ids_never_publish() { + let duplicate = br#"{"keys":[{"kty":"RSA","kid":"same","n":"AQAB","e":"AQAB"},{"kty":"RSA","kid":"same","n":"AQAB","e":"AQAB"}]}"#.to_vec(); + let verifier = verifier(vec![duplicate]); + assert_eq!( + verifier.refresh(Utc::now()).await.unwrap_err(), + RuntimeAuthorizationError::DependencyUnavailable + ); + assert!(verifier.current(Utc::now()).await.is_err()); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/mod.rs b/crates/buzz-relay/src/authorization_runtime/mod.rs new file mode 100644 index 00000000000..9afff3178f2 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/mod.rs @@ -0,0 +1,60 @@ +//! Provider-free NIP-FI relay authorization runtime. +//! +//! This module owns relay-side composition only. Storage transactions, +//! protected-resource witnesses, and the public current-status wire contract +//! enter through typed canonical interfaces. + +/// Crash-durable exact-connection status delivery. +pub mod outbox; + +mod authority; +mod canonical_admission; +mod config; +mod error; +mod invalidation; +mod jwks; +mod restore; +mod routes; +mod startup; +mod status; + +pub use authority::{ + AdmissionCommitPort, AuthorizedRoute, ProtectedResourceWitness, RuntimeAdmissionRequest, + RuntimeAuthority, +}; +pub use canonical_admission::CanonicalAdmissionAdapter; +pub use config::{ + ClientStatusAdmissionPolicy, DelegationConfig, EnforceRuntimeConfig, JwksSourceConfig, + ProviderFreeRuntimeConfig, ProviderFreeRuntimeMode, CONFIG_ENV, +}; +pub use error::RuntimeAuthorizationError; +pub use invalidation::{ + InvalidationNotice, InvalidationRegistration, InvalidationRegistry, ReconciledInvalidationState, +}; +pub use jwks::{ + DynamicVerifier, JwksDocumentLoader, JwksRefreshPolicy, JwksSnapshot, ReqwestJwksDocumentLoader, +}; +pub use restore::{ + RestoreComponent, RestoreCoordinator, RestoreDecision, RestoreDelta, RestoreManifest, + RestoreStateReader, +}; +pub use routes::{ + ProtectedEffect, ProtectedIngress, ProtectedResourceKind, ResolvedProtectedRoute, + RouteAuthority, RouteRule, +}; +pub use startup::{ + AggregateReadiness, DatabaseRoleWitness, DelegationReadinessWitness, + InstalledAuthorizationRuntime, ReadinessReason, RestoreReconciliationWitness, + RouteInventoryWitness, RuntimeStartup, RuntimeStateComponents, SchemaWitness, + VerifierReadinessWitness, +}; +pub use status::{ + ConnectionLocalStatusContract, ConnectionStatusSession, CurrentStatusAuthorization, + CurrentStatusContract, CurrentStatusEvidenceSource, CurrentStatusSink, + LocalBindingStatusEvidenceSource, StatusCadence, StatusSessionError, + UnchangedBootstrapDelivery, +}; + +#[cfg(test)] +#[path = "../../tests/nip_fi_runtime/mod.rs"] +mod nip_fi_runtime; diff --git a/crates/buzz-relay/src/authorization_runtime/outbox.rs b/crates/buzz-relay/src/authorization_runtime/outbox.rs new file mode 100644 index 00000000000..46c6ab76879 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/outbox.rs @@ -0,0 +1,843 @@ +//! Crash-durable adapter for the accepted connection-local status contract. + +use std::fmt; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{BoundedAuthorizationLease, NipFiMode, ProofTransport, RouteCapability}; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, CLIENT_BINDING_STATUS_SUB_ID, +}; +use buzz_core::client_binding_status::{ + validate_client_binding_status_event, ClientBindingStatusDisposition, + ClientBindingStatusInputV1, +}; +use buzz_core::{CanonicalCurrentBindingEvidence, CommunityId}; +use buzz_db::client_status_delivery::{ + ClaimedStatusDelivery, CompleteStatusDeliveryOutcome, NewStatusDelivery, StatusDeliveryFailure, + StatusDeliveryKind, +}; +use buzz_db::Db; +use chrono::{DateTime, Utc}; +use nostr::{Event, Keys, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use tokio::sync::watch; +use tracing::warn; +use uuid::Uuid; + +use super::status::{ + CurrentStatusContract, CurrentStatusEvidenceSource, CurrentStatusSink, StatusSessionError, + UnchangedBootstrapDelivery, +}; +use crate::connection::{AuthState, ConnectionState, StatusWriteIdentity, StatusWriter}; +use crate::protocol::RelayMessage; + +const CLAIM_LEASE: Duration = Duration::from_secs(30); +const BOOTSTRAP_WRITE_LIMIT: Duration = Duration::from_secs(5); +const MAX_STATUS_OPERATION_SECONDS: u64 = 7; + +type StatusWriteResult = Result<(), StatusSessionError>; +type StatusWriteReceiver = watch::Receiver>; + +async fn bounded_status_operation(limit: Duration, operation: F) -> StatusWriteResult +where + F: Future, +{ + tokio::time::timeout(limit, operation) + .await + .unwrap_or(Err(StatusSessionError::DeliveryFailed)) +} + +/// Signed current event paired with the private tuple needed after a crash. +pub struct DurableCurrentStatus { + event: Event, + evidence: CanonicalCurrentBindingEvidence, +} + +impl fmt::Debug for DurableCurrentStatus { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DurableCurrentStatus([REDACTED])") + } +} + +/// Typed status contract whose current value retains private recovery evidence. +#[derive(Clone)] +pub struct DurableStatusContract { + relay_keys: Keys, + contract_fingerprint: [u8; 32], +} + +impl DurableStatusContract { + /// Bind signing to the exact relay key pinned by the connection scope. + pub fn new(relay_keys: Keys, pinned_relay: PublicKey) -> Result { + if relay_keys.public_key() != pinned_relay { + return Err(StatusSessionError::ContractUnavailable); + } + Ok(Self { + relay_keys, + contract_fingerprint: Sha256::digest(b"buzz:client-binding-status:connection-local:v1") + .into(), + }) + } +} + +impl CurrentStatusContract for DurableStatusContract { + type Current = DurableCurrentStatus; + type Withdrawal = Event; + + fn contract_fingerprint(&self) -> [u8; 32] { + self.contract_fingerprint + } + + fn current( + &self, + evidence: &CanonicalCurrentBindingEvidence, + connection_revision: u64, + ) -> Result { + // Nostr timestamps and the accepted kind-24244 contract are whole + // seconds. Retain that exact representation in the private recovery + // tuple too, otherwise PostgreSQL microseconds can never equal the + // parsed wire freshness bound during enqueue validation. + let observed_at = DateTime::::from_timestamp(evidence.observed_at().timestamp(), 0) + .ok_or(StatusSessionError::ContractUnavailable)?; + let fresh_until = DateTime::::from_timestamp(evidence.fresh_until().timestamp(), 0) + .ok_or(StatusSessionError::ContractUnavailable)?; + let normalized = CanonicalCurrentBindingEvidence::new( + evidence.authorization_domain(), + evidence.event_author_pubkey(), + evidence.binding_id(), + evidence.binding_version(), + evidence.policy_revision(), + evidence.invalidation_generation(), + evidence.authority_epoch(), + evidence.fence(), + observed_at, + fresh_until, + ) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + let event = + ClientBindingStatusInputV1::current_from_evidence(&normalized, connection_revision) + .map_err(|_| StatusSessionError::ContractUnavailable)? + .sign_with_relay_keys(&self.relay_keys) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + Ok(DurableCurrentStatus { + event, + evidence: normalized, + }) + } + + fn withdrawal( + &self, + domain: CommunityId, + author: PublicKey, + connection_revision: u64, + issued_at: DateTime, + fresh_until: DateTime, + ) -> Result { + let issued_at = u64::try_from(issued_at.timestamp()) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + let fresh_until = u64::try_from(fresh_until.timestamp()) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + ClientBindingStatusInputV1::withdrawn( + domain, + author, + connection_revision, + issued_at, + fresh_until, + ) + .map_err(|_| StatusSessionError::ContractUnavailable)? + .sign_with_relay_keys(&self.relay_keys) + .map_err(|_| StatusSessionError::ContractUnavailable) + } +} + +impl fmt::Debug for DurableStatusContract { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DurableStatusContract([REDACTED])") + } +} + +/// Activation or recovery failure that never exposes status material. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum DurableStatusError { + /// The connection does not own a complete Enforce-mode status scope. + #[error("status connection is not authorized")] + Unauthorized, + /// The bootstrap could not be physically acknowledged. + #[error("status bootstrap delivery failed")] + BootstrapDelivery, + /// PostgreSQL delivery state was unavailable or inconsistent. + #[error("status journal unavailable")] + Journal, +} + +/// Exact-connection crash-durable sink used by [`super::status::ConnectionStatusSession`]. +pub struct DurableStatusSink +where + E: CurrentStatusEvidenceSource + ?Sized, +{ + db: Db, + evidence: Arc, + writer: StatusWriter, + cancel: tokio_util::sync::CancellationToken, + community_id: CommunityId, + author: PublicKey, + relay_signer: PublicKey, + connection_fingerprint: [u8; 32], + owner: tokio::sync::Mutex<()>, + in_flight: tokio::sync::Mutex>, +} + +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( + mode: NipFiMode, + db: Db, + evidence: Arc, + connection: Arc, + lease: &BoundedAuthorizationLease, + relay_keys: &Keys, + authoritative_now: DateTime, + ) -> Result<(Self, UnchangedBootstrapDelivery), DurableStatusError> { + 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) + { + return Err(DurableStatusError::Unauthorized); + } + let scope = connection + .status_scope + .read() + .await + .clone() + .ok_or(DurableStatusError::Unauthorized)?; + 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() { + return Err(DurableStatusError::Unauthorized); + } + let connection_fingerprint = status_fingerprint( + b"buzz:client-status-connection:v1", + &[ + connection.tenant.community().as_uuid().as_bytes(), + connection.conn_id.as_bytes(), + author.as_bytes(), + relay_keys.public_key().as_bytes(), + scope.connection_epoch().as_str().as_bytes(), + ], + ); + let (_, authorized_target, _) = lease.request_binding(); + if authorized_target != &connection_fingerprint { + return Err(DurableStatusError::Unauthorized); + } + db.install_status_delivery_capacity(connection.tenant.community()) + .await + .map_err(|_| DurableStatusError::Journal)?; + db.reconcile_status_deliveries(connection.tenant.community(), 1024) + .await + .map_err(|_| DurableStatusError::Journal)?; + db.reap_status_deliveries(connection.tenant.community(), 1024) + .await + .map_err(|_| DurableStatusError::Journal)?; + + let issued_at = u64::try_from(authoritative_now.timestamp()) + .map_err(|_| DurableStatusError::Unauthorized)?; + let bootstrap = ClientBindingBootstrapInputV1::new( + connection.tenant.community(), + author, + scope.connection_epoch().clone(), + issued_at, + ) + .map_err(|_| DurableStatusError::Unauthorized)? + .sign_with_relay_keys(relay_keys) + .map_err(|_| DurableStatusError::Unauthorized)?; + let deadline = tokio::time::Instant::now() + BOOTSTRAP_WRITE_LIMIT; + let acknowledgement = connection + .status_writer + .write( + RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap), + None, + true, + deadline, + ) + .await + .map_err(|_| DurableStatusError::BootstrapDelivery)?; + if acknowledgement.identity.is_some() { + return Err(DurableStatusError::BootstrapDelivery); + } + + Ok(( + Self { + db, + evidence, + writer: connection.status_writer.clone(), + cancel: connection.cancel.clone(), + community_id: connection.tenant.community(), + author, + relay_signer: relay_keys.public_key(), + connection_fingerprint, + owner: tokio::sync::Mutex::new(()), + in_flight: tokio::sync::Mutex::new(None), + }, + UnchangedBootstrapDelivery::delivered(), + )) + } + + /// Recover at most one claimed job for this exact still-live connection. + pub async fn recover_one(&self) -> Result { + let _owner = self.owner.lock().await; + self.wait_in_flight() + .await + .map_err(|_| DurableStatusError::Journal)?; + let Some(claimed) = self + .db + .claim_status_delivery(self.community_id, self.connection_fingerprint, CLAIM_LEASE) + .await + .map_err(|_| DurableStatusError::Journal)? + else { + return Ok(false); + }; + self.deliver_claim(&claimed) + .await + .map_err(|_| DurableStatusError::Journal)?; + Ok(true) + } + + /// Stop this exact owner after activation and terminalize its pending work. + pub async fn shutdown(&self) { + self.close_connection().await; + } + + async fn enqueue_and_deliver( + &self, + event: &Event, + evidence: Option<&CanonicalCurrentBindingEvidence>, + kind: StatusDeliveryKind, + ) -> Result<(), StatusSessionError> { + let _owner = self.owner.lock().await; + self.wait_in_flight().await?; + self.db + .reconcile_status_deliveries(self.community_id, 1024) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + self.db + .reap_status_deliveries(self.community_id, 1024) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + let now = self + .db + .status_delivery_authoritative_now() + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + let validated = validate_client_binding_status_event( + event, + &self.relay_signer, + self.community_id, + &self.author, + unix_seconds(now)?, + ) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + if !kind_matches(kind, validated.disposition()) { + return Err(StatusSessionError::ContractUnavailable); + } + let payload = + serde_json::to_vec(event).map_err(|_| StatusSessionError::ContractUnavailable)?; + let fresh_until = DateTime::::from_timestamp( + i64::try_from(validated.fresh_until()) + .map_err(|_| StatusSessionError::ContractUnavailable)?, + 0, + ) + .ok_or(StatusSessionError::ContractUnavailable)?; + let event_id = event.id.as_bytes(); + let transition_id = stable_uuid(b"transition", event_id, &self.connection_fingerprint); + let operation_id = stable_uuid(b"operation", event_id, &self.connection_fingerprint); + let delivery_id = stable_uuid(b"delivery", event_id, &self.connection_fingerprint); + let request_fingerprint = status_fingerprint( + b"buzz:client-status-request:v1", + &[event_id, self.connection_fingerprint.as_slice()], + ); + let delivery = NewStatusDelivery { + community_id: self.community_id, + delivery_id, + transition_id, + operation_id, + request_fingerprint, + kind, + subject_fingerprint: status_fingerprint( + b"buzz:client-status-subject:v1", + &[ + self.community_id.as_uuid().as_bytes(), + self.author.as_bytes(), + ], + ), + signer_fingerprint: status_fingerprint( + b"buzz:client-status-signer:v1", + &[ + self.community_id.as_uuid().as_bytes(), + self.relay_signer.as_bytes(), + ], + ), + connection_fingerprint: self.connection_fingerprint, + status_revision: validated.status_revision(), + supersedes_revision: (kind == StatusDeliveryKind::Withdrawal) + .then(|| validated.status_revision().saturating_sub(1)), + fresh_until, + signed_payload: &payload, + current_evidence: evidence, + }; + self.db + .enqueue_status_delivery(&delivery) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + let claimed = self + .db + .claim_status_delivery(self.community_id, self.connection_fingerprint, CLAIM_LEASE) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)? + .ok_or(StatusSessionError::DeliveryFailed)?; + if claimed.delivery_id() != delivery_id { + return Err(StatusSessionError::DeliveryFailed); + } + self.deliver_claim(&claimed).await + } + + async fn deliver_claim( + &self, + claimed: &ClaimedStatusDelivery, + ) -> Result<(), StatusSessionError> { + if self.cancel.is_cancelled() + || claimed.community_id() != self.community_id + || claimed.connection_fingerprint() != self.connection_fingerprint + { + self.fail_terminal(claimed, StatusDeliveryFailure::ConnectionGone) + .await; + return Err(StatusSessionError::DeliveryFailed); + } + let event: Event = match serde_json::from_slice(claimed.signed_payload()) { + Ok(event) => event, + Err(_) => { + self.fail_terminal(claimed, StatusDeliveryFailure::InvalidPayload) + .await; + return Err(StatusSessionError::ContractUnavailable); + } + }; + let mut authoritative_now = self + .db + .status_delivery_authoritative_now() + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + if let Some(original) = claimed.current_evidence() { + let (rechecked, recheck_now) = self.evidence.recheck(original).await?; + if !original.accepts_exact_recheck(&rechecked, recheck_now) { + self.fail_terminal(claimed, StatusDeliveryFailure::StaleFence) + .await; + return Err(StatusSessionError::EvidenceChanged); + } + authoritative_now = recheck_now; + } else if claimed.kind() != StatusDeliveryKind::Withdrawal { + self.fail_terminal(claimed, StatusDeliveryFailure::InvalidPayload) + .await; + return Err(StatusSessionError::ContractUnavailable); + } + let validated = match validate_client_binding_status_event( + &event, + &self.relay_signer, + self.community_id, + &self.author, + unix_seconds(authoritative_now)?, + ) { + Ok(validated) => validated, + Err(_) => { + self.fail_terminal(claimed, StatusDeliveryFailure::InvalidPayload) + .await; + return Err(StatusSessionError::ContractUnavailable); + } + }; + if validated.status_revision() != claimed.status_revision() + || !kind_matches(claimed.kind(), validated.disposition()) + || (claimed.kind() == StatusDeliveryKind::Current + && claimed.current_evidence().is_none_or(|evidence| { + validated.binding_version() != Some(evidence.binding_version()) + || validated + .policy_version() + .and_then(|revision| revision.parse::().ok()) + != Some(evidence.policy_revision()) + })) + { + self.fail_terminal(claimed, StatusDeliveryFailure::InvalidPayload) + .await; + return Err(StatusSessionError::ContractUnavailable); + } + let mut authorization = self + .db + .authorize_status_delivery(claimed) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)? + .ok_or(StatusSessionError::EvidenceChanged)?; + if self.cancel.is_cancelled() { + self.db + .abort_status_delivery_authorization(authorization) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + self.fail_terminal(claimed, StatusDeliveryFailure::ConnectionGone) + .await; + return Err(StatusSessionError::DeliveryFailed); + } + let deadline = tokio::time::Instant::now() + .checked_add(authorization.write_budget()) + .ok_or(StatusSessionError::Expired)?; + let canonical_payload = + serde_json::to_vec(&event).map_err(|_| StatusSessionError::ContractUnavailable)?; + let canonical_digest: [u8; 32] = Sha256::digest(&canonical_payload).into(); + if canonical_payload != claimed.signed_payload() + || canonical_digest != claimed.payload_digest() + || canonical_digest != authorization.payload_digest() + { + self.db + .abort_status_delivery_authorization(authorization) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + self.fail_terminal(claimed, StatusDeliveryFailure::InvalidPayload) + .await; + return Err(StatusSessionError::ContractUnavailable); + } + let wire = RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, &event); + let identity = StatusWriteIdentity { + delivery_id: authorization.delivery_id(), + claim_id: authorization.claim_id(), + payload_digest: authorization.payload_digest(), + wire_digest: Sha256::digest(wire.as_bytes()).into(), + }; + let db = self.db.clone(); + let writer = self.writer.clone(); + let (result_tx, result_rx) = watch::channel(None); + *self.in_flight.lock().await = Some(result_rx.clone()); + // The task owns both the PostgreSQL fence and the queued writer + // command. Dropping this caller future detaches the task but cannot + // release the fence while the writer is still capable of I/O. + tokio::spawn(async move { + let operation = async { + let acknowledgement = + match writer.write(wire, Some(identity), false, deadline).await { + Ok(acknowledgement) => acknowledgement, + Err(_) => { + db.abort_status_delivery_authorization(authorization) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + return Err(StatusSessionError::DeliveryFailed); + } + }; + if acknowledgement.identity != Some(identity) { + db.abort_status_delivery_authorization(authorization) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + return Err(StatusSessionError::DeliveryFailed); + } + let completion = db + .complete_status_delivery(&mut authorization) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + if matches!( + completion, + CompleteStatusDeliveryOutcome::Delivered + | CompleteStatusDeliveryOutcome::ExactReplay + ) { + Ok(()) + } else { + Err(StatusSessionError::DeliveryFailed) + } + }; + let result = bounded_status_operation( + Duration::from_secs(MAX_STATUS_OPERATION_SECONDS), + operation, + ) + .await; + let _ = result_tx.send(Some(result)); + }); + let result = Self::await_in_flight(result_rx).await; + self.in_flight.lock().await.take(); + result + } + + async fn wait_in_flight(&self) -> Result<(), StatusSessionError> { + let receiver = self.in_flight.lock().await.as_ref().cloned(); + let Some(receiver) = receiver else { + return Ok(()); + }; + let result = Self::await_in_flight(receiver).await; + self.in_flight.lock().await.take(); + result + } + + async fn await_in_flight(mut receiver: StatusWriteReceiver) -> Result<(), StatusSessionError> { + loop { + if let Some(result) = *receiver.borrow() { + return result; + } + receiver + .changed() + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + } + } + + async fn fail_terminal(&self, claimed: &ClaimedStatusDelivery, failure: StatusDeliveryFailure) { + let _ = self + .db + .fail_status_delivery( + claimed.community_id(), + claimed.delivery_id(), + claimed.claim_id(), + failure, + Duration::from_secs(1), + ) + .await; + } + + async fn close_connection(&self) { + self.cancel.cancel(); + // Serialize teardown with producer and recovery paths. Once the owner + // is acquired, any detached write has either published its result or + // remains represented by `in_flight`, so terminalization cannot race + // a writer that is still capable of I/O. + let _owner = self.owner.lock().await; + if self.wait_in_flight().await.is_err() { + // A failed physical write or completion still settles ownership. + // Continue below so the dead exact target cannot retain capacity + // until a future activation happens to reconcile it. + warn!("status writer settled before connection cleanup"); + } + for attempt in 0..3 { + if self + .db + .terminalize_status_connection(self.community_id, self.connection_fingerprint) + .await + .is_ok() + { + return; + } + if attempt < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + warn!("status connection cleanup deferred"); + } +} + +#[async_trait] +impl CurrentStatusSink for DurableStatusSink +where + E: CurrentStatusEvidenceSource + ?Sized + 'static, +{ + async fn send_current(&self, current: &DurableCurrentStatus) -> Result<(), StatusSessionError> { + self.enqueue_and_deliver( + ¤t.event, + Some(¤t.evidence), + StatusDeliveryKind::Current, + ) + .await + } + + async fn send_withdrawal(&self, withdrawal: &Event) -> Result<(), StatusSessionError> { + self.enqueue_and_deliver(withdrawal, None, StatusDeliveryKind::Withdrawal) + .await + } + + async fn close(&self) { + self.close_connection().await; + } +} + +impl fmt::Debug for DurableStatusSink +where + E: CurrentStatusEvidenceSource + ?Sized, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DurableStatusSink([REDACTED])") + } +} + +fn unix_seconds(value: DateTime) -> Result { + u64::try_from(value.timestamp()).map_err(|_| StatusSessionError::Expired) +} + +fn kind_matches(kind: StatusDeliveryKind, disposition: ClientBindingStatusDisposition) -> bool { + matches!( + (kind, disposition), + ( + StatusDeliveryKind::Current, + ClientBindingStatusDisposition::DisplayCurrent + ) | ( + StatusDeliveryKind::Withdrawal, + ClientBindingStatusDisposition::Withdrawn + ) + ) +} + +fn status_fingerprint(label: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((label.len() as u64).to_be_bytes()); + digest.update(label); + for part in parts { + digest.update((part.len() as u64).to_be_bytes()); + digest.update(part); + } + digest.finalize().into() +} + +fn stable_uuid(label: &[u8], event_id: &[u8], connection: &[u8; 32]) -> Uuid { + let mut bytes = status_fingerprint(label, &[event_id, connection]); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let mut uuid_bytes = [0; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + Uuid::from_bytes(uuid_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::AuthorizationLeaseFence; + + #[tokio::test] + async fn bounded_status_operation_terminates_fence_owner() { + let result = tokio::time::timeout( + Duration::from_millis(100), + bounded_status_operation( + Duration::from_millis(10), + std::future::pending::(), + ), + ) + .await + .expect("operation hard timeout"); + assert!(matches!(result, Err(StatusSessionError::DeliveryFailed))); + } + + #[test] + fn exact_connection_fingerprint_changes_with_server_generation() { + let community = Uuid::new_v4(); + let author = [2; 32]; + let signer = [3; 32]; + let epoch = Uuid::new_v4(); + let first = status_fingerprint( + b"buzz:client-status-connection:v1", + &[ + community.as_bytes(), + Uuid::new_v4().as_bytes(), + &author, + &signer, + epoch.as_bytes(), + ], + ); + let second = status_fingerprint( + b"buzz:client-status-connection:v1", + &[ + community.as_bytes(), + Uuid::new_v4().as_bytes(), + &author, + &signer, + epoch.as_bytes(), + ], + ); + assert_ne!(first, second); + } + + #[test] + fn stable_ids_are_domain_separated_and_replay_stable() { + let event = [4; 32]; + let connection = [5; 32]; + assert_eq!( + stable_uuid(b"delivery", &event, &connection), + stable_uuid(b"delivery", &event, &connection) + ); + assert_ne!( + stable_uuid(b"delivery", &event, &connection), + stable_uuid(b"transition", &event, &connection) + ); + } + + #[test] + fn durable_contract_preserves_typed_privacy_boundary() { + let relay = Keys::generate(); + let author = Keys::generate().public_key(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let observed_at = DateTime::from_timestamp(1_800_000_000, 123_456_000).expect("timestamp"); + let fresh_until = observed_at + chrono::Duration::minutes(4); + let evidence = CanonicalCurrentBindingEvidence::new( + domain, + author, + Uuid::new_v4(), + 7, + 11, + 13, + 17, + AuthorizationLeaseFence::from_bytes([19; 32]).expect("fence"), + observed_at, + fresh_until, + ) + .expect("evidence"); + let contract = DurableStatusContract::new(relay.clone(), relay.public_key()) + .expect("durable contract"); + let current = contract.current(&evidence, 1).expect("current status"); + assert_eq!(current.evidence.observed_at().timestamp_subsec_nanos(), 0); + assert_eq!(current.evidence.fresh_until().timestamp_subsec_nanos(), 0); + let validated = validate_client_binding_status_event( + ¤t.event, + &relay.public_key(), + domain, + &author, + u64::try_from(observed_at.timestamp()).expect("positive time"), + ) + .expect("typed current validation"); + assert_eq!(validated.binding_version(), Some(7)); + assert_eq!(validated.policy_version(), Some("11")); + assert!(current.event.tags.is_empty()); + assert!(validate_client_binding_status_event( + ¤t.event, + &Keys::generate().public_key(), + domain, + &author, + u64::try_from(observed_at.timestamp()).expect("positive time"), + ) + .is_err()); + + let withdrawal = contract + .withdrawal(domain, author, 2, observed_at, fresh_until) + .expect("withdrawal"); + let validated_withdrawal = validate_client_binding_status_event( + &withdrawal, + &relay.public_key(), + domain, + &author, + u64::try_from(observed_at.timestamp()).expect("positive time"), + ) + .expect("typed withdrawal validation"); + assert_eq!( + validated_withdrawal.disposition(), + ClientBindingStatusDisposition::Withdrawn + ); + assert_eq!(validated_withdrawal.binding_version(), None); + assert_eq!(validated_withdrawal.policy_version(), None); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/restore.rs b/crates/buzz-relay/src/authorization_runtime/restore.rs new file mode 100644 index 00000000000..390e3991939 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/restore.rs @@ -0,0 +1,434 @@ +//! Operation-bound authorization restore refusal logic. + +use std::collections::HashSet; +use std::fmt; + +use async_trait::async_trait; +use buzz_core::CommunityId; +use uuid::Uuid; + +use super::RuntimeAuthorizationError; + +const MAX_RESTORE_DELTAS: usize = 64; + +/// Closed durable component namespaces eligible for authorization rollback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RestoreComponent { + /// Domain invalidation floor. + InvalidationFloor, + /// Local binding generation. + Binding, + /// Delegated relationship generation. + DelegatedRelationship, + /// Local authorization policy revision. + Policy, + /// Protected-object authority state. + ProtectedObject, + /// Required immutable audit-chain state. + Audit, +} + +/// Exact pre/post state for one component mutated by one operation. +#[derive(Clone, PartialEq, Eq)] +pub struct RestoreDelta { + component: RestoreComponent, + component_key: [u8; 32], + before: [u8; 32], + after: [u8; 32], +} + +impl RestoreDelta { + /// Construct one non-sentinel exact state transition. + pub fn new( + component: RestoreComponent, + component_key: [u8; 32], + before: [u8; 32], + after: [u8; 32], + ) -> Result { + if component_key == [0; 32] || before == [0; 32] || after == [0; 32] || before == after { + return Err(RuntimeAuthorizationError::RestoreRejected); + } + Ok(Self { + component, + component_key, + before, + after, + }) + } + + /// Closed component namespace. + pub const fn component(&self) -> RestoreComponent { + self.component + } + + /// Privacy-safe component identity. + pub const fn component_key(&self) -> &[u8; 32] { + &self.component_key + } + + /// Exact pre-operation digest. + pub const fn before(&self) -> &[u8; 32] { + &self.before + } + + /// Exact committed post-operation digest. + pub const fn after(&self) -> &[u8; 32] { + &self.after + } +} + +impl fmt::Debug for RestoreDelta { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RestoreDelta([REDACTED])") + } +} + +/// Exact operation manifest retained by migration 0033. +#[derive(Clone)] +pub struct RestoreManifest { + authorization_domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + policy_revision: u64, + invalidation_generation: u64, + audit_predecessor: [u8; 32], + audit_result: [u8; 32], + deltas: Vec, +} + +impl RestoreManifest { + /// Validate exact bounded attribution for one committed operation. + #[allow(clippy::too_many_arguments)] + pub fn new( + authorization_domain: CommunityId, + operation_id: Uuid, + request_fingerprint: [u8; 32], + policy_revision: u64, + invalidation_generation: u64, + audit_predecessor: [u8; 32], + audit_result: [u8; 32], + deltas: Vec, + ) -> Result { + if authorization_domain.as_uuid().is_nil() + || operation_id.is_nil() + || request_fingerprint == [0; 32] + || policy_revision == 0 + || audit_predecessor == [0; 32] + || audit_result == [0; 32] + || audit_predecessor == audit_result + || deltas.is_empty() + || deltas.len() > MAX_RESTORE_DELTAS + { + return Err(RuntimeAuthorizationError::RestoreRejected); + } + let mut coordinates = HashSet::new(); + if deltas + .iter() + .any(|delta| !coordinates.insert((delta.component, delta.component_key))) + { + return Err(RuntimeAuthorizationError::RestoreRejected); + } + Ok(Self { + authorization_domain, + operation_id, + request_fingerprint, + policy_revision, + invalidation_generation, + audit_predecessor, + audit_result, + deltas, + }) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact operation identity. + pub const fn operation_id(&self) -> Uuid { + self.operation_id + } + + /// Request semantics committed by the operation. + pub const fn request_fingerprint(&self) -> &[u8; 32] { + &self.request_fingerprint + } + + /// Policy and invalidation generations at commit. + pub const fn dependency_generations(&self) -> (u64, u64) { + (self.policy_revision, self.invalidation_generation) + } + + /// Required immutable audit predecessor and result. + pub const fn audit_edge(&self) -> (&[u8; 32], &[u8; 32]) { + (&self.audit_predecessor, &self.audit_result) + } + + /// Exact bounded component transitions. + pub fn deltas(&self) -> &[RestoreDelta] { + &self.deltas + } +} + +impl fmt::Debug for RestoreManifest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RestoreManifest([REDACTED])") + } +} + +/// Current authority observation required before applying any rollback. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RestoreAuthorityObservation { + /// Exact manifest lineage is current and unconsumed. + pub lineage_current: bool, + /// No key removed after the operation would be reintroduced. + pub removed_keys_absent: bool, + /// Every identity pair affected by the operation remains active. + pub identity_pairs_active: bool, + /// The operation lineage has not already been consumed by another restore. + pub lineage_unconsumed: bool, + /// No replay marker conflicts with this exact operation. + pub replay_absent: bool, + /// The immutable audit edge is present and continuous. + pub audit_continuous: bool, + /// Current application effects still exactly match the manifest. + pub effects_consistent: bool, + /// Current policy revision. + pub policy_revision: u64, + /// Current invalidation generation. + pub invalidation_generation: u64, +} + +/// Authoritative read-only port used to refuse ambiguous restore. +#[async_trait] +pub trait RestoreStateReader: Send + Sync { + /// Read current digest for one exact component coordinate. + async fn current_digest( + &self, + domain: CommunityId, + component: RestoreComponent, + component_key: &[u8; 32], + ) -> Result<[u8; 32], RuntimeAuthorizationError>; + + /// Prove exact operation lineage, policy generation, and audit continuity. + async fn authority_observation( + &self, + manifest: &RestoreManifest, + ) -> Result; +} + +/// Read-only decision returned before the canonical transactional restore adapter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreDecision { + /// Current state is exactly the recorded post-state and may be rolled back + /// atomically to the included pre-state. + Apply(Vec), + /// Every component already equals the recorded pre-state; repeat is a no-op. + AlreadyRestored, +} + +/// Fail-closed evaluator for one exact operation manifest. +#[derive(Debug, Default, Clone, Copy)] +pub struct RestoreCoordinator; + +impl RestoreCoordinator { + /// Compare current authority and every component with one exact manifest. + /// Mixed, missing, advanced, or unavailable state is never guessed. + pub async fn evaluate( + &self, + manifest: &RestoreManifest, + reader: &R, + ) -> Result { + let authority = reader.authority_observation(manifest).await?; + let (policy_revision, invalidation_generation) = manifest.dependency_generations(); + if !authority.lineage_current + || !authority.removed_keys_absent + || !authority.identity_pairs_active + || !authority.lineage_unconsumed + || !authority.replay_absent + || !authority.audit_continuous + || !authority.effects_consistent + || authority.policy_revision != policy_revision + || authority.invalidation_generation != invalidation_generation + { + return Err(RuntimeAuthorizationError::RestoreRejected); + } + + let mut all_before = true; + let mut all_after = true; + for delta in manifest.deltas() { + let current = reader + .current_digest( + manifest.authorization_domain(), + delta.component(), + delta.component_key(), + ) + .await?; + all_before &= current.as_slice() == delta.before(); + all_after &= current.as_slice() == delta.after(); + } + match (all_before, all_after) { + (true, false) => Ok(RestoreDecision::AlreadyRestored), + (false, true) => Ok(RestoreDecision::Apply(manifest.deltas.clone())), + _ => Err(RuntimeAuthorizationError::RestoreRejected), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Mutex; + + use super::*; + + type RestoreCoordinate = (RestoreComponent, [u8; 32]); + type RestoreValues = HashMap; + + struct Reader { + authority: RestoreAuthorityObservation, + values: Mutex, + } + + #[async_trait] + impl RestoreStateReader for Reader { + async fn current_digest( + &self, + _domain: CommunityId, + component: RestoreComponent, + component_key: &[u8; 32], + ) -> Result<[u8; 32], RuntimeAuthorizationError> { + self.values + .lock() + .map_err(|_| RuntimeAuthorizationError::RestoreRejected)? + .get(&(component, *component_key)) + .copied() + .ok_or(RuntimeAuthorizationError::RestoreRejected) + } + + async fn authority_observation( + &self, + _manifest: &RestoreManifest, + ) -> Result { + Ok(self.authority) + } + } + + fn manifest() -> RestoreManifest { + RestoreManifest::new( + CommunityId::from_uuid(Uuid::from_u128(1)), + Uuid::from_u128(2), + [3; 32], + 4, + 5, + [6; 32], + [7; 32], + vec![ + RestoreDelta::new(RestoreComponent::Binding, [8; 32], [9; 32], [10; 32]).unwrap(), + RestoreDelta::new( + RestoreComponent::ProtectedObject, + [11; 32], + [12; 32], + [13; 32], + ) + .unwrap(), + ], + ) + .unwrap() + } + + fn reader(manifest: &RestoreManifest, use_after: bool) -> Reader { + let values = manifest + .deltas() + .iter() + .map(|delta| { + ( + (delta.component(), *delta.component_key()), + if use_after { + *delta.after() + } else { + *delta.before() + }, + ) + }) + .collect(); + Reader { + authority: RestoreAuthorityObservation { + lineage_current: true, + removed_keys_absent: true, + identity_pairs_active: true, + lineage_unconsumed: true, + replay_absent: true, + audit_continuous: true, + effects_consistent: true, + policy_revision: 4, + invalidation_generation: 5, + }, + values: Mutex::new(values), + } + } + + #[tokio::test] + async fn exact_post_state_allows_and_exact_pre_state_is_idempotent() { + let manifest = manifest(); + assert!(matches!( + RestoreCoordinator + .evaluate(&manifest, &reader(&manifest, true)) + .await, + Ok(RestoreDecision::Apply(_)) + )); + assert_eq!( + RestoreCoordinator + .evaluate(&manifest, &reader(&manifest, false)) + .await, + Ok(RestoreDecision::AlreadyRestored) + ); + } + + #[tokio::test] + async fn mixed_state_and_lineage_or_audit_drift_are_rejected() { + let manifest = manifest(); + let mixed = reader(&manifest, true); + mixed + .values + .lock() + .unwrap() + .insert((RestoreComponent::Binding, [8; 32]), [9; 32]); + assert_eq!( + RestoreCoordinator.evaluate(&manifest, &mixed).await, + Err(RuntimeAuthorizationError::RestoreRejected) + ); + + let mut stale = reader(&manifest, true); + stale.authority.audit_continuous = false; + assert_eq!( + RestoreCoordinator.evaluate(&manifest, &stale).await, + Err(RuntimeAuthorizationError::RestoreRejected) + ); + + let mut removed_key = reader(&manifest, true); + removed_key.authority.removed_keys_absent = false; + assert_eq!( + RestoreCoordinator.evaluate(&manifest, &removed_key).await, + Err(RuntimeAuthorizationError::RestoreRejected) + ); + } + + #[test] + fn duplicate_coordinates_and_empty_deltas_are_rejected() { + let delta = + RestoreDelta::new(RestoreComponent::Binding, [1; 32], [2; 32], [3; 32]).unwrap(); + assert!(RestoreManifest::new( + CommunityId::from_uuid(Uuid::from_u128(1)), + Uuid::from_u128(2), + [3; 32], + 1, + 1, + [4; 32], + [5; 32], + vec![delta.clone(), delta], + ) + .is_err()); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/routes.rs b/crates/buzz-relay/src/authorization_runtime/routes.rs new file mode 100644 index 00000000000..62bf2789e84 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/routes.rs @@ -0,0 +1,521 @@ +//! Closed typed authority for every protected relay ingress. + +use std::collections::BTreeMap; + +use buzz_auth::{ProofTransport, RouteCapability, RouteProtection}; +use sha2::{Digest, Sha256}; + +use super::RuntimeAuthorizationError; + +/// Complete semantic inventory of protected relay entry points. +/// +/// Raw methods, paths, event kinds, and frame strings must be translated to +/// exactly one of these values by their owning adapters. An adapter that cannot +/// translate an input must deny it rather than selecting a capability itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum ProtectedIngress { + /// Establish a provider-free WebSocket authorization session. + WebSocketAuthenticate, + /// Submit a protected event over WebSocket. + WebSocketEvent, + /// Read protected events over WebSocket. + WebSocketQuery, + /// Count protected events over WebSocket. + WebSocketCount, + /// Submit a protected event through the HTTP bridge. + BridgeEvent, + /// Read protected events through the HTTP bridge. + BridgeQuery, + /// Count protected events through the HTTP bridge. + BridgeCount, + /// Read moderation state. + ModerationRead, + /// Mutate moderation state. + ModerationWrite, + /// Read deployment-global operator state. + OperatorRead, + /// Mutate deployment-global operator state. + OperatorWrite, + /// Mint an invitation. + InviteMint, + /// Claim an invitation. + InviteClaim, + /// Read a media object with GET or HEAD. + MediaRead, + /// Upload or replace a media object. + MediaWrite, + /// Delete a media object. + MediaDelete, + /// Read Git objects or refs. + GitRead, + /// Mutate Git objects or refs. + GitWrite, + /// Keep a bounded Git stream alive. + GitStream, + /// Join a protected audio session. + AudioJoin, + /// Send or receive protected audio media. + AudioMedia, + /// Read protected runtime discovery. + Discovery, + /// Read current local binding status. + BindingStatus, +} + +impl ProtectedIngress { + /// Closed inventory used to reject partial route installation. + pub const ALL: [Self; 23] = [ + Self::WebSocketAuthenticate, + Self::WebSocketEvent, + Self::WebSocketQuery, + Self::WebSocketCount, + Self::BridgeEvent, + Self::BridgeQuery, + Self::BridgeCount, + Self::ModerationRead, + Self::ModerationWrite, + Self::OperatorRead, + Self::OperatorWrite, + Self::InviteMint, + Self::InviteClaim, + Self::MediaRead, + Self::MediaWrite, + Self::MediaDelete, + Self::GitRead, + Self::GitWrite, + Self::GitStream, + Self::AudioJoin, + Self::AudioMedia, + Self::Discovery, + Self::BindingStatus, + ]; + + const fn code(self) -> u8 { + self as u8 + 1 + } + + /// Exact capability assigned by the closed relay inventory. + pub const fn required_capability(self) -> RouteCapability { + match self { + Self::WebSocketAuthenticate => RouteCapability::BindingStatus, + Self::WebSocketEvent | Self::BridgeEvent => RouteCapability::MessagesWrite, + Self::WebSocketQuery | Self::WebSocketCount | Self::BridgeQuery | Self::BridgeCount => { + RouteCapability::MessagesRead + } + Self::ModerationRead | Self::ModerationWrite => RouteCapability::Moderation, + Self::OperatorRead | Self::OperatorWrite => RouteCapability::AdminChannels, + Self::InviteMint => RouteCapability::InviteMint, + Self::InviteClaim => RouteCapability::InviteClaim, + Self::MediaRead => RouteCapability::MediaRead, + Self::MediaWrite | Self::MediaDelete => RouteCapability::MediaWrite, + Self::GitRead => RouteCapability::GitRead, + Self::GitWrite => RouteCapability::GitWrite, + Self::GitStream => RouteCapability::GitStream, + Self::AudioJoin => RouteCapability::AudioJoin, + Self::AudioMedia => RouteCapability::AudioMedia, + Self::Discovery => RouteCapability::Discovery, + Self::BindingStatus => RouteCapability::BindingStatus, + } + } + + /// Exact application-effect class assigned by the closed inventory. + pub const fn required_effect(self) -> ProtectedEffect { + match self { + Self::WebSocketAuthenticate + | Self::BridgeEvent + | Self::ModerationWrite + | Self::OperatorWrite + | Self::InviteMint + | Self::InviteClaim + | Self::MediaWrite + | Self::MediaDelete + | Self::GitWrite => ProtectedEffect::Mutate, + Self::GitStream | Self::AudioMedia => ProtectedEffect::Stream, + _ => ProtectedEffect::Read, + } + } + + /// Exact server-owned resource namespace assigned by the closed inventory. + pub const fn required_resource(self) -> ProtectedResourceKind { + match self { + Self::BridgeEvent => ProtectedResourceKind::Event, + Self::ModerationRead | Self::ModerationWrite => ProtectedResourceKind::ModerationTarget, + Self::MediaRead | Self::MediaWrite | Self::MediaDelete => ProtectedResourceKind::Media, + Self::GitRead | Self::GitWrite | Self::GitStream => ProtectedResourceKind::Repository, + Self::AudioJoin | Self::AudioMedia => ProtectedResourceKind::AudioSession, + Self::InviteMint | Self::InviteClaim => ProtectedResourceKind::Invitation, + Self::WebSocketAuthenticate | Self::BindingStatus => { + ProtectedResourceKind::BindingStatus + } + Self::WebSocketQuery + | Self::WebSocketEvent + | Self::WebSocketCount + | Self::BridgeQuery + | Self::BridgeCount + | Self::OperatorRead + | Self::OperatorWrite + | Self::Discovery => ProtectedResourceKind::Domain, + } + } + + /// Exact independently verified proof transport for this ingress. + pub const fn required_transport(self) -> ProofTransport { + match self { + Self::WebSocketAuthenticate + | Self::WebSocketEvent + | Self::WebSocketQuery + | Self::WebSocketCount + | Self::BindingStatus => ProofTransport::Nip42, + Self::MediaRead | Self::MediaWrite | Self::MediaDelete => ProofTransport::Blossom, + Self::GitRead | Self::GitWrite | Self::GitStream => ProofTransport::GitSmartHttpSession, + _ => ProofTransport::Nip98, + } + } +} + +/// Server-owned protected object namespace resolved by an ingress adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProtectedResourceKind { + /// Authorization session scoped to one server-owned domain. + Domain, + /// One channel. + Channel, + /// One event or closed event set. + Event, + /// One repository. + Repository, + /// One media object. + Media, + /// One moderation target. + ModerationTarget, + /// One invitation. + Invitation, + /// One actor's connection-local binding-status authority. + BindingStatus, + /// One audio session. + AudioSession, + /// One delegated-agent relationship. + DelegatedAgent, +} + +impl ProtectedResourceKind { + const fn code(self) -> u8 { + self as u8 + 1 + } +} + +/// Whether an admitted route observes, mutates, or streams protected state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProtectedEffect { + /// Bounded read-only observation. + Read, + /// Atomic protected mutation. + Mutate, + /// Long-running operation requiring periodic re-fencing. + Stream, +} + +impl ProtectedEffect { + const fn code(self) -> u8 { + self as u8 + 1 + } +} + +/// One exact rule supplied by the owning protected-ingress layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RouteRule { + ingress: ProtectedIngress, + protection: RouteProtection, + resource: ProtectedResourceKind, + effect: ProtectedEffect, + transport: ProofTransport, +} + +impl RouteRule { + /// Bind one ingress to its exact sealed capability and transport. + pub const fn protected( + ingress: ProtectedIngress, + capability: RouteCapability, + resource: ProtectedResourceKind, + effect: ProtectedEffect, + transport: ProofTransport, + ) -> Self { + Self { + ingress, + protection: RouteProtection::Protected(capability), + resource, + effect, + transport, + } + } + + /// Protected ingress identity. + pub const fn ingress(self) -> ProtectedIngress { + self.ingress + } +} + +/// Immutable complete route inventory installed into production state once. +#[derive(Clone)] +pub struct RouteAuthority { + rules: BTreeMap, + inventory_fingerprint: [u8; 32], +} + +impl RouteAuthority { + /// Install the repository's one closed production inventory. + pub fn closed() -> Result { + Self::new(ProtectedIngress::ALL.into_iter().map(|ingress| { + RouteRule::protected( + ingress, + ingress.required_capability(), + ingress.required_resource(), + ingress.required_effect(), + ingress.required_transport(), + ) + })) + } + + /// Validate a unique rule for every protected ingress. + pub fn new( + rules: impl IntoIterator, + ) -> Result { + let mut by_ingress = BTreeMap::new(); + for rule in rules { + let RouteProtection::Protected(capability) = rule.protection else { + return Err(RuntimeAuthorizationError::IncompleteRouteInventory); + }; + if capability != rule.ingress.required_capability() + || rule.resource != rule.ingress.required_resource() + || rule.effect != rule.ingress.required_effect() + || rule.transport != rule.ingress.required_transport() + { + return Err(RuntimeAuthorizationError::IncompleteRouteInventory); + } + if by_ingress.insert(rule.ingress, rule).is_some() { + return Err(RuntimeAuthorizationError::IncompleteRouteInventory); + } + } + if by_ingress.len() != ProtectedIngress::ALL.len() + || ProtectedIngress::ALL + .iter() + .any(|ingress| !by_ingress.contains_key(ingress)) + { + return Err(RuntimeAuthorizationError::IncompleteRouteInventory); + } + + let mut digest = Sha256::new(); + digest.update(b"buzz:nip-fi:protected-route-inventory:v1\0"); + for ingress in ProtectedIngress::ALL { + let rule = by_ingress + .get(&ingress) + .ok_or(RuntimeAuthorizationError::IncompleteRouteInventory)?; + let RouteProtection::Protected(capability) = rule.protection else { + return Err(RuntimeAuthorizationError::IncompleteRouteInventory); + }; + digest.update([ + ingress.code(), + capability_code(capability), + rule.resource.code(), + rule.effect.code(), + proof_transport_code(rule.transport), + ]); + } + + Ok(Self { + rules: by_ingress, + inventory_fingerprint: digest.finalize().into(), + }) + } + + /// Resolve an exact ingress and independently verified proof transport. + pub fn resolve( + &self, + ingress: ProtectedIngress, + transport: ProofTransport, + ) -> Result { + let rule = self + .rules + .get(&ingress) + .copied() + .ok_or(RuntimeAuthorizationError::UnknownProtectedRoute)?; + if rule.transport != transport { + return Err(RuntimeAuthorizationError::TransportMismatch); + } + let RouteProtection::Protected(capability) = rule.protection else { + return Err(RuntimeAuthorizationError::UnknownProtectedRoute); + }; + Ok(ResolvedProtectedRoute { + ingress, + capability, + resource: rule.resource, + effect: rule.effect, + transport, + }) + } + + /// Stable fingerprint proving that one complete inventory was installed. + pub const fn inventory_fingerprint(&self) -> &[u8; 32] { + &self.inventory_fingerprint + } +} + +impl std::fmt::Debug for RouteAuthority { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RouteAuthority([REDACTED])") + } +} + +/// Exact route classification consumed by preparation and finalization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedProtectedRoute { + ingress: ProtectedIngress, + capability: RouteCapability, + resource: ProtectedResourceKind, + effect: ProtectedEffect, + transport: ProofTransport, +} + +impl ResolvedProtectedRoute { + /// Typed ingress identity. + pub const fn ingress(self) -> ProtectedIngress { + self.ingress + } + + /// Exact sealed capability. + pub const fn capability(self) -> RouteCapability { + self.capability + } + + /// Server-owned resource namespace. + pub const fn resource(self) -> ProtectedResourceKind { + self.resource + } + + /// Protected effect class. + pub const fn effect(self) -> ProtectedEffect { + self.effect + } + + /// Independently verified proof transport. + pub const fn transport(self) -> ProofTransport { + self.transport + } +} + +const fn proof_transport_code(transport: ProofTransport) -> u8 { + match transport { + ProofTransport::Nip42 => 1, + ProofTransport::Nip98 => 2, + ProofTransport::GitSmartHttpSession => 3, + ProofTransport::Blossom => 4, + } +} + +const fn capability_code(capability: RouteCapability) -> u8 { + match capability { + RouteCapability::MessagesRead => 1, + RouteCapability::MessagesWrite => 2, + RouteCapability::ChannelsRead => 3, + RouteCapability::ChannelsWrite => 4, + RouteCapability::AdminChannels => 5, + RouteCapability::UsersRead => 6, + RouteCapability::UsersWrite => 7, + RouteCapability::AdminUsers => 8, + RouteCapability::JobsRead => 9, + RouteCapability::JobsWrite => 10, + RouteCapability::SubscriptionsRead => 11, + RouteCapability::SubscriptionsWrite => 12, + RouteCapability::FilesRead => 13, + RouteCapability::FilesWrite => 14, + RouteCapability::ReposRead => 15, + RouteCapability::ReposWrite => 16, + RouteCapability::GitRead => 17, + RouteCapability::GitWrite => 18, + RouteCapability::GitStream => 19, + RouteCapability::MediaRead => 20, + RouteCapability::MediaWrite => 21, + RouteCapability::Moderation => 22, + RouteCapability::AudioJoin => 23, + RouteCapability::AudioMedia => 24, + RouteCapability::Discovery => 25, + RouteCapability::BindingStatus => 26, + RouteCapability::Enrollment => 27, + RouteCapability::InviteMint => 28, + RouteCapability::InviteClaim => 29, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn complete_rules() -> Vec { + RouteAuthority::closed() + .unwrap() + .rules + .into_values() + .collect() + } + + #[test] + fn inventory_must_be_complete_and_unique() { + let mut missing = complete_rules(); + missing.pop(); + assert_eq!( + RouteAuthority::new(missing).unwrap_err(), + RuntimeAuthorizationError::IncompleteRouteInventory + ); + + let mut duplicate = complete_rules(); + duplicate.push(duplicate[0]); + assert_eq!( + RouteAuthority::new(duplicate).unwrap_err(), + RuntimeAuthorizationError::IncompleteRouteInventory + ); + + let mut wrong_resource = complete_rules(); + let ingress = ProtectedIngress::GitRead; + wrong_resource[ingress as usize] = RouteRule::protected( + ingress, + ingress.required_capability(), + ProtectedResourceKind::Domain, + ingress.required_effect(), + ingress.required_transport(), + ); + assert_eq!( + RouteAuthority::new(wrong_resource).unwrap_err(), + RuntimeAuthorizationError::IncompleteRouteInventory + ); + } + + #[test] + fn transport_mismatch_fails_before_capability_use() { + let authority = RouteAuthority::new(complete_rules()).unwrap(); + assert_eq!( + authority + .resolve(ProtectedIngress::GitRead, ProofTransport::Nip42) + .unwrap_err(), + RuntimeAuthorizationError::TransportMismatch + ); + let resolved = authority + .resolve( + ProtectedIngress::GitRead, + ProofTransport::GitSmartHttpSession, + ) + .unwrap(); + assert_eq!(resolved.capability(), RouteCapability::GitRead); + } + + #[test] + fn complete_inventory_fingerprint_is_order_independent() { + let first = RouteAuthority::new(complete_rules()).unwrap(); + let mut reversed = complete_rules(); + reversed.reverse(); + let second = RouteAuthority::new(reversed).unwrap(); + assert_eq!( + first.inventory_fingerprint(), + second.inventory_fingerprint() + ); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/startup.rs b/crates/buzz-relay/src/authorization_runtime/startup.rs new file mode 100644 index 00000000000..21df920aca7 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/startup.rs @@ -0,0 +1,1186 @@ +//! Ordered immutable startup and aggregate readiness. + +use std::collections::BTreeSet; +use std::fmt; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use buzz_auth::{LocalBindingResolverCapability, RouteCapability, VerifierPolicyStamp}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use super::{ + DelegationConfig, DynamicVerifier, InvalidationRegistry, ProviderFreeRuntimeConfig, + ProviderFreeRuntimeMode, RouteAuthority, RuntimeAuthority, RuntimeAuthorizationError, +}; + +const SCHEMA_BIT: u16 = 1 << 0; +const DATABASE_BIT: u16 = 1 << 1; +const VERIFIER_BIT: u16 = 1 << 2; +const DISCOVERY_BIT: u16 = 1 << 3; +const RECONCILIATION_BIT: u16 = 1 << 4; +const STATE_BIT: u16 = 1 << 5; +const ROUTES_BIT: u16 = 1 << 6; +const STATUS_BIT: u16 = 1 << 7; +const ENFORCE_READY_BITS: u16 = SCHEMA_BIT + | DATABASE_BIT + | VERIFIER_BIT + | DISCOVERY_BIT + | RECONCILIATION_BIT + | STATE_BIT + | ROUTES_BIT + | STATUS_BIT; +const MAX_STARTUP_OBSERVATION_AGE_SECONDS: i64 = 300; +const MAX_STARTUP_CLOCK_SKEW_SECONDS: i64 = 30; + +/// Readiness dependency whose loss immediately denies protected operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadinessReason { + /// Exact schema and migration lineage. + Schema, + /// Independent writer, listener, and witness roles. + DatabaseRoles, + /// Stable policy and current key generation. + Verifier, + /// Issuer-matched discovery state. + Discovery, + /// Restore and invalidation reconciliation. + Reconciliation, + /// One complete immutable runtime state. + State, + /// Complete closed route inventory. + Routes, + /// Connection-local current-status production contract. + Status, +} + +impl ReadinessReason { + const fn bit(self) -> u16 { + match self { + Self::Schema => SCHEMA_BIT, + Self::DatabaseRoles => DATABASE_BIT, + Self::Verifier => VERIFIER_BIT, + Self::Discovery => DISCOVERY_BIT, + Self::Reconciliation => RECONCILIATION_BIT, + Self::State => STATE_BIT, + Self::Routes => ROUTES_BIT, + Self::Status => STATUS_BIT, + } + } +} + +/// Shared aggregate readiness. Health workers may only withdraw bits; recovery +/// is installed through a new proof-bearing immutable runtime generation. +pub struct AggregateReadiness { + required: u16, + healthy: AtomicU16, +} + +impl AggregateReadiness { + fn complete_enforce() -> Self { + Self { + required: ENFORCE_READY_BITS, + healthy: AtomicU16::new(ENFORCE_READY_BITS), + } + } + + fn disabled() -> Self { + Self { + required: 0, + healthy: AtomicU16::new(0), + } + } + + fn unavailable_enforce() -> Self { + Self { + required: ENFORCE_READY_BITS, + healthy: AtomicU16::new(0), + } + } + + /// Whether every required startup and live dependency remains healthy. + pub fn is_ready(&self) -> bool { + self.healthy.load(Ordering::Acquire) & self.required == self.required + } + + /// Permanently withdraw one dependency from this installed generation. + /// Recovery constructs and atomically installs a freshly reconciled state; + /// it never toggles a stale generation optimistic again. + pub fn withdraw(&self, reason: ReadinessReason) { + self.healthy.fetch_and(!reason.bit(), Ordering::AcqRel); + } + + /// Current privacy-safe health mask for diagnostics. + pub fn health_mask(&self) -> u16 { + self.healthy.load(Ordering::Acquire) + } +} + +impl fmt::Debug for AggregateReadiness { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AggregateReadiness") + .field("ready", &self.is_ready()) + .finish_non_exhaustive() + } +} + +/// Exact schema and signed-seal witness produced after migrations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaWitness { + seal_tree: [u8; 20], + schema_fingerprint: [u8; 32], + migration_ordinal: u32, +} + +impl SchemaWitness { + /// Construct only after schema attachment and drift checks pass. + pub fn new( + seal_tree: [u8; 20], + schema_fingerprint: [u8; 32], + migration_ordinal: u32, + fully_attached: bool, + ) -> Result { + if seal_tree == [0; 20] + || schema_fingerprint == [0; 32] + || migration_ordinal == 0 + || !fully_attached + { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(Self { + seal_tree, + schema_fingerprint, + migration_ordinal, + }) + } + + /// Exact signed-seal tree identity. + pub const fn seal_tree(&self) -> &[u8; 20] { + &self.seal_tree + } + + /// Fingerprint of the exact fully attached schema. + pub const fn schema_fingerprint(&self) -> &[u8; 32] { + &self.schema_fingerprint + } + + /// Highest verified migration ordinal. + pub const fn migration_ordinal(&self) -> u32 { + self.migration_ordinal + } +} + +/// Non-substitutable writer/listener/witness role probe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DatabaseRoleWitness { + writer: Uuid, + listener: Uuid, + witness: Uuid, + probed_at: DateTime, +} + +impl DatabaseRoleWitness { + /// Validate three independently owned healthy role identities. + pub fn new( + writer: Uuid, + listener: Uuid, + witness: Uuid, + probed_at: DateTime, + healthy: bool, + ) -> Result { + let now = Utc::now(); + if writer.is_nil() + || listener.is_nil() + || witness.is_nil() + || writer == listener + || writer == witness + || listener == witness + || !healthy + || !observation_is_recent(probed_at, now) + { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(Self { + writer, + listener, + witness, + probed_at, + }) + } + + /// Independent writer, listener, and witness role identities. + pub const fn role_identities(&self) -> (Uuid, Uuid, Uuid) { + (self.writer, self.listener, self.witness) + } + + /// Authoritative role-probe observation time. + pub const fn probed_at(&self) -> DateTime { + self.probed_at + } +} + +/// Canonical verifier/discovery observation with separate hard deadlines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerifierReadinessWitness { + stamp: VerifierPolicyStamp, + observed_at: DateTime, + verifier_fresh_until: DateTime, + discovery_fresh_until: DateTime, +} + +impl VerifierReadinessWitness { + /// Validate non-stale verifier and issuer-matched discovery observations. + pub fn new( + stamp: VerifierPolicyStamp, + observed_at: DateTime, + verifier_fresh_until: DateTime, + discovery_fresh_until: DateTime, + ) -> Result { + if !observation_is_recent(observed_at, Utc::now()) + || observed_at >= verifier_fresh_until + || observed_at >= discovery_fresh_until + { + return Err(RuntimeAuthorizationError::StaleVerifier); + } + Ok(Self { + stamp, + observed_at, + verifier_fresh_until, + discovery_fresh_until, + }) + } + + /// Stable policy and current rotating key generation. + pub const fn stamp(&self) -> VerifierPolicyStamp { + self.stamp + } + + /// Observation time and independent hard deadlines. + pub const fn deadlines(&self) -> (DateTime, DateTime, DateTime) { + ( + self.observed_at, + self.verifier_fresh_until, + self.discovery_fresh_until, + ) + } +} + +/// Complete restore/invalidation reconciliation witness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RestoreReconciliationWitness { + digest: [u8; 32], + observed_at: DateTime, +} + +impl RestoreReconciliationWitness { + /// Validate a non-sentinel result only after every refusal check passes. + pub fn new( + digest: [u8; 32], + observed_at: DateTime, + complete: bool, + ) -> Result { + if digest == [0; 32] || !complete || !observation_is_recent(observed_at, Utc::now()) { + return Err(RuntimeAuthorizationError::RestoreRejected); + } + Ok(Self { + digest, + observed_at, + }) + } + + /// Privacy-safe reconciliation digest and observation time. + pub const fn observation(&self) -> (&[u8; 32], DateTime) { + (&self.digest, self.observed_at) + } +} + +/// Complete route inventory plus connection-local contract witness. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RouteInventoryWitness { + route_fingerprint: [u8; 32], + status_contract_fingerprint: [u8; 32], +} + +/// Positive proof that the installed resolver implements the exact configured +/// owner-bound delegation surface. Absence is the only valid disabled state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DelegationReadinessWitness { + capabilities: BTreeSet, + maximum_lifetime: Duration, + resolver: LocalBindingResolverCapability, + contract_fingerprint: [u8; 32], +} + +impl DelegationReadinessWitness { + /// Bind one nonempty finite capability set to the positive resolver + /// capability and a stable implementation-contract fingerprint. + pub fn new( + capabilities: BTreeSet, + maximum_lifetime: Duration, + resolver: LocalBindingResolverCapability, + contract_fingerprint: [u8; 32], + ) -> Result { + if capabilities.is_empty() + || maximum_lifetime.is_zero() + || resolver != LocalBindingResolverCapability::DirectAndDelegatedOwnerBound + || contract_fingerprint == [0; 32] + { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(Self { + capabilities, + maximum_lifetime, + resolver, + contract_fingerprint, + }) + } +} + +impl RouteInventoryWitness { + /// Bind exact complete routes to the immutable typed current-binding contract. + pub fn new( + route_fingerprint: [u8; 32], + status_contract_fingerprint: [u8; 32], + ) -> Result { + if route_fingerprint == [0; 32] || status_contract_fingerprint == [0; 32] { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(Self { + route_fingerprint, + status_contract_fingerprint, + }) + } +} + +/// Concrete immutable objects installed together after reconciliation. +pub struct RuntimeStateComponents { + /// Complete typed route authority. + pub routes: Arc, + /// Single dynamic canonical verifier. + pub verifier: Arc, + /// Reconciled live invalidation registry. + pub invalidation: Arc, + /// Canonical authorization authority. + pub authority: Arc, + /// Exact reconciliation lineage installed with the invalidation registry. + pub reconciliation_digest: [u8; 32], + /// Exact current-binding typed-contract identity installed with route adapters. + pub status_contract_fingerprint: [u8; 32], + /// Positive owner-bound resolver proof. Configuration alone never creates + /// delegation authority; disabled configuration requires this to be absent. + pub delegation: Option, +} + +impl fmt::Debug for RuntimeStateComponents { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("RuntimeStateComponents([REDACTED])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StartupPhase { + Schema, + Database, + Verifier, + Reconciliation, + State, + Aggregate, +} + +/// Ordered Enforce-mode builder. Calling phases out of order fails closed. +pub struct RuntimeStartup { + config: ProviderFreeRuntimeConfig, + phase: StartupPhase, + schema: Option, + database: Option, + verifier_witness: Option, + reconciliation: Option, + route_witness: Option, + components: Option, +} + +impl RuntimeStartup { + /// Begin Enforce startup. Off and emergency denial use + /// [`InstalledAuthorizationRuntime::disabled`]. + pub fn enforce(config: ProviderFreeRuntimeConfig) -> Result { + if config.mode() != ProviderFreeRuntimeMode::Enforce || config.enforce().is_none() { + return Err(RuntimeAuthorizationError::InvalidConfiguration); + } + Ok(Self { + config, + phase: StartupPhase::Schema, + schema: None, + database: None, + verifier_witness: None, + reconciliation: None, + route_witness: None, + components: None, + }) + } + + /// Phase 1: accept exact migration/schema witness. + pub fn migrations(&mut self, witness: SchemaWitness) -> Result<(), RuntimeAuthorizationError> { + self.require_phase(StartupPhase::Schema)?; + self.schema = Some(witness); + self.phase = StartupPhase::Database; + Ok(()) + } + + /// Phase 2: accept non-substitutable DB-role probes. + pub fn database_roles( + &mut self, + witness: DatabaseRoleWitness, + ) -> Result<(), RuntimeAuthorizationError> { + self.require_phase(StartupPhase::Database)?; + self.database = Some(witness); + self.phase = StartupPhase::Verifier; + Ok(()) + } + + /// Phase 3: accept canonical key generation and discovery deadlines. + pub fn verifier( + &mut self, + witness: VerifierReadinessWitness, + ) -> Result<(), RuntimeAuthorizationError> { + self.require_phase(StartupPhase::Verifier)?; + let expected_policy = self + .config + .enforce() + .map(|config| config.verifier_policy().id()) + .ok_or(RuntimeAuthorizationError::PartialState)?; + if witness.stamp.policy_id() != expected_policy { + return Err(RuntimeAuthorizationError::PartialState); + } + self.verifier_witness = Some(witness); + self.phase = StartupPhase::Reconciliation; + Ok(()) + } + + /// Phase 4: accept complete restore/invalidation reconciliation. + pub fn reconciliation( + &mut self, + witness: RestoreReconciliationWitness, + ) -> Result<(), RuntimeAuthorizationError> { + self.require_phase(StartupPhase::Reconciliation)?; + self.reconciliation = Some(witness); + self.phase = StartupPhase::State; + Ok(()) + } + + /// Phase 5: install one immutable, internally matching runtime state. + pub async fn install_state( + &mut self, + components: RuntimeStateComponents, + routes: RouteInventoryWitness, + ) -> Result<(), RuntimeAuthorizationError> { + self.require_phase(StartupPhase::State)?; + let verifier_witness = self + .verifier_witness + .ok_or(RuntimeAuthorizationError::PartialState)?; + let reconciliation = self + .reconciliation + .ok_or(RuntimeAuthorizationError::PartialState)?; + let database = self + .database + .ok_or(RuntimeAuthorizationError::PartialState)?; + let delegation_matches = match ( + self.config.enforce().map(|config| config.delegation()), + components.delegation.as_ref(), + ) { + (Some(DelegationConfig::Disabled), None) => true, + ( + Some(DelegationConfig::Enabled { + capabilities, + maximum_lifetime, + }), + Some(witness), + ) => { + witness.capabilities == *capabilities + && witness.maximum_lifetime == *maximum_lifetime + && witness.resolver + == LocalBindingResolverCapability::DirectAndDelegatedOwnerBound + && witness.contract_fingerprint != [0; 32] + } + _ => false, + }; + let install_now = Utc::now(); + if !observation_is_recent(database.probed_at, install_now) + || !observation_is_recent(reconciliation.observed_at, install_now) + || !observation_is_recent(verifier_witness.observed_at, install_now) + { + return Err(RuntimeAuthorizationError::PartialState); + } + let verifier_snapshot = components.verifier.current(install_now).await?; + let discovery_matches = match self.config.enforce().map(|config| config.jwks_source()) { + Some(super::JwksSourceConfig::DiscoveryUri(_)) => { + verifier_snapshot.discovery_fresh_until() + == Some(verifier_witness.discovery_fresh_until) + } + Some(super::JwksSourceConfig::JwksUri(_)) => { + verifier_snapshot.discovery_fresh_until().is_none() + && verifier_witness.discovery_fresh_until + == verifier_witness.verifier_fresh_until + } + None => false, + }; + if components.routes.inventory_fingerprint() != &routes.route_fingerprint + || !components.invalidation.is_ready() + || components.authority.routes().inventory_fingerprint() + != components.routes.inventory_fingerprint() + || !Arc::ptr_eq( + components.authority.invalidation(), + &components.invalidation, + ) + || verifier_snapshot.stamp() != verifier_witness.stamp + || verifier_snapshot.fresh_until() != verifier_witness.verifier_fresh_until + || install_now >= verifier_witness.verifier_fresh_until + || install_now >= verifier_witness.discovery_fresh_until + || !discovery_matches + || components.reconciliation_digest != reconciliation.digest + || components.status_contract_fingerprint != routes.status_contract_fingerprint + || !delegation_matches + { + return Err(RuntimeAuthorizationError::PartialState); + } + self.components = Some(components); + self.route_witness = Some(routes); + self.phase = StartupPhase::Aggregate; + Ok(()) + } + + /// Phase 6: construct aggregate readiness. Routers, listeners, and + /// background tasks may be exposed only after this succeeds. + pub fn finish(self) -> Result { + if self.config.mode() != ProviderFreeRuntimeMode::Enforce + || self.phase != StartupPhase::Aggregate + || self.schema.is_none() + || self.database.is_none() + || self.verifier_witness.is_none() + || self.reconciliation.is_none() + || self.route_witness.is_none() + { + return Err(RuntimeAuthorizationError::PartialState); + } + let verifier_witness = self + .verifier_witness + .ok_or(RuntimeAuthorizationError::PartialState)?; + let database = self + .database + .ok_or(RuntimeAuthorizationError::PartialState)?; + let reconciliation = self + .reconciliation + .ok_or(RuntimeAuthorizationError::PartialState)?; + let finish_now = Utc::now(); + if !observation_is_recent(database.probed_at, finish_now) + || !observation_is_recent(reconciliation.observed_at, finish_now) + || !observation_is_recent(verifier_witness.observed_at, finish_now) + || finish_now >= verifier_witness.verifier_fresh_until + || finish_now >= verifier_witness.discovery_fresh_until + { + return Err(RuntimeAuthorizationError::PartialState); + } + let components = self + .components + .ok_or(RuntimeAuthorizationError::PartialState)?; + if components.verifier.current_stamp() != Some(verifier_witness.stamp) + || !components.invalidation.is_ready() + { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(InstalledAuthorizationRuntime { + mode: ProviderFreeRuntimeMode::Enforce, + readiness: Arc::new(AggregateReadiness::complete_enforce()), + routes: Some(components.routes), + verifier: Some(components.verifier), + trusted_proxy: None, + invalidation: Some(components.invalidation), + authority: Some(components.authority), + status_contract_fingerprint: self + .route_witness + .map(|witness| witness.status_contract_fingerprint), + verifier_fresh_until: Some(verifier_witness.verifier_fresh_until), + discovery_fresh_until: Some(verifier_witness.discovery_fresh_until), + verifier_stamp: Some(verifier_witness.stamp), + follows_dynamic_verifier: false, + }) + } + + fn require_phase(&self, expected: StartupPhase) -> Result<(), RuntimeAuthorizationError> { + if self.phase == expected { + Ok(()) + } else { + Err(RuntimeAuthorizationError::PartialState) + } + } +} + +impl fmt::Debug for RuntimeStartup { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeStartup") + .field("phase", &self.phase) + .finish_non_exhaustive() + } +} + +/// Complete provider-free state stored once on `AppState`. +#[derive(Clone)] +pub struct InstalledAuthorizationRuntime { + mode: ProviderFreeRuntimeMode, + readiness: Arc, + routes: Option>, + verifier: Option>, + trusted_proxy: Option>, + invalidation: Option>, + authority: Option>, + status_contract_fingerprint: Option<[u8; 32]>, + verifier_fresh_until: Option>, + discovery_fresh_until: Option>, + verifier_stamp: Option, + follows_dynamic_verifier: bool, +} + +impl InstalledAuthorizationRuntime { + /// Construct an assertion-only ready runtime for route-adapter tests. + /// + /// Production must use [`RuntimeStartup::finish`]; this helper deliberately + /// installs no route, database, invalidation, or status authority. + #[cfg(test)] + pub(crate) fn for_canonical_assertion_test( + verifier: Arc, + snapshot: super::JwksSnapshot, + ) -> Self { + let routes = Arc::new(RouteAuthority::closed().expect("closed route inventory")); + Self { + mode: ProviderFreeRuntimeMode::Enforce, + readiness: Arc::new(AggregateReadiness::complete_enforce()), + routes: Some(routes), + verifier: Some(verifier), + trusted_proxy: None, + invalidation: None, + authority: None, + status_contract_fingerprint: None, + verifier_fresh_until: Some(snapshot.fresh_until()), + discovery_fresh_until: Some(snapshot.fresh_until()), + verifier_stamp: Some(snapshot.stamp()), + follows_dynamic_verifier: false, + } + } + + /// Install the production handler-owned canonical authority. + /// + /// Startup fetches one current key generation and constructs the exact v2 + /// trusted-proxy verifier before listeners can open. Refresh is continuous; + /// a failed or stale refresh is denied by `DynamicVerifier::verify` at the + /// owning handler before application mutation. + pub async fn production( + config: &ProviderFreeRuntimeConfig, + ) -> Result { + let enforce = config + .enforce() + .ok_or(RuntimeAuthorizationError::InvalidConfiguration)?; + let refresh_policy = super::JwksRefreshPolicy::new( + 1024 * 1024, + Duration::from_secs(10), + enforce.lease_maximum().max(Duration::from_secs(300)), + )?; + let verifier = Arc::new(DynamicVerifier::new( + enforce.verifier_policy().clone(), + enforce.issuer().to_owned(), + enforce.jwks_source().clone(), + refresh_policy, + Arc::new(super::ReqwestJwksDocumentLoader::new()?), + )?); + let snapshot = verifier.refresh(Utc::now()).await?; + let trusted_proxy = Arc::new(enforce.trusted_proxy_verifier()?); + let routes = Arc::new(RouteAuthority::closed()?); + let refresh_verifier = Arc::clone(&verifier); + tokio::spawn(async move { + let interval = refresh_policy.fresh_lifetime / 2; + loop { + tokio::time::sleep(interval).await; + if let Err(error) = refresh_verifier.refresh(Utc::now()).await { + tracing::warn!( + code = error.code(), + "canonical verifier refresh failed closed" + ); + } + } + }); + Ok(Self { + mode: ProviderFreeRuntimeMode::Enforce, + readiness: Arc::new(AggregateReadiness::complete_enforce()), + routes: Some(routes), + verifier: Some(verifier), + trusted_proxy: Some(trusted_proxy), + invalidation: None, + authority: None, + status_contract_fingerprint: None, + verifier_fresh_until: Some(snapshot.fresh_until()), + discovery_fresh_until: Some( + snapshot + .discovery_fresh_until() + .unwrap_or(snapshot.fresh_until()), + ), + verifier_stamp: Some(snapshot.stamp()), + follows_dynamic_verifier: true, + }) + } + + /// Install complete Off or emergency-denial state without initializing + /// verifier, database, restore, or status dependencies. + pub fn disabled(config: &ProviderFreeRuntimeConfig) -> Result { + if config.mode() == ProviderFreeRuntimeMode::Enforce { + return Err(RuntimeAuthorizationError::PartialState); + } + Ok(Self { + mode: config.mode(), + readiness: Arc::new(AggregateReadiness::disabled()), + routes: None, + verifier: None, + trusted_proxy: None, + invalidation: None, + authority: None, + status_contract_fingerprint: None, + verifier_fresh_until: None, + discovery_fresh_until: None, + verifier_stamp: None, + follows_dynamic_verifier: false, + }) + } + + /// Construct the only safe pre-start state for a configured Enforce + /// runtime. It carries no dependency objects, denies every protected + /// operation, and can never become ready. Production must replace it with + /// the result of [`RuntimeStartup::finish`] before serving. + pub fn fail_closed(config: &ProviderFreeRuntimeConfig) -> Self { + if config.mode() != ProviderFreeRuntimeMode::Enforce { + return Self { + mode: config.mode(), + readiness: Arc::new(AggregateReadiness::disabled()), + routes: None, + verifier: None, + trusted_proxy: None, + invalidation: None, + authority: None, + status_contract_fingerprint: None, + verifier_fresh_until: None, + discovery_fresh_until: None, + verifier_stamp: None, + follows_dynamic_verifier: false, + }; + } + Self { + mode: ProviderFreeRuntimeMode::Enforce, + readiness: Arc::new(AggregateReadiness::unavailable_enforce()), + routes: None, + verifier: None, + trusted_proxy: None, + invalidation: None, + authority: None, + status_contract_fingerprint: None, + verifier_fresh_until: None, + discovery_fresh_until: None, + verifier_stamp: None, + follows_dynamic_verifier: false, + } + } + + /// Closed configured mode. + pub const fn mode(&self) -> ProviderFreeRuntimeMode { + self.mode + } + + /// Aggregate readiness including dynamic dependency loss. + pub fn is_ready(&self) -> bool { + let now = Utc::now(); + self.readiness.is_ready() + && (self.mode != ProviderFreeRuntimeMode::Enforce || self.routes.is_some()) + && (self.mode != ProviderFreeRuntimeMode::Enforce + || (self.follows_dynamic_verifier + && self + .verifier + .as_ref() + .is_some_and(|verifier| verifier.has_current_snapshot(now)) + && self.trusted_proxy.is_some()) + || (self + .verifier_fresh_until + .is_some_and(|deadline| now < deadline) + && self + .discovery_fresh_until + .is_some_and(|deadline| now < deadline) + && self + .verifier + .as_ref() + .is_some_and(|verifier| verifier.current_stamp() == self.verifier_stamp))) + && self + .invalidation + .as_ref() + .is_none_or(|registry| registry.is_ready()) + } + + /// Whether protected work must be denied before any adapter executes. + pub fn denies_protected(&self) -> bool { + self.mode != ProviderFreeRuntimeMode::Off + && (self.mode == ProviderFreeRuntimeMode::DenyProtected || !self.is_ready()) + } + + /// Withdraw one live dependency immediately. + pub fn withdraw_readiness(&self, reason: ReadinessReason) { + self.readiness.withdraw(reason); + } + + /// Complete route table, available only in healthy Enforce mode. + pub fn routes(&self) -> Result<&Arc, RuntimeAuthorizationError> { + if self.mode != ProviderFreeRuntimeMode::Enforce || !self.is_ready() { + return Err(RuntimeAuthorizationError::NotReady); + } + self.routes + .as_ref() + .ok_or(RuntimeAuthorizationError::PartialState) + } + + /// Canonical admission authority, available only in healthy Enforce mode. + pub fn authority(&self) -> Result<&Arc, RuntimeAuthorizationError> { + if self.mode != ProviderFreeRuntimeMode::Enforce || !self.is_ready() { + return Err(RuntimeAuthorizationError::NotReady); + } + self.authority + .as_ref() + .ok_or(RuntimeAuthorizationError::PartialState) + } + + /// Dynamic verifier, available only in healthy Enforce mode. + pub fn verifier(&self) -> Result<&Arc, RuntimeAuthorizationError> { + if self.mode != ProviderFreeRuntimeMode::Enforce || !self.is_ready() { + return Err(RuntimeAuthorizationError::NotReady); + } + self.verifier + .as_ref() + .ok_or(RuntimeAuthorizationError::PartialState) + } + + /// Trusted-proxy authority, available only in healthy production Enforce mode. + pub fn trusted_proxy_verifier( + &self, + ) -> Result<&Arc, RuntimeAuthorizationError> { + if self.mode != ProviderFreeRuntimeMode::Enforce || !self.is_ready() { + return Err(RuntimeAuthorizationError::NotReady); + } + self.trusted_proxy + .as_ref() + .ok_or(RuntimeAuthorizationError::PartialState) + } + + /// Live invalidation registry, available only in healthy Enforce mode. + pub fn invalidation(&self) -> Result<&Arc, RuntimeAuthorizationError> { + if self.mode != ProviderFreeRuntimeMode::Enforce || !self.is_ready() { + return Err(RuntimeAuthorizationError::NotReady); + } + self.invalidation + .as_ref() + .ok_or(RuntimeAuthorizationError::PartialState) + } + + /// Immutable current-binding contract fingerprint without exposing wire internals. + pub const fn status_contract_fingerprint(&self) -> Option<&[u8; 32]> { + self.status_contract_fingerprint.as_ref() + } +} + +fn observation_is_recent(observed_at: DateTime, now: DateTime) -> bool { + observed_at >= now - chrono::Duration::seconds(MAX_STARTUP_OBSERVATION_AGE_SECONDS) + && observed_at <= now + chrono::Duration::seconds(MAX_STARTUP_CLOCK_SKEW_SECONDS) +} + +impl fmt::Debug for InstalledAuthorizationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("InstalledAuthorizationRuntime") + .field("mode", &self.mode) + .field("ready", &self.is_ready()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use buzz_auth::{CanonicalVerifierPolicy, VerifierKeyGeneration, VerifierPolicyStamp}; + use buzz_core::CommunityId; + + use super::*; + use crate::authorization_runtime::{ + AdmissionCommitPort, JwksDocumentLoader, JwksRefreshPolicy, ProtectedIngress, + ReconciledInvalidationState, RouteRule, RuntimeAdmissionRequest, + }; + + fn deny_config() -> ProviderFreeRuntimeConfig { + ProviderFreeRuntimeConfig::from_optional_json(Some(r#"{"deny_protected":true}"#)).unwrap() + } + + #[test] + fn disabled_modes_initialize_no_partial_dependencies() { + let off = + InstalledAuthorizationRuntime::disabled(&ProviderFreeRuntimeConfig::off()).unwrap(); + assert!(off.is_ready()); + assert!(!off.denies_protected()); + assert!(off.routes.is_none()); + + let deny = InstalledAuthorizationRuntime::disabled(&deny_config()).unwrap(); + assert!(deny.is_ready()); + assert!(deny.denies_protected()); + assert!(deny.verifier.is_none()); + + let enforce = InstalledAuthorizationRuntime::fail_closed(&enforce_config()); + assert!(!enforce.is_ready()); + assert!(enforce.denies_protected()); + assert!(enforce.authority.is_none()); + } + + #[test] + fn witnesses_reject_partial_or_substitutable_state() { + assert!(SchemaWitness::new([1; 20], [2; 32], 30, false).is_err()); + let same = Uuid::from_u128(1); + assert!( + DatabaseRoleWitness::new(same, same, Uuid::from_u128(2), Utc::now(), true).is_err() + ); + assert!(DatabaseRoleWitness::new( + Uuid::from_u128(1), + Uuid::from_u128(2), + Uuid::from_u128(3), + Utc::now() - chrono::Duration::seconds(301), + true, + ) + .is_err()); + assert!(DelegationReadinessWitness::new( + BTreeSet::from([RouteCapability::MessagesRead]), + Duration::from_secs(60), + LocalBindingResolverCapability::Direct, + [9; 32], + ) + .is_err()); + assert!(RestoreReconciliationWitness::new( + [4; 32], + Utc::now() - chrono::Duration::seconds(301), + true, + ) + .is_err()); + assert!(RouteInventoryWitness::new([1; 32], [0; 32]).is_err()); + } + + #[test] + fn readiness_loss_is_sticky_for_installed_generation() { + let readiness = AggregateReadiness::complete_enforce(); + assert!(readiness.is_ready()); + readiness.withdraw(ReadinessReason::Verifier); + assert!(!readiness.is_ready()); + assert_eq!(readiness.health_mask() & VERIFIER_BIT, 0); + } + + fn enforce_config() -> ProviderFreeRuntimeConfig { + ProviderFreeRuntimeConfig::from_optional_json(Some( + r#"{ + "issuer":"https://issuer.example", + "audience":"buzz", + "maximum_token_lifetime_seconds":300, + "jwks":{"jwks_uri":"https://issuer.example/keys"}, + "lease":{"maximum_seconds":120}, + "policy_revision":1, + "audit":{"max_events_per_domain":100,"max_bytes_per_domain":65536,"max_envelope_bytes":4096}, + "client_status_admission":{"max_presentations_per_domain":100,"max_presentations_per_actor":5,"max_presentations_per_peer":20}, + "transport":{"kind":"sealed_nostr_proof"}, + "enrollment":{"kind":"canonical_admission"}, + "restore":{"kind":"operation_manifest"} + }"#, + )) + .unwrap() + } + + fn verifier_witness(now: DateTime) -> VerifierReadinessWitness { + let policy = CanonicalVerifierPolicy::new( + "https://issuer.example".to_owned(), + "buzz".to_owned(), + "sub".to_owned(), + None, + 0, + 300, + ) + .unwrap(); + let generation = VerifierKeyGeneration::new(1).unwrap(); + VerifierReadinessWitness::new( + VerifierPolicyStamp::new(policy.id(), generation), + now, + now + chrono::Duration::minutes(5), + now + chrono::Duration::minutes(5), + ) + .unwrap() + } + + #[test] + fn startup_phases_cannot_be_reordered_or_skipped() { + let now = Utc::now(); + let mut startup = RuntimeStartup::enforce(enforce_config()).unwrap(); + let database = DatabaseRoleWitness::new( + Uuid::from_u128(1), + Uuid::from_u128(2), + Uuid::from_u128(3), + now, + true, + ) + .unwrap(); + assert_eq!( + startup.database_roles(database), + Err(RuntimeAuthorizationError::PartialState) + ); + startup + .migrations(SchemaWitness::new([1; 20], [2; 32], 30, true).unwrap()) + .unwrap(); + assert_eq!( + startup.verifier(verifier_witness(now)), + Err(RuntimeAuthorizationError::PartialState) + ); + startup.database_roles(database).unwrap(); + startup.verifier(verifier_witness(now)).unwrap(); + assert!(startup.finish().is_err()); + } + + struct Loader(tokio::sync::Mutex>>); + + #[async_trait::async_trait] + impl JwksDocumentLoader for Loader { + async fn load( + &self, + _source: &super::super::JwksSourceConfig, + _expected_issuer: &str, + _policy: JwksRefreshPolicy, + ) -> Result, RuntimeAuthorizationError> { + self.0 + .lock() + .await + .pop_front() + .ok_or(RuntimeAuthorizationError::DependencyUnavailable) + } + } + + struct DenyCommitter; + + #[async_trait::async_trait] + impl AdmissionCommitPort for DenyCommitter { + async fn commit( + &self, + _request: RuntimeAdmissionRequest, + ) -> Result { + Err(RuntimeAuthorizationError::DependencyUnavailable) + } + } + + #[tokio::test] + async fn complete_startup_installs_once_in_mandatory_order() { + let now = Utc::now(); + let config = enforce_config(); + let enforce = config.enforce().unwrap(); + let verifier = Arc::new( + DynamicVerifier::new( + enforce.verifier_policy().clone(), + enforce.issuer().to_owned(), + enforce.jwks_source().clone(), + JwksRefreshPolicy::new( + 64 * 1024, + std::time::Duration::from_secs(2), + std::time::Duration::from_secs(300), + ) + .unwrap(), + Arc::new(Loader(tokio::sync::Mutex::new(VecDeque::from([ + br#"{"keys":[{"kty":"RSA","kid":"one","n":"AQAB","e":"AQAB"}]}"#.to_vec(), + br#"{"keys":[{"kty":"RSA","kid":"two","n":"AQAB","e":"AQAB"}]}"#.to_vec(), + ])))), + ) + .unwrap(), + ); + let snapshot = verifier.refresh(now).await.unwrap(); + let rules = ProtectedIngress::ALL.into_iter().map(|ingress| { + RouteRule::protected( + ingress, + ingress.required_capability(), + ingress.required_resource(), + ingress.required_effect(), + ingress.required_transport(), + ) + }); + let routes = Arc::new(RouteAuthority::new(rules).unwrap()); + let invalidation = Arc::new(InvalidationRegistry::new()); + invalidation + .reconcile(ReconciledInvalidationState { + complete: true, + domain_generations: vec![(CommunityId::from_uuid(Uuid::from_u128(1)), 1)], + observed_at: now, + }) + .unwrap(); + let authority = Arc::new(RuntimeAuthority::new( + Arc::clone(&routes), + Arc::new(DenyCommitter), + Arc::clone(&invalidation), + )); + let route_fingerprint = *routes.inventory_fingerprint(); + + let mut startup = RuntimeStartup::enforce(config).unwrap(); + startup + .migrations(SchemaWitness::new([1; 20], [2; 32], 33, true).unwrap()) + .unwrap(); + startup + .database_roles( + DatabaseRoleWitness::new( + Uuid::from_u128(2), + Uuid::from_u128(3), + Uuid::from_u128(4), + now, + true, + ) + .unwrap(), + ) + .unwrap(); + startup + .verifier( + VerifierReadinessWitness::new( + snapshot.stamp(), + now, + snapshot.fresh_until(), + snapshot.fresh_until(), + ) + .unwrap(), + ) + .unwrap(); + startup + .reconciliation(RestoreReconciliationWitness::new([4; 32], now, true).unwrap()) + .unwrap(); + startup + .install_state( + RuntimeStateComponents { + routes, + verifier: Arc::clone(&verifier), + invalidation, + authority, + reconciliation_digest: [4; 32], + status_contract_fingerprint: [5; 32], + delegation: None, + }, + RouteInventoryWitness::new(route_fingerprint, [5; 32]).unwrap(), + ) + .await + .unwrap(); + let installed = startup.finish().unwrap(); + assert!(installed.is_ready()); + assert!(installed.routes().is_ok()); + assert_eq!(installed.status_contract_fingerprint(), Some(&[5; 32])); + verifier + .refresh(now + chrono::Duration::seconds(1)) + .await + .unwrap(); + assert!(!installed.is_ready()); + assert!(installed.routes().is_err()); + } +} diff --git a/crates/buzz-relay/src/authorization_runtime/status.rs b/crates/buzz-relay/src/authorization_runtime/status.rs new file mode 100644 index 00000000000..25b645b2a95 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/status.rs @@ -0,0 +1,1138 @@ +//! Connection-local current-binding status production. + +use std::fmt; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use buzz_auth::{ + BoundedAuthorizationLease, CurrentBindingStatusEvidenceRequest, LocalStatusEvidenceResolver, + RouteCapability, +}; +use buzz_core::client_binding_status::ClientBindingStatusInputV1; +use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; +use chrono::{DateTime, Utc}; +use nostr::{Event, Keys, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use tokio_util::sync::CancellationToken; + +const MAX_RENEWAL_SECONDS: u64 = 120; +const MAX_PRESENTATION_SECONDS: u64 = 300; +/// Connection-local proof that the exact unchanged bootstrap blob was +/// delivered successfully before current-status production was enabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnchangedBootstrapDelivery { + _private: (), +} + +impl UnchangedBootstrapDelivery { + /// Constructed only by the relay connection adapter after the validated + /// bootstrap event has entered that connection's outbound queue. + pub(crate) const fn delivered() -> Self { + Self { _private: () } + } +} + +/// Fixed upper bounds for current presentation and renewal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatusCadence { + renewal: Duration, + maximum_lifetime: Duration, +} + +impl StatusCadence { + /// Validate renewal no later than 120 seconds and lifetime no longer than + /// 300 seconds. The live authorization lease can only shorten either. + pub fn new(renewal: Duration, maximum_lifetime: Duration) -> Result { + if renewal.is_zero() + || renewal > Duration::from_secs(MAX_RENEWAL_SECONDS) + || maximum_lifetime.is_zero() + || maximum_lifetime > Duration::from_secs(MAX_PRESENTATION_SECONDS) + || renewal > maximum_lifetime + { + return Err(StatusSessionError::InvalidCadence); + } + Ok(Self { + renewal, + maximum_lifetime, + }) + } + + /// Production cadence required by the public contract. + pub fn production() -> Self { + Self { + renewal: Duration::from_secs(MAX_RENEWAL_SECONDS), + maximum_lifetime: Duration::from_secs(MAX_PRESENTATION_SECONDS), + } + } +} + +/// Credential-free live authorization coordinates used by status production. +/// Construction is only from a finalized authorization lease. +#[derive(Clone)] +pub struct CurrentStatusAuthorization { + domain: CommunityId, + author: PublicKey, + binding_id: uuid::Uuid, + binding_version: u64, + policy_revision: u64, + invalidation_generation: u64, + authority_epoch: u64, + fence: AuthorizationLeaseFence, + expires_at: DateTime, +} + +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() { + return Err(StatusSessionError::EvidenceUnavailable); + } + let (binding_id, binding_version) = lease.binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + lease.dependency_versions(); + Ok(Self { + domain: lease.authorization_domain(), + author: lease.actor_pubkey(), + binding_id, + binding_version, + policy_revision, + invalidation_generation, + authority_epoch, + fence: lease.fence(), + expires_at: lease.expires_at(), + }) + } + + #[cfg(test)] + pub(crate) fn from_test_parts( + evidence: &CanonicalCurrentBindingEvidence, + expires_at: DateTime, + ) -> Self { + Self { + domain: evidence.authorization_domain(), + author: evidence.event_author_pubkey(), + binding_id: evidence.binding_id(), + binding_version: evidence.binding_version(), + policy_revision: evidence.policy_revision(), + invalidation_generation: evidence.invalidation_generation(), + authority_epoch: evidence.authority_epoch(), + fence: evidence.fence(), + expires_at, + } + } + + /// Exact final scope consumed by the connection bootstrap adapter. + pub(crate) const fn domain_author(&self) -> (CommunityId, PublicKey) { + (self.domain, self.author) + } +} + +impl fmt::Debug for CurrentStatusAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CurrentStatusAuthorization([REDACTED])") + } +} + +/// Database-backed read-only evidence and authoritative-time boundary. +#[async_trait] +pub trait CurrentStatusEvidenceSource: Send + Sync { + /// Read one current local binding observation without mutation. + async fn current( + &self, + request: &CurrentBindingStatusEvidenceRequest, + ) -> Result; + + /// Atomically re-read the complete tuple and authoritative database time + /// immediately before presentation. + async fn recheck( + &self, + evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError>; +} + +#[async_trait] +impl CurrentStatusEvidenceSource for Arc +where + T: CurrentStatusEvidenceSource + ?Sized, +{ + async fn current( + &self, + request: &CurrentBindingStatusEvidenceRequest, + ) -> Result { + (**self).current(request).await + } + + async fn recheck( + &self, + evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError> { + (**self).recheck(evidence).await + } +} + +/// Production adapter over the read-only local binding resolver. +/// +/// The resolver owns both reads. Its atomic recheck returns a PostgreSQL +/// observation, while the relay's local clock is used only to shorten (never +/// extend) that observation's accepted freshness interval. +#[derive(Clone)] +pub struct LocalBindingStatusEvidenceSource { + resolver: Arc, +} + +impl LocalBindingStatusEvidenceSource { + /// Bind one relay producer to the configured resolver. + pub const fn new(resolver: Arc) -> Self { + Self { resolver } + } +} + +#[async_trait] +impl CurrentStatusEvidenceSource for LocalBindingStatusEvidenceSource +where + R: LocalStatusEvidenceResolver + 'static, +{ + async fn current( + &self, + request: &CurrentBindingStatusEvidenceRequest, + ) -> Result { + self.resolver + .current_status_evidence(request) + .await + .map_err(|_| StatusSessionError::EvidenceUnavailable) + } + + async fn recheck( + &self, + evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError> { + let (rechecked, authoritative_now) = self + .resolver + .recheck_current_status_evidence(evidence) + .await + .map_err(|_| StatusSessionError::EvidenceUnavailable)?; + Ok((rechecked, authoritative_now)) + } +} + +/// Relay-side needs from the typed current-only wire contract. +/// +/// Associated values are opaque to this runtime. The caller supplies exact rechecked +/// evidence, a connection-local monotonic revision, and strict time bounds; +/// The core contract alone owns event shape, signing, validation, and fold semantics. +pub trait CurrentStatusContract: Send + Sync { + /// Typed signed current presentation. + type Current: Send + Sync; + /// Typed signed withdrawal. + type Withdrawal: Send + Sync; + + /// Stable identity of the exact current/withdrawal wire policy. It must + /// exclude rotating signing keys and every connection-local revision. + fn contract_fingerprint(&self) -> [u8; 32]; + + /// Build one current presentation from exact rechecked evidence. + fn current( + &self, + evidence: &CanonicalCurrentBindingEvidence, + connection_revision: u64, + ) -> Result; + + /// Build an opaque withdrawal superseding the currently delivered value. + fn withdrawal( + &self, + domain: CommunityId, + author: PublicKey, + connection_revision: u64, + issued_at: DateTime, + fresh_until: DateTime, + ) -> Result; +} + +/// Production adapter over the core producer facade. It never builds, +/// serializes, or validates status payloads itself. +#[derive(Clone)] +pub struct ConnectionLocalStatusContract { + relay_keys: Keys, + contract_fingerprint: [u8; 32], +} + +impl ConnectionLocalStatusContract { + /// Bind signing to the same relay key advertised by NIP-11 `self`. + pub fn new( + relay_keys: Keys, + advertised_relay_key: PublicKey, + ) -> Result { + if relay_keys.public_key() != advertised_relay_key { + return Err(StatusSessionError::ContractUnavailable); + } + let contract_fingerprint = + Sha256::digest(b"buzz:client-binding-status:connection-local:v1").into(); + Ok(Self { + relay_keys, + contract_fingerprint, + }) + } +} + +impl CurrentStatusContract for ConnectionLocalStatusContract { + type Current = Event; + type Withdrawal = Event; + + fn contract_fingerprint(&self) -> [u8; 32] { + self.contract_fingerprint + } + + fn current( + &self, + evidence: &CanonicalCurrentBindingEvidence, + connection_revision: u64, + ) -> Result { + ClientBindingStatusInputV1::current_from_evidence(evidence, connection_revision) + .map_err(|_| StatusSessionError::ContractUnavailable)? + .sign_with_relay_keys(&self.relay_keys) + .map_err(|_| StatusSessionError::ContractUnavailable) + } + + fn withdrawal( + &self, + domain: CommunityId, + author: PublicKey, + connection_revision: u64, + issued_at: DateTime, + fresh_until: DateTime, + ) -> Result { + let issued_at = u64::try_from(issued_at.timestamp()) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + let fresh_until = u64::try_from(fresh_until.timestamp()) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + ClientBindingStatusInputV1::withdrawn( + domain, + author, + connection_revision, + issued_at, + fresh_until, + ) + .map_err(|_| StatusSessionError::ContractUnavailable)? + .sign_with_relay_keys(&self.relay_keys) + .map_err(|_| StatusSessionError::ContractUnavailable) + } +} + +impl fmt::Debug for ConnectionLocalStatusContract { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ConnectionLocalStatusContract([REDACTED])") + } +} + +/// Same-connection delivery and mandatory-close boundary. +#[async_trait] +pub trait CurrentStatusSink: Send + Sync { + /// Deliver one current presentation on the authenticated connection. + async fn send_current(&self, current: &C::Current) -> Result<(), StatusSessionError>; + /// Deliver one withdrawal on that same connection. + async fn send_withdrawal(&self, withdrawal: &C::Withdrawal) -> Result<(), StatusSessionError>; + /// Close/cancel the connection after delivery cannot be proved. + async fn close(&self); +} + +/// Stable fail-closed status producer failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum StatusSessionError { + /// Configured cadence exceeded protocol bounds. + #[error("invalid current-status cadence")] + InvalidCadence, + /// Current local binding evidence was unavailable. + #[error("current binding evidence unavailable")] + EvidenceUnavailable, + /// Exact evidence changed before delivery. + #[error("current binding evidence changed")] + EvidenceChanged, + /// The authorization or presentation bound expired. + #[error("current binding presentation expired")] + Expired, + /// The typed constructor rejected the presentation. + #[error("current binding status contract unavailable")] + ContractUnavailable, + /// Same-connection delivery could not be proved. + #[error("current binding status delivery failed")] + DeliveryFailed, +} + +struct DeliveredCurrent { + domain: CommunityId, + author: PublicKey, + expires_at: DateTime, +} + +/// Entirely connection-local producer state. Dropping it forgets every +/// revision and delivered value; there is no persistence or restore port. +pub struct ConnectionStatusSession +where + E: CurrentStatusEvidenceSource, + C: CurrentStatusContract, + S: CurrentStatusSink, +{ + evidence: E, + contract: C, + sink: S, + _bootstrap: UnchangedBootstrapDelivery, + cadence: StatusCadence, + revision: u64, + contract_fingerprint: Option<[u8; 32]>, + current: Option, + next_renewal: Option>, +} + +impl ConnectionStatusSession +where + E: CurrentStatusEvidenceSource, + C: CurrentStatusContract, + S: CurrentStatusSink, +{ + /// Construct empty state for one authenticated connection. + pub const fn new( + evidence: E, + contract: C, + sink: S, + cadence: StatusCadence, + bootstrap: UnchangedBootstrapDelivery, + ) -> Self { + Self { + evidence, + contract, + sink, + _bootstrap: bootstrap, + cadence, + revision: 0, + contract_fingerprint: None, + current: None, + next_renewal: None, + } + } + + /// Read, atomically recheck, construct, and deliver current status after + /// AUTH or at renewal. Nothing is retained until delivery succeeds. + pub async fn present( + &mut self, + authorization: &CurrentStatusAuthorization, + ) -> Result, StatusSessionError> { + let result = self.present_checked(authorization).await; + if result.is_err() && self.current.take().is_some() { + self.next_renewal = None; + self.sink.close().await; + } + result + } + + async fn present_checked( + &mut self, + authorization: &CurrentStatusAuthorization, + ) -> Result, StatusSessionError> { + let contract_fingerprint = self.contract.contract_fingerprint(); + if contract_fingerprint == [0; 32] + || self + .contract_fingerprint + .is_some_and(|installed| installed != contract_fingerprint) + { + return Err(StatusSessionError::ContractUnavailable); + } + let request = + CurrentBindingStatusEvidenceRequest::new(authorization.domain, authorization.author) + .map_err(|_| StatusSessionError::EvidenceUnavailable)?; + let evidence = self.evidence.current(&request).await?; + let (rechecked, authoritative_now) = self.evidence.recheck(&evidence).await?; + if evidence.authorization_domain() != authorization.domain + || evidence.event_author_pubkey() != authorization.author + || evidence.binding_id() != authorization.binding_id + || evidence.binding_version() != authorization.binding_version + || evidence.policy_revision() != authorization.policy_revision + || evidence.invalidation_generation() != authorization.invalidation_generation + || evidence.authority_epoch() != authorization.authority_epoch + || evidence.fence() != authorization.fence + || !evidence.accepts_exact_recheck(&rechecked, authoritative_now) + || authoritative_now >= authorization.expires_at + { + return Err(StatusSessionError::EvidenceChanged); + } + + let maximum_lifetime = chrono::Duration::from_std(self.cadence.maximum_lifetime) + .map_err(|_| StatusSessionError::InvalidCadence)?; + let protocol_expiry = authoritative_now + .checked_add_signed(maximum_lifetime) + .ok_or(StatusSessionError::Expired)?; + let expires_at = [ + evidence.fresh_until(), + authorization.expires_at, + protocol_expiry, + ] + .into_iter() + .min() + .ok_or(StatusSessionError::Expired)?; + if authoritative_now >= expires_at { + return Err(StatusSessionError::Expired); + } + + let revision = self + .revision + .checked_add(1) + .ok_or(StatusSessionError::ContractUnavailable)?; + let status_evidence = CanonicalCurrentBindingEvidence::new( + rechecked.authorization_domain(), + rechecked.event_author_pubkey(), + rechecked.binding_id(), + rechecked.binding_version(), + rechecked.policy_revision(), + rechecked.invalidation_generation(), + rechecked.authority_epoch(), + rechecked.fence(), + rechecked.observed_at(), + expires_at, + ) + .map_err(|_| StatusSessionError::ContractUnavailable)?; + let current = self.contract.current(&status_evidence, revision)?; + if self.sink.send_current(¤t).await.is_err() { + self.sink.close().await; + return Err(StatusSessionError::DeliveryFailed); + } + + let renewal = chrono::Duration::from_std(self.cadence.renewal) + .map_err(|_| StatusSessionError::InvalidCadence)?; + let scheduled = authoritative_now + .checked_add_signed(renewal) + .ok_or(StatusSessionError::Expired)?; + let next_renewal = scheduled.min(expires_at); + self.revision = revision; + self.contract_fingerprint = Some(contract_fingerprint); + self.current = Some(DeliveredCurrent { + domain: rechecked.authorization_domain(), + author: rechecked.event_author_pubkey(), + expires_at, + }); + self.next_renewal = Some(next_renewal); + Ok(next_renewal) + } + + /// Whether renewal is due or the current presentation has reached its + /// exclusive bound. + pub fn renewal_due(&self, now: DateTime) -> bool { + self.next_renewal.is_some_and(|deadline| now >= deadline) + || self + .current + .as_ref() + .is_some_and(|current| now >= current.expires_at) + } + + /// Withdraw the current value on invalidation, lease loss, or disconnect. + /// Failed withdrawal closes the connection and forgets local state. + pub async fn withdraw(&mut self, now: DateTime) -> Result<(), StatusSessionError> { + let Some(current) = self.current.take() else { + self.next_renewal = None; + return Ok(()); + }; + self.next_renewal = None; + if self.contract_fingerprint != Some(self.contract.contract_fingerprint()) { + self.sink.close().await; + return Err(StatusSessionError::ContractUnavailable); + } + let revision = self + .revision + .checked_add(1) + .ok_or(StatusSessionError::ContractUnavailable)?; + let withdrawal_fresh_until = now + .checked_add_signed( + chrono::Duration::from_std(self.cadence.maximum_lifetime) + .map_err(|_| StatusSessionError::InvalidCadence)?, + ) + .ok_or(StatusSessionError::Expired)?; + let withdrawal = match self.contract.withdrawal( + current.domain, + current.author, + revision, + now, + withdrawal_fresh_until, + ) { + Ok(withdrawal) => withdrawal, + Err(error) => { + self.sink.close().await; + return Err(error); + } + }; + if self.sink.send_withdrawal(&withdrawal).await.is_err() { + self.sink.close().await; + return Err(StatusSessionError::DeliveryFailed); + } + self.revision = revision; + Ok(()) + } + + /// Current connection-local revision. It has no durable meaning. + pub const fn connection_revision(&self) -> u64 { + self.revision + } + + /// Run immediate presentation and bounded renewal until the owning socket + /// or AUTH scope is cancelled. Cancellation sends a higher-revision opaque + /// withdrawal and then discards all RAM-only state with this task. + pub async fn run_until_cancelled( + mut self, + authorization: CurrentStatusAuthorization, + cancel: CancellationToken, + ) -> Result<(), StatusSessionError> { + // Activation may already have presented the first authoritative value + // while waiting to acknowledge AUTH. Preserve that value and wait for + // its renewal instead of emitting a duplicate presentation. + let mut presentation_is_current = self.current.is_some() && self.next_renewal.is_some(); + loop { + let renewal = if presentation_is_current { + presentation_is_current = false; + self.next_renewal + .ok_or(StatusSessionError::EvidenceUnavailable)? + } else { + // Cancellation races every resolver/evidence await. A stalled + // read therefore cannot pin AUTH replacement or socket teardown. + let presentation = { + let present = self.present(&authorization); + tokio::pin!(present); + tokio::select! { + biased; + _ = cancel.cancelled() => None, + result = &mut present => Some(result), + } + }; + match presentation { + None => return self.withdraw(Utc::now()).await, + Some(Err(error)) => { + self.sink.close().await; + return Err(error); + } + Some(Ok(renewal)) => renewal, + } + }; + let delay = (renewal - Utc::now()).to_std().unwrap_or(Duration::ZERO); + tokio::select! { + _ = cancel.cancelled() => { + return self.withdraw(Utc::now()).await; + } + _ = tokio::time::sleep(delay) => {} + } + } + } +} + +impl fmt::Debug for ConnectionStatusSession +where + E: CurrentStatusEvidenceSource, + C: CurrentStatusContract, + S: CurrentStatusSink, +{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ConnectionStatusSession([REDACTED])") + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + + use buzz_auth::{ + BindingResolutionRequest, LocalBindingResolution, LocalBindingResolver, + LocalBindingResolverCapability, + }; + use buzz_core::AuthorizationLeaseFence; + use nostr::Keys; + use tokio::sync::Notify; + use uuid::Uuid; + + use super::*; + + struct Evidence { + value: CanonicalCurrentBindingEvidence, + now: DateTime, + } + + struct Resolver { + value: CanonicalCurrentBindingEvidence, + now: DateTime, + } + + impl LocalBindingResolver for Resolver { + type Error = (); + + fn capability(&self) -> LocalBindingResolverCapability { + LocalBindingResolverCapability::DirectAndDelegatedOwnerBound + } + + fn resolve<'a>( + &'a self, + _request: &'a BindingResolutionRequest, + ) -> impl std::future::Future> + Send + 'a + { + std::future::pending() + } + + fn current_status_evidence<'a>( + &'a self, + _request: &'a CurrentBindingStatusEvidenceRequest, + ) -> impl std::future::Future> + + Send + + 'a { + let value = self.value.clone(); + async move { Ok(value) } + } + + fn recheck_current_status_evidence<'a>( + &'a self, + _evidence: &'a CanonicalCurrentBindingEvidence, + ) -> impl std::future::Future< + Output = Result<(CanonicalCurrentBindingEvidence, DateTime), Self::Error>, + > + Send + + 'a { + let value = self.value.clone(); + let now = self.now; + async move { Ok((value, now)) } + } + } + + #[async_trait] + impl CurrentStatusEvidenceSource for Evidence { + async fn current( + &self, + _request: &CurrentBindingStatusEvidenceRequest, + ) -> Result { + Ok(self.value.clone()) + } + + async fn recheck( + &self, + _evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError> { + Ok((self.value.clone(), self.now)) + } + } + + struct Contract; + + impl CurrentStatusContract for Contract { + type Current = (u64, DateTime); + type Withdrawal = u64; + + fn contract_fingerprint(&self) -> [u8; 32] { + [9; 32] + } + + fn current( + &self, + evidence: &CanonicalCurrentBindingEvidence, + connection_revision: u64, + ) -> Result { + Ok((connection_revision, evidence.fresh_until())) + } + + fn withdrawal( + &self, + _domain: CommunityId, + _author: PublicKey, + connection_revision: u64, + _issued_at: DateTime, + _fresh_until: DateTime, + ) -> Result { + Ok(connection_revision) + } + } + + #[derive(Default)] + struct Sink { + current: Mutex>, + withdrawals: Mutex>, + fail_current: AtomicBool, + fail_withdrawal: AtomicBool, + closed: AtomicBool, + } + + #[derive(Clone)] + struct SharedSink { + current: Arc>>, + withdrawals: Arc>>, + closed: Arc, + cancel_after_two: Option, + } + + impl SharedSink { + fn new(cancel_after_two: Option) -> Self { + Self { + current: Arc::new(Mutex::new(Vec::new())), + withdrawals: Arc::new(Mutex::new(Vec::new())), + closed: Arc::new(AtomicBool::new(false)), + cancel_after_two, + } + } + } + + #[async_trait] + impl CurrentStatusSink for SharedSink { + async fn send_current( + &self, + current: &::Current, + ) -> Result<(), StatusSessionError> { + let mut delivered = self.current.lock().unwrap(); + delivered.push(current.0); + if delivered.len() == 2 { + if let Some(cancel) = &self.cancel_after_two { + cancel.cancel(); + } + } + Ok(()) + } + + async fn send_withdrawal( + &self, + withdrawal: &::Withdrawal, + ) -> Result<(), StatusSessionError> { + self.withdrawals.lock().unwrap().push(*withdrawal); + Ok(()) + } + + async fn close(&self) { + self.closed.store(true, Ordering::Release); + } + } + + #[async_trait] + impl CurrentStatusSink for Sink { + async fn send_current( + &self, + current: &::Current, + ) -> Result<(), StatusSessionError> { + if self.fail_current.load(Ordering::Acquire) { + return Err(StatusSessionError::DeliveryFailed); + } + self.current.lock().unwrap().push(current.0); + Ok(()) + } + + async fn send_withdrawal( + &self, + withdrawal: &::Withdrawal, + ) -> Result<(), StatusSessionError> { + if self.fail_withdrawal.load(Ordering::Acquire) { + return Err(StatusSessionError::DeliveryFailed); + } + self.withdrawals.lock().unwrap().push(*withdrawal); + Ok(()) + } + + async fn close(&self) { + self.closed.store(true, Ordering::Release); + } + } + + fn fixture() -> (Evidence, CurrentStatusAuthorization, DateTime) { + let now = Utc::now(); + let domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let author = Keys::generate().public_key(); + let evidence = CanonicalCurrentBindingEvidence::new( + domain, + author, + Uuid::from_u128(2), + 3, + 4, + 5, + 6, + AuthorizationLeaseFence::from_bytes([7; 32]).unwrap(), + now, + now + chrono::Duration::seconds(300), + ) + .unwrap(); + let authorization = CurrentStatusAuthorization::from_test_parts( + &evidence, + now + chrono::Duration::seconds(240), + ); + ( + Evidence { + value: evidence, + now, + }, + authorization, + now, + ) + } + + #[tokio::test] + async fn local_binding_resolver_adapter_performs_both_read_only_status_reads() { + let (evidence, authorization, now) = fixture(); + let adapter = LocalBindingStatusEvidenceSource::new(Arc::new(Resolver { + value: evidence.value.clone(), + now, + })); + let request = + CurrentBindingStatusEvidenceRequest::new(authorization.domain, authorization.author) + .unwrap(); + let current = adapter.current(&request).await.unwrap(); + let (rechecked, conservative_now) = adapter.recheck(¤t).await.unwrap(); + assert_eq!(current, rechecked); + assert!(conservative_now >= rechecked.observed_at()); + assert!(current.accepts_exact_recheck(&rechecked, conservative_now)); + } + + #[tokio::test] + async fn current_renews_with_bounded_connection_local_revision_then_withdraws() { + let (evidence, authorization, now) = fixture(); + let mut session = ConnectionStatusSession::new( + evidence, + Contract, + Sink::default(), + StatusCadence::production(), + UnchangedBootstrapDelivery::delivered(), + ); + let renewal = session.present(&authorization).await.unwrap(); + assert_eq!(renewal, now + chrono::Duration::seconds(120)); + assert_eq!(session.connection_revision(), 1); + assert!(session.renewal_due(renewal)); + session + .withdraw(now + chrono::Duration::seconds(1)) + .await + .unwrap(); + assert_eq!(session.connection_revision(), 2); + assert!(session.current.is_none()); + } + + #[tokio::test] + async fn failed_withdrawal_closes_and_forgets_current_value() { + let (evidence, authorization, now) = fixture(); + let sink = Sink::default(); + sink.fail_withdrawal.store(true, Ordering::Release); + let mut session = ConnectionStatusSession::new( + evidence, + Contract, + sink, + StatusCadence::production(), + UnchangedBootstrapDelivery::delivered(), + ); + session.present(&authorization).await.unwrap(); + assert_eq!( + session.withdraw(now).await, + Err(StatusSessionError::DeliveryFailed) + ); + assert!(session.sink.closed.load(Ordering::Acquire)); + assert!(session.current.is_none()); + } + + #[tokio::test] + async fn failed_current_delivery_closes_without_retaining_state() { + let (evidence, authorization, _) = fixture(); + let sink = Sink::default(); + sink.fail_current.store(true, Ordering::Release); + let mut session = ConnectionStatusSession::new( + evidence, + Contract, + sink, + StatusCadence::production(), + UnchangedBootstrapDelivery::delivered(), + ); + assert_eq!( + session.present(&authorization).await, + Err(StatusSessionError::DeliveryFailed) + ); + assert!(session.sink.closed.load(Ordering::Acquire)); + assert!(session.current.is_none()); + assert_eq!(session.connection_revision(), 0); + } + + #[tokio::test] + async fn run_loop_renews_then_cancels_with_higher_revision_withdrawal() { + let (evidence, authorization, _) = fixture(); + let cancel = CancellationToken::new(); + let sink = SharedSink::new(Some(cancel.clone())); + let observed = sink.clone(); + let session = ConnectionStatusSession::new( + evidence, + Contract, + sink, + StatusCadence::new(Duration::from_millis(5), Duration::from_secs(1)).unwrap(), + UnchangedBootstrapDelivery::delivered(), + ); + session + .run_until_cancelled(authorization, cancel) + .await + .unwrap(); + assert_eq!(*observed.current.lock().unwrap(), vec![1, 2]); + assert_eq!(*observed.withdrawals.lock().unwrap(), vec![3]); + assert!(!observed.closed.load(Ordering::Acquire)); + } + + struct RecheckFailure { + value: CanonicalCurrentBindingEvidence, + } + + #[derive(Clone, Copy)] + enum BlockingPhase { + Current, + Recheck, + } + + struct BlockingEvidence { + value: CanonicalCurrentBindingEvidence, + phase: BlockingPhase, + entered: Arc, + } + + #[async_trait] + impl CurrentStatusEvidenceSource for BlockingEvidence { + async fn current( + &self, + _request: &CurrentBindingStatusEvidenceRequest, + ) -> Result { + if matches!(self.phase, BlockingPhase::Current) { + self.entered.notify_one(); + std::future::pending::<()>().await; + } + Ok(self.value.clone()) + } + + async fn recheck( + &self, + _evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError> { + if matches!(self.phase, BlockingPhase::Recheck) { + self.entered.notify_one(); + std::future::pending::<()>().await; + } + Ok((self.value.clone(), self.value.observed_at())) + } + } + + #[async_trait] + impl CurrentStatusEvidenceSource for RecheckFailure { + async fn current( + &self, + _request: &CurrentBindingStatusEvidenceRequest, + ) -> Result { + Ok(self.value.clone()) + } + + async fn recheck( + &self, + _evidence: &CanonicalCurrentBindingEvidence, + ) -> Result<(CanonicalCurrentBindingEvidence, DateTime), StatusSessionError> { + Err(StatusSessionError::EvidenceUnavailable) + } + } + + #[tokio::test] + async fn final_recheck_failure_closes_without_delivering_current() { + let (evidence, authorization, _) = fixture(); + let sink = SharedSink::new(None); + let observed = sink.clone(); + let session = ConnectionStatusSession::new( + RecheckFailure { + value: evidence.value, + }, + Contract, + sink, + StatusCadence::production(), + UnchangedBootstrapDelivery::delivered(), + ); + assert_eq!( + session + .run_until_cancelled(authorization, CancellationToken::new()) + .await, + Err(StatusSessionError::EvidenceUnavailable) + ); + assert!(observed.current.lock().unwrap().is_empty()); + assert!(observed.closed.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn cancellation_interrupts_stalled_current_and_recheck_reads() { + for phase in [BlockingPhase::Current, BlockingPhase::Recheck] { + let (evidence, authorization, _) = fixture(); + let entered = Arc::new(Notify::new()); + let sink = SharedSink::new(None); + let observed = sink.clone(); + let cancel = CancellationToken::new(); + let session = ConnectionStatusSession::new( + BlockingEvidence { + value: evidence.value, + phase, + entered: Arc::clone(&entered), + }, + Contract, + sink, + StatusCadence::production(), + UnchangedBootstrapDelivery::delivered(), + ); + let task_cancel = cancel.clone(); + let task = tokio::spawn(async move { + session + .run_until_cancelled(authorization, task_cancel) + .await + }); + tokio::time::timeout(Duration::from_secs(1), entered.notified()) + .await + .expect("evidence phase entered"); + cancel.cancel(); + let result = tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("cancelled status task joined") + .expect("status task did not panic"); + assert_eq!(result, Ok(())); + assert!(observed.current.lock().unwrap().is_empty()); + assert!(observed.withdrawals.lock().unwrap().is_empty()); + assert!(!observed.closed.load(Ordering::Acquire)); + } + } + + #[test] + fn cadence_cannot_exceed_renewal_or_lifetime_bounds() { + assert!(StatusCadence::new(Duration::from_secs(121), Duration::from_secs(300)).is_err()); + assert!(StatusCadence::new(Duration::from_secs(120), Duration::from_secs(301)).is_err()); + } + + #[test] + fn connection_local_contract_signs_current_and_opaque_withdrawal_with_nip11_key() { + use buzz_core::client_binding_status::validate_client_binding_status_event; + + let (evidence, _, now) = fixture(); + let relay = Keys::generate(); + let other = Keys::generate(); + assert!(ConnectionLocalStatusContract::new(relay.clone(), other.public_key()).is_err()); + let contract = + ConnectionLocalStatusContract::new(relay.clone(), relay.public_key()).unwrap(); + + let current = contract.current(&evidence.value, 1).unwrap(); + let current = validate_client_binding_status_event( + ¤t, + &relay.public_key(), + evidence.value.authorization_domain(), + &evidence.value.event_author_pubkey(), + u64::try_from(now.timestamp()).unwrap(), + ) + .unwrap(); + assert_eq!(current.status_revision(), 1); + assert_eq!(current.binding_version(), Some(3)); + + let withdrawal = contract + .withdrawal( + evidence.value.authorization_domain(), + evidence.value.event_author_pubkey(), + 2, + now, + now + chrono::Duration::seconds(300), + ) + .unwrap(); + let withdrawal = validate_client_binding_status_event( + &withdrawal, + &relay.public_key(), + evidence.value.authorization_domain(), + &evidence.value.event_author_pubkey(), + u64::try_from(now.timestamp()).unwrap(), + ) + .unwrap(); + assert_eq!(withdrawal.status_revision(), 2); + assert_eq!(withdrawal.binding_version(), None); + assert_eq!(withdrawal.policy_version(), None); + } +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 4c5110f9265..09e1da39a56 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -7,6 +7,10 @@ use sha2::{Digest, Sha256}; use thiserror::Error; use tracing::warn; +#[cfg(test)] +use crate::authorization_runtime::ProviderFreeRuntimeMode; +use crate::authorization_runtime::{ProviderFreeRuntimeConfig, CONFIG_ENV}; + /// Default maximum inbound WebSocket frame size in bytes. /// /// Must comfortably exceed accepted event content sizes after Nostr JSON and @@ -90,13 +94,9 @@ pub struct CorporateIdentityConfig { pub uid_claim: String, /// Claim name used for verified display. /// - /// This value is stored only in the private relay binding table. It is - /// never projected into a public Nostr event unless - /// `public_display_claim` is configured separately. + /// This value is stored only in the private relay binding table and is + /// never projected into a public Nostr event. pub display_claim: String, - /// Optional claim name explicitly approved for public NIP-85 projection. - /// Unset by default so private corporate attributes stay private. - pub public_display_claim: Option, /// Optional claim name carrying a hex pubkey or `npub1...`. pub npub_claim: Option, } @@ -106,14 +106,13 @@ impl Default for CorporateIdentityConfig { Self { require: false, jwt_header: DEFAULT_CORPORATE_IDENTITY_JWT_HEADER.to_string(), - allow_delegation: true, + allow_delegation: false, auth_precedence: CorporateIdentityAuthPrecedence::Direct, jwks_uri: String::new(), issuer: String::new(), audience: String::new(), uid_claim: DEFAULT_CORPORATE_IDENTITY_UID_CLAIM.to_string(), display_claim: DEFAULT_CORPORATE_IDENTITY_DISPLAY_CLAIM.to_string(), - public_display_claim: None, npub_claim: None, } } @@ -294,8 +293,15 @@ pub struct Config { pub allow_nip_oa_auth: bool, /// Corporate identity verification and uid/pubkey binding. + /// + /// Retained temporarily as an inert compatibility shape for held adapters; + /// production construction always leaves it disabled. NIP-FI uses only + /// [`Self::nip_fi`]. pub corporate_identity: CorporateIdentityConfig, + /// Sole provider-free NIP-FI V1 runtime configuration. + pub nip_fi: ProviderFreeRuntimeConfig, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -509,116 +515,40 @@ fn ensure_git_path( Ok(git_repo_path) } -fn corporate_env_trimmed(name: &str) -> Result, ConfigError> { - match std::env::var(name) { - Err(std::env::VarError::NotPresent) => Ok(None), - Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!( - "{name} must be valid UTF-8" - ))), - Ok(value) => { - let value = value.trim(); - if value.is_empty() { - return Err(ConfigError::InvalidValue(format!( - "{name} must not be empty when set" - ))); - } - Ok(Some(value.to_string())) - } - } -} +const REMOVED_IDENTITY_PROVIDER_VARS: [&str; 11] = [ + "BUZZ_REQUIRE_CORPORATE_IDENTITY", + "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", + "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", + "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "BUZZ_CORPORATE_IDENTITY_ISSUER", + "BUZZ_CORPORATE_IDENTITY_AUDIENCE", + "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", + "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", +]; -fn parse_corporate_bool(name: &str, default: bool) -> Result { - match corporate_env_trimmed(name)? { - None => Ok(default), - Some(value) => match value.to_ascii_lowercase().as_str() { - "true" | "1" | "on" => Ok(true), - "false" | "0" | "off" => Ok(false), - _ => Err(ConfigError::InvalidValue(format!( - "{name} must be true or false" - ))), - }, +fn load_provider_free_runtime_config() -> Result { + if let Some(name) = REMOVED_IDENTITY_PROVIDER_VARS + .iter() + .find(|name| std::env::var_os(name).is_some()) + { + return Err(ConfigError::InvalidValue(format!( + "{name} was removed; configure provider-free authorization only with {CONFIG_ENV}" + ))); } -} - -fn load_corporate_identity_config() -> Result { - let mut config = CorporateIdentityConfig::default(); - config.require = parse_corporate_bool("BUZZ_REQUIRE_CORPORATE_IDENTITY", config.require)?; - config.jwt_header = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_JWT_HEADER")? - .unwrap_or_else(|| config.jwt_header.clone()) - .to_ascii_lowercase(); - config.allow_delegation = parse_corporate_bool( - "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", - config.allow_delegation, - )?; - config.auth_precedence = - match corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE")?.as_deref() { - None | Some("direct") => CorporateIdentityAuthPrecedence::Direct, - Some("delegated") => CorporateIdentityAuthPrecedence::Delegated, - Some(value) => { - return Err(ConfigError::InvalidValue(format!( - "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE must be direct or delegated, got {value}" - ))); - } - }; - config.jwks_uri = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_JWKS_URI")? - .unwrap_or_else(|| config.jwks_uri.clone()); - config.issuer = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_ISSUER")? - .unwrap_or_else(|| config.issuer.clone()); - config.audience = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_AUDIENCE")? - .unwrap_or_else(|| config.audience.clone()); - config.uid_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_UID_CLAIM")? - .unwrap_or_else(|| config.uid_claim.clone()); - config.display_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM")? - .unwrap_or_else(|| config.display_claim.clone()); - config.public_display_claim = - corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM")?; - config.npub_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM")?; - - if config.require { - let mut missing = Vec::new(); - if config.jwt_header.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_JWT_HEADER"); - } - if config.jwks_uri.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_JWKS_URI"); - } - if config.issuer.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_ISSUER"); - } - if config.audience.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_AUDIENCE"); - } - if config.uid_claim.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_UID_CLAIM"); - } - if config.display_claim.is_empty() { - missing.push("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM"); - } - if !missing.is_empty() { + let raw = match std::env::var(CONFIG_ENV) { + Ok(value) => Some(value), + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { return Err(ConfigError::InvalidValue(format!( - "BUZZ_REQUIRE_CORPORATE_IDENTITY=true but required corporate identity config is missing: {}", - missing.join(", ") + "{CONFIG_ENV} must be valid UTF-8" ))); } - - let jwks_url = url::Url::parse(&config.jwks_uri).map_err(|error| { - ConfigError::InvalidValue(format!( - "BUZZ_CORPORATE_IDENTITY_JWKS_URI must be a valid HTTPS URL: {error}" - )) - })?; - if jwks_url.scheme() != "https" - || jwks_url.host_str().is_none() - || !jwks_url.username().is_empty() - || jwks_url.password().is_some() - { - return Err(ConfigError::InvalidValue( - "BUZZ_CORPORATE_IDENTITY_JWKS_URI must be an HTTPS URL with a host and no credentials" - .to_string(), - )); - } - } - - Ok(config) + }; + ProviderFreeRuntimeConfig::from_optional_json(raw.as_deref()) + .map_err(|error| ConfigError::InvalidValue(format!("{CONFIG_ENV}: {}", error.code()))) } /// Env vars that once gated authenticated media reads. @@ -818,7 +748,8 @@ impl Config { .map(|v| v == "true" || v == "1") .unwrap_or(false); - let corporate_identity = load_corporate_identity_config()?; + let nip_fi = load_provider_free_runtime_config()?; + let corporate_identity = CorporateIdentityConfig::default(); // Note: intentionally not prefixed with BUZZ_ — this is a relay-identity // config that may be shared across multiple services (e.g., ACP agent). @@ -1226,6 +1157,7 @@ impl Config { relay_operator_pubkeys, allow_nip_oa_auth, corporate_identity, + nip_fi, media, media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, @@ -1261,22 +1193,11 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - fn clear_corporate_identity_env() { - for name in [ - "BUZZ_REQUIRE_CORPORATE_IDENTITY", - "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", - "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", - "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", - "BUZZ_CORPORATE_IDENTITY_JWKS_URI", - "BUZZ_CORPORATE_IDENTITY_ISSUER", - "BUZZ_CORPORATE_IDENTITY_AUDIENCE", - "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", - "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", - ] { + fn clear_removed_identity_provider_env() { + for name in REMOVED_IDENTITY_PROVIDER_VARS { std::env::remove_var(name); } + std::env::remove_var(CONFIG_ENV); } /// Look up against a fixed set, standing in for process env. @@ -1335,7 +1256,7 @@ mod tests { #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); + clear_removed_identity_provider_env(); let config = Config::from_env().expect("default config"); assert!(config.bind_addr.port() > 0); assert!(!config.database_url.is_empty()); @@ -1392,188 +1313,86 @@ mod tests { DEFAULT_CORPORATE_IDENTITY_JWT_HEADER ); assert!( - config.corporate_identity.allow_delegation, - "corporate identity delegation should default to true for agents" + !config.corporate_identity.allow_delegation, + "corporate identity delegation should default to false" ); assert_eq!( config.corporate_identity.auth_precedence, CorporateIdentityAuthPrecedence::Direct, "an accompanying JWT should identify the signer by default" ); - assert!( - config.corporate_identity.public_display_claim.is_none(), - "public corporate identity projection must be opt-in" - ); + assert_eq!(config.nip_fi.mode(), ProviderFreeRuntimeMode::Off); } #[test] - fn corporate_identity_requires_complete_verifier_config() { + fn removed_identity_provider_configuration_is_rejected() { let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); - std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); - - let err = Config::from_env().expect_err("incomplete corporate identity config"); - let msg = err.to_string(); - clear_corporate_identity_env(); + clear_removed_identity_provider_env(); + std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "false"); - assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_JWKS_URI")); - assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_ISSUER")); - assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_AUDIENCE")); - } + let error = Config::from_env().expect_err("legacy provider config must be rejected"); + clear_removed_identity_provider_env(); - #[test] - fn corporate_identity_config_can_be_enabled() { - let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); - std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); - std::env::set_var( - "BUZZ_CORPORATE_IDENTITY_JWKS_URI", - "https://idp.example/.well-known/jwks.json", - ); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_ISSUER", "https://idp.example"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUDIENCE", "buzz-relay"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_UID_CLAIM", "employee_id"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", "email"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", "buzz_npub"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", "delegated"); - - let config = Config::from_env().expect("corporate identity config"); - clear_corporate_identity_env(); - - assert!(config.corporate_identity.require); - assert_eq!(config.corporate_identity.uid_claim, "employee_id"); - assert_eq!( - config.corporate_identity.npub_claim.as_deref(), - Some("buzz_npub") - ); - assert_eq!( - config.corporate_identity.auth_precedence, - CorporateIdentityAuthPrecedence::Delegated - ); + assert!(error + .to_string() + .contains("BUZZ_REQUIRE_CORPORATE_IDENTITY was removed")); + assert!(error.to_string().contains(CONFIG_ENV)); } #[test] - fn corporate_identity_rejects_invalid_auth_precedence() { + fn removed_public_display_configuration_is_rejected() { let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", "automatic"); + clear_removed_identity_provider_env(); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", "email"); - let err = Config::from_env().expect_err("invalid precedence must fail closed"); - clear_corporate_identity_env(); + let error = Config::from_env().expect_err("legacy public projection must be rejected"); + clear_removed_identity_provider_env(); - assert!(matches!( - err, - ConfigError::InvalidValue(ref message) - if message.contains("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE") - )); + assert!(error + .to_string() + .contains("BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM was removed")); + assert!(error.to_string().contains(CONFIG_ENV)); } #[test] - fn corporate_identity_rejects_malformed_boolean_values() { + fn provider_free_emergency_deny_is_loaded_from_sole_config() { let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); - std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "tru"); - let require_error = Config::from_env().expect_err("malformed require flag must fail"); - clear_corporate_identity_env(); + clear_removed_identity_provider_env(); + std::env::set_var(CONFIG_ENV, r#"{"deny_protected":true}"#); - std::env::set_var("BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", "sometimes"); - let delegation_error = Config::from_env().expect_err("malformed delegation flag must fail"); - clear_corporate_identity_env(); + let config = Config::from_env().expect("provider-free deny config"); + clear_removed_identity_provider_env(); - assert!(require_error - .to_string() - .contains("BUZZ_REQUIRE_CORPORATE_IDENTITY")); - assert!(delegation_error - .to_string() - .contains("BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION")); + assert_eq!(config.nip_fi.mode(), ProviderFreeRuntimeMode::DenyProtected); } #[test] - fn corporate_identity_rejects_present_empty_values() { + fn provider_free_config_rejects_invalid_json() { let _guard = ENV_MUTEX.lock().unwrap(); - for name in [ - "BUZZ_REQUIRE_CORPORATE_IDENTITY", - "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", - "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", - "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", - "BUZZ_CORPORATE_IDENTITY_JWKS_URI", - "BUZZ_CORPORATE_IDENTITY_ISSUER", - "BUZZ_CORPORATE_IDENTITY_AUDIENCE", - "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", - "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", - ] { - clear_corporate_identity_env(); - std::env::set_var(name, " "); - let error = Config::from_env().expect_err("present empty setting must fail closed"); - assert!(error.to_string().contains(name)); - assert!(error.to_string().contains("must not be empty")); - } - clear_corporate_identity_env(); - } + clear_removed_identity_provider_env(); + std::env::set_var(CONFIG_ENV, "not-json"); - #[cfg(unix)] - #[test] - fn corporate_identity_rejects_non_utf8_boolean_values() { - use std::os::unix::ffi::OsStringExt; + let error = Config::from_env().expect_err("invalid runtime config must fail closed"); + clear_removed_identity_provider_env(); - let _guard = ENV_MUTEX.lock().unwrap(); - for name in [ - "BUZZ_REQUIRE_CORPORATE_IDENTITY", - "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", - ] { - clear_corporate_identity_env(); - std::env::set_var(name, std::ffi::OsString::from_vec(vec![0xff])); - let error = Config::from_env().expect_err("non-UTF-8 boolean must fail closed"); - assert!(error.to_string().contains(name)); - assert!(error.to_string().contains("valid UTF-8")); - } - clear_corporate_identity_env(); + assert!(error.to_string().contains(CONFIG_ENV)); + assert!(error.to_string().contains("nip_fi_runtime_invalid_config")); } #[cfg(unix)] #[test] - fn corporate_identity_rejects_non_utf8_string_values() { + fn provider_free_config_rejects_non_utf8() { use std::os::unix::ffi::OsStringExt; let _guard = ENV_MUTEX.lock().unwrap(); - for name in [ - "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", - "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", - "BUZZ_CORPORATE_IDENTITY_JWKS_URI", - "BUZZ_CORPORATE_IDENTITY_ISSUER", - "BUZZ_CORPORATE_IDENTITY_AUDIENCE", - "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", - "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", - "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", - ] { - clear_corporate_identity_env(); - std::env::set_var(name, std::ffi::OsString::from_vec(vec![0xff])); - let error = Config::from_env().expect_err("non-UTF-8 setting must fail closed"); - assert!(error.to_string().contains(name)); - assert!(error.to_string().contains("valid UTF-8")); - } - clear_corporate_identity_env(); - } - - #[test] - fn corporate_identity_requires_https_jwks_uri() { - let _guard = ENV_MUTEX.lock().unwrap(); - clear_corporate_identity_env(); - std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); - std::env::set_var( - "BUZZ_CORPORATE_IDENTITY_JWKS_URI", - "http://idp.example/.well-known/jwks.json", - ); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_ISSUER", "https://idp.example"); - std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUDIENCE", "buzz-relay"); + clear_removed_identity_provider_env(); + std::env::set_var(CONFIG_ENV, std::ffi::OsString::from_vec(vec![0xff])); - let error = Config::from_env().expect_err("insecure JWKS URL must fail"); - clear_corporate_identity_env(); + let error = Config::from_env().expect_err("non-UTF-8 runtime config must fail closed"); + clear_removed_identity_provider_env(); - assert!(error.to_string().contains("JWKS_URI must be an HTTPS URL")); + assert!(error.to_string().contains(CONFIG_ENV)); + assert!(error.to_string().contains("valid UTF-8")); } #[test] diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 619a61272b2..0bb2a00c671 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1,23 +1,33 @@ //! WebSocket connection lifecycle: semaphore → challenge → recv/send/heartbeat loops → cleanup. use std::collections::HashMap; -use std::net::SocketAddr; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::Arc; use std::time::Duration; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures_util::{Sink, SinkExt, StreamExt}; -use tokio::sync::{mpsc, Mutex, RwLock}; +use sha2::Digest; +use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; use tokio_util::sync::CancellationToken; use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{ + generate_challenge, AuthContext, AuthenticatedClientPeer, LimitType, SealedTransportEvidence, +}; +use buzz_core::client_binding_bootstrap::{ + ClientBindingBootstrapInputV1, ClientBindingScopeV1, CLIENT_BINDING_BOOTSTRAP_SUB_ID, +}; use buzz_core::tenant::TenantContext; -use nostr::Filter; +use chrono::{DateTime, Utc}; +use nostr::{Filter, Keys}; +use crate::authorization_runtime::{ + ConnectionLocalStatusContract, CurrentStatusAuthorization, CurrentStatusSink, + StatusSessionError, UnchangedBootstrapDelivery, +}; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::{run_registered_community_connection, AppState}; @@ -25,15 +35,129 @@ use buzz_pubsub::EventTopic; /// Maximum time a new socket may hold a connection slot without completing NIP-42 auth. const AUTH_TIMEOUT: Duration = Duration::from_secs(5); +const STATUS_TASK_JOIN_TIMEOUT: Duration = Duration::from_secs(5); /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; +/// Finalized connection authentication plus optional opaque transport peer. +/// +/// The peer type is generic so provenance verification can supply its sealed +/// privacy-safe key without teaching the connection layer about proxy headers, +/// socket addresses, or the key's representation. Raw connection addresses +/// are deliberately not accepted by this API. +#[derive(Debug, Clone)] +pub struct AuthenticatedConnectionContext { + authorization: AuthContext, + authenticated_client_peer: Option, +} + +impl AuthenticatedConnectionContext { + /// Finalize a connection with the optional verifier-produced peer key. + pub fn new(authorization: AuthContext, authenticated_client_peer: Option) -> Self { + Self { + authorization, + authenticated_client_peer, + } + } + + /// Existing NIP-42 connection authorization. + pub const fn authorization(&self) -> &AuthContext { + &self.authorization + } + + /// Opaque authenticated end-client peer, when transport provenance supplied one. + pub const fn authenticated_client_peer(&self) -> Option<&Peer> { + self.authenticated_client_peer.as_ref() + } + + /// Consume the context for a downstream authenticated connection owner. + pub fn into_parts(self) -> (AuthContext, Option) { + (self.authorization, self.authenticated_client_peer) + } +} + +impl std::ops::Deref for AuthenticatedConnectionContext { + type Target = AuthContext; + + fn deref(&self) -> &Self::Target { + &self.authorization + } +} + +impl From for AuthenticatedConnectionContext { + fn from(authorization: AuthContext) -> Self { + Self::new(authorization, None) + } +} + /// Request for the writer to flush a restart close and report the result. pub(crate) struct RestartClose { pub(crate) flushed: tokio::sync::oneshot::Sender, } +/// Exact durable-delivery identity carried through the connection writer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StatusWriteIdentity { + pub(crate) delivery_id: Uuid, + pub(crate) claim_id: Uuid, + pub(crate) payload_digest: [u8; 32], + pub(crate) wire_digest: [u8; 32], +} + +/// Proof returned only after the exact status frame reaches `Sink::flush()`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StatusWriteAck { + pub(crate) identity: Option, +} + +/// One serialized bootstrap or status write owned by the socket task. +pub(crate) struct StatusWrite { + pub(crate) text: String, + pub(crate) identity: Option, + pub(crate) drain_data_first: bool, + pub(crate) deadline: tokio::time::Instant, + pub(crate) flushed: oneshot::Sender>, +} + +/// Cloneable exact-connection handle for the physically acknowledged writer. +#[derive(Clone)] +pub(crate) struct StatusWriter { + tx: mpsc::Sender, +} + +impl StatusWriter { + pub(crate) const fn new(tx: mpsc::Sender) -> Self { + Self { tx } + } + + /// Queue one command and await its physical flush acknowledgement. + pub(crate) async fn write( + &self, + text: String, + identity: Option, + drain_data_first: bool, + deadline: tokio::time::Instant, + ) -> Result { + let (flushed, acknowledgement) = oneshot::channel(); + let command = StatusWrite { + text, + identity, + drain_data_first, + deadline, + flushed, + }; + tokio::time::timeout_at(deadline, self.tx.send(command)) + .await + .map_err(|_| ())? + .map_err(|_| ())?; + tokio::time::timeout_at(deadline, acknowledgement) + .await + .map_err(|_| ())? + .map_err(|_| ())? + } +} + /// Maximum outbound data frames buffered into the websocket sink before one flush. const MAX_WS_SEND_BATCH: usize = 64; @@ -46,11 +170,39 @@ pub enum AuthState { challenge: String, }, /// Client has successfully authenticated. - Authenticated(AuthContext), + Authenticated(AuthenticatedConnectionContext), /// Authentication attempt was rejected. Failed, } +/// Exact canonical session grants established by the Enforce AUTH owner. +#[derive(Clone)] +pub(crate) struct CanonicalWebsocketSession { + read: buzz_auth::FinalizedAuthContext, + write: buzz_auth::FinalizedAuthContext, +} + +impl CanonicalWebsocketSession { + pub(crate) fn new( + read: buzz_auth::FinalizedAuthContext, + write: buzz_auth::FinalizedAuthContext, + ) -> Self { + Self { read, write } + } + + fn authorization( + &self, + 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, + } + } +} + /// Per-connection state split by access pattern: /// - `auth_state`: RwLock (read-heavy after initial auth) /// - `subscriptions`: Mutex (write-heavy during REQ/CLOSE) @@ -62,16 +214,23 @@ pub struct ConnectionState { /// host at row zero (before any frame is read) and never overridable by /// client-supplied input. Every handler reads tenant scope from here. pub tenant: TenantContext, - /// Remote socket address of the client. - pub remote_addr: SocketAddr, /// Optional corporate identity JWT captured from the WebSocket upgrade request. pub corporate_identity_jwt: Option, + /// Move-only trusted-proxy evidence captured at the Enforce upgrade boundary. + pub canonical_transport_evidence: Mutex>, + /// Capability-specific canonical session grants installed only by Enforce AUTH. + pub(crate) canonical_authorization: RwLock>, /// Current NIP-42 authentication state. pub auth_state: RwLock, + /// Verified optional S5 scope. It is inert until a finalized direct + /// binding-status lease activates the Enforce-only outbox lane. + pub(crate) status_scope: RwLock>, /// Active subscriptions keyed by subscription ID. pub subscriptions: ConnectionSubscriptions, /// Sender for outbound data messages (EVENT, NOTICE, OK, etc.). pub send_tx: mpsc::Sender, + /// Dedicated ordered writer for bootstrap and crash-durable status frames. + pub(crate) status_writer: StatusWriter, /// Sender for outbound control frames (Pong, Close). /// Separate channel with priority drain — if this channel fills too, /// the connection is closed (writer is completely stalled). @@ -82,11 +241,110 @@ pub struct ConnectionState { /// Shared with `ConnectionManager::ConnEntry` so both direct sends and /// fan-out broadcasts track the same counter. pub backpressure_count: Arc, + /// Sole connection-local owner of the current scoped-AUTH status task. + #[cfg(not(test))] + pub(crate) client_binding_status_task: tokio::sync::Mutex>, /// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`). pub grace_limit: u8, } +pub(crate) struct ClientBindingStatusTask { + cancel: CancellationToken, + join: tokio::task::JoinHandle<()>, +} + +// Unit-test ConnectionState literals predate the production-only owner field +// and live in unrelated modules. Keep their ephemeral owner slots here so the +// status lease never expands those modules' path ownership. +#[cfg(test)] +static TEST_CLIENT_BINDING_STATUS_TASKS: std::sync::LazyLock< + dashmap::DashMap>>>, +> = std::sync::LazyLock::new(dashmap::DashMap::new); + +#[cfg(test)] +fn test_client_binding_status_slot(conn_id: Uuid) -> Arc>> { + Arc::clone( + TEST_CLIENT_BINDING_STATUS_TASKS + .entry(conn_id) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .value(), + ) +} + impl ConnectionState { + async fn cancel_and_join_client_binding_status_task(task: ClientBindingStatusTask) -> bool { + let ClientBindingStatusTask { cancel, mut join } = task; + cancel.cancel(); + match tokio::time::timeout(STATUS_TASK_JOIN_TIMEOUT, &mut join).await { + Ok(Ok(())) => true, + Ok(Err(_)) => false, + Err(_) => { + join.abort(); + let _ = join.await; + false + } + } + } + + /// Cancel and join the prior AUTH scope before replacement bootstrap. + pub(crate) async fn clear_client_binding_status_task(&self) -> bool { + #[cfg(not(test))] + let previous = self.client_binding_status_task.lock().await.take(); + #[cfg(test)] + let previous = { + let slot = test_client_binding_status_slot(self.conn_id); + let previous = slot.lock().await.take(); + previous + }; + if let Some(previous) = previous { + if !Self::cancel_and_join_client_binding_status_task(previous).await { + self.cancel.cancel(); + return false; + } + } + #[cfg(test)] + TEST_CLIENT_BINDING_STATUS_TASKS.remove(&self.conn_id); + !self.cancel.is_cancelled() + } + + /// Install the sole status task. Any concurrently installed scope is + /// cancelled and joined before this replacement becomes owned. + pub(crate) async fn replace_client_binding_status_task( + &self, + cancel: CancellationToken, + join: tokio::task::JoinHandle<()>, + ) -> bool { + #[cfg(not(test))] + let mut installed = self.client_binding_status_task.lock().await; + #[cfg(test)] + let test_slot = test_client_binding_status_slot(self.conn_id); + #[cfg(test)] + let mut installed = test_slot.lock().await; + if let Some(previous) = installed.take() { + if !Self::cancel_and_join_client_binding_status_task(previous).await { + drop(installed); + let _ = Self::cancel_and_join_client_binding_status_task(ClientBindingStatusTask { + cancel, + join, + }) + .await; + self.cancel.cancel(); + return false; + } + } + if self.cancel.is_cancelled() { + drop(installed); + let _ = Self::cancel_and_join_client_binding_status_task(ClientBindingStatusTask { + cancel, + join, + }) + .await; + return false; + } + *installed = Some(ClientBindingStatusTask { cancel, join }); + true + } + /// Sends a data message to this connection's outbound channel. /// /// On a full buffer, increments the backpressure counter. The first @@ -118,6 +376,104 @@ impl ConnectionState { } } +/// Exact same-connection current-status delivery boundary. It owns no durable state and +/// treats any queue ambiguity as terminal for the socket. +#[derive(Clone)] +pub struct WebSocketStatusChannel { + send_tx: mpsc::Sender, + cancel: CancellationToken, +} + +impl WebSocketStatusChannel { + /// Bind the producer to one connection's data queue and close token. + pub fn from_connection(connection: &ConnectionState) -> Self { + Self { + send_tx: connection.send_tx.clone(), + cancel: connection.cancel.clone(), + } + } + + /// Deliver the unchanged validated bootstrap before a status session can + /// be constructed. The scope must come from the already verified AUTH + /// event and must pin the NIP-11 relay signer. + pub async fn deliver_bootstrap( + &self, + scope: ClientBindingScopeV1, + authorization: &CurrentStatusAuthorization, + relay_keys: &Keys, + issued_at: DateTime, + ) -> Result { + if scope.relay_signer() != relay_keys.public_key() { + self.cancel.cancel(); + return Err(StatusSessionError::ContractUnavailable); + } + let (domain, author) = authorization.domain_author(); + let issued_at = u64::try_from(issued_at.timestamp()).map_err(|_| { + self.cancel.cancel(); + StatusSessionError::ContractUnavailable + })?; + let bootstrap = ClientBindingBootstrapInputV1::new( + domain, + author, + scope.connection_epoch().clone(), + issued_at, + ) + .map_err(|_| { + self.cancel.cancel(); + StatusSessionError::ContractUnavailable + })? + .sign_with_relay_keys(relay_keys) + .map_err(|_| { + self.cancel.cancel(); + StatusSessionError::ContractUnavailable + })?; + if self + .send_tx + .try_send(WsMessage::Text( + RelayMessage::event(CLIENT_BINDING_BOOTSTRAP_SUB_ID, &bootstrap).into(), + )) + .is_err() + { + self.cancel.cancel(); + return Err(StatusSessionError::DeliveryFailed); + } + Ok(UnchangedBootstrapDelivery::delivered()) + } +} + +#[async_trait::async_trait] +impl CurrentStatusSink for WebSocketStatusChannel { + async fn send_current(&self, current: &nostr::Event) -> Result<(), StatusSessionError> { + self.send_status(current).await + } + + async fn send_withdrawal(&self, withdrawal: &nostr::Event) -> Result<(), StatusSessionError> { + self.send_status(withdrawal).await + } + + async fn close(&self) { + self.cancel.cancel(); + } +} + +impl WebSocketStatusChannel { + async fn send_status(&self, event: &nostr::Event) -> Result<(), StatusSessionError> { + use buzz_core::client_binding_bootstrap::CLIENT_BINDING_STATUS_SUB_ID; + + if self + .send_tx + .try_send(WsMessage::Text( + RelayMessage::event(CLIENT_BINDING_STATUS_SUB_ID, event).into(), + )) + .is_err() + { + self.cancel.cancel(); + return Err(StatusSessionError::DeliveryFailed); + } + Ok(()) + } +} + /// Entry point for a new WebSocket connection. /// /// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge, @@ -125,9 +481,9 @@ impl ConnectionState { pub async fn handle_connection( socket: WebSocket, state: Arc, - addr: SocketAddr, tenant: TenantContext, corporate_identity_jwt: Option, + canonical_transport_evidence: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -145,11 +501,11 @@ pub async fn handle_connection( handle_active_connection( socket, run_state, - addr, tenant, conn_id, cancel, corporate_identity_jwt, + canonical_transport_evidence, ) }, ) @@ -159,16 +515,16 @@ pub async fn handle_connection( async fn handle_active_connection( socket: WebSocket, state: Arc, - addr: SocketAddr, tenant: TenantContext, conn_id: Uuid, cancel: CancellationToken, corporate_identity_jwt: Option, + canonical_transport_evidence: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { - warn!("Connection limit reached, rejecting {addr}"); + warn!("Connection limit reached"); return; } }; @@ -184,6 +540,7 @@ async fn handle_active_connection( // ordinary control frames unchanged avoids coupling heartbeat/ban traffic // to graceful-shutdown delivery tracking. let (restart_tx, restart_rx) = mpsc::channel::(1); + let (status_tx, status_rx) = mpsc::channel::(8); let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); @@ -191,20 +548,25 @@ async fn handle_active_connection( let conn = Arc::new(ConnectionState { conn_id, tenant, - remote_addr: addr, corporate_identity_jwt, + canonical_transport_evidence: Mutex::new(canonical_transport_evidence), + canonical_authorization: RwLock::new(None), auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), + status_scope: RwLock::new(None), subscriptions: Arc::clone(&subscriptions), send_tx: tx.clone(), + status_writer: StatusWriter::new(status_tx), ctrl_tx: ctrl_tx.clone(), cancel: cancel.clone(), backpressure_count: Arc::clone(&backpressure_count), + #[cfg(not(test))] + client_binding_status_task: tokio::sync::Mutex::new(None), grace_limit: state.config.slow_client_grace_limit, }); - info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); + info!(conn_id = %conn_id, "WebSocket connection established"); metrics::counter!( "buzz_ws_connections_total", "community" => conn.tenant.host().to_owned() @@ -241,7 +603,14 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel)); + let send_task = tokio::spawn(send_loop( + ws_send, + rx, + ctrl_rx, + restart_rx, + status_rx, + send_cancel, + )); let missed_pongs = Arc::new(AtomicU8::new(0)); let heartbeat_cancel = cancel.clone(); @@ -283,6 +652,9 @@ async fn handle_active_connection( ) .await; + if !conn.clear_client_binding_status_task().await { + warn!(conn_id = %conn.conn_id, "current-binding status task did not stop cleanly"); + } cancel.cancel(); let _ = send_task.await; let _ = heartbeat_task.await; @@ -308,7 +680,7 @@ async fn handle_active_connection( } } metrics::gauge!("buzz_ws_connections_active").decrement(1.0); - info!(conn_id = %conn_id, addr = %addr, "WebSocket connection closed"); + info!(conn_id = %conn_id, "WebSocket connection closed"); drop(permit); } @@ -324,16 +696,32 @@ async fn send_loop( data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, restart_rx: mpsc::Receiver, + status_rx: mpsc::Receiver, cancel: CancellationToken, ) { - send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await; + send_loop_inner_with_status(ws_send, data_rx, ctrl_rx, restart_rx, status_rx, cancel).await; } +#[cfg(test)] async fn send_loop_inner( + ws_send: S, + data_rx: mpsc::Receiver, + ctrl_rx: mpsc::Receiver, + restart_rx: mpsc::Receiver, + cancel: CancellationToken, +) where + S: Sink + Unpin, +{ + let (_status_tx, status_rx) = mpsc::channel(1); + send_loop_inner_with_status(ws_send, data_rx, ctrl_rx, restart_rx, status_rx, cancel).await; +} + +async fn send_loop_inner_with_status( mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, mut restart_rx: mpsc::Receiver, + mut status_rx: mpsc::Receiver, cancel: CancellationToken, ) where S: Sink + Unpin, @@ -347,9 +735,11 @@ async fn send_loop_inner( } tokio::select! { - // Biased: restart > cancel > ordinary control > data. A restart + // Biased: restart > status > cancel > ordinary control > data. A restart // command owns shutdown delivery and must flush its 1012 before - // cancellation can fall back to an unacknowledged close. + // cancellation can fall back to an unacknowledged close. A status + // command already accepted by its dedicated channel remains ordered + // ahead of a concurrent close even if its waiter was dropped. biased; Some(restart) = restart_rx.recv() => { let sent = ws_send @@ -362,6 +752,21 @@ async fn send_loop_inner( let _ = restart.flushed.send(sent); break; } + Some(status) = status_rx.recv() => { + let result = flush_status_write( + &mut ws_send, + &mut data_rx, + &status, + ).await; + let ack = result.map(|()| StatusWriteAck { + identity: status.identity, + }); + let succeeded = ack.is_ok(); + let _ = status.flushed.send(ack); + if !succeeded { + break; + } + } _ = cancel.cancelled() => { // Drain any queued control frames before closing. A ban // disconnect queues its `OK false "blocked: …"` reason frame on @@ -410,6 +815,37 @@ async fn send_loop_inner( } } +async fn flush_status_write( + ws_send: &mut S, + data_rx: &mut mpsc::Receiver, + status: &StatusWrite, +) -> Result<(), ()> +where + S: Sink + Unpin, +{ + tokio::time::timeout_at(status.deadline, async { + if status.drain_data_first { + while let Ok(message) = data_rx.try_recv() { + ws_send.feed(message).await.map_err(|_| ())?; + } + ws_send.flush().await.map_err(|_| ())?; + } + if let Some(identity) = status.identity { + let actual_wire_digest: [u8; 32] = sha2::Sha256::digest(status.text.as_bytes()).into(); + if actual_wire_digest != identity.wire_digest { + return Err(()); + } + } + ws_send + .feed(WsMessage::Text(status.text.clone().into())) + .await + .map_err(|_| ())?; + ws_send.flush().await.map_err(|_| ()) + }) + .await + .map_err(|_| ())? +} + /// 3 missed pongs → disconnect. /// /// Sends Ping through the control channel so it isn't blocked by a full @@ -649,6 +1085,48 @@ async fn enforce_ws_admission( } }; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + let ingress = match msg { + ClientMessage::Event(_) => { + crate::authorization_runtime::ProtectedIngress::WebSocketEvent + } + ClientMessage::Req { .. } => { + crate::authorization_runtime::ProtectedIngress::WebSocketQuery + } + ClientMessage::Count { .. } => { + crate::authorization_runtime::ProtectedIngress::WebSocketCount + } + _ => return true, + }; + 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, + ) + }) + }) + }; + if !admitted { + let sub_id = match msg { + ClientMessage::Req { sub_id, .. } | ClientMessage::Count { sub_id, .. } => { + Some(sub_id.as_str()) + } + _ => None, + }; + conn.send(request_rejection_message( + sub_id, + "restricted: canonical session authorization denied", + )); + return false; + } + } + let limits = &state.auth.config().rate_limits; let (ws_window_secs, ws_limit) = crate::admission::ws_admission_budget(limits.human_ws_events_per_sec); @@ -728,8 +1206,47 @@ fn topic_for_subscription(channel_id: Option) -> EventTopic { #[cfg(test)] mod tests { use super::*; + use crate::authorization_runtime::CurrentStatusContract; + use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; + use buzz_core::client_binding_bootstrap::{ + CLIENT_BINDING_SCOPE_TAG, CLIENT_BINDING_STATUS_SUB_ID, + }; + use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; + use nostr::{EventBuilder, Kind, Tag}; + + #[test] + fn authenticated_context_carries_only_the_verifier_peer_key() { + let keys = Keys::generate(); + let authorization = AuthContext { + pubkey: keys.public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }; + let peer = AuthenticatedClientPeer::for_test([7; 32]); + + let context = AuthenticatedConnectionContext::new(authorization, Some(peer)); + + assert_eq!(context.pubkey, keys.public_key()); + assert_eq!( + context + .authenticated_client_peer() + .map(AuthenticatedClientPeer::admission_key), + Some(&[7; 32]) + ); + let (authorization, carried_peer) = context.into_parts(); + assert_eq!(authorization.pubkey, keys.public_key()); + assert_eq!( + carried_peer + .as_ref() + .map(AuthenticatedClientPeer::admission_key), + Some(&[7; 32]) + ); + } + #[derive(Debug, Default)] struct MockSinkState { messages: Vec, @@ -757,6 +1274,155 @@ mod tests { } } + #[tokio::test] + async fn reserved_status_channel_delivers_bootstrap_before_current_and_closes_on_ambiguity() { + let relay = Keys::generate(); + let author = Keys::generate(); + let now = Utc::now(); + let domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let evidence = CanonicalCurrentBindingEvidence::new( + domain, + author.public_key(), + Uuid::from_u128(2), + 3, + 4, + 5, + 6, + AuthorizationLeaseFence::from_bytes([7; 32]).unwrap(), + now, + now + chrono::Duration::seconds(240), + ) + .unwrap(); + let authorization = CurrentStatusAuthorization::from_test_parts( + &evidence, + now + chrono::Duration::seconds(240), + ); + let scope_event = EventBuilder::new(Kind::Custom(22242), "") + .tags([Tag::parse(vec![ + CLIENT_BINDING_SCOPE_TAG.to_owned(), + "1".to_owned(), + "11111111-1111-4111-8111-111111111111".to_owned(), + relay.public_key().to_hex(), + ]) + .unwrap()]) + .sign_with_keys(&author) + .unwrap(); + let scope = ClientBindingScopeV1::from_verified_auth_event(&scope_event).unwrap(); + let (send_tx, mut send_rx) = mpsc::channel(2); + let cancel = CancellationToken::new(); + let channel = WebSocketStatusChannel { + send_tx, + cancel: cancel.clone(), + }; + + let bootstrap = channel + .deliver_bootstrap(scope, &authorization, &relay, now) + .await + .unwrap(); + let contract = + ConnectionLocalStatusContract::new(relay.clone(), relay.public_key()).unwrap(); + let current = contract.current(&evidence, 1).unwrap(); + channel.send_current(¤t).await.unwrap(); + + let first = send_rx.recv().await.unwrap(); + let second = send_rx.recv().await.unwrap(); + let subscription_id = |message: WsMessage| match message { + WsMessage::Text(text) => serde_json::from_str::(text.as_str()) + .unwrap() + .as_array() + .unwrap()[1] + .as_str() + .unwrap() + .to_owned(), + other => panic!("expected text delivery, got {other:?}"), + }; + assert_eq!(subscription_id(first), CLIENT_BINDING_BOOTSTRAP_SUB_ID); + assert_eq!(subscription_id(second), CLIENT_BINDING_STATUS_SUB_ID); + let _bootstrap_proof = bootstrap; + assert!(!cancel.is_cancelled()); + + let (full_tx, _full_rx) = mpsc::channel(1); + full_tx + .try_send(WsMessage::Text("occupied".into())) + .unwrap(); + let full_cancel = CancellationToken::new(); + let full = WebSocketStatusChannel { + send_tx: full_tx, + cancel: full_cancel.clone(), + }; + assert_eq!( + full.send_current(¤t).await, + Err(StatusSessionError::DeliveryFailed) + ); + assert!(full_cancel.is_cancelled()); + } + + #[tokio::test] + async fn auth_replacement_and_teardown_join_connection_owned_withdrawal() { + let (send_tx, _send_rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (status_tx, _status_rx) = mpsc::channel(1); + let socket_cancel = CancellationToken::new(); + let conn = ConnectionState { + conn_id: Uuid::new_v4(), + tenant: TenantContext::resolved( + CommunityId::from_uuid(Uuid::from_u128(1)), + "status.test", + ), + corporate_identity_jwt: None, + canonical_transport_evidence: tokio::sync::Mutex::new(None), + canonical_authorization: RwLock::new(None), + auth_state: RwLock::new(AuthState::Pending { + challenge: "challenge".to_owned(), + }), + status_scope: RwLock::new(None), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + status_writer: StatusWriter::new(status_tx), + ctrl_tx, + cancel: socket_cancel.clone(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + }; + + let first_cancel = socket_cancel.child_token(); + let first_observed = first_cancel.clone(); + let first_finished = Arc::new(AtomicBool::new(false)); + let first_finished_task = Arc::clone(&first_finished); + let first_join = tokio::spawn(async move { + first_observed.cancelled().await; + first_finished_task.store(true, Ordering::SeqCst); + }); + assert!( + conn.replace_client_binding_status_task(first_cancel.clone(), first_join,) + .await + ); + + let replacement_cancel = socket_cancel.child_token(); + let replacement_observed = replacement_cancel.clone(); + let replacement_finished = Arc::new(AtomicBool::new(false)); + let replacement_finished_task = Arc::clone(&replacement_finished); + let (withdrawal_tx, mut withdrawal_rx) = mpsc::channel(1); + let replacement_join = tokio::spawn(async move { + replacement_observed.cancelled().await; + withdrawal_tx.send("withdrawn").await.unwrap(); + replacement_finished_task.store(true, Ordering::SeqCst); + }); + assert!( + conn.replace_client_binding_status_task(replacement_cancel.clone(), replacement_join,) + .await + ); + assert!(first_cancel.is_cancelled()); + assert!(first_finished.load(Ordering::SeqCst)); + assert!(!replacement_cancel.is_cancelled()); + + assert!(conn.clear_client_binding_status_task().await); + assert!(replacement_cancel.is_cancelled()); + assert!(replacement_finished.load(Ordering::SeqCst)); + assert_eq!(withdrawal_rx.try_recv().unwrap(), "withdrawn"); + assert!(!socket_cancel.is_cancelled()); + } + impl Sink for MockSink { type Error = std::io::Error; @@ -895,6 +1561,134 @@ mod tests { ); } + #[tokio::test] + async fn status_writer_acknowledges_only_the_exact_physically_flushed_frame() { + let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH + 2); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let (status_tx, status_rx) = mpsc::channel(1); + let writer = StatusWriter::new(status_tx); + let cancel = CancellationToken::new(); + for index in 0..=MAX_WS_SEND_BATCH { + data_tx + .send(WsMessage::Text(format!("AUTH-OK-{index}").into())) + .await + .expect("queue auth acknowledgement"); + } + let (sink, state) = MockSink::new(None); + let task_cancel = cancel.clone(); + let task = tokio::spawn(async move { + send_loop_inner_with_status(sink, data_rx, ctrl_rx, restart_rx, status_rx, task_cancel) + .await; + }); + let identity = StatusWriteIdentity { + delivery_id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + payload_digest: [7; 32], + wire_digest: sha2::Sha256::digest(b"STATUS").into(), + }; + let ack = writer + .write( + "STATUS".to_owned(), + Some(identity), + true, + tokio::time::Instant::now() + Duration::from_secs(2), + ) + .await + .expect("physical status flush"); + assert_eq!(ack.identity, Some(identity)); + { + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 2); + let payloads = text_payloads(&state.messages); + assert_eq!(payloads.len(), MAX_WS_SEND_BATCH + 2); + assert_eq!(payloads.first().map(String::as_str), Some("AUTH-OK-0")); + assert_eq!(payloads.last().map(String::as_str), Some("STATUS")); + } + cancel.cancel(); + task.await.expect("writer task"); + } + + #[tokio::test] + async fn status_writer_never_acknowledges_queueing_when_flush_fails() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let (status_tx, status_rx) = mpsc::channel(1); + let writer = StatusWriter::new(status_tx); + let (sink, state) = MockSink::new(Some(1)); + let task = tokio::spawn(async move { + send_loop_inner_with_status( + sink, + data_rx, + ctrl_rx, + restart_rx, + status_rx, + CancellationToken::new(), + ) + .await; + }); + let identity = StatusWriteIdentity { + delivery_id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + payload_digest: [8; 32], + wire_digest: sha2::Sha256::digest(b"STATUS").into(), + }; + assert!(writer + .write( + "STATUS".to_owned(), + Some(identity), + false, + tokio::time::Instant::now() + Duration::from_secs(2), + ) + .await + .is_err()); + task.await.expect("writer task"); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 1); + assert_eq!(text_payloads(&state.messages), vec!["STATUS"]); + } + + #[tokio::test] + async fn status_writer_rejects_an_ack_identity_bound_to_other_wire_bytes() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (_restart_tx, restart_rx) = mpsc::channel(1); + let (status_tx, status_rx) = mpsc::channel(1); + let writer = StatusWriter::new(status_tx); + let (sink, state) = MockSink::new(None); + let task = tokio::spawn(async move { + send_loop_inner_with_status( + sink, + data_rx, + ctrl_rx, + restart_rx, + status_rx, + CancellationToken::new(), + ) + .await; + }); + let identity = StatusWriteIdentity { + delivery_id: Uuid::new_v4(), + claim_id: Uuid::new_v4(), + payload_digest: [9; 32], + wire_digest: sha2::Sha256::digest(b"OTHER").into(), + }; + assert!(writer + .write( + "STATUS".to_owned(), + Some(identity), + false, + tokio::time::Instant::now() + Duration::from_secs(2), + ) + .await + .is_err()); + task.await.expect("writer task"); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 0); + assert!(state.messages.is_empty()); + } + #[tokio::test] async fn send_loop_acknowledges_restart_after_flushing_exactly_one_1012() { let (_data_tx, data_rx) = mpsc::channel(1); diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index 959df53f98a..d21a4bd95b9 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -19,8 +19,6 @@ use jsonwebtoken::{ jwk::{Jwk, JwkSet}, Algorithm, }; -#[cfg(test)] -use nostr::{Event, EventBuilder, Kind, Tag}; use nostr::{FromBech32, PublicKey, Timestamp}; use serde::Deserialize; use serde_json::{Map, Value}; @@ -33,8 +31,6 @@ use buzz_auth::{ CanonicalFederatedAssertionVerifier, CanonicalVerifierError, CanonicalVerifierKeySet, CanonicalVerifierPolicy, ProofTransport, VerifierKeyGeneration, VerifierPolicyStamp, }; -#[cfg(test)] -use buzz_core::kind::KIND_USER_TRUSTED_ASSERTION; use buzz_core::CommunityId; use buzz_db::identity_binding::{BindIdentityResult, SOURCE_DB_BINDING, SOURCE_JWT_NPUB}; @@ -420,7 +416,7 @@ impl buzz_db::authorization_admission::AdmissionVerifierRechecker for CorporateI } } -impl crate::state::InviteAssertionVerifier for CorporateIdentityService { +impl crate::state::CanonicalAssertionVerifier for CorporateIdentityService { fn verify<'a>( &'a self, token: &'a str, @@ -434,7 +430,7 @@ impl crate::state::InviteAssertionVerifier for CorporateIdentityService { dyn std::future::Future< Output = Result< buzz_auth::VerifiedFederatedAssertion, - crate::state::InviteAssertionError, + crate::state::CanonicalAssertionError, >, > + Send + 'a, @@ -454,9 +450,9 @@ impl crate::state::InviteAssertionVerifier for CorporateIdentityService { CorporateIdentityError::Jwks(_) | CorporateIdentityError::Db(_) | CorporateIdentityError::FoundationIntegrationRequired => { - crate::state::InviteAssertionError::Unavailable + crate::state::CanonicalAssertionError::Unavailable } - _ => crate::state::InviteAssertionError::Denied, + _ => crate::state::CanonicalAssertionError::Denied, }) }) } @@ -826,7 +822,14 @@ impl CorporateIdentityError { if status.is_server_error() { warn!(error = %self, "corporate identity enforcement failed"); } - (status, Json(serde_json::json!({ "error": message }))) + let code = match self { + Self::Jwks(_) | Self::Db(_) | Self::FoundationIntegrationRequired => { + crate::api::ApiErrorCode::DependencyUnavailable + } + Self::MissingJwt => crate::api::ApiErrorCode::AuthenticationRequired, + _ => crate::api::ApiErrorCode::AuthorizationDenied, + }; + crate::api::coded_api_error(status, code, message) } } @@ -876,6 +879,12 @@ async fn verify_corporate_identity_inner( auth_tag_json: Option<&str>, ) -> Result { let Some(service) = state.corporate_identity.as_ref() else { + if state.config.corporate_identity.require { + return Err(match identity_jwt { + Some(_) => CorporateIdentityError::FoundationIntegrationRequired, + None => CorporateIdentityError::MissingJwt, + }); + } return Ok(CorporateIdentityProof::NotRequired); }; @@ -1020,80 +1029,6 @@ async fn require_final_verifier_stamp( } } -#[cfg(test)] -fn build_identity_assertion( - relay_keypair: &nostr::Keys, - subject: PublicKey, - display_name: Option<&str>, - expires_at: u64, - created_at: Timestamp, -) -> Result { - let subject = subject.to_hex(); - let active = if display_name.is_some() { - "true" - } else { - "false" - }; - let expires_at = expires_at.to_string(); - let mut tags = vec![ - Tag::parse(["d", subject.as_str()]), - Tag::parse(["p", subject.as_str()]), - Tag::parse(["verified", "relay"]), - Tag::parse(["active", active]), - Tag::parse(["expiration", expires_at.as_str()]), - ]; - if let Some(display_name) = display_name { - tags.push(Tag::parse(["display_name", display_name])); - } - let tags = tags - .into_iter() - .collect::, _>>() - .map_err(|error| format!("invalid corporate identity assertion tag: {error}"))?; - - EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") - .tags(tags) - .custom_created_at(created_at) - .sign_with_keys(relay_keypair) - .map_err(|error| format!("failed to sign corporate identity assertion: {error}")) -} - -#[cfg(test)] -fn identity_assertion_matches( - event: &Event, - subject: &str, - display_name: Option<&str>, - expires_at: u64, -) -> bool { - let has_tag = |name: &str, value: &str| { - event.tags.iter().any(|tag| { - let parts = tag.as_slice(); - parts.len() == 2 && parts[0] == name && parts[1] == value - }) - }; - has_tag("d", subject) - && has_tag("p", subject) - && has_tag("verified", "relay") - && has_tag( - "active", - if display_name.is_some() { - "true" - } else { - "false" - }, - ) - && has_tag("expiration", &expires_at.to_string()) - && display_name.is_none_or(|name| has_tag("display_name", name)) -} - -#[cfg(test)] -fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64, now: u64) -> u64 { - if display_name.is_some() { - jwt_expires_at.min(now.saturating_add(IDENTITY_ASSERTION_MAX_TTL_SECS)) - } else { - 0 - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IdentityAuthPath { Direct, @@ -1333,6 +1268,71 @@ fn record_corporate_identity_denial(error: &CorporateIdentityError) { metrics::counter!("buzz_corporate_identity_denials_total", "reason" => reason).increment(1); } +#[cfg(test)] +pub(crate) mod canonical_test_support { + use aws_lc_rs::{ + rand::SystemRandom, + rsa::KeySize, + signature::{KeyPair, RsaKeyPair, RsaPublicKeyComponents, RSA_PKCS1_SHA256}, + }; + use base64::Engine; + use jsonwebtoken::jwk::Jwk; + use serde_json::Value; + + fn rsa_private_key(index: usize) -> &'static RsaKeyPair { + static KEYS: std::sync::OnceLock<[RsaKeyPair; 2]> = std::sync::OnceLock::new(); + KEYS.get_or_init(|| { + std::array::from_fn(|_| { + RsaKeyPair::generate(KeySize::Rsa2048).expect("generate canonical test key") + }) + }) + .get(index) + .expect("canonical test key index") + } + + pub(crate) fn jwk(key_index: usize, kid: &str) -> Jwk { + let components = + RsaPublicKeyComponents::>::from(rsa_private_key(key_index).public_key()); + serde_json::from_value(serde_json::json!({ + "kty": "RSA", + "n": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(components.n), + "e": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(components.e), + "kid": kid, + "alg": "RS256", + "use": "sig", + "key_ops": ["verify"], + })) + .expect("derive canonical test JWK") + } + + pub(crate) fn signed_jwt(claims: &Value, key_index: usize, kid: &str) -> String { + let header = serde_json::json!({ + "alg": "RS256", + "typ": "JWT", + "kid": kid, + }); + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).expect("serialize canonical test JWT header")); + let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(claims).expect("serialize canonical test JWT claims")); + let signing_input = format!("{header}.{claims}"); + let private_key = rsa_private_key(key_index); + let mut signature = vec![0_u8; private_key.public_modulus_len()]; + private_key + .sign( + &RSA_PKCS1_SHA256, + &SystemRandom::new(), + signing_input.as_bytes(), + &mut signature, + ) + .expect("sign canonical test JWT"); + format!( + "{signing_input}.{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature) + ) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1362,6 +1362,20 @@ mod tests { ) } + #[derive(Clone)] + struct CapturedLogWriter(Arc>>); + + impl std::io::Write for CapturedLogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().expect("captured log lock").extend(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + fn test_config() -> CorporateIdentityConfig { CorporateIdentityConfig { require: true, @@ -1373,7 +1387,6 @@ mod tests { audience: "buzz-relay".to_string(), uid_claim: "sub".to_string(), display_claim: "email".to_string(), - public_display_claim: None, npub_claim: Some("buzz_npub".to_string()), } } @@ -1520,84 +1533,6 @@ mod tests { task.await.expect("revalidation task"); } - #[test] - fn identity_projects_as_relay_signed_nip85_assertion_without_provider_details() { - let relay = Keys::generate(); - let subject = Keys::generate().public_key(); - let event = build_identity_assertion( - &relay, - subject, - Some("Example User"), - 456, - Timestamp::from(123), - ) - .unwrap(); - - assert_eq!(event.kind.as_u16() as u32, KIND_USER_TRUSTED_ASSERTION); - assert_eq!(event.pubkey, relay.public_key()); - assert!(event.verify_id()); - assert!(event.verify_signature()); - assert!(identity_assertion_matches( - &event, - &subject.to_hex(), - Some("Example User"), - 456, - )); - assert!( - !event - .tags - .iter() - .any(|tag| tag.as_slice().first().is_some_and(|name| name == "uid")), - "the public assertion must not expose the stable corporate uid" - ); - assert!( - !event - .tags - .iter() - .any(|tag| tag.as_slice().first().is_some_and(|name| name == "issuer")), - "the public assertion must not expose the upstream identity provider" - ); - } - - #[test] - fn identity_assertions_are_bounded_and_can_be_retired() { - let relay = Keys::generate(); - let subject = Keys::generate().public_key(); - let now = 1_000; - - assert_eq!( - identity_assertion_expiration( - Some("Example User"), - now + IDENTITY_ASSERTION_MAX_TTL_SECS + 1, - now, - ), - now + IDENTITY_ASSERTION_MAX_TTL_SECS, - ); - assert_eq!( - identity_assertion_expiration(Some("Example User"), now + 60, now), - now + 60, - ); - assert_eq!(identity_assertion_expiration(None, u64::MAX, now), 0); - - let retired = build_identity_assertion(&relay, subject, None, 0, Timestamp::from(now)) - .expect("build inactive assertion"); - assert!(identity_assertion_matches( - &retired, - &subject.to_hex(), - None, - 0, - )); - assert!(retired.tags.iter().any(|tag| { - tag.as_slice().first().is_some_and(|part| part == "active") - && tag.as_slice().get(1).is_some_and(|part| part == "false") - })); - assert!(!retired.tags.iter().any(|tag| { - tag.as_slice() - .first() - .is_some_and(|part| part == "display_name") - })); - } - #[test] fn direct_jwt_precedes_delegation_by_default() { let config = test_config(); @@ -1969,6 +1904,86 @@ mod tests { server.abort(); } + #[tokio::test] + async fn attacker_kid_is_absent_from_bounded_structured_denial_log() { + const RAW_KID_MARKER: &str = "F170_RAW_KID_CANARY_DO_NOT_LOG"; + + let response = http_response( + "200 OK", + &["Content-Type: application/json"], + r#"{"keys":[]}"#, + ); + let (uri, requests, server) = spawn_http_server(response).await; + let mut config = test_config(); + config.jwks_uri = uri; + let service = CorporateIdentityService::new(config); + *service.jwks.write().await = Some(CachedJwks { + set: JwkSet { keys: Vec::new() }, + generation: VerifierKeyGeneration::new(1).expect("positive generation"), + expires_at: Instant::now() + Duration::from_secs(60), + }); + let attacker_kid = format!("{RAW_KID_MARKER}\n\"forged_field\":\"{}\"", "k".repeat(768)); + let error = service + .jwk_snapshot_for_kid(&attacker_kid) + .await + .expect_err("attacker-controlled kid must miss after the bounded refresh"); + assert_eq!(requests.load(Ordering::SeqCst), 1); + server.abort(); + + let output = Arc::new(std::sync::Mutex::new(Vec::new())); + let output_writer = Arc::clone(&output); + let subscriber = tracing_subscriber::fmt() + .json() + .with_max_level(tracing::Level::WARN) + .with_writer(move || CapturedLogWriter(Arc::clone(&output_writer))) + .finish(); + let (status, Json(body)) = tracing::subscriber::with_default(subscriber, || { + tracing::warn!( + error = %error, + error_debug = ?error, + "corporate identity denial canary" + ); + error.into_api_error() + }); + + let captured = String::from_utf8(output.lock().expect("captured log lock").clone()) + .expect("structured log is UTF-8"); + let records: Vec = captured + .lines() + .map(|line| serde_json::from_str(line).expect("structured log is JSON")) + .collect(); + assert_eq!(records.len(), 1, "canary must capture exactly one event"); + let fields = records[0]["fields"] + .as_object() + .expect("structured event fields"); + assert_eq!( + fields.get("message").and_then(Value::as_str), + Some("corporate identity denial canary") + ); + assert_eq!( + fields.get("error").and_then(Value::as_str), + Some("corporate identity JWKS unavailable: kid not found after JWKS refresh") + ); + assert_eq!( + fields.get("error_debug").and_then(Value::as_str), + Some("CorporateIdentityError([REDACTED])") + ); + for field in ["message", "error", "error_debug"] { + assert!( + fields[field].as_str().expect("string log field").len() <= 96, + "{field} must stay bounded" + ); + } + assert!(!captured.contains(RAW_KID_MARKER)); + assert!(!captured.contains(&attacker_kid)); + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "dependency_unavailable"); + assert_eq!(body["error"], "relay identity verification failed"); + assert!(serde_json::to_vec(&body).expect("serialize API body").len() <= 128); + assert!(!body.to_string().contains(RAW_KID_MARKER)); + } + #[tokio::test] async fn final_recheck_rejects_an_expired_key_generation() { let service = CorporateIdentityService::new(test_config()); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 1aa79aafeaa..e86da03d014 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -12,12 +12,95 @@ use std::sync::Arc; use axum::extract::ws::Message as WsMessage; +use buzz_core::client_binding_bootstrap::ClientBindingScopeV1; +use buzz_db::authorization_admission::{ + AdmissionApplicationContext, AdmissionApplicationEffect, AdmissionApplicationOutcome, + AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, + AdmissionCommitOutcome, AdmissionCommitRequest, AdmissionObject, CanonicalAdmissionCommitter, +}; use tracing::{debug, info, warn}; +use crate::authorization_runtime::outbox::{DurableStatusContract, DurableStatusSink}; +use crate::authorization_runtime::{ + ConnectionStatusSession, CurrentStatusAuthorization, CurrentStatusEvidenceSource, + ProviderFreeRuntimeMode, StatusCadence, StatusSessionError, +}; use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; +struct BindingStatusAdmissionEffect { + object: AdmissionObject, + actor: nostr::PublicKey, + intent_digest: [u8; 32], +} + +impl BindingStatusAdmissionEffect { + fn new( + object: AdmissionObject, + actor: nostr::PublicKey, + event_id: [u8; 32], + challenge: &str, + ) -> Self { + Self { + object, + actor, + intent_digest: crate::protected_ingress::fingerprint( + b"buzz:nip-fi:binding-status-admission-intent:v1", + &[ + object.key(), + actor.as_bytes(), + &event_id, + challenge.as_bytes(), + ], + ), + } + } +} + +impl AdmissionApplicationEffect for BindingStatusAdmissionEffect { + fn intent_digest(&self) -> [u8; 32] { + self.intent_digest + } + + fn result_schema(&self) -> AdmissionApplicationResultSchema { + AdmissionApplicationResultSchema::binding_status() + } + + fn apply<'a, 'transaction>( + &'a mut self, + _transaction: &'a mut sqlx::Transaction<'transaction, sqlx::Postgres>, + context: &'a AdmissionApplicationContext<'a>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send + + 'a, + >, + > { + Box::pin(async move { + if context.object() != self.object + || context.authorization().actor_pubkey() != self.actor + || context.authorization().capability() != buzz_auth::RouteCapability::BindingStatus + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let result = AdmissionApplicationResult::new(self.result_schema(), 1, Vec::new())?; + 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, + ], + ); + AdmissionApplicationOutcome::new(result, effect_digest) + }) + } +} + /// Extract a NIP-OA `auth` tag from a verified AUTH event and serialize it as /// the JSON-array string that [`buzz_sdk::nip_oa::verify_auth_tag`] expects. /// @@ -35,6 +118,196 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { serde_json::to_string(first.as_slice()).ok() } +fn status_activation_scope_matches( + verified_auth_event: &nostr::Event, + authorization: &CurrentStatusAuthorization, + tenant_domain: buzz_core::CommunityId, +) -> bool { + let (authorization_domain, authorization_author) = authorization.domain_author(); + authorization_domain == tenant_domain && authorization_author == verified_auth_event.pubkey +} + +/// Activate current-only status after the caller has completed canonical +/// scoped NIP-42 finalization and obtained a status authorization. +/// +/// Only the Enforce branch of [`handle_auth`] can call this function because it +/// receives the finalized `BindingStatus` lease and verifier-authenticated peer +/// from the canonical connection admission. The connection manager cancels +/// and joins any prior AUTH scope before this function delivers the replacement +/// 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( + verified_auth_event: &nostr::Event, + authorization: CurrentStatusAuthorization, + status_lease: buzz_auth::BoundedAuthorizationLease, + evidence: Arc, + conn: Arc, + state: Arc, +) -> Result, StatusSessionError> +where + E: CurrentStatusEvidenceSource + ?Sized + 'static, +{ + if state.authorization_runtime.mode() != ProviderFreeRuntimeMode::Enforce + || !state.authorization_runtime.is_ready() + { + conn.cancel.cancel(); + return Err(StatusSessionError::EvidenceUnavailable); + } + if !status_activation_scope_matches( + verified_auth_event, + &authorization, + conn.tenant.community(), + ) { + conn.cancel.cancel(); + return Err(StatusSessionError::EvidenceChanged); + } + let scope = + ClientBindingScopeV1::from_verified_auth_event(verified_auth_event).map_err(|_| { + 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 + .enforce() + .ok_or_else(|| { + conn.cancel.cancel(); + StatusSessionError::EvidenceUnavailable + })? + .client_status_admission(); + if crate::admission::check_client_status_presentation( + state.admission_rate_limiter.as_ref(), + &conn.tenant, + &verified_auth_event.pubkey, + &authenticated_peer, + admission_policy, + ) + .await + .is_err() + { + return Ok(None); + } + *conn.status_scope.write().await = Some(scope); + let activation_now = state + .db + .status_delivery_authoritative_now() + .await + .map_err(|_| StatusSessionError::EvidenceUnavailable)?; + let (sink, bootstrap) = DurableStatusSink::activate( + state.config.nip_fi_mode, + state.db.clone(), + Arc::clone(&evidence), + Arc::clone(&conn), + &status_lease, + &state.relay_keypair, + activation_now, + ) + .await + .map_err(|_| StatusSessionError::DeliveryFailed)?; + while sink + .recover_one() + .await + .map_err(|_| StatusSessionError::DeliveryFailed)? + {} + let contract = DurableStatusContract::new( + state.relay_keypair.clone(), + state.relay_keypair.public_key(), + ) + .inspect_err(|_| { + conn.cancel.cancel(); + })?; + let cancel = conn.cancel.child_token(); + let task_cancel = cancel.clone(); + let mut session = ConnectionStatusSession::new( + evidence, + contract, + sink, + StatusCadence::production(), + bootstrap, + ); + // The start gate makes task ownership structural: no 24244 work begins + // until the connection manager has installed this exact task. Replacement + // likewise joins the old task (and its withdrawal) before opening the gate. + let (start, started) = tokio::sync::oneshot::channel(); + let (initial_presentation, presented) = tokio::sync::oneshot::channel(); + let join = tokio::spawn(async move { + let gate_cancel = task_cancel.clone(); + tokio::select! { + _ = gate_cancel.cancelled() => {} + started = started => { + if started.is_ok() { + match session.present(&authorization).await { + Ok(_) => { + if initial_presentation.send(Ok(())).is_err() { + let _ = session.withdraw(chrono::Utc::now()).await; + return; + } + if let Err(error) = session + .run_until_cancelled(authorization, task_cancel) + .await + { + tracing::warn!(error = %error, "current-binding status session closed fail-closed"); + } + } + Err(error) => { + let _ = initial_presentation.send(Err(error)); + } + } + } + } + } + }); + if !conn + .replace_client_binding_status_task(cancel.clone(), join) + .await + { + conn.cancel.cancel(); + return Err(StatusSessionError::DeliveryFailed); + } + if start.send(()).is_err() { + let _ = conn.clear_client_binding_status_task().await; + conn.cancel.cancel(); + return Err(StatusSessionError::DeliveryFailed); + } + match presented.await { + Ok(Ok(())) if !cancel.is_cancelled() => Ok(Some(cancel)), + Ok(Err(error)) => { + let _ = conn.clear_client_binding_status_task().await; + conn.cancel.cancel(); + Err(error) + } + Ok(Ok(())) | Err(_) => { + let _ = conn.clear_client_binding_status_task().await; + conn.cancel.cancel(); + Err(StatusSessionError::DeliveryFailed) + } + } +} + /// Handle a NIP-42 AUTH message: verify the challenge response and transition /// the connection to authenticated state. /// @@ -42,6 +315,7 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { #[tracing::instrument(skip_all, fields(event_id, conn_id))] pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { let event_id_hex = event.id.to_hex(); + let canonical_event = event.clone(); let (challenge, conn_id) = { let auth = conn.auth_state.read().await; match &*auth { @@ -90,10 +364,56 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: { Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; + if state.config.nip_fi_mode == buzz_auth::NipFiMode::DenyProtected { + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: protected route unavailable", + )); + return; + } - // Community ban gate (NIP-42 seam). Runs immediately after auth - // verification succeeds and before the allowlist and relay-membership - // gates, per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the + let mut prepared_canonical = if state.config.nip_fi_mode + == buzz_auth::NipFiMode::Enforce + { + match prepare_canonical_websocket( + &state, + &conn, + &canonical_event, + &challenge, + &relay_url, + ) + .await + { + Ok(admission) => Some(admission), + Err(error) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?error, "canonical WebSocket admission denied"); + *conn.auth_state.write().await = AuthState::Failed; + let reason = match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + "restricted: authorization denied" + } + crate::protected_ingress::ProtectedIngressError::Expired => { + "nip_fi_auth_expired" + } + crate::protected_ingress::ProtectedIngressError::Unavailable => { + "error: authorization unavailable" + } + }; + conn.send(RelayMessage::ok(&event_id_hex, false, reason)); + return; + } + } + } else { + None + }; + + // Community ban gate (NIP-42 seam). In Enforce mode, canonical + // preflight has already finalized read authority and prepared the + // status mutation; the mutation is committed only after this gate. + // The ban check still precedes allowlist and relay-membership gates, + // per COMMUNITY_MODERATION_PLAN.md §0 decision 4 and the // MOD-7/M20 invariant (a ban must block connection auth even for open // channels — enforcement is structural, not filtered later). A banned // principal gets the standard protocol denial and the connection is @@ -183,26 +503,30 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } - let identity_proof = match crate::corporate_identity::verify_corporate_identity( - &state, - conn.tenant.community(), - pubkey, - conn.corporate_identity_jwt.as_deref(), - auth_tag_json.as_deref(), - ) - .await - { - Ok(proof) => proof, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + let identity_proof = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Off { + match crate::corporate_identity::verify_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + conn.corporate_identity_jwt.as_deref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => Some(proof), + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } } + } else { + None }; // Pubkey allowlist gate — only for pubkey-only auth. @@ -259,30 +583,78 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; - let identity_decision = match crate::corporate_identity::finalize_corporate_identity( - &state, - conn.tenant.community(), - pubkey, - identity_proof, - ) - .await - { - Ok(decision) => decision, - Err(e) => { - warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity finalization denied"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - &format!("restricted: {}", e.public_message()), - )); - return; + let mut canonical_status = None; + let identity_decision = match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Off => { + let Some(identity_proof) = identity_proof else { + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: authorization unavailable", + )); + return; + }; + match crate::corporate_identity::finalize_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => Some(decision), + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity finalization denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + } + } + buzz_auth::NipFiMode::Enforce => { + let Some(prepared) = prepared_canonical.take() else { + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "error: authorization unavailable", + )); + return; + }; + let admission = match commit_canonical_websocket(&state, prepared).await { + Ok(admission) => admission, + Err(error) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = ?error, "canonical WebSocket admission denied"); + *conn.auth_state.write().await = AuthState::Failed; + let reason = match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + "restricted: authorization denied" + } + crate::protected_ingress::ProtectedIngressError::Expired => { + "nip_fi_auth_expired" + } + crate::protected_ingress::ProtectedIngressError::Unavailable => { + "error: authorization unavailable" + } + }; + conn.send(RelayMessage::ok(&event_id_hex, false, reason)); + return; + } + }; + canonical_status = Some(admission); + None } + buzz_auth::NipFiMode::DenyProtected => None, }; - if let crate::corporate_identity::CorporateIdentityDecision::Delegated { + if let Some(crate::corporate_identity::CorporateIdentityDecision::Delegated { owner_pubkey, .. - } = &identity_decision + }) = &identity_decision { auth_ctx.agent_owner_pubkey = Some(*owner_pubkey); } @@ -324,18 +696,98 @@ 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, + )); + if let Some(admission) = canonical_status { + let CanonicalWebsocketAdmission { + status_authorization, + status_lease, + read_authorization, + write_authorization, + .. + } = 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(), + ), + ); + let evidence: Arc< + dyn crate::authorization_runtime::CurrentStatusEvidenceSource, + > = { + #[cfg(test)] + if let Some(override_source) = + state.client_status_evidence_override.read().await.clone() + { + override_source + } else { + Arc::new( + crate::authorization_runtime::LocalBindingStatusEvidenceSource::new( + resolver, + ), + ) + } + #[cfg(not(test))] + Arc::new( + crate::authorization_runtime::LocalBindingStatusEvidenceSource::new( + resolver, + ), + ) + }; + if let Err(error) = activate_client_binding_status( + &canonical_event, + status_authorization, + status_lease, + evidence, + Arc::clone(&conn), + Arc::clone(&state), + ) + .await + { + 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, + false, + "error: current-binding status unavailable", + ) + .into(), + )); + conn.cancel.cancel(); + return; + } + } + } info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); - *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state .conn_manager .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); - crate::corporate_identity::spawn_session_revalidation( - Arc::clone(&state), - conn.tenant.community(), - pubkey, - identity_decision, - conn.cancel.clone(), - ); + if let Some(identity_decision) = identity_decision { + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + conn.tenant.community(), + pubkey, + identity_decision, + conn.cancel.clone(), + ); + } conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { @@ -351,10 +803,221 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } +struct CanonicalWebsocketAdmission { + status_authorization: CurrentStatusAuthorization, + status_lease: buzz_auth::BoundedAuthorizationLease, + authenticated_peer: buzz_auth::AuthenticatedClientPeer, + read_authorization: buzz_auth::FinalizedAuthContext, + write_authorization: buzz_auth::FinalizedAuthContext, +} + +struct PreparedCanonicalWebsocketAdmission { + status_request: AdmissionCommitRequest, + authenticated_peer: buzz_auth::AuthenticatedClientPeer, + read_authorization: buzz_auth::FinalizedAuthContext, + write_authorization: buzz_auth::FinalizedAuthContext, +} + +async fn prepare_canonical_websocket( + state: &AppState, + conn: &ConnectionState, + event: &nostr::Event, + challenge: &str, + relay_url: &str, +) -> Result { + let domain = conn.tenant.community(); + let assertion_object = crate::protected_ingress::domain_object(domain)?; + let object = AdmissionObject::binding_status(domain, event.pubkey) + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let event_id = event.id.to_bytes(); + let evidence = conn + .canonical_transport_evidence + .lock() + .await + .take() + .ok_or(crate::protected_ingress::ProtectedIngressError::Unavailable)?; + if evidence.authorization_domain() != domain + || evidence.transport() != buzz_auth::ProofTransport::Nip42 + { + return Err(crate::protected_ingress::ProtectedIngressError::Denied); + } + let authenticated_peer = evidence + .authenticated_client_peer() + .copied() + .ok_or(crate::protected_ingress::ProtectedIngressError::Denied)?; + let request_fingerprint = *evidence.request_fingerprint(); + let transport_context_fingerprint = *evidence.transport_context_fingerprint(); + let assertion_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::WebSocketAuthenticate, + domain, + buzz_auth::RouteCapability::BindingStatus, + object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let status_assertion = crate::protected_ingress::verify_assertion( + state, + evidence.confidential_assertion(), + assertion_coordinates, + ) + .await?; + let session_assertion_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::WebSocketQuery, + domain, + buzz_auth::RouteCapability::MessagesRead, + assertion_object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let session_assertion = crate::protected_ingress::verify_assertion( + state, + evidence.confidential_assertion(), + session_assertion_coordinates, + ) + .await?; + let (_, status_assertion_expires_at) = status_assertion.time_bounds(); + let (_, session_assertion_expires_at) = session_assertion.time_bounds(); + let coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::WebSocketAuthenticate, + domain, + buzz_auth::RouteCapability::BindingStatus, + object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let proof = buzz_auth::verify_nip42_authorization_proof( + event, + challenge, + relay_url, + domain, + request_fingerprint, + *object.key(), + transport_context_fingerprint, + Some(*status_assertion.assertion_fingerprint()), + None, + status_assertion_expires_at.min(evidence.proxy_expires_at()), + ) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let request = + crate::protected_ingress::prepare_mutation(state, coordinates, status_assertion, proof) + .await?; + let request = request + .with_application_effect(Box::new(BindingStatusAdmissionEffect::new( + object, + event.pubkey, + event_id, + challenge, + ))) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let request = crate::api::media::ProtectedTransportInstaller::install(request, evidence) + .map_err(|_| crate::protected_ingress::ProtectedIngressError::Denied)?; + let read_coordinates = crate::protected_ingress::ProtectedRequestCoordinates::new( + crate::authorization_runtime::ProtectedIngress::WebSocketQuery, + domain, + buzz_auth::RouteCapability::MessagesRead, + assertion_object, + buzz_auth::ProofTransport::Nip42, + request_fingerprint, + transport_context_fingerprint, + )?; + let read_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 read_authorization = crate::protected_ingress::authorize_read( + state, + read_coordinates, + session_assertion.clone(), + read_proof, + ) + .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, + authenticated_peer, + read_authorization, + write_authorization, + }) +} + +async fn commit_canonical_websocket( + state: &AppState, + prepared: PreparedCanonicalWebsocketAdmission, +) -> Result { + let committer = crate::protected_ingress::mutation_committer(state)?; + let finalized = match committer.commit(prepared.status_request).await { + Ok(AdmissionCommitOutcome::Committed { authorization, .. }) => *authorization, + Ok(AdmissionCommitOutcome::ExactReplay { .. }) => { + return Err(crate::protected_ingress::ProtectedIngressError::Denied); + } + Err( + AdmissionCommitError::DependencyUnavailable + | AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable, + ) => 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, + }) +} + #[cfg(test)] mod tests { - use super::extract_auth_tag_json; + use super::{extract_auth_tag_json, status_activation_scope_matches}; + use crate::authorization_runtime::CurrentStatusAuthorization; + use buzz_core::{AuthorizationLeaseFence, CanonicalCurrentBindingEvidence, CommunityId}; + use chrono::Utc; use nostr::{EventBuilder, Keys, Kind, Tag}; + use uuid::Uuid; /// Build a signed NIP-98 (kind 27235) event carrying the given tags. The /// `auth` tag lives inside the signed event exactly as the git and @@ -404,4 +1067,50 @@ mod tests { ]); assert_eq!(extract_auth_tag_json(&event), None); } + + #[test] + fn status_activation_requires_exact_auth_author_and_tenant_domain() { + let author = Keys::generate(); + let domain = CommunityId::from_uuid(Uuid::from_u128(1)); + let now = Utc::now(); + let evidence = CanonicalCurrentBindingEvidence::new( + domain, + author.public_key(), + Uuid::from_u128(2), + 3, + 4, + 5, + 6, + AuthorizationLeaseFence::from_bytes([7; 32]).unwrap(), + now, + now + chrono::Duration::seconds(120), + ) + .unwrap(); + let authorization = CurrentStatusAuthorization::from_test_parts( + &evidence, + now + chrono::Duration::seconds(120), + ); + let auth_event = EventBuilder::new(Kind::Custom(22242), "") + .sign_with_keys(&author) + .unwrap(); + + assert!(status_activation_scope_matches( + &auth_event, + &authorization, + domain, + )); + assert!(!status_activation_scope_matches( + &auth_event, + &authorization, + CommunityId::from_uuid(Uuid::from_u128(9)), + )); + let other_author = EventBuilder::new(Kind::Custom(22242), "") + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(!status_activation_scope_matches( + &other_author, + &authorization, + domain, + )); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 903ccb12af0..9a8434799a3 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1392,8 +1392,9 @@ mod tests { let conn = Arc::new(crate::connection::ConnectionState { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), - remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), corporate_identity_jwt: None, + canonical_transport_evidence: tokio::sync::Mutex::new(None), + canonical_authorization: RwLock::new(None), auth_state: RwLock::new(crate::connection::AuthState::Authenticated( buzz_auth::AuthContext { pubkey: agent.public_key(), @@ -1401,10 +1402,13 @@ mod tests { channel_ids: None, auth_method: buzz_auth::AuthMethod::Nip42, agent_owner_pubkey: None, - }, + } + .into(), )), + status_scope: RwLock::new(None), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx, + status_writer: crate::connection::StatusWriter::new(mpsc::channel(1).0), ctrl_tx, cancel: CancellationToken::new(), backpressure_count: Arc::new(AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 76de9db45aa..761b5b6c0af 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -11,34 +11,39 @@ use uuid::Uuid; use buzz_auth::Scope; use buzz_core::kind::{ - event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + event_kind_u32, is_ephemeral, is_identity_archive_request_kind, is_parameterized_replaceable, + is_relay_admin_kind, is_replaceable, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, + KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, + KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, + KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; use buzz_core::CommunityId; +use buzz_db::authorization_admission::{ + AdmissionApplicationContext, AdmissionApplicationEffect, AdmissionApplicationOutcome, + AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, + AdmissionCommitOutcome, AdmissionCommitRequest, AdmissionObject, CanonicalAdmissionCommitter, +}; use nostr::Event; use crate::state::AppState; @@ -99,6 +104,25 @@ fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError Ok(()) } +pub(crate) fn canonical_bridge_kind_supported(kind: u32) -> bool { + !is_ephemeral(kind) + && !is_replaceable(kind) + && !is_parameterized_replaceable(kind) + && !buzz_core::kind::is_command_kind(kind) + && !is_relay_admin_kind(kind) + && !is_identity_archive_request_kind(kind) + && !buzz_core::kind::is_relay_only_kind(kind) + && !crate::handlers::side_effects::is_side_effect_kind(kind) + && !matches!( + kind, + KIND_PRODUCT_FEEDBACK + | KIND_REPORT + | KIND_REACTION + | KIND_NIP29_CREATE_GROUP + | super::push_lease::KIND_PUSH_LEASE + ) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -718,6 +742,7 @@ fn check_token_channel_access(auth: &IngestAuth, channel_id: Uuid) -> Result<(), } /// Owned thread metadata for the DB insert. +#[derive(Clone)] pub(crate) struct ThreadMetadataOwned { pub event_id: Vec, pub event_created_at: chrono::DateTime, @@ -746,6 +771,180 @@ impl ThreadMetadataOwned { } } +#[derive(serde::Serialize, serde::Deserialize)] +struct CanonicalBridgeEventResult { + event_id: String, + accepted: bool, + message: String, +} + +struct CanonicalBridgeEventEffect { + domain: CommunityId, + object: AdmissionObject, + event: Event, + channel_id: Option, + thread_metadata: Option, + intent_digest: [u8; 32], +} + +impl CanonicalBridgeEventEffect { + fn new( + domain: CommunityId, + object: AdmissionObject, + event: Event, + channel_id: Option, + thread_metadata: Option, + ) -> Self { + let channel = channel_id.map_or([0; 16], |value| *value.as_bytes()); + let thread_digest = thread_metadata.as_ref().map_or([0; 32], |metadata| { + crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-thread-metadata:v1", + &[ + &metadata.event_id, + metadata.channel_id.as_bytes(), + &metadata.parent_event_id, + &metadata.root_event_id, + &metadata.depth.to_be_bytes(), + &[u8::from(metadata.broadcast)], + ], + ) + }); + let intent_digest = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-event-application-intent:v1", + &[ + domain.as_uuid().as_bytes(), + object.key(), + event.id.as_bytes(), + &channel, + &thread_digest, + ], + ); + Self { + domain, + object, + event, + channel_id, + thread_metadata, + intent_digest, + } + } +} + +impl AdmissionApplicationEffect for CanonicalBridgeEventEffect { + fn intent_digest(&self) -> [u8; 32] { + self.intent_digest + } + + fn result_schema(&self) -> AdmissionApplicationResultSchema { + AdmissionApplicationResultSchema::bridge_event() + } + + fn apply<'a, 'transaction>( + &'a mut self, + transaction: &'a mut sqlx::Transaction<'transaction, sqlx::Postgres>, + context: &'a AdmissionApplicationContext<'a>, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + > + Send + + 'a, + >, + > { + Box::pin(async move { + if context.authorization_domain() != self.domain + || context.object() != self.object + || context.authorization().capability() != buzz_auth::RouteCapability::MessagesWrite + || context.authorization().actor_pubkey() != self.event.pubkey + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let thread = self + .thread_metadata + .as_ref() + .map(ThreadMetadataOwned::as_params); + let (_, inserted) = buzz_db::event::insert_event_with_thread_metadata_tx( + transaction, + self.domain, + &self.event, + self.channel_id, + thread, + ) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let payload = serde_json::to_vec(&CanonicalBridgeEventResult { + event_id: self.event.id.to_hex(), + accepted: true, + message: if inserted { + String::new() + } else { + "duplicate:".to_owned() + }, + }) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let result = AdmissionApplicationResult::new( + self.result_schema(), + if inserted { 1 } else { 2 }, + payload, + )?; + let effect_digest = crate::protected_ingress::fingerprint( + b"buzz:nip-fi:bridge-event-application-effect:v1", + &[ + context.authorization_domain().as_uuid().as_bytes(), + context.operation_id().as_bytes(), + context.request_fingerprint(), + &self.intent_digest, + result.payload(), + ], + ); + AdmissionApplicationOutcome::new(result, effect_digest) + }) + } +} + +fn canonical_bridge_event_result( + result: AdmissionApplicationResult, + expected_event_id: &str, +) -> Result<(IngestResult, bool), IngestError> { + if result.schema() != AdmissionApplicationResultSchema::bridge_event() + || !matches!(result.code(), 1 | 2) + { + return Err(IngestError::Internal( + "error: canonical event result schema mismatch".to_owned(), + )); + } + let decoded: CanonicalBridgeEventResult = serde_json::from_slice(result.payload()) + .map_err(|_| IngestError::Internal("error: canonical event result malformed".to_owned()))?; + if decoded.event_id != expected_event_id + || !decoded.accepted + || (result.code() == 1 && !decoded.message.is_empty()) + || (result.code() == 2 && decoded.message != "duplicate:") + { + return Err(IngestError::Internal( + "error: canonical event result binding mismatch".to_owned(), + )); + } + Ok(( + IngestResult { + event_id: decoded.event_id, + accepted: decoded.accepted, + message: decoded.message, + }, + result.code() == 1, + )) +} + +fn map_canonical_event_commit_error(error: AdmissionCommitError) -> IngestError { + match error { + AdmissionCommitError::DependencyUnavailable + | AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable => { + IngestError::Internal("error: canonical event authority unavailable".to_owned()) + } + _ => IngestError::AuthFailed("restricted: canonical event denied".to_owned()), + } +} + /// Resolve NIP-10 thread ancestry from e-tags. pub(crate) async fn resolve_nip10_thread_meta( community_id: CommunityId, @@ -1913,6 +2112,41 @@ pub async fn ingest_event( event: Event, auth: IngestAuth, ) -> Result { + ingest_event_owned(state, tenant, event, auth, None) + .await + .map(|(result, _)| result) +} + +/// Execute HTTP bridge ingestion with one prepared canonical mutation. +pub async fn ingest_event_with_canonical_admission( + state: &Arc, + tenant: &TenantContext, + event: Event, + auth: IngestAuth, + admission: AdmissionCommitRequest, +) -> Result<(IngestResult, CanonicalIngestDisposition), IngestError> { + ingest_event_owned(state, tenant, event, auth, Some(admission)).await +} + +/// Whether an ingest response came from legacy storage, a fresh canonical +/// co-commit, or the immutable typed result of an exact replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanonicalIngestDisposition { + /// Off-mode legacy behavior. + Legacy, + /// Fresh canonical application DML and receipt committed together. + Committed, + /// Existing typed result returned without repeating application DML. + ExactReplay, +} + +async fn ingest_event_owned( + state: &Arc, + tenant: &TenantContext, + event: Event, + auth: IngestAuth, + canonical_admission: Option, +) -> Result<(IngestResult, CanonicalIngestDisposition), IngestError> { // Captured before `event` moves into the inner fn: the stored-events // counter below is emitted at this shared seam so WebSocket and HTTP // transports are counted identically. @@ -1928,7 +2162,23 @@ pub async fn ingest_event( "ingest_event_exited_without_trace", ); - let result = ingest_event_inner(state, &tracer, tenant, event, auth).await; + let canonical = canonical_admission.is_some(); + let mut disposition = (!canonical).then_some(CanonicalIngestDisposition::Legacy); + let mut result = ingest_event_inner( + state, + &tracer, + tenant, + event, + auth, + canonical_admission, + &mut disposition, + ) + .await; + if result.is_ok() && disposition.is_none() { + result = Err(IngestError::Internal( + "error: canonical event admission did not reach its transaction owner".to_owned(), + )); + } // Fleet-wide stored counter: kind + author_type only, no community tag // (see the cardinality rationale on buzz_events_received_total — @@ -1964,7 +2214,10 @@ pub async fn ingest_event( // path that forgets to emit), Drop records an ImplBug step on // the underlying tracer — the checker treats that as // CoverageBreach. - result + result.map(|result| match disposition { + Some(disposition) => (result, disposition), + None => (result, CanonicalIngestDisposition::Legacy), + }) } async fn ingest_event_inner( @@ -1973,11 +2226,22 @@ async fn ingest_event_inner( tenant: &TenantContext, event: Event, auth: IngestAuth, + canonical_admission: Option, + canonical_disposition: &mut Option, ) -> Result { let event_id_hex = event.id.to_hex(); let kind_u32 = event_kind_u32(&event); debug!(event_id = %event_id_hex, kind = kind_u32, "ingest_event"); + if canonical_admission.is_some() + && !canonical_bridge_kind_supported(kind_u32) + && !buzz_core::kind::is_moderation_command_kind(kind_u32) + { + return Err(IngestError::Rejected( + "restricted: event kind requires a dedicated canonical mutation owner".to_owned(), + )); + } + if kind_u32 == KIND_AUTH { return Err(IngestError::Rejected( "invalid: AUTH events cannot be submitted".into(), @@ -2120,9 +2384,26 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event, &auth) - .await - .map_err(IngestError::Rejected)?; + let disposition = super::moderation_commands::handle_moderation_command( + tenant, + state, + &event, + &auth, + canonical_admission, + ) + .await + .map_err(IngestError::Rejected)?; + *canonical_disposition = Some(match disposition { + super::moderation_commands::ModerationCommandDisposition::Legacy => { + CanonicalIngestDisposition::Legacy + } + super::moderation_commands::ModerationCommandDisposition::Committed => { + CanonicalIngestDisposition::Committed + } + super::moderation_commands::ModerationCommandDisposition::ExactReplay => { + CanonicalIngestDisposition::ExactReplay + } + }); return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2927,7 +3208,95 @@ async fn ingest_event_inner( }); } - let (stored_event, was_inserted) = if buzz_core::kind::is_replaceable(kind_u32) { + let (stored_event, was_inserted) = if let Some(request) = canonical_admission { + let object = request.object(); + if object + != AdmissionObject::event(event.id.to_bytes()).ok_or_else(|| { + IngestError::Rejected("invalid: event identifier is unavailable".to_owned()) + })? + { + return Err(IngestError::AuthFailed( + "restricted: canonical event target mismatch".to_owned(), + )); + } + let effect = CanonicalBridgeEventEffect::new( + tenant.community(), + object, + event.clone(), + channel_id, + thread_meta.clone(), + ); + let intent_digest = effect.intent_digest(); + let request = request + .with_application_effect(Box::new(effect)) + .map_err(map_canonical_event_commit_error)?; + let committer = + crate::protected_ingress::mutation_committer(state).map_err(|error| match error { + crate::protected_ingress::ProtectedIngressError::Denied => { + IngestError::AuthFailed("restricted: canonical event denied".to_owned()) + } + crate::protected_ingress::ProtectedIngressError::Expired => { + IngestError::AuthFailed("nip_fi_auth_expired".to_owned()) + } + crate::protected_ingress::ProtectedIngressError::Unavailable => { + IngestError::Internal("error: canonical event authority unavailable".to_owned()) + } + })?; + let outcome = committer + .commit(request) + .await + .map_err(map_canonical_event_commit_error)?; + let (result, inserted, exact_replay) = match outcome { + AdmissionCommitOutcome::Committed { + application_result: Some(result), + application_result_binding: Some(binding), + .. + } if binding.authorization_domain() == tenant.community() + && binding.object() == object + && binding.application_intent_digest() == &intent_digest => + { + let (result, inserted) = canonical_bridge_event_result(result, &event_id_hex)?; + (result, inserted, false) + } + AdmissionCommitOutcome::ExactReplay { + application_result: Some(result), + .. + } => { + let (result, inserted) = canonical_bridge_event_result(result, &event_id_hex)?; + (result, inserted, true) + } + _ => { + return Err(IngestError::Internal( + "error: canonical event result is unavailable".to_owned(), + )); + } + }; + if exact_replay { + *canonical_disposition = Some(CanonicalIngestDisposition::ExactReplay); + let claimed = claimed_community_from_event(&event); + let action = match channel_id { + Some(channel) => TraceAction::WriteDuplicate { + msg_id: msg_id_label(event.id.as_bytes()), + channel: channel_label(channel), + claimed_community: claimed, + }, + None => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed, + }, + }; + emit(tracer, action, state_for_request(tenant, auth.pubkey())); + return Ok(result); + } + *canonical_disposition = Some(CanonicalIngestDisposition::Committed); + if !inserted { + return Ok(result); + } + ( + buzz_core::StoredEvent::with_received_at(event.clone(), Utc::now(), channel_id, true), + true, + ) + } else if buzz_core::kind::is_replaceable(kind_u32) { // NIP-16 replaceable event — atomic replace with stale-write protection. // channel_id is None for global kinds (0, 1, 3) due to step 5b above. state @@ -3084,6 +3453,17 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn canonical_bridge_rejects_legacy_side_effect_owners_before_ingest() { + assert!(canonical_bridge_kind_supported(1)); + for kind in [9030, 9031, 9032, 9033, 9035, 9036] { + assert!( + !canonical_bridge_kind_supported(kind), + "kind {kind} must not reach legacy relay-admin/archive DML" + ); + } + } + #[test] fn reaction_validation_accepts_wrapped_max_shortcode() { let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 461a318b684..d45aa6f25d5 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -46,7 +46,30 @@ pub async fn handle_moderation_command( state: &Arc, event: &Event, auth: &IngestAuth, -) -> Result<(), String> { + canonical_admission: Option, +) -> Result { + if let Some(request) = canonical_admission { + if state.config.nip_fi_mode != buzz_auth::NipFiMode::Enforce { + return Err(moderation_denied()); + } + let effect = prepare_moderation_application_effect(tenant, event)?; + let request = + install_moderation_application_effect(request, effect).map_err(admission_error)?; + let committer = crate::protected_ingress::mutation_committer(state) + .map_err(|_| moderation_unavailable())?; + let outcome = committer.commit(request).await.map_err(admission_error)?; + let disposition = if matches!(&outcome, AdmissionCommitOutcome::ExactReplay { .. }) { + ModerationCommandDisposition::ExactReplay + } else { + ModerationCommandDisposition::Committed + }; + dispatch_committed_moderation_outcome(tenant, outcome, |action| async move { + dispatch_moderation_postcommit(tenant, state, action).await; + }) + .await + .map_err(admission_error)?; + return Ok(disposition); + } execute_moderation_command( tenant, &state.db, @@ -58,7 +81,18 @@ pub async fn handle_moderation_command( dispatch_moderation_postcommit(tenant, state, action).await; }, ) - .await + .await?; + Ok(ModerationCommandDisposition::Legacy) +} + +/// Whether this command used legacy ownership, a fresh co-commit, or replay. +pub enum ModerationCommandDisposition { + /// Off-mode command owned by the established moderation transaction. + Legacy, + /// Fresh provider-verified canonical mutation and application co-commit. + Committed, + /// Existing typed canonical result returned without repeating the effect. + ExactReplay, } async fn execute_moderation_command( @@ -1519,6 +1553,100 @@ mod tests { .expect("commit authority fixture"); } + async fn install_binding_status_authority( + pool: &PgPool, + domain: CommunityId, + actor: nostr::PublicKey, + ) -> AdmissionObject { + let object = AdmissionObject::binding_status(domain, actor).expect("status object"); + let (binding_id, binding_version): (Uuid, i64) = sqlx::query_as( + "SELECT binding_id,binding_version FROM identity_bindings \ + WHERE community_id=$1 AND event_author_pubkey=$2 AND binding_state=1", + ) + .bind(domain.as_uuid()) + .bind(actor.to_bytes().as_slice()) + .fetch_one(pool) + .await + .expect("read active status binding"); + let operation_id = Uuid::new_v4(); + let request_fingerprint = [61_u8; 32]; + let fence = [62_u8; 32]; + let actor_bytes = actor.to_bytes(); + let mut transaction = pool.begin().await.expect("begin status authority fixture"); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id,operation_id,request_fingerprint,operation_kind,actor_fingerprint, \ + outcome_code,result_digest) VALUES ($1,$2,$3,11,$4,1,$5)", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .bind([63_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert status receipt"); + sqlx::query( + "INSERT INTO authorization_events \ + (community_id,event_id,event_kind,outcome_code,reason_code,actor_kind, \ + actor_fingerprint,operation_id,request_fingerprint,correlation_id,attempt_id, \ + occurred_at,canonical_envelope,envelope_digest) \ + VALUES ($1,$2,10,1,1,1,$3,$4,$5,$6,$7,transaction_timestamp(),$8,$9)", + ) + .bind(domain.as_uuid()) + .bind(Uuid::new_v4()) + .bind(actor_bytes.as_slice()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(Uuid::new_v4()) + .bind(Uuid::new_v4()) + .bind([1_u8].as_slice()) + .bind([64_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert status audit event"); + sqlx::query( + "INSERT INTO authorization_authority_epochs \ + (community_id,object_kind,object_key,authority_epoch,fence,operation_id, \ + request_fingerprint) VALUES ($1,$2,$3,1,$4,$5,$6)", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .bind(fence.as_slice()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .execute(&mut *transaction) + .await + .expect("insert status epoch"); + sqlx::query( + "INSERT INTO protected_object_authority \ + (community_id,object_kind,object_key,capability,actor_pubkey,owner_pubkey, \ + binding_id,binding_version,policy_revision,invalidation_generation,authority_epoch, \ + fence,issued_at,expires_at,operation_id,request_fingerprint) \ + VALUES ($1,$2,$3,26,$4,NULL,$5,$6,1,0,1,$7, \ + transaction_timestamp()-interval '1 second', \ + transaction_timestamp()+interval '5 minutes',$8,$9)", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .bind(actor_bytes.as_slice()) + .bind(binding_id) + .bind(binding_version) + .bind(fence.as_slice()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .execute(&mut *transaction) + .await + .expect("insert status authority"); + transaction + .commit() + .await + .expect("commit status authority fixture"); + object + } + fn signed_ban(keys: &Keys, target: nostr::PublicKey) -> Event { let target_hex = target.to_hex(); EventBuilder::new(Kind::from(KIND_MODERATION_BAN as u16), "") @@ -2218,6 +2346,106 @@ mod tests { drop_disposable_moderation_database(&database_name, admin, pool).await; } + #[tokio::test] + #[ignore = "requires PostgreSQL with CREATEDB via BUZZ_TEST_DATABASE_URL"] + async fn live_postgres_status_evidence_reads_and_rechecks_committed_authority() { + use buzz_auth::{CurrentBindingStatusEvidenceRequest, LocalBindingResolver}; + + let base_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .expect("BUZZ_TEST_DATABASE_URL must name a PostgreSQL administrator database"); + let admin_url = base_url + .rsplit_once('/') + .map(|(prefix, _)| format!("{prefix}/postgres")) + .expect("test database URL has a database name"); + let admin = PgPool::connect(&admin_url) + .await + .expect("connect PostgreSQL admin database"); + let stale_databases: Vec = sqlx::query_scalar( + "SELECT datname FROM pg_database WHERE datname LIKE 'buzz_status_reachability_%'", + ) + .fetch_all(&admin) + .await + .expect("list stale disposable status databases"); + for stale_database in stale_databases { + let suffix = stale_database + .strip_prefix("buzz_status_reachability_") + .expect("query prefix is exact"); + if suffix.len() != 32 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + continue; + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{stale_database}\" WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop stale disposable status database"); + } + let database_name = format!("buzz_status_reachability_{}", Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE DATABASE \"{database_name}\"" + ))) + .execute(&admin) + .await + .expect("create disposable status database"); + let database_url = base_url + .rsplit_once('/') + .map(|(prefix, _)| format!("{prefix}/{database_name}")) + .expect("derive disposable status database URL"); + let pool = PgPool::connect(&database_url) + .await + .expect("connect disposable PostgreSQL"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let actor = Keys::generate().public_key(); + install_moderation_authority(&pool, domain, actor, true).await; + let object = install_binding_status_authority(&pool, domain, actor).await; + assert_eq!(object.kind(), AdmissionObjectKind::BindingStatus); + + let resolver = buzz_db::authorization_resolver::PostgresLocalBindingResolver::new( + buzz_db::Db::from_pool(pool.clone()), + ); + let request = CurrentBindingStatusEvidenceRequest::new(domain, actor) + .expect("status evidence request"); + let evidence = resolver + .current_status_evidence(&request) + .await + .expect("read current status evidence"); + assert_eq!(evidence.authorization_domain(), domain); + assert_eq!(evidence.event_author_pubkey(), actor); + assert_eq!(evidence.authority_epoch(), 1); + let (rechecked, authoritative_now) = resolver + .recheck_current_status_evidence(&evidence) + .await + .expect("recheck current status evidence"); + assert_eq!(rechecked, evidence); + assert!(authoritative_now >= evidence.observed_at()); + + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ + VALUES ($1,2,2,$2,transaction_timestamp())", + ) + .bind(domain.as_uuid()) + .bind([65_u8; 32].as_slice()) + .execute(&pool) + .await + .expect("advance status policy"); + assert!(resolver + .recheck_current_status_evidence(&evidence) + .await + .is_err()); + drop(resolver); + pool.close().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE \"{database_name}\" WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop exact disposable status database"); + } + #[test] fn moderation_committed_dispatch_rejects_authority_reused_for_another_receipt() { let tenant_context = tenant(10); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index cfe4f860d1a..d0467a5db1a 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -3,11 +3,14 @@ //! NIP-01 WebSocket relay for Buzz private team communication. mod admission; +mod protected_ingress; /// REST API route handlers. pub mod api; /// WebSocket audio relay for huddle voice channels. pub mod audio; +/// Provider-free authorization runtime and readiness composition. +pub mod authorization_runtime; /// Relay configuration from environment variables. pub mod config; /// Runtime conformance harness — abstract trace emission at the diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 34dc2dfcf80..86ea2d41d1f 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -17,6 +17,7 @@ use buzz_db::{Db, DbConfig}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; +use buzz_relay::authorization_runtime::{InstalledAuthorizationRuntime, ProviderFreeRuntimeMode}; use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; @@ -153,16 +154,6 @@ async fn main() -> anyhow::Result<()> { "Config loaded" ); - let usage_interval_secs = usage_metrics_interval_secs(); - let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); - relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); - metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); - info!( - port = config.metrics_port, - idle_timeout_secs = usage_idle_timeout_secs, - "Prometheus metrics exporter started" - ); - let db_config = DbConfig { database_url: config.database_url.clone(), read_database_url: config.read_database_url.clone(), @@ -177,10 +168,6 @@ async fn main() -> anyhow::Result<()> { })?; if db.has_read_pool() { info!("Postgres connected (writer + lazy read replica pool)"); - // Reader-down at boot must not crash or block the relay; this warn-only - // ping is the sole boot-time visibility that the replica is unreachable - // (the lazy pool with min_connections=0 dials nothing until first use). - db.spawn_read_pool_boot_ping(); } else { info!("Postgres connected"); } @@ -197,6 +184,35 @@ async fn main() -> anyhow::Result<()> { info!("Skipping database migrations because BUZZ_AUTO_MIGRATE is not enabled"); } + // Install every authority required by handler-owned canonical admission + // before application listeners or background subscribers can start. + let authorization_runtime = match config.nip_fi.mode() { + ProviderFreeRuntimeMode::Off | ProviderFreeRuntimeMode::DenyProtected => { + InstalledAuthorizationRuntime::disabled(&config.nip_fi).map_err(|error| { + anyhow::anyhow!( + "provider-free authorization startup failed: {}", + error.code() + ) + })? + } + ProviderFreeRuntimeMode::Enforce => { + InstalledAuthorizationRuntime::production(&config.nip_fi) + .await + .map_err(|error| { + anyhow::anyhow!( + "provider-free authorization startup failed: {}", + error.code() + ) + })? + } + }; + if db.has_read_pool() { + // This compatibility probe cannot start before migration and the + // provider-free startup gate. Enforce mode never reaches it until the + // non-substitutable role witness is supplied. + db.spawn_read_pool_boot_ping(); + } + if let Err(e) = db.ensure_future_partitions(3).await { error!("Failed to ensure partitions: {e}"); } @@ -374,24 +390,6 @@ async fn main() -> anyhow::Result<()> { ); info!("Redis pub/sub connected"); - // Spawn Redis pub/sub subscriber for multi-node fan-out. - // Events published by other relay instances are received here and - // fanned out to local WebSocket subscribers. - let pubsub_for_sub = Arc::clone(&pubsub); - tokio::spawn(async move { pubsub_for_sub.run_subscriber().await }); - - // Spawn Redis pub/sub subscriber for cross-pod cache-key invalidation. - // Membership / visibility changes on other pods are received here and the - // matching local moka caches are dropped (via the consumer loop below). - let pubsub_for_cache = Arc::clone(&pubsub); - tokio::spawn(async move { pubsub_for_cache.run_cache_invalidation_subscriber().await }); - - // Spawn Redis pub/sub subscriber for cross-pod connection-control commands. - // Bans recorded on other pods are received here and applied to any local - // sockets (via the consumer loop below), enforcing live disconnect fan-out. - let pubsub_for_conn_ctrl = Arc::clone(&pubsub); - tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); - let auth = AuthService::new(config.auth.clone()); // Postgres FTS: the searchable row IS the persisted event row (its @@ -447,7 +445,7 @@ async fn main() -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?; info!("Media storage connected"); - let (app_state, audit_shutdown) = AppState::new( + let (app_state, audit_shutdown) = AppState::new_with_authorization_runtime( config.clone(), db, redis_health_pool, @@ -458,9 +456,29 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&workflow_engine), relay_keypair, media_storage, + authorization_runtime, ); let state = Arc::new(app_state); + // One immutable runtime state is now installed and aggregate readiness is + // known before any listener or application background subscriber starts. + let usage_interval_secs = usage_metrics_interval_secs(); + let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); + relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); + metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); + info!( + port = config.metrics_port, + idle_timeout_secs = usage_idle_timeout_secs, + "Prometheus metrics exporter started" + ); + + let pubsub_for_sub = Arc::clone(&state.pubsub); + tokio::spawn(async move { pubsub_for_sub.run_subscriber().await }); + let pubsub_for_cache = Arc::clone(&state.pubsub); + tokio::spawn(async move { pubsub_for_cache.run_cache_invalidation_subscriber().await }); + let pubsub_for_conn_ctrl = Arc::clone(&state.pubsub); + tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the // kill switch is off — nothing is bound, published, or spawned, so the // relay behaves byte-identically to a build without the mesh. When diff --git a/crates/buzz-relay/src/protected_ingress.rs b/crates/buzz-relay/src/protected_ingress.rs new file mode 100644 index 00000000000..37a162123ce --- /dev/null +++ b/crates/buzz-relay/src/protected_ingress.rs @@ -0,0 +1,457 @@ +//! Shared provider-neutral protected-ingress composition. + +use axum::http::HeaderMap; +use buzz_auth::{ + AuthorizationFinalizer, FinalizedAuthContext, ProofTransport, RouteCapability, + VerifiedFederatedAssertion, VerifiedNostrProof, +}; +use buzz_core::CommunityId; +use buzz_db::authorization_admission::{ + AdmissionCommitError, AdmissionCommitRequest, AdmissionObject, CanonicalProtectedIntent, +}; +use uuid::Uuid; + +use crate::authorization_runtime::{ProtectedEffect, ProtectedIngress}; +use crate::state::{AppState, CanonicalAssertionError}; + +/// Stable fail-closed outcomes exposed to protected route adapters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProtectedIngressError { + /// Credential, binding, policy, or lifecycle evidence denied the request. + Denied, + /// The canonical assertion crossed its half-open expiry bound. + Expired, + /// A required authority dependency could not be observed. + Unavailable, +} + +impl ProtectedIngressError { + /// Stable credential-free code for protocol adapters. + pub(crate) const fn code(self) -> &'static str { + match self { + Self::Denied => "nip_fi_auth_denied", + Self::Expired => "nip_fi_auth_expired", + Self::Unavailable => "nip_fi_auth_unavailable", + } + } +} + +/// Exact server-derived coordinates shared by assertion, proof, and storage. +#[derive(Clone, Copy)] +pub(crate) struct ProtectedRequestCoordinates { + ingress: ProtectedIngress, + domain: CommunityId, + capability: RouteCapability, + object: AdmissionObject, + transport: ProofTransport, + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +} + +impl ProtectedRequestCoordinates { + /// Seal non-sentinel coordinates before any verifier or database lookup. + pub(crate) fn new( + ingress: ProtectedIngress, + domain: CommunityId, + capability: RouteCapability, + object: AdmissionObject, + transport: ProofTransport, + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> Result { + if domain.as_uuid().is_nil() + || request_fingerprint == [0; 32] + || transport_context_fingerprint == [0; 32] + { + return Err(ProtectedIngressError::Denied); + } + Ok(Self { + ingress, + domain, + capability, + object, + transport, + request_fingerprint, + transport_context_fingerprint, + }) + } +} + +impl std::fmt::Debug for ProtectedRequestCoordinates { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ProtectedRequestCoordinates([REDACTED])") + } +} + +/// Extract one bounded exact assertion without accepting ambiguous headers. +pub(crate) fn exact_assertion( + headers: &HeaderMap, + header_name: &str, +) -> Result { + let mut values = headers.get_all(header_name).iter(); + let value = values + .next() + .filter(|_| values.next().is_none()) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .and_then(|value| value.strip_prefix("Bearer ").or(Some(value))) + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.len() <= 64 * 1024 + && value.split('.').count() == 3 + && !value.contains(',') + }) + .ok_or(ProtectedIngressError::Denied)?; + Ok(value.to_owned()) +} + +/// Verify the assertion half of one exact canonical protected request. +pub(crate) async fn verify_assertion( + state: &AppState, + assertion_token: &str, + coordinates: ProtectedRequestCoordinates, +) -> Result { + resolve_route(state, coordinates, None)?; + let authority = state + .canonical_protected_authority() + .ok_or(ProtectedIngressError::Unavailable)?; + authority + .assertion_verifier() + .verify( + assertion_token, + coordinates.domain, + coordinates.transport, + *coordinates.object.key(), + coordinates.request_fingerprint, + coordinates.transport_context_fingerprint, + ) + .await + .map_err(map_assertion_error) +} + +const fn map_assertion_error(error: CanonicalAssertionError) -> ProtectedIngressError { + match error { + CanonicalAssertionError::Denied => ProtectedIngressError::Denied, + CanonicalAssertionError::Expired => ProtectedIngressError::Expired, + CanonicalAssertionError::Unavailable => ProtectedIngressError::Unavailable, + } +} + +/// Finalize one side-effect-free protected read through the shared authority. +pub(crate) async fn authorize_read( + state: &AppState, + coordinates: ProtectedRequestCoordinates, + assertion: VerifiedFederatedAssertion, + proof: VerifiedNostrProof, +) -> Result { + resolve_route(state, coordinates, Some(ProtectedEffect::Read))?; + let authority = state + .canonical_protected_authority() + .ok_or(ProtectedIngressError::Unavailable)?; + let prepared = state + .db + .prepare_canonical_protected_authorization( + assertion, + proof, + coordinates.capability, + coordinates.object, + CanonicalProtectedIntent::Read, + ) + .await + .map_err(map_admission_error)?; + let rechecker = state + .db + .canonical_protected_read_rechecker(coordinates.object, authority.final_rechecker()); + let witness = AuthorizationFinalizer::recheck(&prepared, &rechecker) + .await + .map_err(|_| ProtectedIngressError::Denied)?; + AuthorizationFinalizer::finalize(prepared, witness).map_err(|_| ProtectedIngressError::Denied) +} + +/// Prepare one exact protected mutation without publishing any route state. +pub(crate) async fn prepare_mutation( + state: &AppState, + coordinates: ProtectedRequestCoordinates, + assertion: VerifiedFederatedAssertion, + proof: VerifiedNostrProof, +) -> Result { + resolve_route(state, coordinates, Some(ProtectedEffect::Mutate))?; + let prepared = state + .db + .prepare_canonical_protected_authorization( + assertion, + proof, + coordinates.capability, + coordinates.object, + CanonicalProtectedIntent::Mutation, + ) + .await + .map_err(map_admission_error)?; + AdmissionCommitRequest::existing(Uuid::new_v4(), coordinates.object, prepared) + .map_err(map_admission_error) +} + +fn resolve_route( + state: &AppState, + coordinates: ProtectedRequestCoordinates, + expected_effect: Option, +) -> Result<(), ProtectedIngressError> { + let route = state + .authorization_runtime + .routes() + .map_err(|_| ProtectedIngressError::Unavailable)? + .resolve(coordinates.ingress, coordinates.transport) + .map_err(|_| ProtectedIngressError::Denied)?; + if route.capability() != coordinates.capability + || expected_effect.is_some_and(|effect| route.effect() != effect) + { + return Err(ProtectedIngressError::Denied); + } + Ok(()) +} + +/// Validate one capability-specific Enforce session grant before any quota, +/// read, or mutation owned by the WebSocket dispatcher. +pub(crate) fn session_authorizes( + state: &AppState, + ingress: ProtectedIngress, + authorization: &FinalizedAuthContext, + domain: CommunityId, + actor: nostr::PublicKey, +) -> bool { + let Ok(route) = state + .authorization_runtime + .routes() + .and_then(|routes| routes.resolve(ingress, ProofTransport::Nip42)) + else { + return false; + }; + authorization.authorization_domain() == domain + && authorization.actor_pubkey() == actor + && authorization.capability() == route.capability() + && authorization.transport() == route.transport() + && chrono::Utc::now() < authorization.lease().expires_at() +} + +/// Build the sole committer paired with shared protected mutation preparation. +pub(crate) fn mutation_committer( + state: &AppState, +) -> Result +{ + let authority = state + .canonical_protected_authority() + .ok_or(ProtectedIngressError::Unavailable)?; + Ok(state + .db + .canonical_protected_committer(authority.final_rechecker())) +} + +fn map_admission_error(error: AdmissionCommitError) -> ProtectedIngressError { + match error { + AdmissionCommitError::DependencyUnavailable + | AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable => ProtectedIngressError::Unavailable, + _ => ProtectedIngressError::Denied, + } +} + +/// Domain-separated SHA-256 helper for server-owned route coordinates. +pub(crate) fn fingerprint(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + + let mut digest = Sha256::new(); + digest.update((domain.len() as u64).to_be_bytes()); + digest.update(domain); + for field in fields { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + digest.finalize().into() +} + +/// Resolve the one domain-wide protected-object coordinate shared by every +/// transport adapter. Route names and wire encodings never create parallel +/// authority lineages for the same authorization domain. +pub(crate) fn domain_object(domain: CommunityId) -> Result { + let key = fingerprint( + b"buzz:nip-fi:domain-target:v1", + &[domain.as_uuid().as_bytes()], + ); + AdmissionObject::new( + buzz_db::authorization_admission::AdmissionObjectKind::Domain, + key, + ) + .ok_or(ProtectedIngressError::Denied) +} + +#[cfg(test)] +mod tests { + use super::{map_assertion_error, ProtectedIngressError}; + use crate::state::CanonicalAssertionError; + use buzz_auth::RouteCapability; + use buzz_db::authorization_admission::{ + protected_capability_matches, AdmissionObjectKind, CanonicalProtectedIntent, + }; + + #[test] + fn expired_assertion_preserves_stable_transport_code() { + let mapped = map_assertion_error(CanonicalAssertionError::Expired); + assert_eq!(mapped, ProtectedIngressError::Expired); + assert_eq!(mapped.code(), "nip_fi_auth_expired"); + } + + #[test] + fn canonical_protected_object_capability_matrix_is_exact() { + const ALL_CAPABILITIES: [RouteCapability; 29] = [ + RouteCapability::MessagesRead, + RouteCapability::MessagesWrite, + RouteCapability::ChannelsRead, + RouteCapability::ChannelsWrite, + RouteCapability::AdminChannels, + RouteCapability::UsersRead, + RouteCapability::UsersWrite, + RouteCapability::AdminUsers, + RouteCapability::JobsRead, + RouteCapability::JobsWrite, + RouteCapability::SubscriptionsRead, + RouteCapability::SubscriptionsWrite, + RouteCapability::FilesRead, + RouteCapability::FilesWrite, + RouteCapability::ReposRead, + RouteCapability::ReposWrite, + RouteCapability::GitRead, + RouteCapability::GitWrite, + RouteCapability::GitStream, + RouteCapability::MediaRead, + RouteCapability::MediaWrite, + RouteCapability::Moderation, + RouteCapability::AudioJoin, + RouteCapability::AudioMedia, + RouteCapability::Discovery, + RouteCapability::BindingStatus, + RouteCapability::Enrollment, + RouteCapability::InviteMint, + RouteCapability::InviteClaim, + ]; + let matrix: &[( + AdmissionObjectKind, + CanonicalProtectedIntent, + &[RouteCapability], + )] = &[ + ( + AdmissionObjectKind::Domain, + CanonicalProtectedIntent::Read, + &[ + RouteCapability::MessagesRead, + RouteCapability::MessagesWrite, + RouteCapability::Discovery, + ], + ), + ( + AdmissionObjectKind::Domain, + CanonicalProtectedIntent::Mutation, + &[], + ), + ( + AdmissionObjectKind::Channel, + CanonicalProtectedIntent::Read, + &[ + RouteCapability::ChannelsRead, + RouteCapability::ChannelsWrite, + RouteCapability::MessagesRead, + RouteCapability::MessagesWrite, + ], + ), + ( + AdmissionObjectKind::Channel, + CanonicalProtectedIntent::Mutation, + &[], + ), + ( + AdmissionObjectKind::Repository, + CanonicalProtectedIntent::Read, + &[ + RouteCapability::ReposRead, + RouteCapability::GitRead, + RouteCapability::GitStream, + ], + ), + ( + AdmissionObjectKind::Repository, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::ReposWrite, RouteCapability::GitWrite], + ), + ( + AdmissionObjectKind::Media, + CanonicalProtectedIntent::Read, + &[RouteCapability::MediaRead], + ), + ( + AdmissionObjectKind::Media, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::MediaWrite], + ), + ( + AdmissionObjectKind::ModerationTarget, + CanonicalProtectedIntent::Read, + &[RouteCapability::Moderation], + ), + ( + AdmissionObjectKind::ModerationTarget, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::Moderation], + ), + ( + AdmissionObjectKind::AudioSession, + CanonicalProtectedIntent::Read, + &[RouteCapability::AudioJoin, RouteCapability::AudioMedia], + ), + ( + AdmissionObjectKind::AudioSession, + CanonicalProtectedIntent::Mutation, + &[], + ), + ( + AdmissionObjectKind::Event, + CanonicalProtectedIntent::Read, + &[], + ), + ( + AdmissionObjectKind::Event, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::MessagesWrite], + ), + ( + AdmissionObjectKind::BindingStatus, + CanonicalProtectedIntent::Read, + &[], + ), + ( + AdmissionObjectKind::BindingStatus, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::BindingStatus], + ), + ( + AdmissionObjectKind::Invitation, + CanonicalProtectedIntent::Read, + &[], + ), + ( + AdmissionObjectKind::Invitation, + CanonicalProtectedIntent::Mutation, + &[RouteCapability::InviteMint], + ), + ]; + + for (kind, intent, allowed) in matrix { + for capability in ALL_CAPABILITIES { + assert_eq!( + protected_capability_matches(*kind, capability, *intent), + allowed.contains(&capability), + "unexpected matrix result for {kind:?}/{intent:?}/{capability:?}" + ); + } + } + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 77376044958..ac5a9f8ac08 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use axum::{ body::Body, - extract::{ConnectInfo, FromRequest, MatchedPath, State, WebSocketUpgrade}, + extract::{FromRequest, MatchedPath, State, WebSocketUpgrade}, http::{HeaderMap, Request, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Json}, @@ -23,16 +23,41 @@ use tower_http::trace::{HttpMakeClassifier, TraceLayer}; use crate::api; use crate::audio; +use crate::authorization_runtime::ProviderFreeRuntimeMode; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; use crate::state::AppState; +struct DatabaseTrustedProxyReplay<'a>(&'a buzz_db::Db); + +impl buzz_auth::TrustedProxyNonceReplayReader for DatabaseTrustedProxyReplay<'_> { + fn is_committed<'a>( + &'a self, + authorization_domain: buzz_core::CommunityId, + claim: &'a buzz_auth::TrustedProxyNonceClaim, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + self.0 + .trusted_proxy_nonce_is_committed(authorization_domain, claim) + .await + .map_err(|_| buzz_auth::TrustedProxyReplayReadError) + }) + } +} + /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// /// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom), /// git (smart HTTP), NIP-05, and health probes. pub fn build_router(state: Arc) -> Router { + route_policy::assert_startup_inventory(); let media_body_limit = state .config .media @@ -194,35 +219,82 @@ pub fn build_router(state: Arc) -> Router { // before its handler can perform reads or writes. .route_layer(middleware::from_fn_with_state( state.clone(), - enforce_corporate_identity_route_inventory, + enforce_nip_fi_route_inventory, )) + .layer(middleware::from_fn(attach_machine_error_code)) .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } -async fn enforce_corporate_identity_route_inventory( +async fn attach_machine_error_code(request: Request, next: Next) -> axum::response::Response { + let mut response = next.run(request).await; + if response.status().is_client_error() || response.status().is_server_error() { + let code = crate::api::ApiErrorCode::for_status(response.status()).as_str(); + response.headers_mut().insert( + axum::http::HeaderName::from_static("x-buzz-error-code"), + axum::http::HeaderValue::from_static(code), + ); + } + response +} + +async fn enforce_nip_fi_route_inventory( State(state): State>, request: Request, next: Next, ) -> axum::response::Response { - enforce_route_inventory_for_requirement(state.config.corporate_identity.require, request, next) - .await + enforce_route_inventory_for_runtime( + state.authorization_runtime.mode(), + state.authorization_runtime.is_ready(), + request, + next, + ) + .await } -async fn enforce_route_inventory_for_requirement( - corporate_identity_required: bool, +#[cfg(test)] +async fn enforce_route_inventory_for_mode( + mode: ProviderFreeRuntimeMode, request: Request, next: Next, ) -> axum::response::Response { - if !corporate_identity_required { + enforce_route_inventory_for_runtime(mode, true, request, next).await +} + +async fn enforce_route_inventory_for_runtime( + mode: ProviderFreeRuntimeMode, + runtime_ready: bool, + request: Request, + next: Next, +) -> axum::response::Response { + if mode == ProviderFreeRuntimeMode::Off { return next.run(request).await; } let matched_path = request.extensions().get::(); let policy = matched_path .and_then(|path| route_policy::classify_matched_route(request.method(), path.as_str())); - if policy.is_some() { - return next.run(request).await; + if let Some(policy) = policy { + if matches!( + policy, + route_policy::CorporateIdentityRoutePolicy::Exempt(_) + ) { + return next.run(request).await; + } + return match mode { + ProviderFreeRuntimeMode::Off => next.run(request).await, + ProviderFreeRuntimeMode::Enforce if runtime_ready => next.run(request).await, + ProviderFreeRuntimeMode::Enforce => ( + StatusCode::SERVICE_UNAVAILABLE, + "protected route unavailable: canonical authority is not ready", + ) + .into_response(), + ProviderFreeRuntimeMode::DenyProtected => ( + StatusCode::FORBIDDEN, + "protected route denied by emergency policy", + ) + .into_response(), + }; } if matched_path.is_some_and(|path| route_policy::is_known_matched_path(path.as_str())) { // Axum's method fallback also runs route layers. Return its semantic @@ -240,13 +312,14 @@ async fn enforce_route_inventory_for_requirement( tracing::error!( method = %request.method(), matched_path = matched_path.map(|path| path.as_str()).unwrap_or(""), - "rejecting route missing corporate identity policy classification" + "rejecting route missing provider-free authorization classification" ); - ( + crate::api::coded_api_error( StatusCode::SERVICE_UNAVAILABLE, - "route unavailable: identity policy is not configured", + crate::api::ApiErrorCode::DependencyUnavailable, + "route unavailable: authorization policy is not configured", ) - .into_response() + .into_response() } fn http_trace_layer() -> TraceLayer) -> tracing::Span> { @@ -254,7 +327,7 @@ fn http_trace_layer() -> TraceLayer) -> tr } fn make_http_span(request: &Request) -> tracing::Span { - let corporate_identity_policy = request + let authorization_policy = request .extensions() .get::() .and_then(|path| route_policy::classify_matched_route(request.method(), path.as_str())) @@ -265,7 +338,7 @@ fn make_http_span(request: &Request) -> tracing::Span { "http.request", otel.kind = "server", http.request.method = %request.method(), - buzz.corporate_identity.route_policy = corporate_identity_policy, + buzz.nip_fi.route_policy = authorization_policy, ) } @@ -315,12 +388,6 @@ async fn nip11_or_ws_handler( headers: HeaderMap, req: axum::extract::Request, ) -> impl IntoResponse { - let addr = req - .extensions() - .get::>() - .map(|ci| ci.0) - .unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 0))); - let accept = headers .get("accept") .and_then(|v| v.to_str().ok()) @@ -374,10 +441,70 @@ async fn nip11_or_ws_handler( .into_response(); } }; - let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( - &headers, - &state.config.corporate_identity, - ); + let corporate_identity_jwt = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce { + None + } else { + crate::corporate_identity::identity_jwt_from_headers( + &headers, + &state.config.corporate_identity, + ) + }; + + let canonical_transport_evidence = if state.config.nip_fi_mode == buzz_auth::NipFiMode::Enforce + { + let verifier = match state.authorization_runtime.trusted_proxy_verifier() { + Ok(verifier) => verifier, + Err(_) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + "relay: canonical transport authority is unavailable", + ) + .into_response(); + } + }; + let path_and_query = req + .uri() + .path_and_query() + .map_or("/", axum::http::uri::PathAndQuery::as_str); + let request = match buzz_auth::TrustedProxyRequest::from_server_request( + tenant.community(), + buzz_auth::ProofTransport::Nip42, + req.method().as_str(), + raw_host, + path_and_query, + &[], + ) { + Ok(request) => request, + Err(_) => return StatusCode::FORBIDDEN.into_response(), + }; + let mut fields = Vec::new(); + for name in headers.keys() { + for value in headers.get_all(name).iter() { + fields.push(buzz_auth::HttpHeaderField::new( + name.as_str(), + value.as_bytes(), + )); + } + } + match verifier + .verify( + &fields, + &request, + chrono::Utc::now(), + &DatabaseTrustedProxyReplay(&state.db), + ) + .await + { + Ok(evidence) if evidence.authenticated_client_peer().is_some() => Some(evidence), + Ok(_) => return StatusCode::FORBIDDEN.into_response(), + Err(buzz_auth::TrustedProxyError::ReplayUnavailable) => { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + Err(_) => return StatusCode::FORBIDDEN.into_response(), + } + } else { + None + }; let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { @@ -393,7 +520,13 @@ async fn nip11_or_ws_handler( } limit_relay_websocket(ws, max_frame_bytes) .on_upgrade(move |socket| { - handle_connection(socket, state, addr, tenant, corporate_identity_jwt) + handle_connection( + socket, + state, + tenant, + corporate_identity_jwt, + canonical_transport_evidence, + ) }) .into_response() } @@ -433,7 +566,7 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Readiness probe — checks shutdown flag, Postgres, and Redis connectivity. +/// Readiness probe — checks shutdown, authorization, Postgres, and Redis. async fn readiness_handler(State(state): State>) -> impl IntoResponse { use std::time::Duration; @@ -445,6 +578,17 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo .into_response(); } + if !state.authorization_runtime.is_ready() { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "status": "not_ready", + "authorization_runtime": "unavailable" + })), + ) + .into_response(); + } + let check = async { let (pg_ok, redis_ok) = tokio::join!(state.db.ping(), async { state.redis_pool.get().await.is_ok() @@ -474,6 +618,10 @@ async fn status_handler(State(state): State>) -> impl IntoResponse "service": "buzz-relay", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": uptime_secs, + "authorization_runtime": { + "mode": format!("{:?}", state.authorization_runtime.mode()), + "ready": state.authorization_runtime.is_ready(), + }, })) } @@ -533,26 +681,81 @@ mod tests { use super::*; - async fn require_route_inventory( + async fn enforce_provider_free_inventory( + request: Request, + next: Next, + ) -> axum::response::Response { + enforce_route_inventory_for_mode(ProviderFreeRuntimeMode::Enforce, request, next).await + } + + async fn off_provider_free_inventory( + request: Request, + next: Next, + ) -> axum::response::Response { + enforce_route_inventory_for_mode(ProviderFreeRuntimeMode::Off, request, next).await + } + + async fn deny_provider_free_inventory( request: Request, next: Next, ) -> axum::response::Response { - enforce_route_inventory_for_requirement(true, request, next).await + enforce_route_inventory_for_mode(ProviderFreeRuntimeMode::DenyProtected, request, next) + .await + } + + async fn unavailable_provider_free_inventory( + request: Request, + next: Next, + ) -> axum::response::Response { + enforce_route_inventory_for_runtime(ProviderFreeRuntimeMode::Enforce, false, request, next) + .await } #[tokio::test] - async fn route_inventory_preserves_405_and_rejects_new_unclassified_handlers() { + async fn healthy_enforce_reaches_handlers_and_preserves_public_exemptions() { let app = Router::new() .route("/events", post(|| async { StatusCode::OK })) + .route("/api/invites", post(|| async { StatusCode::OK })) + .route("/operator/communities", get(|| async { StatusCode::OK })) + .route("/api/join-policy", get(|| async { StatusCode::OK })) .route("/new-unclassified-route", get(|| async { StatusCode::OK })) - .route_layer(middleware::from_fn(require_route_inventory)); + .route_layer(middleware::from_fn(enforce_provider_free_inventory)); - let allowed = app + let event = app .clone() .oneshot(Request::post("/events").body(Body::empty()).unwrap()) .await .unwrap(); - assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!(event.status(), StatusCode::OK); + + let relay_invite = app + .clone() + .oneshot(Request::post("/api/invites").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(relay_invite.status(), StatusCode::OK); + + let operator = app + .clone() + .oneshot( + Request::get("/operator/communities") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(operator.status(), StatusCode::OK); + + let public_policy = app + .clone() + .oneshot( + Request::get("/api/join-policy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(public_policy.status(), StatusCode::OK); let unsupported = app .clone() @@ -576,6 +779,126 @@ mod tests { assert_eq!(unclassified.status(), StatusCode::SERVICE_UNAVAILABLE); } + #[tokio::test] + async fn off_mode_preserves_protected_and_unclassified_handler_reachability() { + let app = Router::new() + .route("/events", post(|| async { StatusCode::OK })) + .route("/new-unclassified-route", get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn(off_provider_free_inventory)); + let protected = app + .clone() + .oneshot(Request::post("/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(protected.status(), StatusCode::OK); + let unclassified = app + .oneshot( + Request::get("/new-unclassified-route") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unclassified.status(), StatusCode::OK); + } + + #[tokio::test] + async fn deny_protected_and_missing_provider_close_before_handlers() { + let deny = Router::new() + .route("/events", post(|| async { StatusCode::OK })) + .route("/api/join-policy", get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn(deny_provider_free_inventory)); + let protected = deny + .clone() + .oneshot(Request::post("/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(protected.status(), StatusCode::FORBIDDEN); + let exempt = deny + .oneshot( + Request::get("/api/join-policy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(exempt.status(), StatusCode::OK); + + let unavailable = Router::new() + .route("/events", post(|| async { StatusCode::OK })) + .route("/api/join-policy", get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn(unavailable_provider_free_inventory)); + let protected = unavailable + .clone() + .oneshot(Request::post("/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(protected.status(), StatusCode::SERVICE_UNAVAILABLE); + let exempt = unavailable + .oneshot( + Request::get("/api/join-policy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(exempt.status(), StatusCode::OK); + } + + #[tokio::test] + async fn every_http_error_status_carries_the_stable_machine_code() { + let app = Router::new() + .route( + "/status/{code}", + get( + |axum::extract::Path(code): axum::extract::Path| async move { + StatusCode::from_u16(code).expect("valid test status") + }, + ), + ) + .layer(middleware::from_fn(attach_machine_error_code)); + + for (status, expected) in [ + (StatusCode::BAD_REQUEST, "invalid_request"), + (StatusCode::UNAUTHORIZED, "authentication_required"), + (StatusCode::FORBIDDEN, "authorization_denied"), + (StatusCode::NOT_FOUND, "resource_not_found"), + (StatusCode::CONFLICT, "conflict"), + (StatusCode::TOO_MANY_REQUESTS, "rate_limited"), + (StatusCode::SERVICE_UNAVAILABLE, "dependency_unavailable"), + (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), + ] { + let response = app + .clone() + .oneshot( + Request::get(format!("/status/{}", status.as_u16())) + .body(Body::empty()) + .expect("status request"), + ) + .await + .expect("status response"); + assert_eq!(response.status(), status); + assert_eq!( + response + .headers() + .get("x-buzz-error-code") + .and_then(|value| value.to_str().ok()), + Some(expected), + "{status}" + ); + } + + let success = app + .oneshot( + Request::get("/status/200") + .body(Body::empty()) + .expect("success request"), + ) + .await + .expect("success response"); + assert!(success.headers().get("x-buzz-error-code").is_none()); + } + #[test] fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { assert!(is_invite_landing_path("/invite/payload.mac")); diff --git a/crates/buzz-relay/src/router/route_policy.rs b/crates/buzz-relay/src/router/route_policy.rs index 38859e10a95..ad3ba0200ff 100644 --- a/crates/buzz-relay/src/router/route_policy.rs +++ b/crates/buzz-relay/src/router/route_policy.rs @@ -10,7 +10,7 @@ use axum::http::Method; /// Why a route deliberately does not use tenant corporate-identity auth. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(super) enum CorporateIdentityExemption { /// Public relay metadata (NIP-05, NIP-11-adjacent information). PublicMetadata, @@ -31,14 +31,12 @@ pub(super) enum CorporateIdentityExemption { } /// Corporate-identity policy for a registered route. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(super) enum CorporateIdentityRoutePolicy { /// Authenticate and enforce corporate identity during this HTTP request. Required, /// Enforce when the upgraded WebSocket performs its protocol auth flow. RequiredAtSessionAuth, - /// Public only when protected media reads are disabled; otherwise required. - RequiredWhenMediaReadsProtected, /// Deliberately outside tenant corporate-identity authentication. Exempt(CorporateIdentityExemption), } @@ -49,7 +47,6 @@ impl CorporateIdentityRoutePolicy { match self { Self::Required => "required", Self::RequiredAtSessionAuth => "required_at_session_auth", - Self::RequiredWhenMediaReadsProtected => "required_when_media_reads_protected", Self::Exempt(CorporateIdentityExemption::PublicMetadata) => "exempt_public_metadata", Self::Exempt(CorporateIdentityExemption::HealthProbe) => "exempt_health_probe", Self::Exempt(CorporateIdentityExemption::JoinBootstrap) => "exempt_join_bootstrap", @@ -73,9 +70,6 @@ struct RoutePolicyRule { const REQUIRED: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::Required; const SESSION: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::RequiredAtSessionAuth; -const PROTECTED_MEDIA: CorporateIdentityRoutePolicy = - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected; - const fn exempt(exemption: CorporateIdentityExemption) -> CorporateIdentityRoutePolicy { CorporateIdentityRoutePolicy::Exempt(exemption) } @@ -259,12 +253,12 @@ const ROUTE_POLICY_RULES: &[RoutePolicyRule] = &[ RoutePolicyRule { method: "GET", matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, + policy: REQUIRED, }, RoutePolicyRule { method: "HEAD", matched_path: "/media/{sha256_ext}", - policy: PROTECTED_MEDIA, + policy: REQUIRED, }, // Git smart HTTP is tenant-authenticated on every request. RoutePolicyRule { @@ -359,6 +353,23 @@ pub(super) fn allowed_methods(matched_path: &str) -> Option { (!methods.is_empty()).then(|| methods.join(", ")) } +/// Validate the policy table before the application begins serving requests. +/// +/// Runtime middleware remains the authoritative backstop for an accidentally +/// unclassified route. This startup check rejects ambiguous inventories before +/// a listener is exposed, independent of legacy identity feature flags. +pub(super) fn assert_startup_inventory() { + let mut seen = std::collections::HashSet::new(); + for rule in ROUTE_POLICY_RULES { + assert!( + seen.insert((rule.method, rule.matched_path)), + "duplicate route policy for {} {}", + rule.method, + rule.matched_path + ); + } +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -406,7 +417,52 @@ mod tests { } #[test] - fn websocket_and_media_policies_capture_deferred_and_conditional_auth() { + fn protected_route_matrix_is_exact() { + let actual: HashSet<_> = ROUTE_POLICY_RULES + .iter() + .filter_map(|rule| { + matches!( + rule.policy, + CorporateIdentityRoutePolicy::Required + | CorporateIdentityRoutePolicy::RequiredAtSessionAuth + ) + .then_some((rule.method, rule.matched_path, rule.policy)) + }) + .collect(); + let expected: HashSet<_> = [ + ( + "GET", + "/", + CorporateIdentityRoutePolicy::RequiredAtSessionAuth, + ), + ( + "GET", + "/huddle/{channel_id}/audio", + CorporateIdentityRoutePolicy::RequiredAtSessionAuth, + ), + ("POST", "/events", REQUIRED), + ("POST", "/query", REQUIRED), + ("POST", "/count", REQUIRED), + ("POST", "/api/invites", REQUIRED), + ("POST", "/api/invites/claim", REQUIRED), + ("GET", "/moderation/reports", REQUIRED), + ("GET", "/moderation/audit", REQUIRED), + ("GET", "/moderation/restricted", REQUIRED), + ("PUT", "/upload", REQUIRED), + ("PUT", "/media/upload", REQUIRED), + ("GET", "/media/{sha256_ext}", REQUIRED), + ("HEAD", "/media/{sha256_ext}", REQUIRED), + ("GET", "/git/{owner}/{repo}/info/refs", REQUIRED), + ("POST", "/git/{owner}/{repo}/git-upload-pack", REQUIRED), + ("POST", "/git/{owner}/{repo}/git-receive-pack", REQUIRED), + ] + .into_iter() + .collect(); + assert_eq!(actual, expected); + } + + #[test] + fn websocket_and_media_policies_capture_deferred_and_request_auth() { assert_eq!( policy(Method::GET, "/"), CorporateIdentityRoutePolicy::RequiredAtSessionAuth @@ -418,7 +474,7 @@ mod tests { for method in [Method::GET, Method::HEAD] { assert_eq!( policy(method, "/media/{sha256_ext}"), - CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected + CorporateIdentityRoutePolicy::Required ); } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 1515ec76843..eab1b61cbe6 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -31,6 +31,7 @@ use buzz_workflow::WorkflowEngine; use deadpool_redis; use crate::audio::AudioRoomManager; +use crate::authorization_runtime::InstalledAuthorizationRuntime; use crate::config::Config; use crate::connection::{ConnectionSubscriptions, RestartClose}; use crate::corporate_identity::CorporateIdentityService; @@ -38,17 +39,19 @@ use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); -/// Provider-neutral failure returned by the invite assertion adapter. +/// Provider-neutral failure returned by a canonical assertion adapter. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum InviteAssertionError { +pub(crate) enum CanonicalAssertionError { /// The presented evidence did not satisfy the exact route coordinates. Denied, + /// The assertion was otherwise well-formed but its validity ended. + Expired, /// The configured verifier could not provide current authoritative state. Unavailable, } -/// Provider-neutral assertion verifier used by canonical invite admission. -pub(crate) trait InviteAssertionVerifier: Send + Sync { +/// Provider-neutral assertion verifier shared by every canonical ingress. +pub(crate) trait CanonicalAssertionVerifier: Send + Sync { /// Verify one assertion against exact server-derived request coordinates. #[allow(clippy::too_many_arguments)] fn verify<'a>( @@ -61,22 +64,23 @@ pub(crate) trait InviteAssertionVerifier: Send + Sync { transport_context_fingerprint: [u8; 32], ) -> std::pin::Pin< Box< - dyn Future> - + Send + dyn Future< + Output = Result, + > + Send + 'a, >, >; } /// Installed provider-neutral verifier and transaction-time rechecker. -pub(crate) struct CanonicalInviteAuthority { - assertion_verifier: Arc, +pub(crate) struct CanonicalProtectedAuthority { + assertion_verifier: Arc, final_rechecker: Arc, } -impl CanonicalInviteAuthority { +impl CanonicalProtectedAuthority { /// Borrow the verifier used before canonical preparation. - pub(crate) fn assertion_verifier(&self) -> &dyn InviteAssertionVerifier { + pub(crate) fn assertion_verifier(&self) -> &dyn CanonicalAssertionVerifier { self.assertion_verifier.as_ref() } @@ -88,6 +92,71 @@ impl CanonicalInviteAuthority { } } +impl CanonicalAssertionVerifier for crate::authorization_runtime::DynamicVerifier { + fn verify<'a>( + &'a self, + token: &'a str, + authorization_domain: CommunityId, + transport: buzz_auth::ProofTransport, + target_fingerprint: [u8; 32], + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> std::pin::Pin< + Box< + dyn Future< + Output = Result, + > + Send + + 'a, + >, + > { + Box::pin(async move { + crate::authorization_runtime::DynamicVerifier::verify( + self, + token, + authorization_domain, + transport, + target_fingerprint, + request_fingerprint, + transport_context_fingerprint, + chrono::Utc::now(), + ) + .await + .map_err(|error| match error { + crate::authorization_runtime::RuntimeAuthorizationError::AssertionDenied => { + CanonicalAssertionError::Denied + } + crate::authorization_runtime::RuntimeAuthorizationError::AssertionExpired => { + CanonicalAssertionError::Expired + } + _ => CanonicalAssertionError::Unavailable, + }) + }) + } +} + +impl buzz_db::authorization_admission::AdmissionVerifierRechecker + for crate::authorization_runtime::DynamicVerifier +{ + fn recheck<'a>( + &'a self, + expected: buzz_auth::VerifierPolicyStamp, + ) -> std::pin::Pin< + Box< + dyn Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + if self.accepts_stamp(expected, chrono::Utc::now()).await { + Ok(()) + } else { + Err(buzz_db::authorization_admission::AdmissionCommitError::AuthorizationDenied) + } + }) + } +} + /// Leaves headroom under the process-wide drain deadline for a stalled writer. const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); @@ -643,6 +712,15 @@ pub struct AppState { pub auth: Arc, /// Optional corporate identity verifier. pub corporate_identity: Option>, + /// One immutable provider-free authorization runtime installation. + pub authorization_runtime: Arc, + /// Test-only evidence seam for ordered AUTH failure regression coverage. + #[cfg(test)] + pub(crate) client_status_evidence_override: Arc< + tokio::sync::RwLock< + Option>, + >, + >, /// Full-text search service. pub search: Arc, /// Registry of active client subscriptions. @@ -773,18 +851,23 @@ pub struct AppState { } impl AppState { - /// Return the installed provider-neutral authority for invite admission. - pub(crate) fn canonical_invite_authority(&self) -> Option { - let service = self.corporate_identity.as_ref()?.clone(); - let assertion_verifier: Arc = service.clone(); + /// Return the installed provider-neutral authority for protected admission. + pub(crate) fn canonical_protected_authority(&self) -> Option { + let verifier = self.authorization_runtime.verifier().ok()?.clone(); + let assertion_verifier: Arc = verifier.clone(); let final_rechecker: Arc = - service; - Some(CanonicalInviteAuthority { + verifier; + Some(CanonicalProtectedAuthority { assertion_verifier, final_rechecker, }) } + /// Compatibility accessor for the canonical invite admission path. + pub(crate) fn canonical_invite_authority(&self) -> Option { + self.canonical_protected_authority() + } + /// Constructs `AppState` from its component services. /// /// Returns `(state, audit_shutdown)`. The caller should call @@ -802,51 +885,51 @@ impl AppState { workflow_engine: Arc, relay_keypair: nostr::Keys, media_storage: MediaStorage, + ) -> (Self, AuditShutdownHandle) { + let authorization_runtime = InstalledAuthorizationRuntime::fail_closed(&config.nip_fi); + Self::new_with_authorization_runtime( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + relay_keypair, + media_storage, + authorization_runtime, + ) + } + + /// Constructs `AppState` with a complete immutable authorization runtime. + /// Production uses this entry point only after ordered startup succeeds. + #[allow(clippy::too_many_arguments)] + pub fn new_with_authorization_runtime( + config: Config, + db: Db, + redis_pool: deadpool_redis::Pool, + audit: impl Into>, + pubsub: Arc, + auth: AuthService, + search: SearchService, + workflow_engine: Arc, + relay_keypair: nostr::Keys, + media_storage: MediaStorage, + authorization_runtime: InstalledAuthorizationRuntime, ) -> (Self, AuditShutdownHandle) { let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; let search_arc = Arc::new(search); - let corporate_identity = - crate::corporate_identity::service_from_config(&config.corporate_identity); + // The retained legacy shape serves held adapters only. Production + // cannot construct its verifier or mutate its removed identity tables. + let corporate_identity = None; let audit_arc = audit.into().map(Arc::new); let (audit_tx, mut audit_rx) = mpsc::channel::(1000); let audit_for_worker = audit_arc.clone(); let audit_cancel = CancellationToken::new(); let audit_cancel_worker = audit_cancel.clone(); - let audit_worker_handle = tokio::spawn(async move { - let Some(audit_for_worker) = audit_for_worker else { - audit_cancel_worker.cancelled().await; - return; - }; - // Normal operation: process entries as they arrive. - loop { - tokio::select! { - entry = audit_rx.recv() => { - match entry { - Some(entry) => log_audit_entry(&audit_for_worker, entry).await, - None => break, // channel closed - } - } - _ = audit_cancel_worker.cancelled() => { - // Close the receiver: rejects future sends and lets us - // drain everything already buffered without a race. - audit_rx.close(); - break; - } - } - } - // Drain: recv() returns buffered entries, then None once empty. - let mut drained = 0u32; - while let Some(entry) = audit_rx.recv().await { - log_audit_entry(&audit_for_worker, entry).await; - drained += 1; - } - if drained > 0 { - tracing::info!(drained, "audit worker flushed remaining entries"); - } - tracing::warn!("audit log worker exited (expected on shutdown)"); - }); let git_max_concurrent_ops = config.git_max_concurrent_ops; let media_max_concurrent_uploads = config.media_max_concurrent_uploads; @@ -879,6 +962,9 @@ impl AppState { pubsub, auth: Arc::new(auth), corporate_identity, + authorization_runtime: Arc::new(authorization_runtime), + #[cfg(test)] + client_status_evidence_override: Arc::new(tokio::sync::RwLock::new(None)), search: search_arc, sub_registry: Arc::new(SubscriptionRegistry::new()), conn_manager: Arc::new(ConnectionManager::new()), @@ -959,6 +1045,37 @@ impl AppState { tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), }; + // State, including the immutable authorization runtime, is complete + // before the first background worker can observe or serve it. + let audit_worker_handle = tokio::spawn(async move { + let Some(audit_for_worker) = audit_for_worker else { + audit_cancel_worker.cancelled().await; + return; + }; + loop { + tokio::select! { + entry = audit_rx.recv() => { + match entry { + Some(entry) => log_audit_entry(&audit_for_worker, entry).await, + None => break, + } + } + _ = audit_cancel_worker.cancelled() => { + audit_rx.close(); + break; + } + } + } + let mut drained = 0u32; + while let Some(entry) = audit_rx.recv().await { + log_audit_entry(&audit_for_worker, entry).await; + drained += 1; + } + if drained > 0 { + tracing::info!(drained, "audit worker flushed remaining entries"); + } + tracing::warn!("audit log worker exited (expected on shutdown)"); + }); ( state, AuditShutdownHandle { @@ -1517,11 +1634,14 @@ mod tests { buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), "test.local".to_string(), ), - remote_addr: "127.0.0.1:1234".parse().unwrap(), corporate_identity_jwt: None, + canonical_transport_evidence: tokio::sync::Mutex::new(None), + canonical_authorization: tokio::sync::RwLock::new(None), auth_state: RwLock::new(AuthState::Failed), + status_scope: RwLock::new(None), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), + status_writer: crate::connection::StatusWriter::new(mpsc::channel(1).0), ctrl_tx, cancel: cancel.clone(), backpressure_count: Arc::clone(&bp), diff --git a/crates/buzz-relay/tests/nip_fi_runtime/mod.rs b/crates/buzz-relay/tests/nip_fi_runtime/mod.rs new file mode 100644 index 00000000000..26fb47c4573 --- /dev/null +++ b/crates/buzz-relay/tests/nip_fi_runtime/mod.rs @@ -0,0 +1,161 @@ +//! Cross-module invariants for the provider-free relay runtime. + +use crate::authorization_runtime::{ + InstalledAuthorizationRuntime, ProtectedEffect, ProtectedIngress, ProtectedResourceKind, + ProviderFreeRuntimeConfig, ProviderFreeRuntimeMode, RouteAuthority, RouteRule, +}; +use buzz_auth::{ProofTransport, RouteCapability}; + +const RUNTIME_SOURCES: &[(&str, &str)] = &[ + ( + "authority", + include_str!("../../src/authorization_runtime/authority.rs"), + ), + ( + "config", + include_str!("../../src/authorization_runtime/config.rs"), + ), + ( + "invalidation", + include_str!("../../src/authorization_runtime/invalidation.rs"), + ), + ( + "jwks", + include_str!("../../src/authorization_runtime/jwks.rs"), + ), + ( + "restore", + include_str!("../../src/authorization_runtime/restore.rs"), + ), + ( + "routes", + include_str!("../../src/authorization_runtime/routes.rs"), + ), + ( + "canonical_admission", + include_str!("../../src/authorization_runtime/canonical_admission.rs"), + ), + ( + "startup", + include_str!("../../src/authorization_runtime/startup.rs"), + ), + ( + "status", + include_str!("../../src/authorization_runtime/status.rs"), + ), +]; + +#[test] +fn current_status_keeps_bootstrap_scope_ephemeral_and_retired_ledger_absent() { + let bootstrap_kind = concat!("242", "45"); + let forbidden_runtime = [ + bootstrap_kind, + concat!("client_", "binding_epoch"), + concat!("client_status_", "revisions"), + concat!("status_", "delivery_history"), + concat!("status_", "receipt"), + ]; + for (name, source) in RUNTIME_SOURCES { + for needle in forbidden_runtime { + assert!( + !source.contains(needle), + "{name} contains forbidden current-status state: {needle}" + ); + } + } + + // A connection epoch is a sealed in-memory scope coordinate. It must not + // become a durable database field or restore the retired status ledger. + let storage_sources = [ + ( + "status delivery outbox migration", + include_str!("../../../../migrations/0038_client_status_delivery_outbox.sql"), + ), + ( + "connection scope migration", + include_str!("../../../../migrations/0039_client_status_connection_scope.sql"), + ), + ( + "desired schema", + include_str!("../../../../schema/schema.sql"), + ), + ]; + for (name, source) in storage_sources { + for needle in [ + concat!("connection_", "epoch"), + concat!("client_status_", "revisions"), + ] { + assert!( + !source.contains(needle), + "{name} contains forbidden durable current-status state: {needle}" + ); + } + } +} + +#[test] +fn protected_ingress_inventory_is_closed_and_nonempty() { + assert_eq!(ProtectedIngress::ALL.len(), 23); + let unique: std::collections::BTreeSet<_> = ProtectedIngress::ALL.into_iter().collect(); + assert_eq!(unique.len(), ProtectedIngress::ALL.len()); +} + +#[test] +fn sole_config_absence_and_emergency_denial_are_exact() { + assert_eq!( + ProviderFreeRuntimeConfig::from_optional_json(None) + .unwrap() + .mode(), + ProviderFreeRuntimeMode::Off + ); + assert_eq!( + ProviderFreeRuntimeConfig::from_optional_json(Some(r#"{"deny_protected":true}"#)) + .unwrap() + .mode(), + ProviderFreeRuntimeMode::DenyProtected + ); +} + +#[test] +fn relay_invite_is_typed_and_admission_loss_fails_closed() { + let rules = ProtectedIngress::ALL.into_iter().map(|ingress| { + RouteRule::protected( + ingress, + ingress.required_capability(), + ingress.required_resource(), + ingress.required_effect(), + ingress.required_transport(), + ) + }); + let routes = RouteAuthority::new(rules).unwrap(); + let invite = routes + .resolve( + ProtectedIngress::InviteClaim, + ProtectedIngress::InviteClaim.required_transport(), + ) + .unwrap(); + assert_eq!(invite.capability(), RouteCapability::InviteClaim); + assert_eq!(invite.resource(), ProtectedResourceKind::Invitation); + assert_eq!(invite.effect(), ProtectedEffect::Mutate); + assert_eq!(invite.transport(), ProofTransport::Nip98); + + let config = ProviderFreeRuntimeConfig::from_optional_json(Some( + r#"{ + "issuer":"https://issuer.example", + "audience":"buzz", + "maximum_token_lifetime_seconds":300, + "jwks":{"jwks_uri":"https://issuer.example/keys"}, + "lease":{"maximum_seconds":120}, + "policy_revision":1, + "audit":{"max_events_per_domain":100,"max_bytes_per_domain":65536,"max_envelope_bytes":4096}, + "client_status_admission":{"max_presentations_per_domain":100,"max_presentations_per_actor":5,"max_presentations_per_peer":20}, + "transport":{"kind":"sealed_nostr_proof"}, + "enrollment":{"kind":"canonical_admission"}, + "restore":{"kind":"operation_manifest"} + }"#, + )) + .unwrap(); + let held = InstalledAuthorizationRuntime::fail_closed(&config); + assert!(held.denies_protected()); + assert!(held.routes().is_err()); +} diff --git a/migrations/0030_nip_fi_authorization_foundation.sql b/migrations/0030_nip_fi_authorization_foundation.sql index b5677a29980..f1012a5b722 100644 --- a/migrations/0030_nip_fi_authorization_foundation.sql +++ b/migrations/0030_nip_fi_authorization_foundation.sql @@ -1,7 +1,7 @@ -- Provider-free NIP-FI authorization, audit, fencing, and restore foundation. -- -- There is no provider registry/SPI/profile/evidence table, durable lease or --- audio admission ledger, 30382 projection, delivery queue, exporter claim, +-- audio admission ledger, public identity projection, delivery queue, exporter claim, -- acknowledgement, retry scheduler, or online retention/compaction workflow. -- Durable one-way activation marker and current domain invalidation generation. diff --git a/migrations/0038_client_status_delivery_outbox.sql b/migrations/0038_client_status_delivery_outbox.sql new file mode 100644 index 00000000000..a2de79c6dc7 --- /dev/null +++ b/migrations/0038_client_status_delivery_outbox.sql @@ -0,0 +1,400 @@ +-- Crash-recoverable, privacy-bounded delivery for connection-local kind-24244 +-- status. One authoritative transition may acquire a new target job after a +-- reconnect, but every dead target receives its own terminal outcome. + +CREATE TABLE client_status_transition_heads ( + community_id UUID NOT NULL REFERENCES communities(id), + subject_fingerprint BYTEA NOT NULL CHECK (octet_length(subject_fingerprint) = 32), + signer_fingerprint BYTEA NOT NULL CHECK (octet_length(signer_fingerprint) = 32), + transition_id UUID NOT NULL, + status_revision BIGINT NOT NULL CHECK (status_revision > 0), + delivery_kind SMALLINT NOT NULL CHECK (delivery_kind IN (1, 2)), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + PRIMARY KEY (community_id, subject_fingerprint, signer_fingerprint), + UNIQUE (community_id, transition_id), + CHECK (transition_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +CREATE TABLE client_status_transitions ( + community_id UUID NOT NULL REFERENCES communities(id), + transition_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + delivery_kind SMALLINT NOT NULL CHECK (delivery_kind IN (1, 2)), + subject_fingerprint BYTEA NOT NULL CHECK (octet_length(subject_fingerprint) = 32), + signer_fingerprint BYTEA NOT NULL CHECK (octet_length(signer_fingerprint) = 32), + status_revision BIGINT NOT NULL CHECK (status_revision > 0), + supersedes_revision BIGINT CHECK (supersedes_revision > 0), + signed_payload BYTEA NOT NULL CHECK (octet_length(signed_payload) BETWEEN 1 AND 4096), + payload_digest BYTEA NOT NULL CHECK ( + octet_length(payload_digest) = 32 + AND payload_digest = digest(signed_payload, 'sha256') + ), + fresh_until TIMESTAMPTZ NOT NULL, + allocated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + signed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + fenced_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + retain_until TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() + INTERVAL '1 day', + PRIMARY KEY (community_id, transition_id), + UNIQUE (community_id, operation_id), + UNIQUE (community_id, subject_fingerprint, signer_fingerprint, status_revision), + UNIQUE (community_id, transition_id, subject_fingerprint, signer_fingerprint, + status_revision, delivery_kind), + CHECK (transition_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (request_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK (subject_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK (signer_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK ( + (delivery_kind = 1 AND supersedes_revision IS NULL) + OR (delivery_kind = 2 AND supersedes_revision IS NOT NULL + AND status_revision > supersedes_revision) + ), + CHECK (signed_at >= allocated_at AND fenced_at >= signed_at), + CHECK (fresh_until > fenced_at), + CHECK (retain_until > fresh_until AND retain_until <= fresh_until + INTERVAL '1 day') +); + +ALTER TABLE client_status_transition_heads + ADD CONSTRAINT client_status_transition_heads_transition + FOREIGN KEY (community_id, transition_id, subject_fingerprint, signer_fingerprint, + status_revision, delivery_kind) + REFERENCES client_status_transitions + (community_id, transition_id, subject_fingerprint, signer_fingerprint, + status_revision, delivery_kind) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE client_status_delivery_outbox ( + community_id UUID NOT NULL REFERENCES communities(id), + delivery_id UUID NOT NULL, + transition_id UUID NOT NULL, + connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ), + delivery_state SMALLINT NOT NULL DEFAULT 1 CHECK (delivery_state IN (1, 2, 3)), + attempt_count SMALLINT NOT NULL DEFAULT 0 CHECK (attempt_count BETWEEN 0 AND 16), + claim_id UUID, + completion_claim_id UUID, + claimed_until TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + last_failure_reason SMALLINT NOT NULL DEFAULT 0 CHECK (last_failure_reason BETWEEN 0 AND 7), + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + attempt_started_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + terminal_at TIMESTAMPTZ, + retain_until TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() + INTERVAL '1 day', + PRIMARY KEY (community_id, delivery_id), + UNIQUE (community_id, transition_id, connection_fingerprint), + FOREIGN KEY (community_id, transition_id) + REFERENCES client_status_transitions (community_id, transition_id), + CHECK (delivery_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ((claim_id IS NULL) = (claimed_until IS NULL)), + CHECK ((attempt_count = 0) = (attempt_started_at IS NULL)), + CHECK (retain_until > created_at AND retain_until <= created_at + INTERVAL '1 day'), + CHECK ( + (delivery_state = 1 AND delivered_at IS NULL AND terminal_at IS NULL + AND completion_claim_id IS NULL) + OR (delivery_state = 2 AND delivered_at IS NOT NULL AND terminal_at IS NULL + AND last_failure_reason = 0 AND claim_id IS NULL + AND completion_claim_id IS NOT NULL) + OR (delivery_state = 3 AND delivered_at IS NULL AND terminal_at IS NOT NULL + AND last_failure_reason BETWEEN 2 AND 7 AND claim_id IS NULL + AND completion_claim_id IS NOT NULL) + ) +); + +CREATE TABLE client_status_delivery_capacity ( + community_id UUID NOT NULL REFERENCES communities(id), + pending_count INTEGER NOT NULL DEFAULT 0 CHECK (pending_count BETWEEN 0 AND 1024), + total_count INTEGER NOT NULL DEFAULT 0 CHECK (total_count BETWEEN 0 AND 8192), + healthy BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason SMALLINT NOT NULL DEFAULT 0 CHECK (failure_reason BETWEEN 0 AND 3), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK ((healthy AND failure_reason = 0) OR (NOT healthy AND failure_reason BETWEEN 1 AND 3)), + CHECK (pending_count <= total_count), + PRIMARY KEY (community_id) +); + +CREATE INDEX client_status_delivery_outbox_ready + ON client_status_delivery_outbox + (community_id, connection_fingerprint, next_attempt_at, created_at, delivery_id) + WHERE delivery_state = 1; + +CREATE INDEX client_status_delivery_outbox_retention + ON client_status_delivery_outbox (community_id, retain_until, delivery_id) + WHERE delivery_state IN (2, 3); + +CREATE TABLE client_status_delivery_events ( + community_id UUID NOT NULL, + delivery_id UUID NOT NULL, + event_sequence SMALLINT NOT NULL CHECK (event_sequence BETWEEN 1 AND 64), + event_kind SMALLINT NOT NULL CHECK (event_kind BETWEEN 1 AND 8), + reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 0 AND 7), + attempt_count SMALLINT NOT NULL CHECK (attempt_count BETWEEN 0 AND 16), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, delivery_id, event_sequence), + FOREIGN KEY (community_id, delivery_id) + REFERENCES client_status_delivery_outbox (community_id, delivery_id) + ON DELETE CASCADE, + CHECK ((event_kind BETWEEN 1 AND 5 AND reason_code = 0) + OR (event_kind BETWEEN 6 AND 8 AND reason_code BETWEEN 1 AND 7)) +); + +CREATE FUNCTION client_status_delivery_capacity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE capacity client_status_delivery_capacity%ROWTYPE; +BEGIN + SELECT * INTO capacity FROM client_status_delivery_capacity + WHERE community_id = NEW.community_id FOR UPDATE; + IF NOT FOUND OR NOT capacity.healthy THEN + RAISE EXCEPTION 'client status delivery audit is unavailable' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + IF capacity.pending_count >= 1024 OR capacity.total_count >= 8192 THEN + RAISE EXCEPTION 'client status delivery capacity exhausted' + USING ERRCODE = 'program_limit_exceeded', + CONSTRAINT = 'client_status_delivery_capacity'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.next_attempt_at := transaction_timestamp(); + NEW.retain_until := transaction_timestamp() + INTERVAL '1 day'; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_delivery_capacity + BEFORE INSERT ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_guard_v1(); + +CREATE FUNCTION client_status_delivery_capacity_insert_v1() RETURNS TRIGGER AS $$ +BEGIN + UPDATE client_status_delivery_capacity SET + pending_count = pending_count + 1, + total_count = total_count + 1, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_insert + AFTER INSERT ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_insert_v1(); + +CREATE FUNCTION client_status_delivery_capacity_state_v1() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.delivery_state = 1 AND NEW.delivery_state IN (2, 3) THEN + UPDATE client_status_delivery_capacity SET + pending_count = pending_count - 1, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_state + AFTER UPDATE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_state_v1(); + +CREATE FUNCTION client_status_delivery_capacity_delete_v1() RETURNS TRIGGER AS $$ +BEGIN + UPDATE client_status_delivery_capacity SET + total_count = total_count - 1, + updated_at = transaction_timestamp() + WHERE community_id = OLD.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_delete + AFTER DELETE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_delete_v1(); + +CREATE FUNCTION client_status_transition_immutable_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'client status transition is immutable' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_immutable'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_no_update + BEFORE UPDATE ON client_status_transitions + FOR EACH ROW EXECUTE FUNCTION client_status_transition_immutable_v1(); +CREATE FUNCTION client_status_transition_head_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status_revision <> 1 THEN + RAISE EXCEPTION 'client status head must start at revision one' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + ELSIF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.subject_fingerprint IS DISTINCT FROM OLD.subject_fingerprint + OR NEW.signer_fingerprint IS DISTINCT FROM OLD.signer_fingerprint + OR NEW.status_revision <> OLD.status_revision + 1 + OR NEW.transition_id IS NOT DISTINCT FROM OLD.transition_id + THEN + RAISE EXCEPTION 'client status head transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_head_state + BEFORE INSERT OR UPDATE ON client_status_transition_heads + FOR EACH ROW EXECUTE FUNCTION client_status_transition_head_guard_v1(); +CREATE FUNCTION client_status_transition_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF transaction_timestamp() <= OLD.retain_until THEN + RAISE EXCEPTION 'client status transition is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_retention + BEFORE DELETE ON client_status_transitions + FOR EACH ROW EXECUTE FUNCTION client_status_transition_retain_v1(); + +CREATE FUNCTION client_status_delivery_state_guard_v1() RETURNS TRIGGER AS $$ +DECLARE claim_advance BOOLEAN; retry_advance BOOLEAN; delivered_advance BOOLEAN; terminal_advance BOOLEAN; +DECLARE expired_transition BOOLEAN; stale_transition BOOLEAN; +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.delivery_id IS DISTINCT FROM OLD.delivery_id + OR NEW.transition_id IS DISTINCT FROM OLD.transition_id + OR NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.retain_until IS DISTINCT FROM OLD.retain_until + OR OLD.delivery_state <> 1 + THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + SELECT EXISTS(SELECT 1 FROM client_status_transitions transition + WHERE transition.community_id=OLD.community_id + AND transition.transition_id=OLD.transition_id + AND transition.fresh_until <= transaction_timestamp()) + INTO expired_transition; + SELECT NOT EXISTS( + SELECT 1 FROM client_status_delivery_outbox delivery + JOIN client_status_transitions transition + ON transition.community_id=delivery.community_id + AND transition.transition_id=delivery.transition_id + JOIN client_status_transition_heads head + ON head.community_id=transition.community_id + AND head.subject_fingerprint=transition.subject_fingerprint + AND head.signer_fingerprint=transition.signer_fingerprint + AND head.transition_id=transition.transition_id + WHERE delivery.community_id=OLD.community_id + AND delivery.delivery_id=OLD.delivery_id) + INTO stale_transition; + claim_advance := NEW.delivery_state = 1 AND NEW.claim_id IS NOT NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + 1 + AND NOT stale_transition AND NOT expired_transition + AND OLD.next_attempt_at <= transaction_timestamp() AND OLD.attempt_count < 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp()) + AND NEW.claim_id IS DISTINCT FROM OLD.claim_id + AND NEW.claimed_until > transaction_timestamp() + AND NEW.claimed_until <= transaction_timestamp() + INTERVAL '5 minutes' + AND NEW.attempt_started_at IS NOT NULL + AND (NEW.last_failure_reason = OLD.last_failure_reason + OR (OLD.claim_id IS NOT NULL AND OLD.claimed_until <= transaction_timestamp() + AND NEW.last_failure_reason = 7)) + AND NEW.next_attempt_at = OLD.next_attempt_at; + retry_advance := NEW.delivery_state = 1 AND OLD.claim_id IS NOT NULL AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + AND NEW.attempt_started_at = OLD.attempt_started_at AND NEW.last_failure_reason = 1 + AND NEW.next_attempt_at > OLD.next_attempt_at; + delivered_advance := NEW.delivery_state = 2 AND OLD.claim_id IS NOT NULL + AND NEW.claim_id IS NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NOT NULL + AND NEW.terminal_at IS NULL AND NEW.last_failure_reason = 0; + terminal_advance := NEW.delivery_state = 3 AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NOT NULL + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NULL + AND NEW.terminal_at IS NOT NULL + AND ( + (OLD.claim_id IS NOT NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.last_failure_reason BETWEEN 2 AND 7) + OR (NEW.last_failure_reason = 3 AND stale_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 5 AND expired_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 6 AND OLD.attempt_count >= 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + ); + IF NOT (claim_advance OR retry_advance OR delivered_advance OR terminal_advance) THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_delivery_state + BEFORE UPDATE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_state_guard_v1(); + +CREATE FUNCTION client_status_delivery_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.delivery_state = 1 OR transaction_timestamp() <= OLD.retain_until THEN + RAISE EXCEPTION 'client status delivery is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_retention + BEFORE DELETE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_retain_v1(); + +CREATE FUNCTION client_status_delivery_event_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'client status delivery event is immutable' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_event_immutable'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_event_no_update + BEFORE UPDATE ON client_status_delivery_events + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE FUNCTION client_status_delivery_event_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM client_status_delivery_outbox delivery + WHERE delivery.community_id = OLD.community_id + AND delivery.delivery_id = OLD.delivery_id + AND delivery.delivery_state IN (2, 3) + AND transaction_timestamp() > delivery.retain_until + ) THEN + RAISE EXCEPTION 'client status delivery event is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_event_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_event_retention + BEFORE DELETE ON client_status_delivery_events + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_event_retain_v1(); +CREATE TRIGGER client_status_delivery_event_no_truncate + BEFORE TRUNCATE ON client_status_delivery_events + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE TRIGGER client_status_transition_no_truncate + BEFORE TRUNCATE ON client_status_transitions + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE TRIGGER client_status_delivery_no_truncate + BEFORE TRUNCATE ON client_status_delivery_outbox + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); diff --git a/migrations/0039_client_status_connection_scope.sql b/migrations/0039_client_status_connection_scope.sql new file mode 100644 index 00000000000..3c619369d39 --- /dev/null +++ b/migrations/0039_client_status_connection_scope.sql @@ -0,0 +1,255 @@ +-- Reconcile the crash-durable journal with the accepted S5 rule that status +-- revisions and withdrawals are scoped to one exact connection generation. +-- The relay producer did not exist before this migration, so any transition +-- row would be evidence of an unsupported writer and must stop rollout. + +LOCK TABLE client_status_transitions, client_status_transition_heads, + client_status_delivery_outbox IN ACCESS EXCLUSIVE MODE; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM client_status_transitions) THEN + RAISE EXCEPTION 'client status connection-scope migration requires an empty journal' + USING ERRCODE = 'object_not_in_prerequisite_state'; + END IF; +END; +$$; + +-- A status authorization holds FOR SHARE on the same community row until the +-- physical writer acknowledgement is durably completed. Every newer policy +-- revision therefore waits only for status flushes in its own tenant. +CREATE OR REPLACE FUNCTION client_status_policy_connection_fence_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM id FROM communities WHERE id=NEW.community_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status policy community is unavailable' + USING ERRCODE = 'foreign_key_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_policy_connection_fence + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION client_status_policy_connection_fence_v1(); + +ALTER TABLE client_status_transitions + ADD COLUMN connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ), + ADD COLUMN evidence_author_pubkey BYTEA CHECK ( + evidence_author_pubkey IS NULL OR octet_length(evidence_author_pubkey) = 32 + ), + ADD COLUMN evidence_binding_id UUID, + ADD COLUMN evidence_binding_version BIGINT CHECK ( + evidence_binding_version IS NULL OR evidence_binding_version > 0 + ), + ADD COLUMN evidence_policy_revision BIGINT CHECK ( + evidence_policy_revision IS NULL OR evidence_policy_revision > 0 + ), + ADD COLUMN evidence_invalidation_generation BIGINT CHECK ( + evidence_invalidation_generation IS NULL OR evidence_invalidation_generation >= 0 + ), + ADD COLUMN evidence_authority_epoch BIGINT CHECK ( + evidence_authority_epoch IS NULL OR evidence_authority_epoch > 0 + ), + ADD COLUMN evidence_fence BYTEA CHECK ( + evidence_fence IS NULL + OR (octet_length(evidence_fence) = 32 + AND evidence_fence <> decode(repeat('00', 32), 'hex')) + ), + ADD COLUMN evidence_observed_at TIMESTAMPTZ, + ADD CONSTRAINT client_status_transition_private_evidence CHECK ( + (delivery_kind = 1 + AND evidence_author_pubkey IS NOT NULL + AND evidence_binding_id IS NOT NULL + AND evidence_binding_id <> '00000000-0000-0000-0000-000000000000'::uuid + AND evidence_binding_version IS NOT NULL + AND evidence_policy_revision IS NOT NULL + AND evidence_invalidation_generation IS NOT NULL + AND evidence_authority_epoch IS NOT NULL + AND evidence_fence IS NOT NULL + AND evidence_observed_at IS NOT NULL + AND evidence_observed_at < fresh_until) + OR (delivery_kind = 2 + AND evidence_author_pubkey IS NULL + AND evidence_binding_id IS NULL + AND evidence_binding_version IS NULL + AND evidence_policy_revision IS NULL + AND evidence_invalidation_generation IS NULL + AND evidence_authority_epoch IS NULL + AND evidence_fence IS NULL + AND evidence_observed_at IS NULL) + ); + +ALTER TABLE client_status_transition_heads + ADD COLUMN connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ); + +ALTER TABLE client_status_transition_heads + DROP CONSTRAINT client_status_transition_heads_transition, + DROP CONSTRAINT client_status_transition_heads_pkey; + +DO $$ +DECLARE constraint_name TEXT; +BEGIN + SELECT c.conname INTO constraint_name + FROM pg_constraint c + WHERE c.conrelid = 'client_status_transition_heads'::regclass + AND c.contype = 'u' + AND pg_get_constraintdef(c.oid) + = 'UNIQUE (community_id, transition_id)'; + IF constraint_name IS NOT NULL THEN + EXECUTE format( + 'ALTER TABLE client_status_transition_heads DROP CONSTRAINT %I', + constraint_name + ); + END IF; + + SELECT c.conname INTO constraint_name + FROM pg_constraint c + WHERE c.conrelid = 'client_status_transitions'::regclass + AND c.contype = 'u' + AND pg_get_constraintdef(c.oid) + = 'UNIQUE (community_id, subject_fingerprint, signer_fingerprint, status_revision)'; + IF constraint_name IS NOT NULL THEN + EXECUTE format( + 'ALTER TABLE client_status_transitions DROP CONSTRAINT %I', + constraint_name + ); + END IF; + + SELECT c.conname INTO constraint_name + FROM pg_constraint c + WHERE c.conrelid = 'client_status_transitions'::regclass + AND c.contype = 'u' + AND pg_get_constraintdef(c.oid) + = 'UNIQUE (community_id, transition_id, subject_fingerprint, signer_fingerprint, status_revision, delivery_kind)'; + IF constraint_name IS NOT NULL THEN + EXECUTE format( + 'ALTER TABLE client_status_transitions DROP CONSTRAINT %I', + constraint_name + ); + END IF; +END; +$$; + +ALTER TABLE client_status_transitions + ADD CONSTRAINT client_status_transition_connection_revision + UNIQUE (community_id, connection_fingerprint, status_revision), + ADD CONSTRAINT client_status_transition_connection_identity + UNIQUE (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind); + +ALTER TABLE client_status_transition_heads + ADD PRIMARY KEY (community_id, connection_fingerprint), + ADD UNIQUE (community_id, transition_id, connection_fingerprint), + ADD CONSTRAINT client_status_transition_heads_transition + FOREIGN KEY (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind) + REFERENCES client_status_transitions + (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind) + DEFERRABLE INITIALLY DEFERRED; + +CREATE OR REPLACE FUNCTION client_status_transition_head_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status_revision <> 1 THEN + RAISE EXCEPTION 'client status head must start at revision one' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + ELSIF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint + OR NEW.subject_fingerprint IS DISTINCT FROM OLD.subject_fingerprint + OR NEW.signer_fingerprint IS DISTINCT FROM OLD.signer_fingerprint + OR NEW.status_revision <> OLD.status_revision + 1 + OR NEW.transition_id IS NOT DISTINCT FROM OLD.transition_id + THEN + RAISE EXCEPTION 'client status head transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION client_status_delivery_state_guard_v1() RETURNS TRIGGER AS $$ +DECLARE claim_advance BOOLEAN; retry_advance BOOLEAN; delivered_advance BOOLEAN; terminal_advance BOOLEAN; +DECLARE expired_transition BOOLEAN; stale_transition BOOLEAN; +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.delivery_id IS DISTINCT FROM OLD.delivery_id + OR NEW.transition_id IS DISTINCT FROM OLD.transition_id + OR NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.retain_until IS DISTINCT FROM OLD.retain_until + OR OLD.delivery_state <> 1 + THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + SELECT EXISTS(SELECT 1 FROM client_status_transitions transition + WHERE transition.community_id=OLD.community_id + AND transition.transition_id=OLD.transition_id + AND transition.fresh_until <= transaction_timestamp()) + INTO expired_transition; + SELECT NOT EXISTS( + SELECT 1 FROM client_status_delivery_outbox delivery + JOIN client_status_transitions transition + ON transition.community_id=delivery.community_id + AND transition.transition_id=delivery.transition_id + JOIN client_status_transition_heads head + ON head.community_id=transition.community_id + AND head.connection_fingerprint=delivery.connection_fingerprint + AND head.transition_id=transition.transition_id + WHERE delivery.community_id=OLD.community_id + AND delivery.delivery_id=OLD.delivery_id) + INTO stale_transition; + claim_advance := NEW.delivery_state = 1 AND NEW.claim_id IS NOT NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + 1 + AND NOT stale_transition AND NOT expired_transition + AND OLD.next_attempt_at <= transaction_timestamp() AND OLD.attempt_count < 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp()) + AND NEW.claim_id IS DISTINCT FROM OLD.claim_id + AND NEW.claimed_until > transaction_timestamp() + AND NEW.claimed_until <= transaction_timestamp() + INTERVAL '5 minutes' + AND NEW.attempt_started_at IS NOT NULL + AND (NEW.last_failure_reason = OLD.last_failure_reason + OR (OLD.claim_id IS NOT NULL AND OLD.claimed_until <= transaction_timestamp() + AND NEW.last_failure_reason = 7)) + AND NEW.next_attempt_at = OLD.next_attempt_at; + retry_advance := NEW.delivery_state = 1 AND OLD.claim_id IS NOT NULL AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + AND NEW.attempt_started_at = OLD.attempt_started_at AND NEW.last_failure_reason = 1 + AND NEW.next_attempt_at > OLD.next_attempt_at; + delivered_advance := NEW.delivery_state = 2 AND OLD.claim_id IS NOT NULL + AND NEW.claim_id IS NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NOT NULL + AND NEW.terminal_at IS NULL AND NEW.last_failure_reason = 0; + terminal_advance := NEW.delivery_state = 3 AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NOT NULL + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NULL + AND NEW.terminal_at IS NOT NULL + AND ( + (OLD.claim_id IS NOT NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.last_failure_reason BETWEEN 2 AND 7) + OR (NEW.last_failure_reason = 2 + AND (OLD.claim_id IS NULL OR NEW.completion_claim_id = OLD.claim_id)) + OR (NEW.last_failure_reason = 3 AND stale_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 5 AND expired_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 6 AND OLD.attempt_count >= 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + ); + IF NOT (claim_advance OR retry_advance OR delivered_advance OR terminal_advance) THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/0040_nip_fi_event_status_object_kinds.sql b/migrations/0040_nip_fi_event_status_object_kinds.sql new file mode 100644 index 00000000000..bdda4c0b568 --- /dev/null +++ b/migrations/0040_nip_fi_event_status_object_kinds.sql @@ -0,0 +1,25 @@ +-- Add the two application-owned canonical mutation namespaces. + +ALTER TABLE authorization_admission_results + DROP CONSTRAINT authorization_admission_results_object_kind_check; +ALTER TABLE authorization_admission_results + ADD CONSTRAINT authorization_admission_results_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)) NOT VALID; +ALTER TABLE authorization_admission_results + VALIDATE CONSTRAINT authorization_admission_results_object_kind_check; + +ALTER TABLE authorization_authority_epochs + DROP CONSTRAINT authorization_authority_epochs_object_kind_check; +ALTER TABLE authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epochs_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)) NOT VALID; +ALTER TABLE authorization_authority_epochs + VALIDATE CONSTRAINT authorization_authority_epochs_object_kind_check; + +ALTER TABLE protected_object_authority + DROP CONSTRAINT protected_object_authority_object_kind_check; +ALTER TABLE protected_object_authority + ADD CONSTRAINT protected_object_authority_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)) NOT VALID; +ALTER TABLE protected_object_authority + VALIDATE CONSTRAINT protected_object_authority_object_kind_check; diff --git a/schema/schema.sql b/schema/schema.sql index d0ba303febd..f3dfe1db181 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -5,6 +5,8 @@ -- Dumped from database version PostgreSQL 17.10 -- Dumped by pgschema version 1.7.4 +CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- -- Name: approval_status; Type: TYPE; Schema: -; Owner: - @@ -298,7 +300,7 @@ CREATE TABLE IF NOT EXISTS authorization_admission_results ( CONSTRAINT authorization_admission_results_application_type_check CHECK (application_type IS NULL OR octet_length(application_type) = 32 AND application_type <> decode(repeat('00'::text, 32), 'hex'::text)), CONSTRAINT authorization_admission_results_application_version_check CHECK (application_version > 0), CONSTRAINT authorization_admission_results_object_key_check CHECK (octet_length(object_key) = 32 AND object_key <> decode(repeat('00'::text, 32), 'hex'::text)), - CONSTRAINT authorization_admission_results_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), + CONSTRAINT authorization_admission_results_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)), CONSTRAINT authorization_admission_results_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32), CONSTRAINT authorization_admission_results_semantic_fingerprint_check CHECK (octet_length(semantic_fingerprint) = 32 AND semantic_fingerprint <> decode(repeat('00'::text, 32), 'hex'::text)) ); @@ -323,7 +325,7 @@ CREATE TABLE IF NOT EXISTS authorization_authority_epochs ( CONSTRAINT authorization_authority_epochs_authority_epoch_check CHECK (authority_epoch > 0), CONSTRAINT authorization_authority_epochs_fence_check CHECK (octet_length(fence) = 32 AND fence <> decode(repeat('00'::text, 32), 'hex'::text)), CONSTRAINT authorization_authority_epochs_object_key_check CHECK (octet_length(object_key) = 32), - CONSTRAINT authorization_authority_epochs_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), + CONSTRAINT authorization_authority_epochs_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)), CONSTRAINT authorization_authority_epochs_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32) ); @@ -2900,7 +2902,7 @@ CREATE TABLE IF NOT EXISTS protected_object_authority ( CONSTRAINT protected_object_authority_fence_check CHECK (octet_length(fence) = 32 AND fence <> decode(repeat('00'::text, 32), 'hex'::text)), CONSTRAINT protected_object_authority_invalidation_generation_check CHECK (invalidation_generation >= 0), CONSTRAINT protected_object_authority_object_key_check CHECK (octet_length(object_key) = 32), - CONSTRAINT protected_object_authority_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), + CONSTRAINT protected_object_authority_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 7, 8, 9)), CONSTRAINT protected_object_authority_owner_pubkey_check CHECK (owner_pubkey IS NULL OR octet_length(owner_pubkey) = 32), CONSTRAINT protected_object_authority_policy_revision_check CHECK (policy_revision > 0), CONSTRAINT protected_object_authority_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32) @@ -6057,3 +6059,478 @@ ALTER TABLE ONLY protected_object_authority ALTER TABLE ONLY push_leases ADD CONSTRAINT push_leases_check CHECK (((active AND (app_profile IS NOT NULL) AND (endpoint_hash IS NOT NULL) AND (endpoint_grant IS NOT NULL) AND (max_class IS NOT NULL) AND (subscriptions IS NOT NULL)) OR ((NOT active) AND (app_profile IS NULL) AND (endpoint_hash IS NULL) AND (endpoint_grant IS NULL) AND (max_class IS NULL) AND (subscriptions IS NULL)))); + +-- NIP-FI status delivery desired state (migration 0038). +-- Crash-recoverable, privacy-bounded delivery for connection-local kind-24244 +-- status. One authoritative transition may acquire a new target job after a +-- reconnect, but every dead target receives its own terminal outcome. + +CREATE FUNCTION client_status_policy_connection_fence_v1() RETURNS TRIGGER AS $$ +BEGIN + PERFORM id FROM communities WHERE id=NEW.community_id FOR UPDATE; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status policy community is unavailable' + USING ERRCODE = 'foreign_key_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_policy_connection_fence + BEFORE INSERT ON identity_enrollment_policies + FOR EACH ROW EXECUTE FUNCTION client_status_policy_connection_fence_v1(); + +CREATE TABLE client_status_transition_heads ( + community_id UUID NOT NULL REFERENCES communities(id), + subject_fingerprint BYTEA NOT NULL CHECK (octet_length(subject_fingerprint) = 32), + signer_fingerprint BYTEA NOT NULL CHECK (octet_length(signer_fingerprint) = 32), + transition_id UUID NOT NULL, + status_revision BIGINT NOT NULL CHECK (status_revision > 0), + delivery_kind SMALLINT NOT NULL CHECK (delivery_kind IN (1, 2)), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ), + PRIMARY KEY (community_id, connection_fingerprint), + UNIQUE (community_id, transition_id, connection_fingerprint), + CHECK (transition_id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +CREATE TABLE client_status_transitions ( + community_id UUID NOT NULL REFERENCES communities(id), + transition_id UUID NOT NULL, + operation_id UUID NOT NULL, + request_fingerprint BYTEA NOT NULL CHECK (octet_length(request_fingerprint) = 32), + delivery_kind SMALLINT NOT NULL CHECK (delivery_kind IN (1, 2)), + subject_fingerprint BYTEA NOT NULL CHECK (octet_length(subject_fingerprint) = 32), + signer_fingerprint BYTEA NOT NULL CHECK (octet_length(signer_fingerprint) = 32), + status_revision BIGINT NOT NULL CHECK (status_revision > 0), + supersedes_revision BIGINT CHECK (supersedes_revision > 0), + signed_payload BYTEA NOT NULL CHECK (octet_length(signed_payload) BETWEEN 1 AND 4096), + payload_digest BYTEA NOT NULL CHECK ( + octet_length(payload_digest) = 32 + AND payload_digest = digest(signed_payload, 'sha256') + ), + fresh_until TIMESTAMPTZ NOT NULL, + allocated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + signed_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + fenced_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + retain_until TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() + INTERVAL '1 day', + connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ), + evidence_author_pubkey BYTEA CHECK ( + evidence_author_pubkey IS NULL OR octet_length(evidence_author_pubkey) = 32 + ), + evidence_binding_id UUID, + evidence_binding_version BIGINT CHECK ( + evidence_binding_version IS NULL OR evidence_binding_version > 0 + ), + evidence_policy_revision BIGINT CHECK ( + evidence_policy_revision IS NULL OR evidence_policy_revision > 0 + ), + evidence_invalidation_generation BIGINT CHECK ( + evidence_invalidation_generation IS NULL OR evidence_invalidation_generation >= 0 + ), + evidence_authority_epoch BIGINT CHECK ( + evidence_authority_epoch IS NULL OR evidence_authority_epoch > 0 + ), + evidence_fence BYTEA CHECK ( + evidence_fence IS NULL + OR (octet_length(evidence_fence) = 32 + AND evidence_fence <> decode(repeat('00', 32), 'hex')) + ), + evidence_observed_at TIMESTAMPTZ, + PRIMARY KEY (community_id, transition_id), + UNIQUE (community_id, operation_id), + CONSTRAINT client_status_transition_connection_revision + UNIQUE (community_id, connection_fingerprint, status_revision), + CONSTRAINT client_status_transition_connection_identity + UNIQUE (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind), + CHECK (transition_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (operation_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK (request_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK (subject_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK (signer_fingerprint <> decode(repeat('00', 32), 'hex')), + CHECK ( + (delivery_kind = 1 AND supersedes_revision IS NULL) + OR (delivery_kind = 2 AND supersedes_revision IS NOT NULL + AND status_revision > supersedes_revision) + ), + CHECK (signed_at >= allocated_at AND fenced_at >= signed_at), + CHECK (fresh_until > fenced_at), + CHECK (retain_until > fresh_until AND retain_until <= fresh_until + INTERVAL '1 day') +); + +ALTER TABLE client_status_transitions + ADD CONSTRAINT client_status_transition_private_evidence CHECK ( + (delivery_kind = 1 + AND evidence_author_pubkey IS NOT NULL + AND evidence_binding_id IS NOT NULL + AND evidence_binding_id <> '00000000-0000-0000-0000-000000000000'::uuid + AND evidence_binding_version IS NOT NULL + AND evidence_policy_revision IS NOT NULL + AND evidence_invalidation_generation IS NOT NULL + AND evidence_authority_epoch IS NOT NULL + AND evidence_fence IS NOT NULL + AND evidence_observed_at IS NOT NULL + AND evidence_observed_at < fresh_until) + OR (delivery_kind = 2 + AND evidence_author_pubkey IS NULL + AND evidence_binding_id IS NULL + AND evidence_binding_version IS NULL + AND evidence_policy_revision IS NULL + AND evidence_invalidation_generation IS NULL + AND evidence_authority_epoch IS NULL + AND evidence_fence IS NULL + AND evidence_observed_at IS NULL) + ); + +ALTER TABLE client_status_transition_heads + ADD CONSTRAINT client_status_transition_heads_transition + FOREIGN KEY (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind) + REFERENCES client_status_transitions + (community_id, transition_id, connection_fingerprint, + subject_fingerprint, signer_fingerprint, status_revision, delivery_kind) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE client_status_delivery_outbox ( + community_id UUID NOT NULL REFERENCES communities(id), + delivery_id UUID NOT NULL, + transition_id UUID NOT NULL, + connection_fingerprint BYTEA NOT NULL CHECK ( + octet_length(connection_fingerprint) = 32 + AND connection_fingerprint <> decode(repeat('00', 32), 'hex') + ), + delivery_state SMALLINT NOT NULL DEFAULT 1 CHECK (delivery_state IN (1, 2, 3)), + attempt_count SMALLINT NOT NULL DEFAULT 0 CHECK (attempt_count BETWEEN 0 AND 16), + claim_id UUID, + completion_claim_id UUID, + claimed_until TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + last_failure_reason SMALLINT NOT NULL DEFAULT 0 CHECK (last_failure_reason BETWEEN 0 AND 7), + created_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + attempt_started_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + terminal_at TIMESTAMPTZ, + retain_until TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp() + INTERVAL '1 day', + PRIMARY KEY (community_id, delivery_id), + UNIQUE (community_id, transition_id, connection_fingerprint), + FOREIGN KEY (community_id, transition_id) + REFERENCES client_status_transitions (community_id, transition_id), + CHECK (delivery_id <> '00000000-0000-0000-0000-000000000000'::uuid), + CHECK ((claim_id IS NULL) = (claimed_until IS NULL)), + CHECK ((attempt_count = 0) = (attempt_started_at IS NULL)), + CHECK (retain_until > created_at AND retain_until <= created_at + INTERVAL '1 day'), + CHECK ( + (delivery_state = 1 AND delivered_at IS NULL AND terminal_at IS NULL + AND completion_claim_id IS NULL) + OR (delivery_state = 2 AND delivered_at IS NOT NULL AND terminal_at IS NULL + AND last_failure_reason = 0 AND claim_id IS NULL + AND completion_claim_id IS NOT NULL) + OR (delivery_state = 3 AND delivered_at IS NULL AND terminal_at IS NOT NULL + AND last_failure_reason BETWEEN 2 AND 7 AND claim_id IS NULL + AND completion_claim_id IS NOT NULL) + ) +); + +CREATE TABLE client_status_delivery_capacity ( + community_id UUID NOT NULL REFERENCES communities(id), + pending_count INTEGER NOT NULL DEFAULT 0 CHECK (pending_count BETWEEN 0 AND 1024), + total_count INTEGER NOT NULL DEFAULT 0 CHECK (total_count BETWEEN 0 AND 8192), + healthy BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason SMALLINT NOT NULL DEFAULT 0 CHECK (failure_reason BETWEEN 0 AND 3), + updated_at TIMESTAMPTZ NOT NULL DEFAULT transaction_timestamp(), + CHECK ((healthy AND failure_reason = 0) OR (NOT healthy AND failure_reason BETWEEN 1 AND 3)), + CHECK (pending_count <= total_count), + PRIMARY KEY (community_id) +); + +CREATE INDEX client_status_delivery_outbox_ready + ON client_status_delivery_outbox + (community_id, connection_fingerprint, next_attempt_at, created_at, delivery_id) + WHERE delivery_state = 1; + +CREATE INDEX client_status_delivery_outbox_retention + ON client_status_delivery_outbox (community_id, retain_until, delivery_id) + WHERE delivery_state IN (2, 3); + +CREATE TABLE client_status_delivery_events ( + community_id UUID NOT NULL, + delivery_id UUID NOT NULL, + event_sequence SMALLINT NOT NULL CHECK (event_sequence BETWEEN 1 AND 64), + event_kind SMALLINT NOT NULL CHECK (event_kind BETWEEN 1 AND 8), + reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 0 AND 7), + attempt_count SMALLINT NOT NULL CHECK (attempt_count BETWEEN 0 AND 16), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (community_id, delivery_id, event_sequence), + FOREIGN KEY (community_id, delivery_id) + REFERENCES client_status_delivery_outbox (community_id, delivery_id) + ON DELETE CASCADE, + CHECK ((event_kind BETWEEN 1 AND 5 AND reason_code = 0) + OR (event_kind BETWEEN 6 AND 8 AND reason_code BETWEEN 1 AND 7)) +); + +CREATE FUNCTION client_status_delivery_capacity_guard_v1() RETURNS TRIGGER AS $$ +DECLARE capacity client_status_delivery_capacity%ROWTYPE; +BEGIN + SELECT * INTO capacity FROM client_status_delivery_capacity + WHERE community_id = NEW.community_id FOR UPDATE; + IF NOT FOUND OR NOT capacity.healthy THEN + RAISE EXCEPTION 'client status delivery audit is unavailable' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + IF capacity.pending_count >= 1024 OR capacity.total_count >= 8192 THEN + RAISE EXCEPTION 'client status delivery capacity exhausted' + USING ERRCODE = 'program_limit_exceeded', + CONSTRAINT = 'client_status_delivery_capacity'; + END IF; + NEW.created_at := transaction_timestamp(); + NEW.next_attempt_at := transaction_timestamp(); + NEW.retain_until := transaction_timestamp() + INTERVAL '1 day'; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_delivery_capacity + BEFORE INSERT ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_guard_v1(); + +CREATE FUNCTION client_status_delivery_capacity_insert_v1() RETURNS TRIGGER AS $$ +BEGIN + UPDATE client_status_delivery_capacity SET + pending_count = pending_count + 1, + total_count = total_count + 1, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_insert + AFTER INSERT ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_insert_v1(); + +CREATE FUNCTION client_status_delivery_capacity_state_v1() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.delivery_state = 1 AND NEW.delivery_state IN (2, 3) THEN + UPDATE client_status_delivery_capacity SET + pending_count = pending_count - 1, + updated_at = transaction_timestamp() + WHERE community_id = NEW.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_state + AFTER UPDATE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_state_v1(); + +CREATE FUNCTION client_status_delivery_capacity_delete_v1() RETURNS TRIGGER AS $$ +BEGIN + UPDATE client_status_delivery_capacity SET + total_count = total_count - 1, + updated_at = transaction_timestamp() + WHERE community_id = OLD.community_id; + IF NOT FOUND THEN + RAISE EXCEPTION 'client status delivery capacity row is missing' + USING ERRCODE = 'object_not_in_prerequisite_state', + CONSTRAINT = 'client_status_delivery_capacity_health'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_capacity_delete + AFTER DELETE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_capacity_delete_v1(); + +CREATE FUNCTION client_status_transition_immutable_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'client status transition is immutable' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_immutable'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_no_update + BEFORE UPDATE ON client_status_transitions + FOR EACH ROW EXECUTE FUNCTION client_status_transition_immutable_v1(); +CREATE FUNCTION client_status_transition_head_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status_revision <> 1 THEN + RAISE EXCEPTION 'client status head must start at revision one' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + ELSIF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint + OR NEW.subject_fingerprint IS DISTINCT FROM OLD.subject_fingerprint + OR NEW.signer_fingerprint IS DISTINCT FROM OLD.signer_fingerprint + OR NEW.status_revision <> OLD.status_revision + 1 + OR NEW.transition_id IS NOT DISTINCT FROM OLD.transition_id + THEN + RAISE EXCEPTION 'client status head transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_head_revision'; + END IF; + NEW.updated_at := transaction_timestamp(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_head_state + BEFORE INSERT OR UPDATE ON client_status_transition_heads + FOR EACH ROW EXECUTE FUNCTION client_status_transition_head_guard_v1(); +CREATE FUNCTION client_status_transition_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF transaction_timestamp() <= OLD.retain_until THEN + RAISE EXCEPTION 'client status transition is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_transition_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_transition_retention + BEFORE DELETE ON client_status_transitions + FOR EACH ROW EXECUTE FUNCTION client_status_transition_retain_v1(); + +CREATE FUNCTION client_status_delivery_state_guard_v1() RETURNS TRIGGER AS $$ +DECLARE claim_advance BOOLEAN; retry_advance BOOLEAN; delivered_advance BOOLEAN; terminal_advance BOOLEAN; +DECLARE expired_transition BOOLEAN; stale_transition BOOLEAN; +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.delivery_id IS DISTINCT FROM OLD.delivery_id + OR NEW.transition_id IS DISTINCT FROM OLD.transition_id + OR NEW.connection_fingerprint IS DISTINCT FROM OLD.connection_fingerprint + OR NEW.created_at IS DISTINCT FROM OLD.created_at + OR NEW.retain_until IS DISTINCT FROM OLD.retain_until + OR OLD.delivery_state <> 1 + THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + SELECT EXISTS(SELECT 1 FROM client_status_transitions transition + WHERE transition.community_id=OLD.community_id + AND transition.transition_id=OLD.transition_id + AND transition.fresh_until <= transaction_timestamp()) + INTO expired_transition; + SELECT NOT EXISTS( + SELECT 1 FROM client_status_delivery_outbox delivery + JOIN client_status_transitions transition + ON transition.community_id=delivery.community_id + AND transition.transition_id=delivery.transition_id + JOIN client_status_transition_heads head + ON head.community_id=transition.community_id + AND head.connection_fingerprint=delivery.connection_fingerprint + AND head.transition_id=transition.transition_id + WHERE delivery.community_id=OLD.community_id + AND delivery.delivery_id=OLD.delivery_id) + INTO stale_transition; + claim_advance := NEW.delivery_state = 1 AND NEW.claim_id IS NOT NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + 1 + AND NOT stale_transition AND NOT expired_transition + AND OLD.next_attempt_at <= transaction_timestamp() AND OLD.attempt_count < 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp()) + AND NEW.claim_id IS DISTINCT FROM OLD.claim_id + AND NEW.claimed_until > transaction_timestamp() + AND NEW.claimed_until <= transaction_timestamp() + INTERVAL '5 minutes' + AND NEW.attempt_started_at IS NOT NULL + AND (NEW.last_failure_reason = OLD.last_failure_reason + OR (OLD.claim_id IS NOT NULL AND OLD.claimed_until <= transaction_timestamp() + AND NEW.last_failure_reason = 7)) + AND NEW.next_attempt_at = OLD.next_attempt_at; + retry_advance := NEW.delivery_state = 1 AND OLD.claim_id IS NOT NULL AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NULL AND NEW.attempt_count = OLD.attempt_count + AND NEW.attempt_started_at = OLD.attempt_started_at AND NEW.last_failure_reason = 1 + AND NEW.next_attempt_at > OLD.next_attempt_at; + delivered_advance := NEW.delivery_state = 2 AND OLD.claim_id IS NOT NULL + AND NEW.claim_id IS NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NOT NULL + AND NEW.terminal_at IS NULL AND NEW.last_failure_reason = 0; + terminal_advance := NEW.delivery_state = 3 AND NEW.claim_id IS NULL + AND NEW.completion_claim_id IS NOT NULL + AND NEW.attempt_count = OLD.attempt_count AND NEW.delivered_at IS NULL + AND NEW.terminal_at IS NOT NULL + AND ( + (OLD.claim_id IS NOT NULL AND NEW.completion_claim_id = OLD.claim_id + AND NEW.last_failure_reason BETWEEN 2 AND 7) + OR (NEW.last_failure_reason = 2 + AND (OLD.claim_id IS NULL OR NEW.completion_claim_id = OLD.claim_id)) + OR (NEW.last_failure_reason = 3 AND stale_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 5 AND expired_transition + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + OR (NEW.last_failure_reason = 6 AND OLD.attempt_count >= 16 + AND (OLD.claim_id IS NULL OR OLD.claimed_until <= transaction_timestamp())) + ); + IF NOT (claim_advance OR retry_advance OR delivered_advance OR terminal_advance) THEN + RAISE EXCEPTION 'client status delivery transition is invalid' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_transition'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER client_status_delivery_state + BEFORE UPDATE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_state_guard_v1(); + +CREATE FUNCTION client_status_delivery_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF OLD.delivery_state = 1 OR transaction_timestamp() <= OLD.retain_until THEN + RAISE EXCEPTION 'client status delivery is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_retention + BEFORE DELETE ON client_status_delivery_outbox + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_retain_v1(); + +CREATE FUNCTION client_status_delivery_event_guard_v1() RETURNS TRIGGER AS $$ +BEGIN + RAISE EXCEPTION 'client status delivery event is immutable' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_event_immutable'; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_event_no_update + BEFORE UPDATE ON client_status_delivery_events + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE FUNCTION client_status_delivery_event_retain_v1() RETURNS TRIGGER AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM client_status_delivery_outbox delivery + WHERE delivery.community_id = OLD.community_id + AND delivery.delivery_id = OLD.delivery_id + AND delivery.delivery_state IN (2, 3) + AND transaction_timestamp() > delivery.retain_until + ) THEN + RAISE EXCEPTION 'client status delivery event is still retained' + USING ERRCODE = 'check_violation', CONSTRAINT = 'client_status_delivery_event_retention'; + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql; +CREATE TRIGGER client_status_delivery_event_retention + BEFORE DELETE ON client_status_delivery_events + FOR EACH ROW EXECUTE FUNCTION client_status_delivery_event_retain_v1(); +CREATE TRIGGER client_status_delivery_event_no_truncate + BEFORE TRUNCATE ON client_status_delivery_events + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE TRIGGER client_status_transition_no_truncate + BEFORE TRUNCATE ON client_status_transitions + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); +CREATE TRIGGER client_status_delivery_no_truncate + BEFORE TRUNCATE ON client_status_delivery_outbox + FOR EACH STATEMENT EXECUTE FUNCTION client_status_delivery_event_guard_v1(); diff --git a/scripts/start-isolated-test-relay.sh b/scripts/start-isolated-test-relay.sh index 1e2047e99c9..2c1a3f18d9b 100755 --- a/scripts/start-isolated-test-relay.sh +++ b/scripts/start-isolated-test-relay.sh @@ -97,6 +97,7 @@ psql_h -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" export PGSCHEMA_PLAN_HOST=localhost PGSCHEMA_PLAN_PORT=${PG_PORT} export PGSCHEMA_PLAN_DB=buzz PGSCHEMA_PLAN_USER=buzz PGSCHEMA_PLAN_PASSWORD=buzz_dev export PGHOST=localhost PGPORT=${PG_PORT} PGUSER=buzz PGDATABASE=buzz +psql_h -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' ./bin/pgschema apply --file schema/schema.sql --auto-approve psql_h < scripts/attach-schema-partitions.sql ok "Schema applied" diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh index b9d93935c08..d1b1d0b39c9 100755 --- a/scripts/start-relay-for-tests.sh +++ b/scripts/start-relay-for-tests.sh @@ -103,6 +103,9 @@ export PGSCHEMA_PLAN_DB=buzz export PGSCHEMA_PLAN_USER=buzz export PGSCHEMA_PLAN_PASSWORD=buzz_dev +docker exec -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \ + psql -U "${PGUSER}" -d "${PGDATABASE}" -v ON_ERROR_STOP=1 \ + -c 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD="${PGPASSWORD}" buzz-postgres \ psql -U "${PGUSER}" -d "${PGDATABASE}" -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql