diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index e9ca0e9c9e..2bb56754ce 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -601,6 +601,43 @@ impl VersionedBindingRef { } } + pub(crate) fn from_evidence_adapter( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + resolution_reason: AuthorizationReason, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + let valid_reason = matches!( + (source, resolution_reason), + (_, AuthorizationReason::ExistingBinding) + | ( + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey + ) + | (BindingSource::Tofu, AuthorizationReason::EnrolledTofu) + ); + if !valid_reason { + return Err(AuthContextError::InvalidAuthorizationReason); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason, + }) + } + /// Build a reference to a binding authoritatively resolved as already active. #[cfg(test)] pub(crate) fn new_existing_active_for_test( diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs index c99018a4b0..a7b3580de9 100644 --- a/crates/buzz-auth/src/context/evidence.rs +++ b/crates/buzz-auth/src/context/evidence.rs @@ -1,14 +1,12 @@ -use std::fmt; +use std::{fmt, sync::Arc}; use buzz_core::CommunityId; use nostr::PublicKey; use uuid::Uuid; -use crate::Scope; +use crate::{provider::AuthorizationProfileId, provider::CapabilitySet, Scope}; -#[cfg(test)] -use super::transport_accepts_proof; -use super::AuthContextError; +use super::{transport_accepts_proof, AuthContextError}; /// Cryptographic proof used to authenticate the Nostr actor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,7 +15,7 @@ pub enum AuthMethod { Nip42, /// NIP-98 signed HTTP request. Nip98, - /// Blossom upload authorization. + /// Blossom media authorization for the exact verified operation. Blossom, } @@ -192,9 +190,7 @@ impl AdmissionExpiry { /// Server-verified Nostr authority for a request or connection. #[derive(PartialEq, Eq)] pub struct NostrAuthority { - actor_pubkey: PublicKey, - proof_method: AuthMethod, - verified_delegation: Option, + verified_proof: Arc, } impl fmt::Debug for NostrAuthority { @@ -202,42 +198,42 @@ impl fmt::Debug for NostrAuthority { formatter .debug_struct("NostrAuthority") .field("actor_pubkey", &"[redacted]") - .field("proof_method", &self.proof_method) + .field("proof_method", &self.verified_proof.proof_method()) .field("verified_delegation", &"[redacted]") .finish() } } impl NostrAuthority { - pub(super) fn new(proof: VerifiedNostrProof) -> Self { - Self { - actor_pubkey: proof.actor_pubkey, - proof_method: proof.proof_method, - verified_delegation: proof.verified_delegation, - } + pub(super) fn new(verified_proof: Arc) -> Self { + Self { verified_proof } } /// Authenticated Nostr actor. - pub const fn actor_pubkey(&self) -> PublicKey { - self.actor_pubkey + pub fn actor_pubkey(&self) -> PublicKey { + self.verified_proof.actor_pubkey() } /// Proof method used to authenticate the actor. - pub const fn proof_method(&self) -> AuthMethod { - self.proof_method + pub fn proof_method(&self) -> AuthMethod { + self.verified_proof.proof_method() } /// Cryptographically verified owner for a delegated Nostr actor. - pub const fn verified_owner_pubkey(&self) -> Option { - match &self.verified_delegation { - Some(delegation) => Some(delegation.owner_pubkey()), - None => None, - } + pub fn verified_owner_pubkey(&self) -> Option { + self.verified_proof + .verified_delegation() + .map(VerifiedTransportDelegation::owner_pubkey) } /// Cryptographically verified owner-to-actor delegation, when present. - pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { - self.verified_delegation.as_ref() + pub fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_proof.verified_delegation() + } + + /// Sealed proof retained from transport verification. + pub fn verified_proof(&self) -> &Arc { + &self.verified_proof } } @@ -304,6 +300,10 @@ pub struct VerifiedKeyAttestation { } impl VerifiedKeyAttestation { + pub(crate) const fn from_evidence_adapter(pubkey: PublicKey) -> Self { + Self { pubkey } + } + #[cfg(test)] pub(crate) const fn new(pubkey: PublicKey) -> Self { Self { pubkey } @@ -346,6 +346,26 @@ pub struct VerifiedFederatedAssertion { } impl VerifiedFederatedAssertion { + pub(crate) const fn from_evidence_adapter( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, + ) -> Self { + Self { + authorization_domain, + authorized_transport, + principal, + key_attestation, + transport, + not_before, + expires_at, + } + } + #[cfg(test)] pub(crate) const fn new( authorization_domain: CommunityId, @@ -418,6 +438,140 @@ impl fmt::Debug for VerifiedFederatedAssertion { } } +/// Provider-neutral identity and capability evidence from a verified adapter. +/// +/// This move-only value can be created only from an already sealed federated +/// assertion. It carries the installed server profile, normalized portable +/// capabilities, and a hard freshness window without retaining raw headers, +/// tokens, or provider-specific claims. +#[derive(PartialEq, Eq)] +pub struct VerifiedProviderEvidence { + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, +} + +impl VerifiedProviderEvidence { + pub(crate) const fn from_evidence_adapter( + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, + ) -> Self { + Self { + assertion, + profile_id, + capabilities, + issued_at, + fresh_until, + } + } + + /// Authorization domain for which the provider evidence was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.assertion.authorization_domain() + } + + /// Protected transport for which the provider evidence was verified. + pub const fn authorized_transport(&self) -> AuthTransport { + self.assertion.authorized_transport() + } + + /// Normalized issuer-qualified principal from the verified provenance. + pub const fn principal(&self) -> &FederatedPrincipal { + self.assertion.principal() + } + + /// Installed server profile that produced this evidence. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Normalized capabilities granted by the verified provider result. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Server-observed issuance time for this provider result. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Exclusive hard freshness bound for this provider result. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Revalidate this evidence for one exact protected request. + pub fn validate_for( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + installed_profile: &AuthorizationProfileId, + required_capabilities: &CapabilitySet, + now_unix_seconds: u64, + ) -> Result<(), ProviderEvidenceValidationError> { + if self.authorization_domain() != authorization_domain { + return Err(ProviderEvidenceValidationError::AuthorizationDomainMismatch); + } + if self.authorized_transport() != transport { + return Err(ProviderEvidenceValidationError::TransportMismatch); + } + if self.profile_id() != installed_profile { + return Err(ProviderEvidenceValidationError::ProfileMismatch); + } + if !required_capabilities + .as_slice() + .iter() + .all(|capability| self.capabilities.contains(*capability)) + { + return Err(ProviderEvidenceValidationError::CapabilityMismatch); + } + if self.issued_at > now_unix_seconds { + return Err(ProviderEvidenceValidationError::NotYetValid); + } + if self.fresh_until <= now_unix_seconds { + return Err(ProviderEvidenceValidationError::Expired); + } + Ok(()) + } +} + +impl fmt::Debug for VerifiedProviderEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedProviderEvidence") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Fail-closed reason provider evidence cannot authorize an exact request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderEvidenceValidationError { + /// Evidence belongs to another authorization domain. + AuthorizationDomainMismatch, + /// Evidence was verified for another protected transport. + TransportMismatch, + /// Evidence was produced by another installed provider profile. + ProfileMismatch, + /// Evidence does not grant every capability required by the request. + CapabilityMismatch, + /// Evidence claims an issuance time after the server clock. + NotYetValid, + /// Evidence has reached its hard freshness bound. + Expired, +} + /// Current admission for the owner of a delegated Nostr actor. /// /// This evidence is independent of a federated assertion: a delegated request @@ -434,9 +588,7 @@ pub struct VerifiedOwnerAdmission { } impl VerifiedOwnerAdmission { - // Consumed by the provider finalizer in the stacked capability contract. - #[allow(dead_code)] - pub(crate) const fn new( + pub(crate) const fn from_capability_snapshot( authorization_domain: CommunityId, principal: FederatedPrincipal, fresh_until: AdmissionExpiry, @@ -448,6 +600,15 @@ impl VerifiedOwnerAdmission { } } + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, + ) -> Self { + Self::from_capability_snapshot(authorization_domain, principal, fresh_until) + } + /// Authorization domain for which owner admission was resolved. pub const fn authorization_domain(&self) -> CommunityId { self.authorization_domain @@ -491,6 +652,13 @@ impl fmt::Debug for DelegationCapability { } } +impl DelegationCapability { + /// Whether the verifier proved authority for the complete transport. + pub const fn is_transport_wide(self) -> bool { + matches!(self, Self::TransportWide) + } +} + /// Transport-wide delegation from a bound owner to the authenticated key. /// /// A verifier may construct this only after proving the capability authorizes @@ -522,7 +690,6 @@ impl fmt::Debug for VerifiedTransportDelegation { impl VerifiedTransportDelegation { /// Build transport-wide evidence after validating both keys and confirming /// that no narrower capability constraint is being discarded. - #[cfg(test)] pub(crate) fn new_unrestricted( owner_pubkey: PublicKey, delegate_pubkey: PublicKey, @@ -560,6 +727,78 @@ impl VerifiedTransportDelegation { } } +/// Class of exact verifier input retained by a sealed Nostr proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerifiedOperationBindingKind { + /// NIP-42 challenge and relay URL for a WebSocket session. + NostrSession, + /// NIP-98 method, URL, and payload for an HTTP request. + HttpRequest, + /// Blossom upload verb, blob hash, server, and signed event. + BlossomUpload, + /// Blossom download verb, blob hash, server, and signed event. + BlossomDownload, +} + +/// Opaque exact-operation fingerprint produced only by a trusted verifier. +/// +/// It has no public constructor or serialization path. The fingerprint keeps +/// sensitive URLs, challenges, and payloads out of the authorization context +/// while preventing a proof from being silently widened to another verifier +/// operation class. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct VerifiedOperationBinding { + kind: VerifiedOperationBindingKind, + fingerprint: [u8; 32], +} + +impl VerifiedOperationBinding { + pub(crate) const fn from_evidence_adapter( + kind: VerifiedOperationBindingKind, + fingerprint: [u8; 32], + ) -> Self { + Self { kind, fingerprint } + } + + #[cfg(test)] + fn for_transport(transport: AuthTransport) -> Self { + let kind = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => { + VerifiedOperationBindingKind::NostrSession + } + AuthTransport::HttpBridge | AuthTransport::Git => { + VerifiedOperationBindingKind::HttpRequest + } + AuthTransport::MediaUpload => VerifiedOperationBindingKind::BlossomUpload, + AuthTransport::MediaDownload => VerifiedOperationBindingKind::BlossomDownload, + }; + Self { + kind, + fingerprint: [0; 32], + } + } + + /// Exact verifier-operation class. + pub const fn kind(self) -> VerifiedOperationBindingKind { + self.kind + } + + /// Opaque fingerprint of the exact verified operation inputs. + pub const fn fingerprint(self) -> [u8; 32] { + self.fingerprint + } +} + +impl fmt::Debug for VerifiedOperationBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedOperationBinding") + .field("kind", &self.kind) + .field("fingerprint", &"[redacted]") + .finish() + } +} + /// Cryptographically verified Nostr proof for one request or connection. /// /// Transport verifiers inside `buzz-auth` produce this evidence after checking @@ -574,10 +813,41 @@ pub struct VerifiedNostrProof { authorized_transport: AuthTransport, actor_pubkey: PublicKey, proof_method: AuthMethod, + operation_binding: VerifiedOperationBinding, verified_delegation: Option, } impl VerifiedNostrProof { + pub(crate) fn from_evidence_adapter( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + operation_binding: VerifiedOperationBinding, + verified_delegation: Option, + ) -> Result { + if !transport_accepts_proof(authorized_transport, proof_method) { + return Err(AuthContextError::TransportProofMismatch); + } + if !operation_binding_matches_transport(operation_binding.kind(), authorized_transport) { + return Err(AuthContextError::OperationProofMismatch); + } + if verified_delegation + .as_ref() + .is_some_and(|delegation| delegation.delegate_pubkey() != actor_pubkey) + { + return Err(AuthContextError::DelegateKeyMismatch); + } + Ok(Self { + authorization_domain, + authorized_transport, + actor_pubkey, + proof_method, + operation_binding, + verified_delegation, + }) + } + #[cfg(test)] pub(crate) fn new( authorization_domain: CommunityId, @@ -600,6 +870,7 @@ impl VerifiedNostrProof { authorized_transport, actor_pubkey, proof_method, + operation_binding: VerifiedOperationBinding::for_transport(authorized_transport), verified_delegation, }) } @@ -624,6 +895,11 @@ impl VerifiedNostrProof { self.proof_method } + /// Opaque binding to the exact verifier operation. + pub const fn operation_binding(&self) -> VerifiedOperationBinding { + self.operation_binding + } + /// Verified owner-to-actor delegation, when present. pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { self.verified_delegation.as_ref() @@ -638,11 +914,34 @@ impl fmt::Debug for VerifiedNostrProof { .field("authorized_transport", &self.authorized_transport) .field("actor_pubkey", &"[redacted]") .field("proof_method", &self.proof_method) + .field("operation_binding", &self.operation_binding) .field("verified_delegation", &"[redacted]") .finish() } } +const fn operation_binding_matches_transport( + kind: VerifiedOperationBindingKind, + transport: AuthTransport, +) -> bool { + matches!( + (kind, transport), + ( + VerifiedOperationBindingKind::NostrSession, + AuthTransport::RelayWebSocket | AuthTransport::Audio + ) | ( + VerifiedOperationBindingKind::HttpRequest, + AuthTransport::HttpBridge | AuthTransport::Git + ) | ( + VerifiedOperationBindingKind::BlossomUpload, + AuthTransport::MediaUpload + ) | ( + VerifiedOperationBindingKind::BlossomDownload, + AuthTransport::MediaDownload + ) + ) +} + /// Successful community admission and permissions for one decision. /// /// An authorization adapter may construct this value only after membership, @@ -660,6 +959,18 @@ pub struct AuthorizedCommunityAccess { } impl AuthorizedCommunityAccess { + pub(crate) const fn from_evidence_adapter( + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Self { + Self { + authorization_domain, + scopes, + channel_ids, + } + } + #[cfg(test)] pub(crate) const fn new( authorization_domain: CommunityId, diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index 95883be426..ce24e54390 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -5,13 +5,19 @@ //! the Nostr authority that signed the request. Raw assertions and mutable //! display claims never enter this type. -use std::fmt; +use std::{fmt, sync::Arc}; use buzz_core::{tenant::TenantContext, CommunityId}; use nostr::PublicKey; use uuid::Uuid; -use crate::Scope; +use crate::{ + lease::{ + AuthorizationLease, AuthorizationLeaseValidator, AuthorizationOperationGuard, + LeaseUseRequirement, LeaseValidationError, + }, + Scope, +}; pub(crate) mod authority; mod binding; @@ -32,8 +38,10 @@ pub use binding::{ pub use evidence::{ AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, - FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, - VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, + FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError, + VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, + VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission, + VerifiedProviderEvidence, VerifiedTransportDelegation, }; pub use reason::{AuthContextError, AuthorizationReason}; @@ -133,6 +141,7 @@ pub struct AuthContextV1 { nostr: NostrAuthority, federated_policy: ResolvedFederatedPolicy, federated: FederatedAuthorization, + authorization_lease: Option, scopes: Vec, channel_ids: Option>, } @@ -142,7 +151,7 @@ pub struct AuthContextV1 { pub struct AuthContextInput { tenant: TenantContext, correlation_id: Uuid, - nostr_proof: VerifiedNostrProof, + nostr_proof: Arc, community_access: AuthorizedCommunityAccess, } @@ -177,24 +186,23 @@ impl AuthContextInput { pub fn new( tenant: TenantContext, correlation_id: Uuid, - nostr_proof: VerifiedNostrProof, + nostr_proof: impl Into>, community_access: AuthorizedCommunityAccess, ) -> Self { Self { tenant, correlation_id, - nostr_proof, + nostr_proof: nostr_proof.into(), community_access, } } - #[allow(dead_code)] pub(crate) const fn authorization_domain(&self) -> CommunityId { self.tenant.community() } #[allow(dead_code)] - pub(crate) const fn nostr_proof_authorization_domain(&self) -> CommunityId { + pub(crate) fn nostr_proof_authorization_domain(&self) -> CommunityId { self.nostr_proof.authorization_domain() } @@ -204,32 +212,47 @@ impl AuthContextInput { } #[allow(dead_code)] - pub(crate) const fn correlation_id(&self) -> Uuid { - self.correlation_id - } - - #[allow(dead_code)] - pub(crate) const fn transport(&self) -> AuthTransport { + pub(crate) fn transport(&self) -> AuthTransport { self.nostr_proof.authorized_transport() } #[allow(dead_code)] - pub(crate) const fn proof_method(&self) -> AuthMethod { + pub(crate) fn proof_method(&self) -> AuthMethod { self.nostr_proof.proof_method() } #[allow(dead_code)] - pub(crate) const fn actor_pubkey(&self) -> PublicKey { + pub(crate) fn actor_pubkey(&self) -> PublicKey { self.nostr_proof.actor_pubkey() } #[allow(dead_code)] - pub(crate) const fn verified_owner_pubkey(&self) -> Option { + pub(crate) fn verified_owner_pubkey(&self) -> Option { match self.nostr_proof.verified_delegation() { Some(delegation) => Some(delegation.owner_pubkey()), None => None, } } + + /// Server-resolved tenant carried by the finalization input. + pub const fn tenant(&self) -> &TenantContext { + &self.tenant + } + + /// Correlation identifier for this authorization decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Cryptographically verified Nostr proof. + pub fn nostr_proof(&self) -> &VerifiedNostrProof { + &self.nostr_proof + } + + /// Current community admission and permissions. + pub const fn community_access(&self) -> &AuthorizedCommunityAccess { + &self.community_access + } } impl fmt::Debug for AuthContextV1 { @@ -242,6 +265,7 @@ impl fmt::Debug for AuthContextV1 { .field("nostr", &self.nostr) .field("federated_policy", &self.federated_policy) .field("federated", &self.federated) + .field("authorization_lease", &"[redacted]") .field("scopes", &"[redacted]") .field("channel_ids", &"[redacted]") .finish() @@ -305,7 +329,13 @@ impl AuthContext { } } }; - Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds) + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, + now_unix_seconds, + ) } /// Validate all authorization evidence and finalize an immutable V1 context. @@ -324,26 +354,75 @@ impl AuthContext { authorization: FederatedAuthorization, now_unix_seconds: u64, ) -> Result { - let authorization_domain = input.tenant.community(); - let transport = input.nostr_proof.authorized_transport(); - if input.nostr_proof.authorization_domain() != authorization_domain { - return Err(AuthContextError::NostrProofDomainMismatch); - } - validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; - if input.community_access.authorization_domain() != authorization_domain { - return Err(AuthContextError::CommunityAccessDomainMismatch); + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, + now_unix_seconds, + ) + } + + pub(crate) fn finalize_v1_with_lease( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + authorization_lease: AuthorizationLease, + now_unix_seconds: u64, + ) -> Result { + if !matches!( + federated_policy.requirement(), + FederatedIdentityRequirement::Required(_) + ) || matches!(authorization, FederatedAuthorization::NotRequired) + { + return Err(AuthContextError::FinalizedLeaseMismatch); } - if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { - return Err(AuthContextError::TransportProofMismatch); + let active_binding = authorization + .active_binding() + .ok_or(AuthContextError::FinalizedLeaseMismatch)?; + if authorization_lease.authorization_domain() != input.tenant.community() + || authorization_lease.transport() != input.nostr_proof.authorized_transport() + || authorization_lease.actor_pubkey() != input.nostr_proof.actor_pubkey() + || authorization_lease.binding_id() != active_binding.binding_id() + || authorization_lease.binding_version() != active_binding.binding_version() + || authorization_lease.correlation_id() != input.correlation_id + { + return Err(AuthContextError::FinalizedLeaseMismatch); } - validate_federated_authorization( - authorization_domain, - transport, - &input.nostr_proof, - &federated_policy, - &authorization, + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + Some(authorization_lease), + now_unix_seconds, + ) + } + + #[cfg(test)] + pub(super) fn finalize_v1_evidence_for_test( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + now_unix_seconds: u64, + ) -> Result { + Self::finalize_v1_inner( + input, + federated_policy, + authorization, + None, now_unix_seconds, - )?; + ) + } + + fn finalize_v1_inner( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + authorization_lease: Option, + now_unix_seconds: u64, + ) -> Result { + validate_context_evidence(&input, &federated_policy, &authorization, now_unix_seconds)?; + let transport = input.nostr_proof.authorized_transport(); let nostr = NostrAuthority::new(input.nostr_proof); let (scopes, channel_ids) = input.community_access.into_permissions(); Ok(Self::V1(AuthContextV1 { @@ -353,6 +432,7 @@ impl AuthContext { nostr, federated_policy, federated: authorization, + authorization_lease, scopes, channel_ids, })) @@ -394,17 +474,17 @@ impl AuthContext { } /// Authenticated Nostr actor. - pub const fn pubkey(&self) -> PublicKey { + pub fn pubkey(&self) -> PublicKey { self.nostr().actor_pubkey() } /// Proof method used to authenticate the Nostr actor. - pub const fn auth_method(&self) -> AuthMethod { + pub fn auth_method(&self) -> AuthMethod { self.nostr().proof_method() } /// Cryptographically verified owner for a delegated Nostr actor. - pub const fn agent_owner_pubkey(&self) -> Option { + pub fn agent_owner_pubkey(&self) -> Option { self.nostr().verified_owner_pubkey() } @@ -422,6 +502,43 @@ impl AuthContext { } } + /// Bounded federated access lease, when this is an enforcing context. + pub const fn authorization_lease(&self) -> Option<&AuthorizationLease> { + match self { + Self::V1(context) => context.authorization_lease.as_ref(), + } + } + + /// Validate this context's access lease for one protected operation. + /// + /// Nostr-only contexts have no federated lease and therefore fail closed + /// when presented to a federated-protected operation. + pub fn authorize_lease_use( + &self, + validator: &AuthorizationLeaseValidator, + requirement: &LeaseUseRequirement, + ) -> Result<(), LeaseValidationError> { + let lease = self + .authorization_lease() + .ok_or(LeaseValidationError::MissingLease)?; + validator.authorize(lease, requirement) + } + + /// Validate and retain a per-capability guard for an in-flight operation. + /// + /// Callers must revalidate the returned guard immediately before a + /// protected commit or stream emission. + pub fn operation_guard<'a>( + &'a self, + validator: &AuthorizationLeaseValidator, + requirement: LeaseUseRequirement, + ) -> Result, LeaseValidationError> { + let lease = self + .authorization_lease() + .ok_or(LeaseValidationError::MissingLease)?; + validator.operation_guard(lease, requirement) + } + /// Stable reason for the successful authorization decision. pub const fn authorization_reason(&self) -> AuthorizationReason { match self.federated_authorization() { @@ -474,6 +591,46 @@ fn validate_federated_policy_stamp( Ok(()) } +pub(crate) fn validate_context_evidence( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + authorization: &FederatedAuthorization, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + validate_federated_policy_stamp(input, federated_policy, now_unix_seconds)?; + let authorization_domain = input.tenant.community(); + let transport = input.nostr_proof.authorized_transport(); + if input.nostr_proof.authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + if federated_policy.authorization_domain() != authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch); + } + if input.community_access.authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { + return Err(AuthContextError::TransportProofMismatch); + } + validate_federated_authorization( + authorization_domain, + transport, + &input.nostr_proof, + federated_policy, + authorization, + now_unix_seconds, + ) +} + +impl FederatedAuthorization { + pub(crate) const fn active_binding(&self) -> Option<&VersionedBindingRef> { + match self { + Self::NotRequired => None, + Self::Direct { binding, .. } => Some(binding), + Self::Delegated { owner, .. } => Some(owner), + } + } +} pub(super) const fn transport_accepts_proof( transport: AuthTransport, proof_method: AuthMethod, @@ -486,7 +643,7 @@ pub(super) const fn transport_accepts_proof( AuthTransport::MediaUpload => { matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom) } - AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98), + AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Blossom), } } diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index fe07e9a68f..eba2f4356c 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -154,6 +154,9 @@ pub enum AuthContextError { /// Proof method was not valid for the transport being authorized. #[error("Nostr proof method does not match authorization transport")] TransportProofMismatch, + /// Exact verifier-operation evidence was not valid for the transport. + #[error("Nostr operation proof does not match authorization transport")] + OperationProofMismatch, /// Direct federated authorization was attached to a delegated Nostr actor. #[error("direct federated authorization cannot include a delegated Nostr owner")] DirectAuthorizationHasOwner, @@ -172,6 +175,12 @@ pub enum AuthContextError { /// Delegated owner evidence did not resolve an already-active binding. #[error("delegated federated authorization requires an existing active binding")] DelegatedBindingNotExistingActive, + /// Federated finalization did not consume a current provider decision. + #[error("federated authorization requires a current provider decision")] + ProviderDecisionRequired, + /// An issued lease did not match the context evidence being finalized. + #[error("authorization lease does not match finalized context evidence")] + FinalizedLeaseMismatch, } impl AuthContextError { @@ -214,6 +223,7 @@ impl AuthContextError { Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch", Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch", Self::TransportProofMismatch => "nostr_transport_proof_mismatch", + Self::OperationProofMismatch => "nostr_operation_proof_mismatch", Self::DirectAuthorizationHasOwner => "federated_direct_has_owner", Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch", Self::DelegateKeyMismatch => "federated_delegate_key_mismatch", @@ -222,6 +232,8 @@ impl AuthContextError { Self::DelegatedBindingNotExistingActive => { "federated_delegated_binding_not_existing_active" } + Self::ProviderDecisionRequired => "authorization_provider_decision_required", + Self::FinalizedLeaseMismatch => "authorization_finalized_lease_mismatch", } } } diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs index 528e04e5f5..d444239274 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -353,7 +353,7 @@ fn delegated_authorization( fn context_preserves_server_resolved_authority() { let keys = Keys::generate(); let correlation_id = Uuid::from_u128(2); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), correlation_id, @@ -398,7 +398,7 @@ fn context_preserves_server_resolved_authority() { fn direct_authorization_requires_the_authenticated_key() { let actor = Keys::generate(); let other = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -415,7 +415,7 @@ fn direct_authorization_requires_the_authenticated_key() { fn delegated_authorization_requires_the_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -437,7 +437,7 @@ fn delegated_authorization_requires_the_verified_owner() { fn delegated_authorization_requires_verified_delegation() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), delegated_authorization(1, owner.public_key(), principal(), 200), @@ -482,7 +482,7 @@ fn principal_preserves_exact_validated_values() { fn context_debug_output_omits_tenant_host() { let actor = Keys::generate(); let channel_id = Uuid::from_u128(20); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -522,6 +522,7 @@ fn context_debug_output_omits_tenant_host() { "correlation_id: \"[redacted]\", requirement: \"[redacted]\", ", "effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ", "federated: FederatedAuthorization(\"[redacted]\"), ", + "authorization_lease: \"[redacted]\", ", "scopes: \"[redacted]\", channel_ids: \"[redacted]\" })" ) ); @@ -531,7 +532,7 @@ fn context_debug_output_omits_tenant_host() { fn direct_authorization_rejects_a_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -553,7 +554,7 @@ fn direct_authorization_rejects_a_verified_owner() { fn delegated_authorization_requires_current_owner_admission() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -572,7 +573,7 @@ fn delegated_authorization_requires_current_owner_admission() { fn delegated_authorization_rejects_cross_domain_owner_admission() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -600,7 +601,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() { let owner = Keys::generate(); let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject") .expect("synthetic principal is valid"); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -619,7 +620,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() { fn delegated_authorization_rejects_an_expired_proof() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input_with_delegation_expiry( actor.public_key(), AuthTransport::RelayWebSocket, @@ -663,7 +664,7 @@ fn delegated_authorization_requires_the_bound_owner() { let actor = Keys::generate(); let owner = Keys::generate(); let other_owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -682,7 +683,7 @@ fn delegated_authorization_requires_the_bound_owner() { fn delegated_binding_cannot_cross_authorization_domains() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -832,7 +833,7 @@ fn verified_assertion_debug_output_is_fully_redacted() { #[test] fn direct_authorization_rejects_expired_assertions() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -850,7 +851,7 @@ fn direct_authorization_rejects_expired_assertions() { #[test] fn direct_authorization_rejects_binding_at_exact_expiry() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -869,7 +870,7 @@ fn direct_authorization_rejects_binding_at_exact_expiry() { fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { let owner = Keys::generate(); let delegate = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( delegate.public_key(), AuthTransport::RelayWebSocket, @@ -894,7 +895,7 @@ fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { #[test] fn direct_authorization_rejects_a_future_assertion() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::HttpBridge, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -919,7 +920,7 @@ fn direct_authorization_rejects_a_future_assertion() { #[test] fn direct_authorization_requires_the_assertion_principal() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -942,7 +943,7 @@ fn direct_authorization_requires_the_assertion_principal() { #[test] fn enrolled_reason_must_match_policy_and_binding_source() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Provisioned), FederatedAuthorization::Direct { @@ -981,7 +982,7 @@ fn existing_active_bindings_are_independent_of_enrollment_mode() { for enrollment_mode in enrollment_modes { for binding_source in binding_sources { - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(enrollment_mode), FederatedAuthorization::Direct { @@ -1038,7 +1039,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { let actor = Keys::generate(); let other = Keys::generate(); - let missing = AuthContext::finalize_v1( + let missing = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1054,7 +1055,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { .expect_err("attested enrollment cannot silently accept a missing key claim"); assert_eq!(missing, AuthContextError::KeyAttestationRequired); - let mismatch = AuthContext::finalize_v1( + let mismatch = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1075,7 +1076,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { .expect_err("attested enrollment cannot accept another Nostr key"); assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1104,7 +1105,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() { fn present_key_attestation_never_ignores_an_actor_mismatch() { let actor = Keys::generate(); let other = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), FederatedAuthorization::Direct { @@ -1140,7 +1141,7 @@ fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() { ), }; - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), authorization, @@ -1308,7 +1309,7 @@ fn security_posture_debug_output_is_fully_redacted() { #[test] fn federated_policy_must_match_the_authorization_correlation() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200), FederatedAuthorization::Direct { @@ -1325,7 +1326,7 @@ fn federated_policy_must_match_the_authorization_correlation() { #[test] fn federated_policy_effective_interval_is_half_open() { let actor = Keys::generate(); - let not_yet_effective = AuthContext::finalize_v1( + let not_yet_effective = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200), FederatedAuthorization::Direct { @@ -1340,7 +1341,7 @@ fn federated_policy_effective_interval_is_half_open() { AuthContextError::FederatedPolicyNotYetEffective ); - let expired = AuthContext::finalize_v1( + let expired = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100), FederatedAuthorization::Direct { @@ -1736,7 +1737,7 @@ fn authoritative_finalizer_requires_existing_active_delegated_owner() { #[test] fn tofu_enrollment_cannot_use_attested_key_policy_reason() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), FederatedAuthorization::Direct { @@ -1769,17 +1770,37 @@ fn transport_and_proof_method_must_agree() { } #[test] -fn blossom_upload_proof_cannot_authorize_media_downloads() { +fn verified_blossom_proof_authorizes_media_download_profile() { let actor = Keys::generate(); - let error = VerifiedNostrProof::new( + let proof = VerifiedNostrProof::new( authorization_domain(1), AuthTransport::MediaDownload, actor.public_key(), AuthMethod::Blossom, None, ) - .expect_err("upload-only proof must not be widened to media download authority"); + .expect("verified Blossom GET/HEAD proof matches the media download profile"); + assert_eq!(proof.authorized_transport(), AuthTransport::MediaDownload); + assert_eq!(proof.proof_method(), AuthMethod::Blossom); +} + +#[test] +fn nip98_proof_cannot_substitute_for_blossom_media_download() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaDownload, + actor.public_key(), + AuthMethod::Nip98, + None, + ) + .expect_err("no implemented NIP-98 media GET/HEAD verifier exists"); assert_eq!(error, AuthContextError::TransportProofMismatch); +} + +#[test] +fn blossom_media_proof_remains_bound_to_its_exact_operation() { + let actor = Keys::generate(); VerifiedNostrProof::new( authorization_domain(1), @@ -1788,13 +1809,13 @@ fn blossom_upload_proof_cannot_authorize_media_downloads() { AuthMethod::Blossom, None, ) - .expect("Blossom proof may authorize the verified upload operation"); + .expect("Blossom upload proof may authorize only its verified upload operation"); } #[test] fn binding_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1813,7 +1834,7 @@ fn binding_cannot_cross_authorization_domains() { fn nostr_only_authorization_may_preserve_a_verified_owner() { let actor = Keys::generate(); let owner = Keys::generate(); - let context = AuthContext::finalize_v1( + let context = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, @@ -1859,7 +1880,7 @@ fn transport_delegation_is_explicitly_transport_wide() { #[test] fn required_policy_rejects_nostr_only_authorization() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::NotRequired, @@ -1874,7 +1895,7 @@ fn required_policy_rejects_nostr_only_authorization() { #[test] fn not_required_policy_rejects_federated_authorization() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_not_required(), FederatedAuthorization::Direct { @@ -1892,7 +1913,7 @@ fn not_required_policy_rejects_federated_authorization() { #[test] fn nostr_proof_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -1912,7 +1933,7 @@ fn nostr_proof_cannot_cross_authorization_domains() { #[test] fn federated_policy_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), ResolvedFederatedPolicy::not_required(authorization_domain(2)), FederatedAuthorization::NotRequired, @@ -1927,7 +1948,7 @@ fn federated_policy_cannot_cross_authorization_domains() { #[test] fn community_admission_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( AuthContextInput::new( tenant(1), Uuid::from_u128(2), @@ -1947,7 +1968,7 @@ fn community_admission_cannot_cross_authorization_domains() { #[test] fn assertion_cannot_cross_authorization_domains() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1971,7 +1992,7 @@ fn assertion_cannot_cross_authorization_domains() { #[test] fn assertion_must_match_the_authorized_transport() { let actor = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::AttestedKey), FederatedAuthorization::Direct { @@ -1996,7 +2017,7 @@ fn assertion_must_match_the_authorized_transport() { fn delegated_owner_admission_must_match_the_authorization_domain() { let actor = Keys::generate(); let owner = Keys::generate(); - let error = AuthContext::finalize_v1( + let error = AuthContext::finalize_v1_evidence_for_test( input( actor.public_key(), AuthTransport::RelayWebSocket, diff --git a/crates/buzz-auth/src/evidence_adapter.rs b/crates/buzz-auth/src/evidence_adapter.rs new file mode 100644 index 0000000000..5346053e44 --- /dev/null +++ b/crates/buzz-auth/src/evidence_adapter.rs @@ -0,0 +1,676 @@ +//! Narrow trusted-workspace adapter for sealed authorization evidence. +//! +//! Wire data cannot deserialize into any type in this module. The adapter is +//! used only after the existing cryptographic verifier, assertion verifier, +//! community policy, or typed binding store has returned success. It repeats +//! exact domain, transport, actor, time, identifier, version, and provenance +//! checks before crossing the crate's sealed evidence boundary. + +use buzz_core::{tenant::TenantContext, CommunityId}; +use nostr::{Event, PublicKey}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthContextError, AuthMethod, + AuthTransport, AuthorizedCommunityAccess, BindingExpiry, BindingSource, BindingVersion, + DelegationExpiry, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedOperationBinding, VerifiedOperationBindingKind, + VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, + }, + nip42::verify_nip42_event, + nip98::verify_nip98_event, + AuthorizationProfileId, AuthorizationReason, CapabilitySet, Scope, +}; + +/// Binding lifecycle result returned by an authoritative store adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActiveBindingResolution { + /// Exact active binding already existed. + Existing, + /// Binding was atomically enrolled during this decision. + Enrolled, +} + +/// Existing-verifier output for transport-wide Nostr delegation. +/// +/// Construction is deliberately explicit and non-serializable. The relay may +/// create it only by translating a successful NIP-OA verifier result whose +/// constraints were proved transport-wide; raw `auth` tag data is not such a +/// result. +pub struct VerifiedDelegationOutput { + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + transport_wide: bool, +} + +impl VerifiedDelegationOutput { + /// Translate an existing verifier's exact output. + pub const fn from_workspace_verifier( + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + transport_wide: bool, + ) -> Self { + Self { + owner_pubkey, + delegate_pubkey, + expires_at, + transport_wide, + } + } +} + +/// Stateless factory for sealed evidence. +/// +/// This value carries no configuration and cannot select a domain, provider, +/// or capability. Every method requires the server-resolved values again and +/// rejects mismatches in the translated verifier/store output. +#[derive(Debug, Default, Clone, Copy)] +pub struct VerifiedEvidenceAdapter; + +fn operation_binding( + kind: VerifiedOperationBindingKind, + parts: &[&[u8]], +) -> VerifiedOperationBinding { + let mut hasher = Sha256::new(); + hasher.update(b"buzz-auth:verified-operation-binding:v1"); + for part in parts { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + VerifiedOperationBinding::from_evidence_adapter(kind, hasher.finalize().into()) +} + +impl VerifiedEvidenceAdapter { + /// Create the trusted-workspace adapter. + pub const fn new() -> Self { + Self + } + + /// Attach exact transport-wide delegation output to an already sealed + /// cryptographic proof. + /// + /// This consumes the original proof, rejects replacement of an existing + /// delegation, and rechecks that the verified delegate is the proof actor. + pub fn attach_transport_delegation( + &self, + proof: VerifiedNostrProof, + delegation: VerifiedDelegationOutput, + ) -> Result { + if proof.verified_delegation().is_some() { + return Err(EvidenceAdapterError::DelegationAlreadyPresent); + } + let verified_delegation = self.delegation(proof.actor_pubkey(), delegation)?; + VerifiedNostrProof::from_evidence_adapter( + proof.authorization_domain(), + proof.authorized_transport(), + proof.actor_pubkey(), + proof.proof_method(), + proof.operation_binding(), + Some(verified_delegation), + ) + .map_err(Into::into) + } + + /// Verify NIP-42 and bind its result to relay or audio transport. + pub fn verify_nip42( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + event: &Event, + expected_challenge: &str, + relay_url: &str, + delegation: Option, + ) -> Result { + if !matches!( + transport, + AuthTransport::RelayWebSocket | AuthTransport::Audio + ) { + return Err(EvidenceAdapterError::TransportMethodMismatch); + } + verify_nip42_event(event, expected_challenge, relay_url)?; + let delegation = delegation + .map(|delegation| self.delegation(event.pubkey, delegation)) + .transpose()?; + let binding = operation_binding( + VerifiedOperationBindingKind::NostrSession, + &[expected_challenge.as_bytes(), relay_url.as_bytes()], + ); + VerifiedNostrProof::from_evidence_adapter( + authorization_domain, + transport, + event.pubkey, + AuthMethod::Nip42, + binding, + delegation, + ) + .map_err(Into::into) + } + + /// Verify NIP-98 and bind it to one exact HTTP transport operation. + // The individual arguments are intentional trust-boundary inputs: folding + // them into an unverified request bag would make it easier to omit an exact + // domain, transport, method, body, or delegation cross-check. + #[allow(clippy::too_many_arguments)] + pub fn verify_nip98( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + event_json: &str, + expected_url: &str, + expected_method: &str, + body: Option<&[u8]>, + delegation: Option, + ) -> Result { + if !matches!( + transport, + AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaUpload + ) { + return Err(EvidenceAdapterError::TransportMethodMismatch); + } + let actor = verify_nip98_event(event_json, expected_url, expected_method, body)?; + let delegation = delegation + .map(|delegation| self.delegation(actor, delegation)) + .transpose()?; + let body_presence = [u8::from(body.is_some())]; + let binding = operation_binding( + VerifiedOperationBindingKind::HttpRequest, + &[ + expected_method.as_bytes(), + expected_url.as_bytes(), + &body_presence, + body.unwrap_or_default(), + ], + ); + VerifiedNostrProof::from_evidence_adapter( + authorization_domain, + transport, + actor, + AuthMethod::Nip98, + binding, + delegation, + ) + .map_err(Into::into) + } + + fn delegation( + &self, + actor: PublicKey, + output: VerifiedDelegationOutput, + ) -> Result { + if !output.transport_wide { + return Err(EvidenceAdapterError::NarrowDelegation); + } + if output.delegate_pubkey != actor { + return Err(EvidenceAdapterError::DelegatedActorMismatch); + } + let expires_at = output.expires_at.map(DelegationExpiry::new).transpose()?; + VerifiedTransportDelegation::new_unrestricted( + output.owner_pubkey, + output.delegate_pubkey, + expires_at, + ) + .map_err(Into::into) + } + + /// Translate validated assertion claims into sealed, exact-bound evidence. + /// + /// `now_unix_seconds` and every claim argument must be copied from the + /// successful configured assertion-verifier result, never decoded again + /// from request data at this boundary. + #[allow(clippy::too_many_arguments)] + pub fn federated_assertion_from_validated_claims( + &self, + authorization_domain: CommunityId, + transport: AuthTransport, + issuer: &str, + subject: &str, + attested_pubkey: Option, + assertion_transport: AssertionTransport, + not_before: Option, + expires_at: u64, + now_unix_seconds: u64, + ) -> Result { + let principal = FederatedPrincipal::new(issuer, subject)?; + let expires_at = AssertionExpiry::new(expires_at)?; + if expires_at.is_expired_at(now_unix_seconds) { + return Err(EvidenceAdapterError::AssertionExpired); + } + let not_before = not_before.map(AssertionNotBefore::new); + if not_before.is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) { + return Err(EvidenceAdapterError::AssertionNotYetValid); + } + let key_attestation = attested_pubkey.map(VerifiedKeyAttestation::from_evidence_adapter); + Ok(VerifiedFederatedAssertion::from_evidence_adapter( + authorization_domain, + transport, + principal, + key_attestation, + assertion_transport, + not_before, + expires_at, + )) + } + + /// Seal provider-neutral evidence from an already verified assertion. + /// + /// The adapter rejects malformed or stale freshness bounds before the + /// evidence can reach a protected route. The profile and capabilities are + /// typed server-side values and cannot be selected by raw request input. + pub fn provider_evidence_from_verified_assertion( + &self, + assertion: VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + issued_at: u64, + fresh_until: u64, + now_unix_seconds: u64, + ) -> Result { + if issued_at == 0 || issued_at > now_unix_seconds { + return Err(EvidenceAdapterError::ProviderEvidenceNotYetValid); + } + if fresh_until <= issued_at + || fresh_until <= now_unix_seconds + || fresh_until > assertion.expires_at().unix_seconds() + { + return Err(EvidenceAdapterError::ProviderEvidenceExpired); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(EvidenceAdapterError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(EvidenceAdapterError::AssertionExpired); + } + Ok(VerifiedProviderEvidence::from_evidence_adapter( + assertion, + profile_id, + capabilities, + issued_at, + fresh_until, + )) + } + + /// Translate a typed active binding-store result. + /// + /// The adapter derives the authorization reason from lifecycle resolution + /// and provenance. Callers cannot label an enrolled row as pre-existing or + /// turn a provisioned row into a first-use enrollment. + #[allow(clippy::too_many_arguments)] + pub fn active_binding_from_store( + &self, + authorization_domain: CommunityId, + binding_domain: CommunityId, + binding_id: Uuid, + issuer: &str, + subject: &str, + bound_pubkey: PublicKey, + binding_version: u64, + expires_at: Option, + source: BindingSource, + resolution: ActiveBindingResolution, + assertion: Option<&VerifiedFederatedAssertion>, + ) -> Result { + if binding_domain != authorization_domain { + return Err(EvidenceAdapterError::BindingDomainMismatch); + } + let binding_version = BindingVersion::new(binding_version)?; + let expires_at = expires_at.map(BindingExpiry::new).transpose()?; + let principal = FederatedPrincipal::new(issuer, subject)?; + if let Some(assertion) = assertion { + if assertion.authorization_domain() != authorization_domain + || assertion.principal() != &principal + { + return Err(EvidenceAdapterError::AssertionBindingMismatch); + } + if assertion + .key_attestation() + .is_some_and(|key| key.pubkey() != bound_pubkey) + { + return Err(EvidenceAdapterError::AssertionBindingMismatch); + } + } + let reason = match (resolution, source) { + (ActiveBindingResolution::Existing, _) => AuthorizationReason::ExistingBinding, + (ActiveBindingResolution::Enrolled, BindingSource::AttestedKey) => { + if assertion + .and_then(VerifiedFederatedAssertion::key_attestation) + .is_none_or(|key| key.pubkey() != bound_pubkey) + { + return Err(EvidenceAdapterError::AttestationRequired); + } + AuthorizationReason::EnrolledAttestedKey + } + (ActiveBindingResolution::Enrolled, BindingSource::Tofu) => { + AuthorizationReason::EnrolledTofu + } + (ActiveBindingResolution::Enrolled, BindingSource::Provisioned) => { + return Err(EvidenceAdapterError::InvalidBindingResolution) + } + }; + VersionedBindingRef::from_evidence_adapter( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + reason, + ) + .map_err(Into::into) + } + + /// Translate successful local community policy into sealed admission. + pub fn community_access_from_policy( + &self, + tenant: &TenantContext, + resolved_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Result { + if tenant.community() != resolved_domain { + return Err(EvidenceAdapterError::AdmissionDomainMismatch); + } + Ok(AuthorizedCommunityAccess::from_evidence_adapter( + resolved_domain, + scopes, + channel_ids, + )) + } +} + +/// Rejection while translating trusted-workspace verifier/store output. +#[derive(Debug, Error)] +pub enum EvidenceAdapterError { + /// Existing cryptographic proof failed. + #[error(transparent)] + Authentication(#[from] crate::AuthError), + /// Sealed context evidence was inconsistent. + #[error(transparent)] + Context(#[from] AuthContextError), + /// Proof method cannot establish the requested transport. + #[error("verified proof method does not match requested transport")] + TransportMethodMismatch, + /// Delegation output named a different actor. + #[error("verified delegation output does not match authenticated actor")] + DelegatedActorMismatch, + /// Operation-scoped delegation cannot be promoted to transport-wide. + #[error("verified delegation output is narrower than the transport")] + NarrowDelegation, + /// A sealed proof cannot have its verified delegation replaced. + #[error("verified transport delegation is already present")] + DelegationAlreadyPresent, + /// Validated assertion was already expired. + #[error("validated assertion is expired")] + AssertionExpired, + /// Validated assertion is not yet current. + #[error("validated assertion is not yet valid")] + AssertionNotYetValid, + /// Provider evidence claims an issuance time after the server clock. + #[error("verified provider evidence is not yet valid")] + ProviderEvidenceNotYetValid, + /// Provider evidence has an invalid or expired hard freshness bound. + #[error("verified provider evidence is expired")] + ProviderEvidenceExpired, + /// Binding store result came from another exact domain. + #[error("active binding output belongs to another authorization domain")] + BindingDomainMismatch, + /// Binding does not match the validated assertion. + #[error("active binding output does not match validated assertion")] + AssertionBindingMismatch, + /// Attested-key enrollment did not carry the exact attested key. + #[error("attested-key enrollment lacks matching attestation")] + AttestationRequired, + /// Store provenance cannot produce the supplied lifecycle result. + #[error("active binding resolution and provenance are inconsistent")] + InvalidBindingResolution, + /// Local policy result belongs to another exact domain. + #[error("community admission belongs to another authorization domain")] + AdmissionDomainMismatch, +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, RelayUrl}; + + use super::*; + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn neutral_capabilities() -> CapabilitySet { + CapabilitySet::new(vec![crate::AuthorizationCapability::CommunityRead]) + .expect("synthetic capability set") + } + + fn neutral_profile(value: &str) -> AuthorizationProfileId { + AuthorizationProfileId::from_server_configuration(value) + .expect("synthetic provider profile") + } + + #[test] + fn neutral_verified_evidence_accepts_and_rejects_exact_parts() { + let adapter = VerifiedEvidenceAdapter::new(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain(1), + AuthTransport::HttpBridge, + "https://issuer.example", + "subject", + None, + AssertionTransport::TrustedProxy, + Some(90), + 200, + 100, + ) + .expect("synthetic verified assertion"); + let evidence = adapter + .provider_evidence_from_verified_assertion( + assertion, + neutral_profile("profile-a"), + neutral_capabilities(), + 100, + 180, + 100, + ) + .expect("matching verified provider evidence"); + + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Ok(()) + ); + assert_eq!( + evidence.validate_for( + domain(2), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::AuthorizationDomainMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::Git, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::TransportMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-b"), + &neutral_capabilities(), + 100, + ), + Err(crate::ProviderEvidenceValidationError::ProfileMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &CapabilitySet::new(vec![crate::AuthorizationCapability::GitRead]) + .expect("synthetic capability set"), + 100, + ), + Err(crate::ProviderEvidenceValidationError::CapabilityMismatch) + ); + assert_eq!( + evidence.validate_for( + domain(1), + AuthTransport::HttpBridge, + &neutral_profile("profile-a"), + &neutral_capabilities(), + 180, + ), + Err(crate::ProviderEvidenceValidationError::Expired) + ); + assert_eq!( + format!("{evidence:?}"), + "VerifiedProviderEvidence { authorization_domain: \"[redacted]\", authorized_transport: \"[redacted]\", principal: \"[redacted]\", profile_id: \"[redacted]\", capabilities: \"[redacted]\", issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }" + ); + } + + #[test] + fn nip42_factory_reverifies_signature_challenge_transport_and_actor() { + let adapter = VerifiedEvidenceAdapter::new(); + let actor = Keys::generate(); + let owner = Keys::generate(); + let challenge = "challenge"; + let relay = "wss://relay.example"; + let event = EventBuilder::auth(challenge, RelayUrl::parse(relay).expect("relay url")) + .sign_with_keys(&actor) + .expect("auth event"); + + let proof = adapter + .verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + relay, + Some(VerifiedDelegationOutput::from_workspace_verifier( + owner.public_key(), + actor.public_key(), + None, + true, + )), + ) + .expect("verified NIP-42 proof"); + assert_eq!( + proof.operation_binding().kind(), + VerifiedOperationBindingKind::NostrSession + ); + let substituted = adapter + .verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + "wss://other.example", + None, + ) + .expect_err("relay URL substitution must fail full verification"); + assert!(matches!( + substituted, + EvidenceAdapterError::Authentication(_) + )); + assert!(matches!( + adapter.verify_nip42( + domain(1), + AuthTransport::Git, + &event, + challenge, + relay, + None, + ), + Err(EvidenceAdapterError::TransportMethodMismatch) + )); + assert!(matches!( + adapter.verify_nip42( + domain(1), + AuthTransport::RelayWebSocket, + &event, + challenge, + relay, + Some(VerifiedDelegationOutput::from_workspace_verifier( + owner.public_key(), + Keys::generate().public_key(), + None, + true, + )), + ), + Err(EvidenceAdapterError::DelegatedActorMismatch) + )); + } + + #[test] + fn assertion_and_binding_factories_reject_cross_domain_and_forged_provenance() { + let adapter = VerifiedEvidenceAdapter::new(); + let actor = Keys::generate().public_key(); + let assertion = adapter + .federated_assertion_from_validated_claims( + domain(1), + AuthTransport::HttpBridge, + "https://issuer.example", + "subject", + Some(actor), + AssertionTransport::TrustedProxy, + None, + 200, + 100, + ) + .expect("assertion"); + assert!(matches!( + adapter.active_binding_from_store( + domain(1), + domain(2), + Uuid::new_v4(), + "https://issuer.example", + "subject", + actor, + 1, + None, + BindingSource::AttestedKey, + ActiveBindingResolution::Existing, + Some(&assertion), + ), + Err(EvidenceAdapterError::BindingDomainMismatch) + )); + assert!(matches!( + adapter.active_binding_from_store( + domain(1), + domain(1), + Uuid::new_v4(), + "https://issuer.example", + "subject", + actor, + 1, + None, + BindingSource::Provisioned, + ActiveBindingResolution::Enrolled, + Some(&assertion), + ), + Err(EvidenceAdapterError::InvalidBindingResolution) + )); + } +} diff --git a/crates/buzz-auth/src/lease.rs b/crates/buzz-auth/src/lease.rs new file mode 100644 index 0000000000..39d91013a7 --- /dev/null +++ b/crates/buzz-auth/src/lease.rs @@ -0,0 +1,834 @@ +//! Bounded, versioned authorization leases. +//! +//! A lease is issued only by the crate-owned federated finalizer after all +//! identity, provider, delegation, and binding evidence has been validated. +//! Lease consumers must supply the current typed context, lease, binding, +//! provider profile, and policy versions plus the exact capability being +//! exercised. + +use std::{fmt, sync::Arc, time::SystemTime}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::{ + context::{AuthContextVersion, AuthTransport, BindingVersion, VersionedBindingRef}, + provider::{AuthorizationCapability, AuthorizationProfileId, CapabilitySet, PolicyVersion}, +}; + +/// Largest application lease duration accepted by the portable runtime. +pub const MAX_APPLICATION_LEASE_SECONDS: u64 = 86_400; +/// Largest explicit clock-skew allowance accepted by the portable runtime. +pub const MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS: u64 = 300; +/// Largest renewal lead time accepted by the portable runtime. +pub const MAX_LEASE_RENEWAL_LEAD_SECONDS: u64 = 3_600; + +/// Centrally supplied authorization time in Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AuthorizationTime(u64); + +impl AuthorizationTime { + /// Preserve a Unix timestamp supplied by an authorization clock. + pub const fn from_unix_seconds(unix_seconds: u64) -> Self { + Self(unix_seconds) + } + + /// Timestamp as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for AuthorizationTime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationTime") + .field(&"[redacted]") + .finish() + } +} + +/// Centrally injected time source for authorization decisions and lease use. +pub trait AuthorizationClock: Send + Sync { + /// Return the current authorization time. + fn now(&self) -> Result; +} + +/// System-backed authorization clock used outside deterministic tests. +#[derive(Debug, Default)] +pub struct SystemAuthorizationClock; + +impl AuthorizationClock for SystemAuthorizationClock { + fn now(&self) -> Result { + let elapsed = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| AuthorizationClockError::BeforeUnixEpoch)?; + Ok(AuthorizationTime::from_unix_seconds(elapsed.as_secs())) + } +} + +/// Failure to obtain central authorization time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AuthorizationClockError { + /// The system clock was earlier than the Unix epoch. + #[error("authorization clock is earlier than the Unix epoch")] + BeforeUnixEpoch, + /// The injected clock could not provide a current value. + #[error("authorization clock is unavailable")] + Unavailable, +} + +/// Shared injected authorization clock. +pub type SharedAuthorizationClock = Arc; + +/// Explicit conservative clock-skew allowance. +/// +/// Skew is subtracted from every evidence bound when authority is issued; it +/// never extends a lease past an assertion, provider, delegation, binding, or +/// application expiry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorizationClockSkew(u64); + +impl AuthorizationClockSkew { + /// Build a bounded skew allowance in seconds. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds > MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS { + return Err(LeasePolicyError::ClockSkewTooLarge); + } + Ok(Self(seconds)) + } + + /// Configured conservative skew allowance in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Maximum application lifetime for an access lease or verification status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApplicationLeaseLimit(u64); + +impl ApplicationLeaseLimit { + /// Build a non-zero application lifetime no greater than one day. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds == 0 { + return Err(LeasePolicyError::ZeroApplicationLimit); + } + if seconds > MAX_APPLICATION_LEASE_SECONDS { + return Err(LeasePolicyError::ApplicationLimitTooLarge); + } + Ok(Self(seconds)) + } + + /// Configured maximum lifetime in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Server-owned access-lease bounds for one authorization domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AccessLeasePolicy { + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +} + +impl AccessLeasePolicy { + /// Combine an application maximum with an explicit conservative skew. + pub const fn new( + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, + ) -> Self { + Self { + application_limit, + clock_skew, + } + } + + /// Configured application maximum. + pub const fn application_limit(self) -> ApplicationLeaseLimit { + self.application_limit + } + + /// Configured conservative clock skew. + pub const fn clock_skew(self) -> AuthorizationClockSkew { + self.clock_skew + } +} + +/// Server-owned display-status bounds for verification-only mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerificationStatusPolicy { + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +} + +impl VerificationStatusPolicy { + /// Combine a short display maximum with an explicit conservative skew. + pub const fn new( + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, + ) -> Self { + Self { + application_limit, + clock_skew, + } + } + + /// Configured display-status maximum. + pub const fn application_limit(self) -> ApplicationLeaseLimit { + self.application_limit + } + + /// Configured conservative clock skew. + pub const fn clock_skew(self) -> AuthorizationClockSkew { + self.clock_skew + } +} + +/// Invalid server-owned lease policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeasePolicyError { + /// An application maximum was zero. + #[error("application authorization limit must be greater than zero")] + ZeroApplicationLimit, + /// An application maximum exceeded the portable upper bound. + #[error("application authorization limit exceeds the portable maximum")] + ApplicationLimitTooLarge, + /// The clock-skew allowance exceeded the portable upper bound. + #[error("authorization clock skew exceeds the portable maximum")] + ClockSkewTooLarge, + /// A renewal lead time was zero. + #[error("authorization lease renewal lead must be greater than zero")] + ZeroRenewalLead, + /// A renewal lead time exceeded the portable upper bound. + #[error("authorization lease renewal lead exceeds the portable maximum")] + RenewalLeadTooLarge, +} + +/// Monotonic version of an issued authorization lease. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct LeaseVersion(u64); + +impl LeaseVersion { + /// Initial lease version for a newly authorized session. + pub const INITIAL: Self = Self(1); + + /// Build a positive lease version. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(LeaseIssueError::InvalidLeaseVersion); + } + Ok(Self(value)) + } + + /// Numeric lease version. + pub const fn get(self) -> u64 { + self.0 + } +} + +impl fmt::Debug for LeaseVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("LeaseVersion") + .field(&"[redacted]") + .finish() + } +} + +/// Freshness bound obtained with an authoritative active binding. +/// +/// The identifier and version prevent a bound from one binding snapshot from +/// being reused with another binding or lifecycle version. +#[derive(PartialEq, Eq)] +pub struct BindingLeaseBound { + binding_id: Uuid, + binding_version: BindingVersion, + valid_until: u64, +} + +impl BindingLeaseBound { + /// Attach a non-zero freshness bound to an authoritative active binding. + pub fn new(binding: &VersionedBindingRef, valid_until: u64) -> Result { + if valid_until == 0 { + return Err(LeaseIssueError::InvalidBindingBound); + } + Ok(Self { + binding_id: binding.binding_id(), + binding_version: binding.binding_version(), + valid_until, + }) + } + + /// Stable binding identifier represented by this bound. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact active binding version represented by this bound. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Time after which active binding state must be resolved again. + pub const fn valid_until(&self) -> u64 { + self.valid_until + } +} + +impl fmt::Debug for BindingLeaseBound { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BindingLeaseBound") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("valid_until", &"[redacted]") + .finish() + } +} + +/// Versioned authority for a bounded set of federated capabilities. +/// +/// This type has no public constructor or deserialization path. Only the +/// crate-owned finalizer can issue it from a validated provider snapshot and +/// matching active binding evidence. +#[derive(PartialEq, Eq)] +pub struct AuthorizationLease { + context_version: AuthContextVersion, + lease_version: LeaseVersion, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding_id: Uuid, + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + capabilities: CapabilitySet, + issued_at: u64, + expires_at: u64, + correlation_id: Uuid, +} + +impl AuthorizationLease { + #[allow(clippy::too_many_arguments)] + pub(crate) fn issue( + lease_version: LeaseVersion, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding: &VersionedBindingRef, + binding_bound: BindingLeaseBound, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + capabilities: CapabilitySet, + provider_effective_until: u64, + now: AuthorizationTime, + policy: AccessLeasePolicy, + correlation_id: Uuid, + ) -> Result { + if binding_bound.binding_id != binding.binding_id() + || binding_bound.binding_version != binding.binding_version() + { + return Err(LeaseIssueError::BindingBoundMismatch); + } + let expires_at = conservative_expiry( + now, + provider_effective_until, + binding_bound.valid_until, + policy.application_limit, + policy.clock_skew, + )?; + Ok(Self { + context_version: AuthContextVersion::V1, + lease_version, + authorization_domain, + transport, + actor_pubkey, + owner_pubkey, + binding_id: binding.binding_id(), + binding_version: binding.binding_version(), + profile_id, + policy_version, + capabilities, + issued_at: now.unix_seconds(), + expires_at, + correlation_id, + }) + } + + /// Authorization-context contract version carried by the lease. + pub const fn context_version(&self) -> AuthContextVersion { + self.context_version + } + + /// Monotonic lease version. + pub const fn lease_version(&self) -> LeaseVersion { + self.lease_version + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact transport authorized by the lease. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// Authenticated Nostr actor authorized by the lease. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Verified delegated owner, when the actor is delegated. + pub const fn owner_pubkey(&self) -> Option { + self.owner_pubkey + } + + /// Stable active binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Exact active binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Exact capabilities granted by current provider policy. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Central issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Conservative earliest expiry in Unix seconds. + pub const fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Correlation identifier for the decision that issued the lease. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Compute deterministic renewal and hard-expiry deadlines. + pub fn renewal_schedule(&self, lead_time: LeaseRenewalLeadTime) -> LeaseRenewalSchedule { + LeaseRenewalSchedule { + renew_at: self + .expires_at + .saturating_sub(lead_time.seconds()) + .max(self.issued_at), + expires_at: self.expires_at, + } + } +} + +impl fmt::Debug for AuthorizationLease { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationLease") + .field("context_version", &self.context_version) + .field("lease_version", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport) + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("correlation_id", &"[redacted]") + .finish() + } +} + +/// Current typed state required to consume an authorization lease. +#[derive(Clone)] +pub struct LeaseUseRequirement { + /// Required authorization-context contract version. + pub context_version: AuthContextVersion, + /// Current lease version for the session. + pub lease_version: LeaseVersion, + /// Server-resolved authorization domain of the operation. + pub authorization_domain: CommunityId, + /// Transport carrying the protected operation. + pub transport: AuthTransport, + /// Authenticated actor performing the operation. + pub actor_pubkey: PublicKey, + /// Stable identifier of the current active binding. + pub binding_id: Uuid, + /// Current active binding version. + pub binding_version: BindingVersion, + /// Exact server-resolved provider profile currently selected for the operation. + pub profile_id: AuthorizationProfileId, + /// Current provider policy version. + pub policy_version: PolicyVersion, + /// Exact capability required by the operation. + pub capability: AuthorizationCapability, +} + +impl fmt::Debug for LeaseUseRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LeaseUseRequirement") + .field("context_version", &self.context_version) + .field("lease_version", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport) + .field("actor_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("capability", &"[redacted]") + .finish() + } +} + +/// Central validator for protected lease use. +#[derive(Clone)] +pub struct AuthorizationLeaseValidator { + clock: SharedAuthorizationClock, +} + +impl AuthorizationLeaseValidator { + /// Create a validator that always reads from the supplied central clock. + pub fn new(clock: SharedAuthorizationClock) -> Self { + Self { clock } + } + + /// Validate every typed version, subject, transport, capability, and time bound. + pub fn authorize( + &self, + lease: &AuthorizationLease, + requirement: &LeaseUseRequirement, + ) -> Result<(), LeaseValidationError> { + if lease.context_version != requirement.context_version { + return Err(LeaseValidationError::ContextVersionMismatch); + } + if lease.lease_version != requirement.lease_version { + return Err(LeaseValidationError::LeaseVersionMismatch); + } + if lease.authorization_domain != requirement.authorization_domain { + return Err(LeaseValidationError::AuthorizationDomainMismatch); + } + if lease.transport != requirement.transport { + return Err(LeaseValidationError::TransportMismatch); + } + if lease.actor_pubkey != requirement.actor_pubkey { + return Err(LeaseValidationError::ActorMismatch); + } + if lease.binding_id != requirement.binding_id { + return Err(LeaseValidationError::BindingIdMismatch); + } + if lease.binding_version != requirement.binding_version { + return Err(LeaseValidationError::BindingVersionMismatch); + } + if lease.profile_id != requirement.profile_id { + return Err(LeaseValidationError::AuthorizationProfileMismatch); + } + if lease.policy_version != requirement.policy_version { + return Err(LeaseValidationError::PolicyVersionMismatch); + } + if !lease.capabilities.contains(requirement.capability) { + return Err(LeaseValidationError::MissingCapability); + } + let now = self.clock.now()?; + if now.unix_seconds() < lease.issued_at { + return Err(LeaseValidationError::ClockMovedBeforeIssue); + } + if now.unix_seconds() >= lease.expires_at { + return Err(LeaseValidationError::Expired); + } + Ok(()) + } + + /// Validate and retain a per-capability operation guard. + /// + /// Long-running streams and mutations retain this guard and call + /// [`AuthorizationOperationGuard::revalidate`] immediately before each + /// protected emission or commit. The guard borrows an access lease and + /// cannot be constructed from verification-only status. + pub fn operation_guard<'a>( + &self, + lease: &'a AuthorizationLease, + requirement: LeaseUseRequirement, + ) -> Result, LeaseValidationError> { + self.authorize(lease, &requirement)?; + Ok(AuthorizationOperationGuard { + lease, + validator: self.clone(), + requirement, + }) + } + + /// Return the deterministic renewal action for WebSocket or audio authority. + pub fn renewal_action( + &self, + schedule: LeaseRenewalSchedule, + ) -> Result { + let now = self.clock.now()?; + if now.unix_seconds() >= schedule.expires_at { + return Ok(LeaseRenewalAction::Expired); + } + if now.unix_seconds() >= schedule.renew_at { + return Ok(LeaseRenewalAction::RenewNow); + } + Ok(LeaseRenewalAction::Current) + } + + /// Remaining whole seconds until a trusted authorization deadline. + /// + /// Long-lived transports use the same injected clock as lease validation; + /// they must not schedule expiry from an independently read wall clock. + pub fn seconds_until(&self, expires_at: u64) -> Result { + let now = self.clock.now()?; + Ok(expires_at.saturating_sub(now.unix_seconds())) + } +} + +impl fmt::Debug for AuthorizationLeaseValidator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationLeaseValidator") + .field("clock", &"[injected]") + .finish() + } +} + +/// Retained per-capability authorization for one in-flight operation. +/// +/// Initial validation is not a substitute for the mandatory pre-commit or +/// pre-emission revalidation hook. Expiry and current typed versions are +/// checked again every time [`Self::revalidate`] is called. +pub struct AuthorizationOperationGuard<'a> { + lease: &'a AuthorizationLease, + validator: AuthorizationLeaseValidator, + requirement: LeaseUseRequirement, +} + +impl AuthorizationOperationGuard<'_> { + /// Revalidate the retained authority immediately before a protected effect. + pub fn revalidate(&self) -> Result<(), LeaseValidationError> { + self.validator.authorize(self.lease, &self.requirement) + } + + /// Exact capability retained by this guard. + pub const fn capability(&self) -> AuthorizationCapability { + self.requirement.capability + } + + /// Hard conservative expiry after which revalidation always denies. + pub const fn expires_at(&self) -> u64 { + self.lease.expires_at + } +} + +impl fmt::Debug for AuthorizationOperationGuard<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationOperationGuard") + .field("lease", &"[borrowed]") + .field("validator", &self.validator) + .field("requirement", &self.requirement) + .finish() + } +} + +/// Bounded lead time for renewing long-lived authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaseRenewalLeadTime(u64); + +impl LeaseRenewalLeadTime { + /// Build a non-zero renewal lead no greater than one hour. + pub const fn from_seconds(seconds: u64) -> Result { + if seconds == 0 { + return Err(LeasePolicyError::ZeroRenewalLead); + } + if seconds > MAX_LEASE_RENEWAL_LEAD_SECONDS { + return Err(LeasePolicyError::RenewalLeadTooLarge); + } + Ok(Self(seconds)) + } + + /// Renewal lead in seconds. + pub const fn seconds(self) -> u64 { + self.0 + } +} + +/// Deterministic renewal and fail-closed expiry deadlines. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeaseRenewalSchedule { + renew_at: u64, + expires_at: u64, +} + +impl LeaseRenewalSchedule { + /// Time at which a long-lived transport should begin renewal. + pub const fn renew_at(self) -> u64 { + self.renew_at + } + + /// Hard deadline at which the transport must stop using authority. + pub const fn expires_at(self) -> u64 { + self.expires_at + } +} + +/// Current hook result for a long-lived transport's renewal loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeaseRenewalAction { + /// Existing authority remains current before its renewal window. + Current, + /// Renewal is due; failure to renew before expiry must close fail-closed. + RenewNow, + /// The hard deadline passed; authority must no longer be used. + Expired, +} + +/// Failure to issue an authorization lease or bounded display status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseIssueError { + /// A lease version was zero. + #[error("authorization lease version must be greater than zero")] + InvalidLeaseVersion, + /// A binding freshness bound was zero. + #[error("binding freshness bound must be greater than zero")] + InvalidBindingBound, + /// Binding freshness evidence named another binding or version. + #[error("binding freshness bound does not match the active binding")] + BindingBoundMismatch, + /// Computing the configured application bound overflowed Unix seconds. + #[error("application authorization bound overflowed")] + ApplicationBoundOverflow, + /// At least one required evidence bound was already expired or inside skew. + #[error("authorization evidence is no longer current")] + EvidenceExpired, +} + +impl LeaseIssueError { + /// Stable audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::InvalidLeaseVersion => "authorization_lease_issue_001", + Self::InvalidBindingBound => "authorization_lease_issue_002", + Self::BindingBoundMismatch => "authorization_lease_issue_003", + Self::ApplicationBoundOverflow => "authorization_lease_issue_004", + Self::EvidenceExpired => "authorization_lease_issue_005", + } + } +} + +/// Fail-closed protected-operation lease validation error. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseValidationError { + /// The central authorization clock failed. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// The context did not carry a federated access lease. + #[error("authorization context does not carry an access lease")] + MissingLease, + /// The authorization-context contract version was not exact. + #[error("authorization context version does not match")] + ContextVersionMismatch, + /// The current session lease version was not exact. + #[error("authorization lease version does not match")] + LeaseVersionMismatch, + /// The operation belonged to another authorization domain. + #[error("authorization lease domain does not match")] + AuthorizationDomainMismatch, + /// The operation used another transport. + #[error("authorization lease transport does not match")] + TransportMismatch, + /// The operation was performed by another actor. + #[error("authorization lease actor does not match")] + ActorMismatch, + /// Current binding state identifies another stable binding. + #[error("authorization lease binding identifier does not match")] + BindingIdMismatch, + /// Current binding state has another version. + #[error("authorization lease binding version does not match")] + BindingVersionMismatch, + /// Current server policy selected another provider profile. + #[error("authorization lease provider profile does not match")] + AuthorizationProfileMismatch, + /// Current provider policy has another version. + #[error("authorization lease policy version does not match")] + PolicyVersionMismatch, + /// The lease did not grant the operation's exact capability. + #[error("authorization lease does not include the required capability")] + MissingCapability, + /// Central time moved before the lease issue time. + #[error("authorization clock moved before lease issue time")] + ClockMovedBeforeIssue, + /// The lease reached its conservative earliest expiry. + #[error("authorization lease has expired")] + Expired, +} + +impl LeaseValidationError { + /// Stable audit and metric code. + pub const fn code(&self) -> &'static str { + match self { + Self::Clock(_) => "authorization_lease_use_001", + Self::MissingLease => "authorization_lease_use_002", + Self::ContextVersionMismatch => "authorization_lease_use_003", + Self::LeaseVersionMismatch => "authorization_lease_use_004", + Self::AuthorizationDomainMismatch => "authorization_lease_use_005", + Self::TransportMismatch => "authorization_lease_use_006", + Self::ActorMismatch => "authorization_lease_use_007", + Self::BindingIdMismatch => "authorization_lease_use_013", + Self::BindingVersionMismatch => "authorization_lease_use_008", + Self::AuthorizationProfileMismatch => "authorization_lease_use_014", + Self::PolicyVersionMismatch => "authorization_lease_use_009", + Self::MissingCapability => "authorization_lease_use_010", + Self::ClockMovedBeforeIssue => "authorization_lease_use_011", + Self::Expired => "authorization_lease_use_012", + } + } +} + +pub(crate) fn conservative_expiry( + now: AuthorizationTime, + provider_effective_until: u64, + binding_valid_until: u64, + application_limit: ApplicationLeaseLimit, + clock_skew: AuthorizationClockSkew, +) -> Result { + let application_until = now + .unix_seconds() + .checked_add(application_limit.seconds()) + .ok_or(LeaseIssueError::ApplicationBoundOverflow)?; + let earliest = provider_effective_until + .min(binding_valid_until) + .min(application_until); + let conservative = earliest.saturating_sub(clock_skew.seconds()); + if conservative <= now.unix_seconds() { + return Err(LeaseIssueError::EvidenceExpired); + } + Ok(conservative) +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 1699555831..db12a82428 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -21,6 +21,10 @@ pub mod access; pub mod context; /// Authentication error types. pub mod error; +/// Trusted-workspace adapter for sealed verifier and binding evidence. +pub mod evidence_adapter; +/// Bounded, versioned authorization leases. +pub mod lease; /// NIP-42 challenge–response authentication. pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). @@ -43,11 +47,25 @@ pub use context::{ BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, - FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, - VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, + FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, + ProviderEvidenceValidationError, ResolvedFederatedPolicy, VerifiedFederatedAssertion, + VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef, }; pub use error::AuthError; +pub use evidence_adapter::{ + ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput, + VerifiedEvidenceAdapter, +}; +pub use lease::{ + AccessLeasePolicy, ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockError, + AuthorizationClockSkew, AuthorizationLease, AuthorizationLeaseValidator, + AuthorizationOperationGuard, AuthorizationTime, BindingLeaseBound, LeaseIssueError, + LeasePolicyError, LeaseRenewalAction, LeaseRenewalLeadTime, LeaseRenewalSchedule, + LeaseUseRequirement, LeaseValidationError, LeaseVersion, SharedAuthorizationClock, + SystemAuthorizationClock, VerificationStatusPolicy, MAX_APPLICATION_LEASE_SECONDS, + MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS, MAX_LEASE_RENEWAL_LEAD_SECONDS, +}; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use nip98_replay::{ @@ -55,7 +73,8 @@ pub use nip98_replay::{ MAX_REPLAY_TTL_SECS, }; pub use provider::{ - AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial, + AuthorizationAuthority, AuthorizationCapability, + AuthorizationClock as ProviderAuthorizationClock, AuthorizationDenial, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 7b3e032962..ce65bc1636 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -91,6 +91,11 @@ impl CapabilitySet { .iter() .all(|capability| self.0.binary_search(capability).is_ok()) } + + /// Return whether the set includes one exact portable capability. + pub fn contains(&self, capability: AuthorizationCapability) -> bool { + self.0.binary_search(&capability).is_ok() + } } impl fmt::Debug for CapabilitySet { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5a8f24df89..15ad91119d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -59,6 +59,7 @@ async fn enforce_http_admission( /// /// Returns the authenticated public key and an event ID for replay detection. /// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +#[cfg(test)] pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, @@ -127,6 +128,99 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +/// Verify tenant bridge authentication and retain sealed proof evidence. +/// +/// The development-only `X-Pubkey` path returns no proof and is therefore +/// rejected later if this exact domain is configured for enforcement. +type ProtectedBridgeAuth = ( + nostr::PublicKey, + [u8; 32], + Option, +); + +pub(crate) fn verify_protected_bridge_auth( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, + require_payload: bool, + authorization_domain: buzz_core::CommunityId, +) -> Result)> { + let (pubkey, event_id) = verify_bridge_auth_with_options( + headers, + method, + url, + body, + require_auth_token, + require_payload, + )?; + let Some(encoded) = headers + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Nostr ")) + else { + return Ok((pubkey, event_id, None)); + }; + use base64::Engine as _; + let event_json = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid Nostr auth"))?; + let proof = buzz_auth::VerifiedEvidenceAdapter::new() + .verify_nip98( + authorization_domain, + buzz_auth::AuthTransport::HttpBridge, + &event_json, + url, + method, + body, + None, + ) + .map_err(|error| { + api_error( + StatusCode::UNAUTHORIZED, + &format!("NIP-98 evidence: {error}"), + ) + })?; + if proof.actor_pubkey() != pubkey { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98 evidence actor mismatch", + )); + } + Ok((pubkey, event_id, Some(proof))) +} + +pub(crate) fn retain_bridge_proof( + proof: Option, + auth_tag: Option<&str>, +) -> Result>, (StatusCode, Json)> { + let Some(proof) = proof else { + return Ok(None); + }; + let actor = proof.actor_pubkey(); + let proof = match crate::corporate_identity::verify_unconditional_nip_oa_owner(actor, auth_tag) + { + Some(owner) => buzz_auth::VerifiedEvidenceAdapter::new() + .attach_transport_delegation( + proof, + buzz_auth::VerifiedDelegationOutput::from_workspace_verifier( + owner, actor, None, true, + ), + ) + .map_err(|_| { + api_error( + StatusCode::UNAUTHORIZED, + "NIP-98 delegation evidence mismatch", + ) + })?, + None => proof, + }; + Ok(Some(Arc::new(proof))) +} + /// Corporate identity enrollment must always start from cryptographic proof of /// the Nostr key. The development-only `X-Pubkey` fallback is caller-controlled /// and therefore cannot safely participate in a durable identity binding. @@ -188,28 +282,78 @@ async fn verify_bridge_corporate_identity( headers: &HeaderMap, pubkey: nostr::PublicKey, auth_tag: Option<&str>, -) -> Result)> { - let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( +) -> Result, (StatusCode, Json)> { + let identity_assertion = crate::corporate_identity::identity_assertion_from_headers( + state, + tenant.community(), headers, - &state.config.corporate_identity, - ); - crate::corporate_identity::verify_corporate_identity( + ) + .map_err(crate::corporate_identity::CorporateIdentityError::into_api_error)?; + match crate::corporate_identity::verify_corporate_identity( state, tenant.community(), pubkey, - identity_jwt.as_deref(), + identity_assertion.as_ref(), auth_tag, ) .await - .map_err(|e| e.into_api_error()) + { + Ok(proof) => Ok(Some(proof)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = ?error, "observational bridge identity verification unavailable"); + Ok(None) + } + Err(error) => Err(error.into_api_error()), + } +} + +fn seal_bridge_assertion( + state: &AppState, + tenant: &TenantContext, + proof: Option<&crate::corporate_identity::CorporateIdentityProof>, +) -> Result>, (StatusCode, Json)> { + let Some(proof) = proof else { + return Ok(None); + }; + match crate::corporate_identity::current_verified_assertion_for_proof( + state, + proof, + tenant.community(), + buzz_auth::AuthTransport::HttpBridge, + ) { + Ok(assertion) => Ok(assertion.map(Arc::new)), + Err(error) + if crate::authorization_runtime::transport::legacy_identity_lane( + state, + tenant.community(), + ) == crate::authorization_runtime::transport::LegacyIdentityLane::ObserveOnly => + { + tracing::warn!(error = %error, "observational bridge assertion sealing unavailable"); + Ok(None) + } + Err(error) => Err(error.into_api_error()), + } } async fn finalize_bridge_corporate_identity( state: &AppState, tenant: &TenantContext, pubkey: nostr::PublicKey, - proof: crate::corporate_identity::CorporateIdentityProof, + proof: Option, ) -> Result<(), (StatusCode, Json)> { + if crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + != crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(()); + } + let Some(proof) = proof else { + return Ok(()); + }; crate::corporate_identity::finalize_corporate_identity(state, tenant.community(), pubkey, proof) .await .map(|_| ()) @@ -449,6 +593,7 @@ async fn handle_channel_window_filter( filter: &nostr::Filter, accessible_channels: &[uuid::Uuid], events: &mut Vec, + release_channels: &mut std::collections::BTreeSet, ) -> Result<(), (StatusCode, Json)> { use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; @@ -461,6 +606,7 @@ async fn handle_channel_window_filter( if !accessible_channels.contains(&ch_id) { return Ok(()); } + release_channels.insert(ch_id); // Composite request cursor: `until` + `before_id`, both or neither. The // window path has no timestamp-only fallback — that ambiguity is the @@ -678,7 +824,7 @@ pub async fn submit_event( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -687,20 +833,27 @@ pub async fn submit_event( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Everything after auth — admission, replay, membership, parse, ingest — // runs inside the helper. The thin wrapper here owns the single terminal // attribution line so it fires for every outcome, including admission/ // replay/membership failures that previously returned before any log fired. - let outcome = - submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let outcome = submit_event_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &outcome { SubmitOutcome::Ok { accepted, .. } => { tracing::info!( - pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, @@ -714,7 +867,6 @@ pub async fn submit_event( .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -726,7 +878,6 @@ pub async fn submit_event( } SubmitOutcome::Rejected { kind, reason, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = 400u16, accepted = false, @@ -737,7 +888,6 @@ pub async fn submit_event( } SubmitOutcome::Err { status, .. } => { tracing::warn!( - pubkey = %pubkey_hex, route = "/events", status = status.as_u16(), accepted = false, @@ -802,6 +952,7 @@ async fn submit_event_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. @@ -877,6 +1028,59 @@ async fn submit_event_authed( }; } }; + let verified_proof = match retain_bridge_proof(verified_proof, auth_tag) { + Ok(proof) => proof, + Err(response) => { + return SubmitOutcome::Err { + status: response.0, + response, + } + } + }; + let verified_assertion = match seal_bridge_assertion(state, tenant, identity_proof.as_ref()) { + Ok(assertion) => assertion, + Err(response) => { + return SubmitOutcome::Err { + status: response.0, + response, + } + } + }; + let protected_result = match verified_proof.as_ref() { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + Arc::clone(proof), + verified_assertion.clone(), + crate::protected_surface::event_ingest_capability(buzz_core::kind::event_kind_u32( + &event, + )), + uuid::Uuid::new_v4(), + "http_events", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = match protected_result { + Ok(authority) => authority, + Err(error) => { + tracing::warn!(error = %error, "http event protected authorization denied"); + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization denied"), + }; + } + }; + if protected.revalidate().is_err() { + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization expired"), + }; + } if let Err(e) = finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await { return SubmitOutcome::Err { @@ -884,13 +1088,25 @@ async fn submit_event_authed( response: e, }; } - if let Some(owner) = nip_oa_owner { + if let Some(owner) = nip_oa_owner.filter(|_| { + crate::authorization_runtime::transport::legacy_identity_lane(state, tenant.community()) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + }) { + if protected.revalidate().is_err() { + return SubmitOutcome::Err { + status: StatusCode::FORBIDDEN, + response: api_error(StatusCode::FORBIDDEN, "protected authorization expired"), + }; + } super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } let kind_u32 = buzz_core::kind::event_kind_u32(&event); let auth = IngestAuth::Http { pubkey, + owner_pubkey: nip_oa_owner, + verified_proof, + verified_assertion, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, }; @@ -966,7 +1182,7 @@ pub async fn query_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -975,31 +1191,32 @@ pub async fn query_events( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Admission, replay, membership, and filter execution all run inside the // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = query_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &result { - Ok(Json(Value::Array(events))) => { - tracing::info!( - pubkey = %pubkey_hex, - route = "/query", - status = 200u16, - result_count = events.len(), - "HTTP bridge request" - ); + Ok(Json(Value::Array(_))) => { + tracing::info!(route = "/query", status = 200u16, "HTTP bridge request"); } Ok(_) => { - tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request"); + tracing::info!(route = "/query", status = 200u16, "HTTP bridge request"); } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, route = "/query", status = status.as_u16(), "HTTP bridge request" @@ -1019,6 +1236,7 @@ async fn query_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; @@ -1071,320 +1289,424 @@ 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}")))?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "http_query", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; - if filters.iter().any(|f| f.search.is_some()) { - if has_mixed_search_filters(&filters) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "mixed search and non-search filters not supported", - )); + // Keep the complete post-authorization computation inside one release + // boundary. Every success and every backend-derived failure must pass the + // same final authority check before its response shape becomes observable. + let fetched = async { + if filters.iter().any(|f| f.search.is_some()) { + if has_mixed_search_filters(&filters) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "mixed search and non-search filters not supported", + )); + } + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + let result = handle_bridge_search( + state, + &raw_filters, + &filters, + &accessible_channels, + tenant, + &authed_pubkey_hex, + &pubkey_bytes, + ) + .await?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + return Ok(result); } - return handle_bridge_search( - state, - &raw_filters, - &filters, - &accessible_channels, - tenant, - &authed_pubkey_hex, - &pubkey_bytes, - ) - .await; - } - if let Some(presence_events) = synthesize_presence(state, tenant, &filters).await { - return Ok(Json(Value::Array(presence_events))); - } + if let Some(presence_events) = synthesize_presence(state, tenant, &filters).await { + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + return Ok(( + Json(Value::Array(presence_events)), + std::collections::BTreeSet::new(), + )); + } - let mut events: Vec = Vec::new(); - let mut handled: std::collections::HashSet = std::collections::HashSet::new(); + let mut events: Vec = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); + let mut handled: std::collections::HashSet = std::collections::HashSet::new(); - // Channel-window filters (`top_level: true`) — the GUI read-model surface. - // Dispatched first: a window filter is never a feed/thread/catchall query. - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if !extension_flag(raw, "top_level") { - continue; + // Channel-window filters (`top_level: true`) — the GUI read-model surface. + // Dispatched first: a window filter is never a feed/thread/catchall query. + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if !extension_flag(raw, "top_level") { + continue; + } + handle_channel_window_filter( + state, + tenant, + raw, + filter, + &accessible_channels, + &mut events, + &mut release_channels, + ) + .await?; + handled.insert(idx); } - handle_channel_window_filter( - state, - tenant, - raw, - filter, - &accessible_channels, - &mut events, - ) - .await?; - handled.insert(idx); - } - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { + continue; + } + let feed_types = match extract_feed_types(raw) { + Some(t) => t, + None => continue, + }; + + let limit = filter + .limit + .map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT)) + .unwrap_or(20); + let since = filter + .since + .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); + + let mut seen_types = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + let mut feed_count = 0i64; + for feed_type in &feed_types { + let canonical = if feed_type == "agent_activity" { + "activity" + } else { + feed_type.as_str() + }; + if !seen_types.insert(canonical) { + continue; + } + if feed_count >= limit { + break; + } + let remaining = limit - feed_count; + let type_events = match canonical { + "mentions" => state + .db + .query_feed_mentions_routed( + "bridge_feed", + tenant.community(), + &pubkey_bytes, + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, + "needs_action" => state + .db + .query_feed_needs_action_routed( + "bridge_feed", + tenant.community(), + &pubkey_bytes, + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, + "activity" => state + .db + .query_feed_activity_routed( + "bridge_feed", + tenant.community(), + &accessible_channels, + since, + remaining, + ) + .await + .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, + _ => continue, + }; + for se in type_events { + if !seen.insert(se.event.id) { + continue; + } + if !event_in_accessible_channel(&se, &accessible_channels) { + continue; + } + // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 + // or kind:30622) to a non-owner via the feed path, even though feed SQL + // kind allowlists already exclude these kinds. + if !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) { + continue; + } + if append_bridge_stored_event(&mut events, &mut release_channels, &se) { + feed_count += 1; + } + } + } + handled.insert(idx); } - let feed_types = match extract_feed_types(raw) { - Some(t) => t, - None => continue, - }; - let limit = filter - .limit - .map(|l| (l as i64).min(BRIDGE_FEED_MAX_LIMIT)) - .unwrap_or(20); - let since = filter - .since - .and_then(|s| chrono::DateTime::from_timestamp(s.as_secs() as i64, 0)); - - let mut seen_types = std::collections::HashSet::new(); - let mut seen = std::collections::HashSet::new(); - let mut feed_count = 0i64; - for feed_type in &feed_types { - let canonical = if feed_type == "agent_activity" { - "activity" - } else { - feed_type.as_str() - }; - if !seen_types.insert(canonical) { + let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { continue; } - if feed_count >= limit { - break; - } - let remaining = limit - feed_count; - let type_events = match canonical { - "mentions" => state - .db - .query_feed_mentions_routed( - "bridge_feed", - tenant.community(), - &pubkey_bytes, - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, - "needs_action" => state - .db - .query_feed_needs_action_routed( - "bridge_feed", - tenant.community(), - &pubkey_bytes, - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, - "activity" => state - .db - .query_feed_activity_routed( - "bridge_feed", - tenant.community(), - &accessible_channels, - since, - remaining, - ) - .await - .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, + let depth = match extract_depth_limit(raw) { + Some(d) => d, + None => continue, + }; + let e_values = match filter.generic_tags.get(&e_tag_key) { + Some(vs) if vs.len() == 1 => vs, + _ => continue, + }; + let root_hex = match e_values.iter().next() { + Some(h) => h, + None => continue, + }; + let root_bytes = match hex::decode(root_hex) { + Ok(b) if b.len() == 32 => b, _ => continue, }; - for se in type_events { - if !seen.insert(se.event.id) { + + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + handled.insert(idx); continue; } + } + + let limit = filter + .limit + .unwrap_or(100) + .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; + let thread_cursor = extract_thread_cursor(raw); + let thread_replies = state + .db + .get_thread_replies( + tenant.community(), + &root_bytes, + Some(depth), + limit, + thread_cursor.as_deref(), + ) + .await + .map_err(|e| internal_error(&format!("thread query error: {e}")))?; + + for reply in thread_replies { + let se = reply.stored_event; if !event_in_accessible_channel(&se, &accessible_channels) { continue; } // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the feed path, even though feed SQL - // kind allowlists already exclude these kinds. + // or kind:30622) to a non-owner via the thread path, even though + // requires_h_channel_scope already excludes these kinds from thread metadata. if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { continue; } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); - feed_count += 1; - } + append_bridge_stored_event(&mut events, &mut release_channels, &se); } + handled.insert(idx); } - handled.insert(idx); - } - let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; - } - let depth = match extract_depth_limit(raw) { - Some(d) => d, - None => continue, - }; - let e_values = match filter.generic_tags.get(&e_tag_key) { - Some(vs) if vs.len() == 1 => vs, - _ => continue, - }; - let root_hex = match e_values.iter().next() { - Some(h) => h, - None => continue, - }; - let root_bytes = match hex::decode(root_hex) { - Ok(b) if b.len() == 32 => b, - _ => continue, - }; - - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - handled.insert(idx); + // Phase 1 — pure construction + validation, in filter order. Access-scope + // skips and the `before_id` BAD_REQUEST are decided here, before any DB + // work is issued (validation errors are deterministic client mistakes, so + // surfacing them ahead of transient DB errors is strictly more predictable). + let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); + for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { + if handled.contains(&idx) { continue; } - } - let limit = filter - .limit - .unwrap_or(100) - .min(BRIDGE_THREAD_MAX_LIMIT as usize) as u32; - let thread_cursor = extract_thread_cursor(raw); - let thread_replies = state - .db - .get_thread_replies( - tenant.community(), - &root_bytes, - Some(depth), - limit, - thread_cursor.as_deref(), - ) - .await - .map_err(|e| internal_error(&format!("thread query error: {e}")))?; - - for reply in thread_replies { - let se = reply.stored_event; - if !event_in_accessible_channel(&se, &accessible_channels) { - continue; - } - // Defense-in-depth: never deliver a result-gated event (e.g. kind:44200 - // or kind:30622) to a non-owner via the thread path, even though - // requires_h_channel_scope already excludes these kinds from thread metadata. - if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { - continue; - } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + continue; + } } - } - handled.insert(idx); - } - // Phase 1 — pure construction + validation, in filter order. Access-scope - // skips and the `before_id` BAD_REQUEST are decided here, before any DB - // work is issued (validation errors are deterministic client mistakes, so - // surfacing them ahead of transient DB errors is strictly more predictable). - let mut catchall_queries: Vec<(usize, buzz_db::EventQuery)> = Vec::new(); - for (idx, (raw, filter)) in raw_filters.iter().zip(filters.iter()).enumerate() { - if handled.contains(&idx) { - continue; - } - - if let Some(ch_id) = extract_channel_from_filter(filter) { - if !accessible_channels.contains(&ch_id) { - continue; + let mut query = crate::handlers::req::build_event_query_from_filter( + filter, + &pubkey_bytes, + state, + tenant.community(), + ) + .await; + crate::handlers::req::apply_access_scope_to_query( + &mut query, + extract_channel_from_filter(filter), + &accessible_channels, + ); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } - } - - let mut query = crate::handlers::req::build_event_query_from_filter( - filter, - &pubkey_bytes, - state, - tenant.community(), - ) - .await; - crate::handlers::req::apply_access_scope_to_query( - &mut query, - extract_channel_from_filter(filter), - &accessible_channels, - ); - // Shared-gated visibility pushdown: must mirror WS REQ so that a page of - // newer private events does not starve older shared ones off the page. - if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { - query.shared_gated_reader = Some(pubkey_bytes.clone()); - } - match extract_before_id(raw) { - BeforeId::Malformed => { - return Err(api_error( - StatusCode::BAD_REQUEST, - "before_id must be a 64-char hex event id", - )); - } - BeforeId::Valid(bid) => { - if query.until.is_none() { + match extract_before_id(raw) { + BeforeId::Malformed => { return Err(api_error( StatusCode::BAD_REQUEST, - "before_id requires until to be set", + "before_id must be a 64-char hex event id", )); } - query.before_id = Some(bid); + BeforeId::Valid(bid) => { + if query.until.is_none() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "before_id requires until to be set", + )); + } + query.before_id = Some(bid); + } + BeforeId::Absent => {} } - BeforeId::Absent => {} - } - // Honor `page` on non-search general queries so offset paging works for - // the empty-query people directory (kind:0 listing). The FTS path - // (`handle_bridge_search`) has its own `page`/`per_page`; a filter with - // no `search` field lands here instead, where paging would otherwise be - // dropped and the directory would terminate at its first page. Deterministic - // ordering in `query_events` (`created_at DESC, id ASC`) makes offset paging - // stable. `page` defaults to 1 → offset 0, so unrelated general queries are - // unaffected. - if let Some(offset) = extract_page_offset(raw, query.limit) { - query.offset = Some(offset); - } + // Honor `page` on non-search general queries so offset paging works for + // the empty-query people directory (kind:0 listing). The FTS path + // (`handle_bridge_search`) has its own `page`/`per_page`; a filter with + // no `search` field lands here instead, where paging would otherwise be + // dropped and the directory would terminate at its first page. Deterministic + // ordering in `query_events` (`created_at DESC, id ASC`) makes offset paging + // stable. `page` defaults to 1 → offset 0, so unrelated general queries are + // unaffected. + if let Some(offset) = extract_page_offset(raw, query.limit) { + query.offset = Some(offset); + } - catchall_queries.push((idx, query)); - } + catchall_queries.push((idx, query)); + } - // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). - // Phase 3 consumes results in original filter order, so response ordering - // and error semantics match the previous serial loop. - use futures_util::stream::{self, StreamExt}; - let db = state.db.clone(); - let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { - let db = db.clone(); - async move { (idx, db.query_events_routed("bridge_query", &query).await) } - })) - .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); - - // Phase 3 — post-processing, strictly in filter order. - while let Some((idx, filter_events)) = catchall_results.next().await { - let filter = &filters[idx]; - match filter_events { - Ok(stored_events) => { - for se in stored_events { - if !event_in_accessible_channel(&se, &accessible_channels) { - continue; - } - if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) { - continue; - } - // Result-level read auth: never hand a viewer-private snapshot - // (kind:30622) to anyone but its owner, even via kindless `ids`. - // Also enforces author-only kinds (30300/30350) and the persona - // shared-gate (kind:30175 without ["shared","true"]). Single call - // covers all three gated event classes. - if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) { - continue; - } - if let Ok(v) = serde_json::to_value(&se.event) { - events.push(v); + // Phase 2 — DB reads, bounded-concurrent, order-preserving (`buffered`). + // Phase 3 consumes results in original filter order, so response ordering + // and error semantics match the previous serial loop. + use futures_util::stream::{self, StreamExt}; + let db = state.db.clone(); + let mut catchall_results = + stream::iter(catchall_queries.into_iter().map(|(idx, query)| { + let db = db.clone(); + async move { (idx, db.query_events_routed("bridge_query", &query).await) } + })) + .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); + + // Phase 3 — post-processing, strictly in filter order. + while let Some((idx, filter_events)) = catchall_results.next().await { + let filter = &filters[idx]; + match filter_events { + Ok(stored_events) => { + for se in stored_events { + if !event_in_accessible_channel(&se, &accessible_channels) { + continue; + } + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) { + continue; + } + // Result-level read auth: never hand a viewer-private snapshot + // (kind:30622) to anyone but its owner, even via kindless `ids`. + // Also enforces author-only kinds (30300/30350) and the persona + // shared-gate (kind:30175 without ["shared","true"]). Single call + // covers all three gated event classes. + if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) + { + continue; + } + append_bridge_stored_event(&mut events, &mut release_channels, &se); } } - } - Err(e) => { - return Err(internal_error(&format!("query error: {e}"))); + Err(e) => { + return Err(internal_error(&format!("query error: {e}"))); + } } } + + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + Ok((Json(Value::Array(events)), release_channels)) } + .await; + let fetched = match fetched { + Ok((response, release_channels)) => { + revalidate_bridge_response_channels( + state, + tenant.community(), + &pubkey_bytes, + &release_channels, + ) + .await?; + Ok(response) + } + Err(error) => Err(error), + }; + release_protected_bridge_fetch(fetched, |fetched| protected.release_fetched(fetched))? +} - Ok(Json(Value::Array(events))) +/// Revalidate the authoritative stored channel ids retained alongside the +/// response without caches after all asynchronous fetch work. This is the HTTP +/// bridge's post-fetch/pre-emission channel fence. Event tags are intentionally +/// not consulted: a stored channel-scoped row remains fenced even if its signed +/// event omits or malforms an `h` tag. +async fn revalidate_bridge_response_channels( + state: &AppState, + community_id: buzz_core::tenant::CommunityId, + actor: &[u8], + channels: &std::collections::BTreeSet, +) -> Result<(), (StatusCode, Json)> { + for &channel_id in channels { + let allowed = state + .db + .channel_read_authorized(community_id, channel_id, actor) + .await + .map_err(|error| internal_error(&format!("channel release fence: {error}")))?; + if !allowed { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: channel access changed before response release", + )); + } + } + Ok(()) +} + +fn append_bridge_stored_event( + events: &mut Vec, + release_channels: &mut std::collections::BTreeSet, + stored: &buzz_core::StoredEvent, +) -> bool { + let Ok(value) = serde_json::to_value(&stored.event) else { + return false; + }; + if let Some(channel_id) = stored.channel_id { + release_channels.insert(channel_id); + } + events.push(value); + true } /// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`. @@ -1414,7 +1736,7 @@ pub async fn count_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( &headers, "POST", &url, @@ -1423,29 +1745,29 @@ pub async fn count_events( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; - let pubkey_hex = pubkey.to_hex(); - // Admission, replay, membership, and count execution all run inside the // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = count_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + verified_proof, + ) + .await; match &result { - Ok(Json(value)) => { - let count = value.get("count").and_then(Value::as_u64); - tracing::info!( - pubkey = %pubkey_hex, - route = "/count", - status = 200u16, - result_count = count, - "HTTP bridge request" - ); + Ok(Json(_)) => { + tracing::info!(route = "/count", status = 200u16, "HTTP bridge request"); } Err((status, _)) => { tracing::warn!( - pubkey = %pubkey_hex, route = "/count", status = status.as_u16(), "HTTP bridge request" @@ -1465,6 +1787,7 @@ async fn count_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + verified_proof: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; @@ -1509,9 +1832,36 @@ 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}")))?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::CommunityRead, + uuid::Uuid::new_v4(), + "http_count", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = Arc::new( + protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?, + ); + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; let mut total: u64 = 0; + let mut release_channels = std::collections::BTreeSet::new(); for filter in &filters { let needs_author_only_filtering = crate::handlers::req::filter_can_match_author_only_kinds(filter); @@ -1535,6 +1885,7 @@ async fn count_events_authed( if !accessible_channels.contains(&ch_id) { continue; // Skip filters targeting inaccessible channels. } + release_channels.insert(ch_id); // Channel is accessible — count with pushability check. let mut query = crate::handlers::req::build_event_query_from_filter( filter, @@ -1559,7 +1910,10 @@ async fn count_events_authed( && !needs_result_gated_filtering && !needs_shared_gate_filtering { - match state.db.count_events_routed("bridge_count", &query).await { + let fetched = state.db.count_events_routed("bridge_count", &query).await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1569,11 +1923,13 @@ async fn count_events_authed( // Fallback: query + post-filter for non-pushable constraints. let mut q = query; crate::handlers::req::apply_count_fallback_limit(&mut q); - match state + let fetched = state .db .query_events_routed_bounded("bridge_count_fallback", &q) - .await - { + .await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1604,6 +1960,7 @@ async fn count_events_authed( } else { // No channel filter — use SQL-level channel_ids pushdown to count // only events in accessible channels (+ global events). + release_channels.extend(accessible_channels.iter().copied()); let mut query = crate::handlers::req::build_event_query_from_filter( filter, &pubkey_bytes, @@ -1630,7 +1987,10 @@ async fn count_events_authed( && !needs_shared_gate_filtering { query.limit = None; - match state.db.count_events_routed("bridge_count", &query).await { + let fetched = state.db.count_events_routed("bridge_count", &query).await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1639,11 +1999,13 @@ async fn count_events_authed( } else { // Fallback: query a bounded candidate set + post-filter. crate::handlers::req::apply_count_fallback_limit(&mut query); - match state + let fetched = state .db .query_events_routed_bounded("bridge_count_fallback", &query) - .await - { + .await; + match release_protected_bridge_fetch(fetched, |fetched| { + protected.release_fetched(fetched) + })? { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1674,6 +2036,20 @@ async fn count_events_authed( } } + if !crate::connection::release_channel_set_read_authority( + state.db.clone(), + tenant.community(), + release_channels.into_iter().collect(), + pubkey_bytes, + Some(protected), + ) + .await + { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: channel access changed before response release", + )); + } Ok(Json(serde_json::json!({ "count": total }))) } @@ -1723,7 +2099,7 @@ async fn handle_bridge_search( tenant: &buzz_core::tenant::TenantContext, reader_pubkey_hex: &str, pubkey_bytes: &[u8], -) -> Result, (StatusCode, Json)> { +) -> Result<(Json, std::collections::BTreeSet), (StatusCode, Json)> { // Bridge always includes global (channel-less) events — same as WS with // full scopes. `None` means no accessible channels and no global access → // empty result set (the caller short-circuits exactly as the WS door EOSEs). @@ -1732,10 +2108,16 @@ async fn handle_bridge_search( true, // include_global ) { Some(scope) => scope, - None => return Ok(Json(Value::Array(Vec::new()))), + None => { + return Ok(( + Json(Value::Array(Vec::new())), + std::collections::BTreeSet::new(), + )); + } }; let mut events: Vec = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new(); for (raw, filter) in raw_filters.iter().zip(filters) { @@ -1848,13 +2230,11 @@ async fn handle_bridge_search( if !seen_ids.insert(*id_array) { continue; } - if let Ok(v) = serde_json::to_value(&stored.event) { - events.push(v); - } + append_bridge_stored_event(&mut events, &mut release_channels, stored); } } - Ok(Json(Value::Array(events))) + Ok((Json(Value::Array(events)), release_channels)) } /// Query parameters for the webhook trigger endpoint. @@ -1894,6 +2274,19 @@ pub async fn workflow_webhook( .map_err(|_| not_found("workflow not found"))?; let community_id = tenant.community(); + // A webhook secret authenticates the trigger but carries no protected + // authority into the workflow run or its delayed actions. Enforce must + // therefore stop before even the run row exists until a transaction-owning + // workflow executor can validate authority at every durable commit. + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + crate::protected_surface::require_effect_permit( + mode, + crate::protected_surface::EffectSurfaceId::WorkflowBackgroundExecution, + ) + .map_err(|_| not_found("workflow not found"))?; + let workflow = state .db .get_workflow(community_id, id) @@ -2078,12 +2471,43 @@ async fn synthesize_presence( all_pubkeys.dedup(); // Look up Redis. - let presence_map = state + let stored_presence = state .pubsub .get_presence_bulk(tenant, &all_pubkeys) .await .unwrap_or_default(); + let verifier = crate::authorization_runtime::ephemeral::AuthorityTokenVerifier::new( + state.db.clone(), + state.relay_keypair.secret_key().as_secret_bytes(), + ); + let mut presence_map = std::collections::HashMap::new(); + for (pubkey_hex, stored_status) in stored_presence { + match crate::authorization_runtime::ephemeral::decode_presence(&stored_status) { + Ok(Some(protected)) => { + let Ok(pubkey) = nostr::PublicKey::from_hex(&pubkey_hex) else { + continue; + }; + if verifier + .verify_actor_context( + tenant.community(), + protected.context_id, + pubkey.to_bytes(), + &protected.authority, + ) + .await + .is_ok() + { + presence_map.insert(pubkey_hex, protected.status); + } + } + Ok(None) if !state.is_protected_enforcing(tenant.community()) => { + presence_map.insert(pubkey_hex, stored_status); + } + Ok(None) | Err(_) => {} + } + } + if presence_map.is_empty() { return Some(Vec::new()); } @@ -2139,7 +2563,7 @@ async fn authorize_moderation_read( headers: &HeaderMap, path: &str, raw_query: Option<&str>, -) -> Result)> { +) -> Result)> { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) @@ -2158,7 +2582,7 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let (pubkey, event_id_bytes, verified_proof) = verify_protected_bridge_auth( headers, "GET", &url, @@ -2167,6 +2591,8 @@ async fn authorize_moderation_read( state.config.require_auth_token, state.config.corporate_identity.require, ), + false, + tenant.community(), )?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2190,14 +2616,91 @@ async fn authorize_moderation_read( "restricted: moderator access required", ) })?; + let verified_proof = retain_bridge_proof(verified_proof, auth_tag)?; + let verified_assertion = seal_bridge_assertion(state, &tenant, identity_proof.as_ref())?; + let protected_result = match verified_proof { + Some(proof) => { + crate::authorization_runtime::transport::authorize_if_configured( + state, + proof, + verified_assertion, + buzz_auth::AuthorizationCapability::Moderate, + uuid::Uuid::new_v4(), + "http_moderation_read", + ) + .await + } + None => crate::authorization_runtime::transport::authorize_unwired_if_configured( + state, + tenant.community(), + ), + }; + let protected = protected_result + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization denied"))?; + protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; finalize_bridge_corporate_identity(state, &tenant, pubkey, identity_proof).await?; - Ok(tenant) + Ok(ModerationReadAuthorization { + tenant, + protected, + actor_pubkey: pubkey_bytes, + }) +} + +/// Retains exact protected authority through the moderation fetch boundary. +struct ModerationReadAuthorization { + tenant: TenantContext, + protected: crate::authorization_runtime::transport::ProtectedAuthorization, + actor_pubkey: Vec, +} + +/// Revalidate both provider-neutral protected authority and the local +/// moderation role after the fetch transaction releases its locks and before +/// the response is returned to the transport. +async fn release_moderation_read( + state: &Arc, + authorization: &ModerationReadAuthorization, +) -> Result<(), (StatusCode, Json)> { + authorization + .protected + .revalidate() + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired"))?; + crate::handlers::moderation_authz::authorize_moderation_action( + &authorization.tenant, + state, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map(|_| ()) + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access changed before response release", + ) + }) } /// Cap on rows returned by a single moderation read. const MODERATION_READ_LIMIT: i64 = 500; +/// Releases a bridge fetch result only after the retained authority check. +/// +/// Keeping the complete `Result` inside the release boundary ensures that +/// authority loss takes precedence over both successful rows and backend +/// failures; neither response shape is observable after the lease is stale. +fn release_protected_bridge_fetch( + fetched: Result, + release: impl FnOnce(Result) -> Result, R>, +) -> Result, (StatusCode, Json)> { + release(fetched) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "protected authorization expired")) +} + /// Optional `?status=` and `?limit=` query for moderation reads. #[derive(serde::Deserialize, Default)] pub struct ModerationReadQuery { @@ -2219,23 +2722,51 @@ pub async fn moderation_reports( RawQuery(raw_query): RawQuery, Query(q): Query, ) -> Result, (StatusCode, Json)> { - let tenant = authorize_moderation_read( + let authorization = authorize_moderation_read( &state, &headers, "/moderation/reports", raw_query.as_deref(), ) .await?; - let rows = state + let mut transaction = state .db - .list_moderation_reports( - tenant.community(), - q.status.as_deref(), - clamp_limit(q.limit), + .begin_transaction() + .await + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", ) + })?; + let fetched = buzz_db::moderation::list_reports_tx( + &mut transaction, + authorization.tenant.community(), + q.status.as_deref(), + clamp_limit(q.limit), + ) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list reports: {e}")))?; + let response = Json(Value::Array(rows.iter().map(report_json).collect())); + transaction + .commit() .await - .map_err(|e| internal_error(&format!("list reports: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } /// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). @@ -2245,15 +2776,46 @@ pub async fn moderation_audit( RawQuery(raw_query): RawQuery, Query(q): Query, ) -> Result, (StatusCode, Json)> { - let tenant = + let authorization = authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) .await?; - let rows = state + let mut transaction = state .db - .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) + .begin_transaction() + .await + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + let fetched = buzz_db::moderation::list_actions_tx( + &mut transaction, + authorization.tenant.community(), + clamp_limit(q.limit), + ) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list actions: {e}")))?; + let response = Json(Value::Array(rows.iter().map(action_json).collect())); + transaction + .commit() .await - .map_err(|e| internal_error(&format!("list actions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } /// `GET /moderation/restricted` — currently banned/timed-out members. @@ -2261,14 +2823,42 @@ pub async fn moderation_restricted( State(state): State>, headers: HeaderMap, ) -> Result, (StatusCode, Json)> { - let tenant = + let authorization = authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; - let rows = state + let mut transaction = state .db - .list_community_restrictions(tenant.community()) + .begin_transaction() .await - .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) + .map_err(|error| internal_error(&format!("begin moderation read: {error}")))?; + crate::handlers::moderation_authz::authorize_moderation_action_tx( + &mut transaction, + &authorization.tenant, + &authorization.actor_pubkey, + None, + crate::handlers::moderation_authz::ModerationTarget::None, + crate::handlers::moderation_authz::ModerationAction::ViewQueue, + ) + .await + .map_err(|_| { + api_error( + StatusCode::FORBIDDEN, + "restricted: moderator access required", + ) + })?; + let fetched = + buzz_db::moderation::list_restricted_tx(&mut transaction, authorization.tenant.community()) + .await; + let rows = release_protected_bridge_fetch(fetched, |fetched| { + authorization.protected.release_fetched(fetched) + })? + .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; + let response = Json(Value::Array(rows.iter().map(ban_json).collect())); + transaction + .commit() + .await + .map_err(|error| internal_error(&format!("finish moderation read: {error}")))?; + release_moderation_read(&state, &authorization).await?; + Ok(response) } fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { @@ -2351,6 +2941,61 @@ mod tests { .to_bytes() } + #[test] + fn bridge_release_retains_stored_channel_without_trusting_event_tags() { + let event = EventBuilder::new(Kind::TextNote, "channel-scoped") + .sign_with_keys(&Keys::generate()) + .expect("sign event without h tag"); + let channel_id = uuid::Uuid::new_v4(); + let stored = buzz_core::StoredEvent::new(event, Some(channel_id)); + let mut events = Vec::new(); + let mut release_channels = std::collections::BTreeSet::new(); + + assert!(append_bridge_stored_event( + &mut events, + &mut release_channels, + &stored, + )); + assert_eq!(events.len(), 1); + assert_eq!( + release_channels.into_iter().collect::>(), + [channel_id] + ); + } + + #[test] + fn observational_http_modes_use_read_only_identity_lane() { + use crate::authorization_runtime::{ + finalization::AuthorizationMode, + transport::{legacy_identity_lane_for_mode, LegacyIdentityLane}, + }; + + for mode in [AuthorizationMode::Shadow, AuthorizationMode::VerifyOnly] { + assert_eq!( + legacy_identity_lane_for_mode(Some(mode)), + LegacyIdentityLane::ObserveOnly + ); + } + assert_eq!( + legacy_identity_lane_for_mode(Some(AuthorizationMode::Enforce)), + LegacyIdentityLane::ProtectedEnforce + ); + } + + #[test] + fn protected_bridge_fetch_release_fences_success_and_backend_error_outcomes() { + let released = release_protected_bridge_fetch::(Ok(7), Ok) + .expect("current authority releases fetched rows") + .expect("successful fetch remains successful"); + assert_eq!(released, 7); + + for fetched in [Ok(7), Err("database unavailable")] { + let (status, _) = release_protected_bridge_fetch::(fetched, |_| Err(())) + .expect_err("authority loss must hide every fetched outcome"); + assert_eq!(status, StatusCode::FORBIDDEN); + } + } + #[test] fn corporate_identity_disables_x_pubkey_bridge_fallback() { let keys = Keys::generate(); @@ -3153,13 +3798,6 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } - /// Offsets are sized from the *clamped* limit the DB will honor, not from - /// what the client asked for. `filter_to_query_params` clamps an absent or - /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in - /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) - /// and that clamped value is what arrives here — so page N starts exactly - /// N-1 full pages in. Sizing from an unclamped limit would step past rows - /// the previous page never returned. #[test] fn extract_page_offset_sizes_pages_from_clamped_limit() { let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; @@ -3488,7 +4126,10 @@ mod tests { require_corporate_identity: bool, ) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + config.database_url = database_url.clone(); // 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()); @@ -3502,7 +4143,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(&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)) @@ -3871,8 +4512,8 @@ mod tests { "expected exactly 1 attribution line for invalid-JSON arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), - "attribution line must carry the pubkey;\nlog:\n{log}" + !log.contains(&pubkey_hex[..16]), + "runtime log exposed pubkey;\nlog:\n{log}" ); } @@ -3931,8 +4572,8 @@ mod tests { "expected exactly 1 attribution line for IngestError::Rejected arm, got {n};\nlog:\n{log}" ); assert!( - log.contains(&pubkey_hex[..16]), - "attribution line must carry the pubkey;\nlog:\n{log}" + !log.contains(&pubkey_hex[..16]), + "runtime log exposed pubkey;\nlog:\n{log}" ); } } diff --git a/crates/buzz-relay/src/authorization_runtime/transport.rs b/crates/buzz-relay/src/authorization_runtime/transport.rs new file mode 100644 index 0000000000..d497b4fb42 --- /dev/null +++ b/crates/buzz-relay/src/authorization_runtime/transport.rs @@ -0,0 +1,1420 @@ +//! Provider-neutral protected-transport authorization. +//! +//! Transport handlers request one portable capability at the point of use. +//! An injected resolver owns provider evaluation, binding/admission ordering, +//! and finalization. This module accepts only a finalized access context as +//! authority, binds it back to the exact operation, and retains a guard for +//! mandatory pre-commit or pre-emission revalidation. + +use std::{collections::HashMap, fmt, sync::Arc}; + +use async_trait::async_trait; +use buzz_auth::{ + AuthContext, AuthTransport, AuthorizationCapability, AuthorizationLease, + AuthorizationLeaseValidator, AuthorizationProfileId, BindingVersion, LeaseUseRequirement, + LeaseValidationError, PolicyVersion, SharedAuthorizationClock, VerificationOnlyDisposition, + VerifiedFederatedAssertion, VerifiedNostrProof, +}; +use buzz_core::CommunityId; +use thiserror::Error; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::finalization::{AuthorizationMode, EnrollmentDisposition}; + +/// Compatibility policy for the inherited corporate-identity lane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyIdentityLane { + /// Runtime absent or explicitly Off: run the inherited verifier and + /// finalizer without changing pre-O4 behavior. + Legacy, + /// Shadow or VerifyOnly: cryptographic verification and read-only policy + /// evaluation are allowed, but identity state and public projections are + /// immutable. This lane can never enroll, reactivate, strengthen, update, + /// or retire a binding. + ObserveOnly, + /// Enforce: the protected resolver owns admission/finalization and legacy + /// projection must not run. + ProtectedEnforce, +} + +/// Resolve the centralized identity compatibility policy for one exact domain. +pub fn legacy_identity_lane( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> LegacyIdentityLane { + legacy_identity_lane_for_mode( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)), + ) +} + +/// Resolve compatibility behavior from an already selected exact-domain mode. +pub const fn legacy_identity_lane_for_mode(mode: Option) -> LegacyIdentityLane { + match mode { + None | Some(AuthorizationMode::Off) => LegacyIdentityLane::Legacy, + Some(AuthorizationMode::Shadow) | Some(AuthorizationMode::VerifyOnly) => { + LegacyIdentityLane::ObserveOnly + } + Some(AuthorizationMode::Enforce) => LegacyIdentityLane::ProtectedEnforce, + } +} + +/// Server-owned activation for one exact authorization domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DomainTransportPolicy { + authorization_domain: CommunityId, + mode: AuthorizationMode, +} + +impl DomainTransportPolicy { + /// Construct policy from immutable server configuration. + pub const fn from_server_configuration( + authorization_domain: CommunityId, + mode: AuthorizationMode, + ) -> Self { + Self { + authorization_domain, + mode, + } + } + + /// Exact configured domain. + pub const fn authorization_domain(self) -> CommunityId { + self.authorization_domain + } + + /// Exact configured activation mode. + pub const fn mode(self) -> AuthorizationMode { + self.mode + } +} + +/// Exact protected operation presented to the configured resolver. +#[derive(Clone)] +pub struct ProtectedOperationRequest { + verified_proof: Arc, + capability: AuthorizationCapability, + correlation_id: Uuid, + session_id: Option, + surface: &'static str, + cancellation: Option, + verified_assertion: Option>, + enrollment_assertion: Option>, +} + +impl ProtectedOperationRequest { + /// Build one exact operation after transport authentication. + pub fn new( + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + ) -> Result { + Self::new_with_cancellation( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + None, + None, + ) + } + + pub(crate) fn new_with_cancellation( + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_id: Option, + cancellation: Option, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProtectedTransportError::InvalidCorrelationId); + } + if session_id == Some(Uuid::nil()) { + return Err(ProtectedTransportError::InvalidSessionId); + } + if surface.is_empty() { + return Err(ProtectedTransportError::InvalidSurface); + } + if !crate::protected_surface::protected_operation_matches( + surface, + verified_proof.authorized_transport(), + capability, + ) { + return Err(ProtectedTransportError::SurfaceCapabilityMismatch); + } + Ok(Self { + verified_proof, + capability, + correlation_id, + session_id, + surface, + cancellation, + verified_assertion, + enrollment_assertion: None, + }) + } + + fn new_enrollment( + verified_proof: Arc, + assertion: Arc, + correlation_id: Uuid, + surface: &'static str, + ) -> Result { + let mut request = Self::new( + verified_proof, + Some(Arc::clone(&assertion)), + AuthorizationCapability::InviteClaim, + correlation_id, + surface, + )?; + request.enrollment_assertion = Some(assertion); + Ok(request) + } + + /// Exact server-resolved authorization domain. + pub fn authorization_domain(&self) -> CommunityId { + self.verified_proof.authorization_domain() + } + + /// Transport carrying this operation. + pub fn transport(&self) -> AuthTransport { + self.verified_proof.authorized_transport() + } + + /// Authenticated actor. + pub fn actor_pubkey(&self) -> nostr::PublicKey { + self.verified_proof.actor_pubkey() + } + + /// Verified Nostr owner for delegated authority, when present. + pub fn owner_pubkey(&self) -> Option { + self.verified_proof + .verified_delegation() + .map(buzz_auth::VerifiedTransportDelegation::owner_pubkey) + } + + /// Sealed verifier evidence for this exact transport request or session. + pub fn verified_proof(&self) -> &Arc { + &self.verified_proof + } + + /// Direct verified assertion retained only for first-enrollment resolution. + pub fn enrollment_assertion(&self) -> Option<&Arc> { + self.enrollment_assertion.as_ref() + } + + /// Current direct assertion verified by the transport identity adapter. + pub fn verified_assertion(&self) -> Option<&Arc> { + self.verified_assertion.as_ref() + } + + /// Exact dynamically selected capability. + pub const fn capability(&self) -> AuthorizationCapability { + self.capability + } + + /// Correlation identifier for this decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable server-owned session identity for long-lived transports. + pub const fn session_id(&self) -> Option { + self.session_id + } + + /// Stable low-cardinality surface name for resolver telemetry. + pub const fn surface(&self) -> &'static str { + self.surface + } + + /// Server-owned cancellation for a leased session, when applicable. + pub fn cancellation(&self) -> Option { + self.cancellation.clone() + } +} + +impl fmt::Debug for ProtectedOperationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedOperationRequest") + .field("authorization_domain", &"[redacted]") + .field("transport", &self.transport()) + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("capability", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("surface", &self.surface) + .finish() + } +} + +/// Resolver failure that carries only a stable, non-sensitive code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("protected authorization resolver denied the operation ({code})")] +pub struct ProtectedResolutionError { + code: &'static str, +} + +impl ProtectedResolutionError { + /// Construct a sanitized resolver denial. + pub const fn new(code: &'static str) -> Self { + Self { code } + } + + /// Stable denial code suitable for metrics and protocol mapping. + pub const fn code(self) -> &'static str { + self.code + } +} + +/// Exact-domain resolver implemented by the authorization adapter. +/// +/// Direct and delegated actors intentionally enter through the same method. +/// The request's verified owner is evidence, never a separate bypass path. +#[async_trait] +pub trait ProtectedAuthorizationResolver: Send + Sync { + /// Resolve and fully finalize current authority for one operation. + async fn resolve( + &self, + request: &ProtectedOperationRequest, + ) -> Result; + + /// Run a read-only observation without producing authority or mutating + /// identity, binding, membership, projection, or lease state. + async fn observe( + &self, + _request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + Err(ProtectedResolutionError::new( + "observational_provider_unavailable", + )) + } + + /// Resolve display-only current binding status without granting access or + /// mutating binding, membership, projection, or lease state. + async fn present( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + Err(ProtectedResolutionError::new( + "client_status_presentation_unavailable", + )) + } +} + +/// Display-only result retained with its exact invalidation observer. +pub struct ProtectedStatusResolution { + disposition: VerificationOnlyDisposition, + observer: Arc, + evaluation_generation: u64, +} + +impl ProtectedStatusResolution { + /// Couple one current status to the invalidation fence captured for it. + pub fn new( + disposition: VerificationOnlyDisposition, + observer: Arc, + evaluation_generation: u64, + ) -> Self { + Self { + disposition, + observer, + evaluation_generation, + } + } + + /// Consume the display result for dedicated delivery and reconciliation. + pub(crate) fn into_parts( + self, + ) -> ( + VerificationOnlyDisposition, + Arc, + u64, + ) { + (self.disposition, self.observer, self.evaluation_generation) + } +} + +impl fmt::Debug for ProtectedStatusResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedStatusResolution") + .field("disposition", &"[redacted]") + .field("observer", &"[registered]") + .field("evaluation_generation", &"[redacted]") + .finish() + } +} + +/// Finalized resolver output. +/// +/// Fields are private so enforcing access cannot be constructed without the +/// exact per-decision observer registered during finalization. +pub struct ProtectedResolution { + kind: ProtectedResolutionKind, +} + +enum ProtectedResolutionKind { + Access { + context: Box, + observer: Arc, + }, + VerificationOnly(VerificationOnlyDisposition), + Enrollment { + disposition: EnrollmentDisposition, + observer: Arc, + }, +} + +impl ProtectedResolution { + /// Couple finalized access with its exact read-only invalidation observer. + /// + /// Enforcing access has no observer-free constructor: + /// + /// ```compile_fail + /// use buzz_auth::AuthContext; + /// use buzz_relay::authorization_runtime::transport::ProtectedResolution; + /// + /// fn invalid(context: Box) -> ProtectedResolution { + /// ProtectedResolution::access(context) + /// } + /// ``` + pub fn access(context: Box, observer: Arc) -> Self { + Self { + kind: ProtectedResolutionKind::Access { context, observer }, + } + } + + /// Preserve a display-only result without manufacturing access state. + pub fn verification_only(disposition: VerificationOnlyDisposition) -> Self { + Self { + kind: ProtectedResolutionKind::VerificationOnly(disposition), + } + } + + /// Couple staged direct enrollment with its exact invalidation observer. + pub fn enrollment( + disposition: EnrollmentDisposition, + observer: Arc, + ) -> Self { + Self { + kind: ProtectedResolutionKind::Enrollment { + disposition, + observer, + }, + } + } +} + +impl fmt::Debug for ProtectedResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedResolution") + .field("kind", &"[redacted]") + .finish() + } +} + +/// Current trusted state for every dependency that can invalidate a lease. +#[derive(Clone, PartialEq, Eq)] +pub struct LeaseCurrentState { + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, +} + +impl LeaseCurrentState { + /// Construct state from the trusted finalization/invalidation runtime. + pub const fn from_trusted_runtime( + binding_version: BindingVersion, + profile_id: AuthorizationProfileId, + policy_version: PolicyVersion, + ) -> Self { + Self { + binding_version, + profile_id, + policy_version, + } + } +} + +impl fmt::Debug for LeaseCurrentState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LeaseCurrentState") + .field("binding_version", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("policy_version", &"[redacted]") + .finish() + } +} + +/// Exact per-decision observer used by a retained lease guard. +/// +/// Implementations retain the registered invalidation fence and session +/// dependencies for this exact finalization. They must check those dependencies +/// and read current binding/profile/policy state before returning. The observer +/// is intentionally read-only; transport adoption cannot publish invalidations. +pub trait LeaseCurrentStateObserver: Send + Sync { + /// Validate the registered fence/dependencies and return current versions. + fn observe_current(&self) -> Result; + + /// Return the captured durable generation and exact selector dependencies + /// required by a transaction-owned mutation commit. + /// + /// Read-only observers may retain the default fail-closed implementation. + fn observe_commit_fence( + &self, + ) -> Result { + Err(LeaseCurrentStateError::Unavailable) + } +} + +/// Fail-closed current-state result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum LeaseCurrentStateError { + /// Binding, profile, or policy state changed. + #[error("protected authorization state is stale")] + Stale, + /// Current state could not be read. + #[error("protected authorization state is unavailable")] + Unavailable, +} + +/// Optional protected-transport runtime. +/// +/// Domains absent from this map, and domains explicitly configured `Off`, use +/// legacy behavior. An enforcing domain never falls back after a resolver or +/// lease failure. +pub struct ProtectedTransportRuntime { + domains: HashMap, + resolver: Arc, + validator: AuthorizationLeaseValidator, +} + +impl ProtectedTransportRuntime { + /// Build an exact-domain runtime, rejecting duplicate configuration. + pub fn new( + policies: impl IntoIterator, + resolver: Arc, + clock: SharedAuthorizationClock, + ) -> Result { + let mut domains = HashMap::new(); + for policy in policies { + if domains + .insert(policy.authorization_domain, policy.mode) + .is_some() + { + return Err(ProtectedTransportError::AmbiguousDomainPolicy); + } + } + Ok(Self { + domains, + resolver, + validator: AuthorizationLeaseValidator::new(clock), + }) + } + + /// Return the exact configured mode, if this runtime owns the domain. + pub fn mode_for_domain(&self, domain: CommunityId) -> Option { + self.domains.get(&domain).copied() + } + + /// Exact domains whose background effects must remain unavailable. + pub fn enforcing_domains(&self) -> Vec { + self.domains + .iter() + .filter_map(|(domain, mode)| (*mode == AuthorizationMode::Enforce).then_some(*domain)) + .collect() + } + + /// Resolve one protected operation or explicitly preserve legacy behavior. + pub async fn authorize( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let Some(mode) = self.mode_for_domain(request.authorization_domain()) else { + return Ok(ProtectedAuthorization::Legacy); + }; + match mode { + AuthorizationMode::Off => Ok(ProtectedAuthorization::Legacy), + // Transport adoption has no mutation-free decision-only resolver. + // Preserve legacy access in both observational modes and leave + // their status evaluation to the lane that owns that API. + AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { + // Observation failures are telemetry only. These modes must + // never alter the inherited access result. + let _ = self.resolver.observe(request).await; + Ok(ProtectedAuthorization::Legacy) + } + AuthorizationMode::Enforce => { + let resolution = self + .resolver + .resolve(request) + .await + .map_err(ProtectedTransportError::Resolution)?; + let (context, observer) = match resolution.kind { + ProtectedResolutionKind::Access { context, observer } => (context, observer), + ProtectedResolutionKind::VerificationOnly(_status) => { + return Err(ProtectedTransportError::VerificationOnlyCannotGrant) + } + ProtectedResolutionKind::Enrollment { .. } => { + return Err(ProtectedTransportError::EnrollmentCannotGrantAccess) + } + }; + let context: Arc = Arc::from(context); + let authority = ProtectedOperationAuthority { + context, + capability: request.capability(), + validator: self.validator.clone(), + observer, + }; + authority.validate_exact_request(request)?; + authority.revalidate()?; + Ok(ProtectedAuthorization::Access(authority)) + } + } + } + + /// Resolve a dedicated client presentation only in VerifyOnly or Enforce. + /// Off and Shadow remain byte-for-byte non-presenting legacy behavior. + pub async fn present_status( + &self, + request: &ProtectedOperationRequest, + ) -> Result, ProtectedTransportError> { + match self.mode_for_domain(request.authorization_domain()) { + Some(AuthorizationMode::VerifyOnly | AuthorizationMode::Enforce) => self + .resolver + .present(request) + .await + .map(Some) + .map_err(ProtectedTransportError::Resolution), + None | Some(AuthorizationMode::Off | AuthorizationMode::Shadow) => Ok(None), + } + } + + /// Resolve direct first-enrollment evidence without requiring an active binding. + pub async fn authorize_enrollment( + &self, + request: &ProtectedOperationRequest, + ) -> Result { + let Some(mode) = self.mode_for_domain(request.authorization_domain()) else { + return Ok(ProtectedEnrollmentAuthorization::Legacy); + }; + match mode { + AuthorizationMode::Off | AuthorizationMode::Shadow | AuthorizationMode::VerifyOnly => { + Ok(ProtectedEnrollmentAuthorization::Legacy) + } + AuthorizationMode::Enforce => { + if request.capability() != AuthorizationCapability::InviteClaim + || request.enrollment_assertion().is_none() + || request.owner_pubkey().is_some() + { + return Err(ProtectedTransportError::EnrollmentEvidenceRequired); + } + let resolution = self + .resolver + .resolve(request) + .await + .map_err(ProtectedTransportError::Resolution)?; + let (disposition, observer) = match resolution.kind { + ProtectedResolutionKind::Enrollment { + disposition, + observer, + } => (disposition, observer), + _ => return Err(ProtectedTransportError::EnrollmentEvidenceRequired), + }; + let authority = ProtectedEnrollmentAuthority { + disposition, + observer, + validator: self.validator.clone(), + }; + authority.validate_exact_request(request)?; + authority.revalidate()?; + Ok(ProtectedEnrollmentAuthorization::Enrollment(authority)) + } + } + } +} + +/// Consult the optional runtime for one authenticated relay operation. +/// +/// This is the common transport seam used by HTTP, WebSocket, Git, media, and +/// audio handlers. Runtime absence is the only implicit legacy case. +pub async fn authorize_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + )?; + runtime.authorize(&request).await +} + +/// Consult the runtime for a leased session and expose only its server-owned +/// cancellation token to the resolver's invalidation registration. +#[allow(clippy::too_many_arguments)] +pub async fn authorize_session_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + verified_assertion: Option>, + capability: AuthorizationCapability, + correlation_id: Uuid, + surface: &'static str, + session_id: Uuid, + cancellation: CancellationToken, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new_with_cancellation( + verified_proof, + verified_assertion, + capability, + correlation_id, + surface, + Some(session_id), + Some(cancellation), + )?; + let authority = runtime.authorize(&request).await?; + state + .conn_manager + .retain_protected_session_authority(session_id, &authority); + Ok(authority) +} + +/// Resolve staged direct authority for atomic invite enrollment. +pub async fn authorize_enrollment_if_configured( + state: &crate::state::AppState, + verified_proof: Arc, + assertion: Arc, + correlation_id: Uuid, +) -> Result { + let Some(runtime) = state.protected_transport() else { + return Ok(ProtectedEnrollmentAuthorization::Legacy); + }; + let request = ProtectedOperationRequest::new_enrollment( + verified_proof, + assertion, + correlation_id, + "invite.claim", + )?; + runtime.authorize_enrollment(&request).await +} + +/// Preserve legacy behavior only when an unwired surface cannot enter an +/// enforcing exact-domain runtime. +/// +/// This helper never constructs a resolver request from caller-supplied key +/// primitives. It exists solely to make missing typed evidence fail closed on +/// enforcing domains while retaining the configured Off/Shadow/VerifyOnly +/// behavior for legacy-only authentication paths. +pub fn authorize_unwired_if_configured( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> Result { + authorize_unwired_for_mode( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)), + ) +} + +/// Deny an unproved mutation in Enforce before any backend-visible work starts. +/// +/// Alternate helpers such as secret-triggered workflow execution have no +/// request proof that a protected resolver can finalize. Until a transaction- +/// owning executor carries durable authority into their commit, they must be +/// unavailable in Enforce while preserving every legacy lane. +pub fn require_unwired_atomic_mutation_if_configured( + state: &crate::state::AppState, + authorization_domain: CommunityId, +) -> Result<(), ProtectedTransportError> { + authorize_unwired_if_configured(state, authorization_domain)?.require_atomic_mutation() +} + +fn authorize_unwired_for_mode( + mode: Option, +) -> Result { + match mode { + None + | Some(AuthorizationMode::Off) + | Some(AuthorizationMode::Shadow) + | Some(AuthorizationMode::VerifyOnly) => Ok(ProtectedAuthorization::Legacy), + Some(AuthorizationMode::Enforce) => Err(ProtectedTransportError::MissingVerifiedProof), + } +} + +impl fmt::Debug for ProtectedTransportRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedTransportRuntime") + .field("domains", &"[redacted]") + .field("resolver", &"[configured]") + .field("validator", &self.validator) + .finish() + } +} + +/// Result of consulting the direct first-enrollment runtime. +#[must_use] +pub enum ProtectedEnrollmentAuthorization { + /// Runtime absent or configured non-enforcing. + Legacy, + /// Enforcing staged enrollment with no binding or membership yet. + Enrollment(ProtectedEnrollmentAuthority), +} + +impl ProtectedEnrollmentAuthorization { + /// Seal the enrollment decision for transaction-owned execution. + pub fn seal_postgres_enrollment( + &self, + operation_id: super::executor::ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Enrollment(authority) => super::executor::SealedEnrollmentPermit::from_authority( + authority, + operation_id, + operation_kind, + request_fingerprint, + ) + .map(Some), + } + } + + /// Whether this decision is enforcing. + pub const fn is_enforcing(&self) -> bool { + matches!(self, Self::Enrollment(_)) + } +} + +/// Staged direct authority that cannot be consumed as ordinary access. +pub struct ProtectedEnrollmentAuthority { + disposition: EnrollmentDisposition, + observer: Arc, + validator: AuthorizationLeaseValidator, +} + +impl ProtectedEnrollmentAuthority { + pub(super) const fn disposition(&self) -> &EnrollmentDisposition { + &self.disposition + } + + pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { + self.observer.as_ref() + } + + fn validate_exact_request( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedTransportError> { + if self.disposition.authorization_domain() != request.authorization_domain() + || self.disposition.actor_pubkey() != request.actor_pubkey() + || self.disposition.correlation_id() != request.correlation_id() + || request.capability() != AuthorizationCapability::InviteClaim + { + return Err(ProtectedTransportError::FinalizedContextMismatch); + } + Ok(()) + } + + /// Recheck time and the captured invalidation fence before sealing. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + self.validator + .seconds_until(self.disposition.expires_at())?; + self.observer.observe_commit_fence()?; + Ok(()) + } +} + +impl fmt::Debug for ProtectedEnrollmentAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProtectedEnrollmentAuthority([redacted])") + } +} + +/// Result of consulting the optional exact-domain runtime. +#[derive(Clone)] +#[must_use] +pub enum ProtectedAuthorization { + /// Runtime absent for this exact domain or configured non-enforcing. + Legacy, + /// Enforcing access with a retained operation guard. + Access(ProtectedOperationAuthority), +} + +impl ProtectedAuthorization { + /// Revalidate enforcing authority; legacy mode is a no-op. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + match self { + Self::Legacy => Ok(()), + Self::Access(authority) => authority.revalidate(), + } + } + + /// Release already-fetched protected data only after a final authority check. + /// + /// Callers place this at the last synchronous boundary before constructing + /// a response, queue entry, or stream item. Keeping the fetched value owned + /// by this method makes the post-fetch/pre-emission ordering explicit. + pub fn release_fetched(&self, value: T) -> Result { + self.revalidate()?; + Ok(value) + } + + /// Require an atomic mutation coordinator for an enforcing operation. + /// + /// Legacy lanes retain their exact behavior. Enforcing callers must select + /// the transaction/CAS executor for their registered surface; an adjacent + /// authorization check is never treated as an atomic fence. + pub fn require_atomic_mutation(&self) -> Result<(), ProtectedTransportError> { + require_atomic_mutation_executor(matches!(self, Self::Access(_))) + } + + /// Seal an enforcing mutation for transaction-owned PostgreSQL execution. + /// + /// Legacy lanes return `None` and retain their existing mutation path. + /// Enforcing lanes return a permit that cannot be constructed from request + /// fields or an adjacent authorization check. + pub fn seal_postgres_mutation( + &self, + operation_id: super::executor::ProtectedOperationId, + operation_kind: &'static str, + request_fingerprint: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => super::executor::SealedOperationPermit::from_authority( + authority, + operation_id, + operation_kind, + request_fingerprint, + ) + .map(Some), + } + } + + /// Seal opaque sender authority for one ephemeral event delivered across + /// the trusted relay Redis plane. The resulting claim still requires a + /// relay signature and writer-database revalidation on the receiving pod. + pub(crate) fn seal_ephemeral_delivery( + &self, + event_id: [u8; 32], + ) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => { + super::executor::EphemeralAuthorityClaim::from_authority(authority, event_id) + .map(Some) + } + } + } + + /// Whether this value carries enforcing authority rather than legacy mode. + pub const fn is_enforcing(&self) -> bool { + matches!(self, Self::Access(_)) + } + + /// Hard lease expiry for enforcing authority. + pub fn expires_at(&self) -> Option { + match self { + Self::Legacy => None, + Self::Access(authority) => Some(authority.expires_at()), + } + } + + /// Delay to hard expiry using the same injected clock as lease validation. + pub fn expiry_delay(&self) -> Result, ProtectedTransportError> { + match self { + Self::Legacy => Ok(None), + Self::Access(authority) => authority.expiry_delay().map(Some), + } + } +} + +fn require_atomic_mutation_executor(enforcing: bool) -> Result<(), ProtectedTransportError> { + if enforcing { + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + } else { + Ok(()) + } +} + +impl fmt::Debug for ProtectedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Legacy => formatter.write_str("ProtectedAuthorization::Legacy"), + Self::Access(_) => formatter.write_str("ProtectedAuthorization::Access([redacted])"), + } + } +} + +/// Retained authority for request, stream, and pre-commit checkpoints. +#[derive(Clone)] +pub struct ProtectedOperationAuthority { + context: Arc, + capability: AuthorizationCapability, + validator: AuthorizationLeaseValidator, + observer: Arc, +} + +impl ProtectedOperationAuthority { + pub(super) fn context(&self) -> &AuthContext { + &self.context + } + + pub(super) fn observer(&self) -> &dyn LeaseCurrentStateObserver { + self.observer.as_ref() + } + + fn lease(&self) -> Result<&AuthorizationLease, ProtectedTransportError> { + self.context + .authorization_lease() + .ok_or(ProtectedTransportError::MissingAccessLease) + } + + fn requirement( + &self, + current: &LeaseCurrentState, + ) -> Result { + let lease = self.lease()?; + Ok(LeaseUseRequirement { + context_version: lease.context_version(), + lease_version: lease.lease_version(), + authorization_domain: lease.authorization_domain(), + transport: lease.transport(), + actor_pubkey: lease.actor_pubkey(), + binding_id: lease.binding_id(), + binding_version: current.binding_version, + profile_id: current.profile_id.clone(), + policy_version: current.policy_version.clone(), + capability: self.capability, + }) + } + + fn validate_exact_request( + &self, + request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedTransportError> { + if self.context.tenant().community() != request.authorization_domain() + || self.context.transport() != request.transport() + || self.context.pubkey() != request.actor_pubkey() + || self.context.agent_owner_pubkey() != request.owner_pubkey() + || !exact_correlation_matches(self.context.correlation_id(), request.correlation_id()) + { + return Err(ProtectedTransportError::FinalizedContextMismatch); + } + Ok(()) + } + + /// Revalidate current state, exact capability, typed versions, and expiry. + pub fn revalidate(&self) -> Result<(), ProtectedTransportError> { + let current = self.observer.observe_current()?; + self.context + .authorize_lease_use(&self.validator, &self.requirement(¤t)?)?; + Ok(()) + } + + /// Exact operation capability retained by this authority. + pub const fn capability(&self) -> AuthorizationCapability { + self.capability + } + + /// Hard conservative expiry after which revalidation fails. + pub fn expires_at(&self) -> u64 { + self.context + .authorization_lease() + .map_or(0, AuthorizationLease::expires_at) + } + + fn expiry_delay(&self) -> Result { + let expires_at = self.expires_at(); + let seconds = self.validator.seconds_until(expires_at)?; + Ok(std::time::Duration::from_secs(seconds)) + } +} + +fn exact_correlation_matches(finalized: Uuid, requested: Uuid) -> bool { + finalized == requested +} + +impl fmt::Debug for ProtectedOperationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProtectedOperationAuthority") + .field("context", &"[redacted]") + .field("capability", &"[redacted]") + .field("validator", &self.validator) + .field("observer", &"[configured]") + .finish() + } +} + +/// Fail-closed protected-transport error. +#[derive(Debug, Error)] +pub enum ProtectedTransportError { + /// Duplicate exact-domain activation is ambiguous. + #[error("protected authorization policy is ambiguous for this domain")] + AmbiguousDomainPolicy, + /// Correlation IDs must be non-nil. + #[error("protected authorization correlation id is invalid")] + InvalidCorrelationId, + /// Session IDs for long-lived transports must be non-nil. + #[error("protected authorization session id is invalid")] + InvalidSessionId, + /// Surface labels must be stable non-empty server constants. + #[error("protected authorization surface is invalid")] + InvalidSurface, + /// The surface, sealed proof transport, and capability do not match. + #[error("protected authorization surface does not match proof and capability")] + SurfaceCapabilityMismatch, + /// An enforcing surface did not retain sealed verifier evidence. + #[error("protected authorization requires verified transport evidence")] + MissingVerifiedProof, + /// Resolver denied or could not evaluate current policy. + #[error(transparent)] + Resolution(#[from] ProtectedResolutionError), + /// Display verification is deliberately not access authority. + #[error("verification-only authorization cannot grant protected access")] + VerificationOnlyCannotGrant, + /// A staged enrollment result was presented as ordinary access. + #[error("first-enrollment authorization cannot grant ordinary protected access")] + EnrollmentCannotGrantAccess, + /// Invite enrollment lacked matching direct assertion/provider evidence. + #[error("protected invite enrollment requires direct verified evidence")] + EnrollmentEvidenceRequired, + /// Resolver returned a context for another exact operation. + #[error("finalized authorization context does not match protected operation")] + FinalizedContextMismatch, + /// Enforcing disposition lacked its bounded lease. + #[error("enforcing authorization context is missing its access lease")] + MissingAccessLease, + /// Current binding/profile/policy observation failed closed. + #[error(transparent)] + CurrentState(#[from] LeaseCurrentStateError), + /// Typed lease validation failed. + #[error(transparent)] + Lease(#[from] LeaseValidationError), + /// No transaction- or CAS-coupled mutation coordinator owns this commit. + #[error("protected mutation requires an atomic authorization fence")] + AtomicMutationFenceUnavailable, +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use buzz_auth::{ + AuthorizationClock, AuthorizationClockError, AuthorizationTime, VerifiedEvidenceAdapter, + }; + use nostr::{EventBuilder, Keys, RelayUrl}; + + use super::*; + + struct UnavailableResolver; + + #[async_trait] + impl ProtectedAuthorizationResolver for UnavailableResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + Err(ProtectedResolutionError::new("synthetic_unavailable")) + } + } + + struct PanicResolver; + + #[async_trait] + impl ProtectedAuthorizationResolver for PanicResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + panic!("non-enforcing mode must not call a finalizing resolver") + } + } + + struct ObservingResolver(AtomicUsize); + + #[async_trait] + impl ProtectedAuthorizationResolver for ObservingResolver { + async fn resolve( + &self, + _request: &ProtectedOperationRequest, + ) -> Result { + panic!("observational mode must not finalize authority") + } + + async fn observe( + &self, + _request: &ProtectedOperationRequest, + ) -> Result<(), ProtectedResolutionError> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(ProtectedResolutionError::new("synthetic_denial")) + } + } + + struct FixedClock; + + impl AuthorizationClock for FixedClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(100)) + } + } + + fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) + } + + fn runtime( + policies: impl IntoIterator, + ) -> Result { + ProtectedTransportRuntime::new( + policies, + Arc::new(UnavailableResolver), + Arc::new(FixedClock), + ) + } + + fn request(domain: CommunityId) -> ProtectedOperationRequest { + let keys = Keys::generate(); + let challenge = "protected-request-test"; + let relay_url = "wss://relay.example"; + let event = EventBuilder::auth(challenge, RelayUrl::parse(relay_url).expect("relay URL")) + .sign_with_keys(&keys) + .expect("signed NIP-42 event"); + let proof = VerifiedEvidenceAdapter::new() + .verify_nip42( + domain, + AuthTransport::RelayWebSocket, + &event, + challenge, + relay_url, + None, + ) + .expect("verified NIP-42 proof"); + ProtectedOperationRequest::new( + Arc::new(proof), + None, + AuthorizationCapability::CommunityRead, + Uuid::new_v4(), + "ws_req", + ) + .expect("valid request") + } + + #[test] + fn request_retains_sealed_proof_and_rejects_surface_substitution() { + let request = request(domain(1)); + let retained = Arc::clone(request.verified_proof()); + assert!(Arc::ptr_eq(&retained, request.verified_proof())); + assert_eq!(request.authorization_domain(), domain(1)); + assert_eq!(request.transport(), AuthTransport::RelayWebSocket); + + assert!(matches!( + ProtectedOperationRequest::new( + retained, + None, + AuthorizationCapability::MediaWrite, + Uuid::new_v4(), + "media.upload", + ), + Err(ProtectedTransportError::SurfaceCapabilityMismatch) + )); + } + + #[test] + fn finalized_context_cannot_be_replayed_across_correlations() { + let finalized = Uuid::from_u128(1); + assert!(exact_correlation_matches(finalized, finalized)); + assert!(!exact_correlation_matches(finalized, Uuid::from_u128(2))); + } + + #[tokio::test] + async fn absent_and_off_domains_preserve_legacy_behavior() { + let configured = domain(1); + let absent = domain(2); + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Off, + )]) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + assert!(matches!( + runtime.authorize(&request(absent)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn enforcing_domain_never_falls_back_on_resolver_failure() { + let configured = domain(1); + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Enforce, + )]) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Err(ProtectedTransportError::Resolution(_)) + )); + } + + #[tokio::test] + async fn verify_only_preserves_legacy_access_without_finalizing() { + let configured = domain(1); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::VerifyOnly, + )], + Arc::new(PanicResolver), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn shadow_preserves_legacy_access_without_finalizing() { + let configured = domain(1); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Shadow, + )], + Arc::new(PanicResolver), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + } + + #[tokio::test] + async fn observational_denial_is_read_only_and_never_changes_access() { + let configured = domain(1); + let resolver = Arc::new(ObservingResolver(AtomicUsize::new(0))); + let runtime = ProtectedTransportRuntime::new( + [DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Shadow, + )], + resolver.clone(), + Arc::new(FixedClock), + ) + .expect("runtime"); + assert!(matches!( + runtime.authorize(&request(configured)).await, + Ok(ProtectedAuthorization::Legacy) + )); + assert_eq!(resolver.0.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn client_status_is_absent_in_off_and_shadow_and_fail_closed_when_unavailable() { + let configured = domain(1); + for mode in [AuthorizationMode::Off, AuthorizationMode::Shadow] { + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, mode, + )]) + .expect("runtime"); + assert!(runtime + .present_status(&request(configured)) + .await + .expect("non-presenting mode") + .is_none()); + } + + for mode in [AuthorizationMode::VerifyOnly, AuthorizationMode::Enforce] { + let runtime = runtime([DomainTransportPolicy::from_server_configuration( + configured, mode, + )]) + .expect("runtime"); + assert!(matches!( + runtime.present_status(&request(configured)).await, + Err(ProtectedTransportError::Resolution(_)) + )); + } + } + + #[test] + fn duplicate_exact_domain_policy_is_rejected() { + let configured = domain(1); + assert!(matches!( + runtime([ + DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Off, + ), + DomainTransportPolicy::from_server_configuration( + configured, + AuthorizationMode::Enforce, + ), + ]), + Err(ProtectedTransportError::AmbiguousDomainPolicy) + )); + } + + #[test] + fn atomic_mutations_are_unavailable_only_for_enforcing_authority() { + assert!(require_atomic_mutation_executor(false).is_ok()); + assert!(matches!( + require_atomic_mutation_executor(true), + Err(ProtectedTransportError::AtomicMutationFenceUnavailable) + )); + assert!(ProtectedAuthorization::Legacy + .require_atomic_mutation() + .is_ok()); + } + + #[test] + fn unwired_mutations_preserve_legacy_modes_and_deny_enforce() { + for mode in [ + None, + Some(AuthorizationMode::Off), + Some(AuthorizationMode::Shadow), + Some(AuthorizationMode::VerifyOnly), + ] { + assert!(matches!( + authorize_unwired_for_mode(mode), + Ok(ProtectedAuthorization::Legacy) + )); + } + assert!(matches!( + authorize_unwired_for_mode(Some(AuthorizationMode::Enforce)), + Err(ProtectedTransportError::MissingVerifiedProof) + )); + } +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e0b10a91d2..7f480cf3b8 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -119,6 +119,19 @@ impl Default for CorporateIdentityConfig { } } +impl CorporateIdentityConfig { + /// Whether a complete verifier is configured independently of the legacy + /// process-wide enforcement switch. + pub fn verifier_configured(&self) -> bool { + !self.jwt_header.is_empty() + && !self.jwks_uri.is_empty() + && !self.issuer.is_empty() + && !self.audience.is_empty() + && !self.uid_claim.is_empty() + && !self.display_claim.is_empty() + } +} + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -558,7 +571,11 @@ fn load_corporate_identity_config() -> Result Result) -> fmt::Result { + formatter + .debug_struct("CorporateJwtClaims") + .field("issuer", &"[redacted]") + .field("uid", &"[redacted]") + .field("display_name", &"[redacted]") + .field("public_display_name", &"[redacted]") + .field("pubkey", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +#[derive(Deserialize)] struct RawJwtClaims { #[serde(flatten)] claims: Map, } +impl fmt::Debug for RawJwtClaims { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RawJwtClaims") + .field("claims", &"[redacted]") + .finish() + } +} + /// Service that verifies corporate identity JWTs against configured JWKS. -#[derive(Debug)] pub struct CorporateIdentityService { config: CorporateIdentityConfig, http: Result, jwks: RwLock>, refresh: Mutex<()>, + authorization_clock: SharedAuthorizationClock, +} + +impl fmt::Debug for CorporateIdentityService { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CorporateIdentityService") + .field("config", &"[redacted]") + .field("http", &"[initialized]") + .field("jwks", &"[cache]") + .field("refresh", &"[lock]") + .field("authorization_clock", &"[injected]") + .finish() + } } impl CorporateIdentityService { /// Build a corporate identity verifier from relay config. pub fn new(config: CorporateIdentityConfig) -> Self { + Self::with_authorization_clock(config, Arc::new(SystemAuthorizationClock)) + } + + /// Build a verifier with the shared authorization clock. + pub fn with_authorization_clock( + config: CorporateIdentityConfig, + authorization_clock: SharedAuthorizationClock, + ) -> Self { let http = reqwest::Client::builder() .connect_timeout(JWKS_CONNECT_TIMEOUT) .timeout(JWKS_REQUEST_TIMEOUT) @@ -91,13 +153,30 @@ impl CorporateIdentityService { http, jwks: RwLock::new(None), refresh: Mutex::new(()), + authorization_clock, } } + /// Read the shared verifier clock for portable evidence finalization. + pub fn authorization_now(&self) -> Result { + Ok(self.authorization_clock.now()?.unix_seconds()) + } + /// Validate a JWT and extract the configured corporate identity claims. pub async fn validate_jwt( &self, token: &str, + ) -> Result { + let result = self.validate_jwt_inner(token).await; + if result.is_err() { + metrics::counter!("buzz_jwt_verification_errors_total").increment(1); + } + result + } + + async fn validate_jwt_inner( + &self, + token: &str, ) -> Result { let header = decode_header(token) .map_err(|e| CorporateIdentityError::InvalidJwt(format!("invalid JWT header: {e}")))?; @@ -121,6 +200,10 @@ impl CorporateIdentityService { let decoded = decode::(token, &decoding_key, &validation) .map_err(|e| CorporateIdentityError::InvalidJwt(e.to_string()))?; + validate_optional_iat( + &decoded.claims.claims, + self.authorization_clock.now()?.unix_seconds(), + )?; let issuer = claim_string_exact(&decoded.claims.claims, "iss")?; let uid = claim_string_exact(&decoded.claims.claims, &self.config.uid_claim)?; @@ -150,14 +233,28 @@ impl CorporateIdentityService { { let cache = self.jwks.read().await; if let Some(cached) = cache.as_ref() { - if cached.expires_at > now { + record_jwks_cache_age(cached, now); + if cached.fresh_until > now { if let Some(jwk) = cached.set.find(kid) { return Ok(jwk.clone()); } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); return Err(CorporateIdentityError::Jwks(format!( "kid not found in fresh JWKS cache: {kid}" ))); } + if cached.refresh_after > now { + if cached.hard_expires_at > now { + if let Some(jwk) = cached.set.find(kid) { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk.clone()); + } + } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + return Err(CorporateIdentityError::Jwks( + "JWKS refresh is temporarily unavailable".to_string(), + )); + } } } @@ -165,26 +262,72 @@ impl CorporateIdentityService { // mutex because another waiter may already have populated the cache. let _refresh = self.refresh.lock().await; let now = Instant::now(); - { + let stale_known_key = { let cache = self.jwks.read().await; if let Some(cached) = cache.as_ref() { - if cached.expires_at > now { + record_jwks_cache_age(cached, now); + if cached.fresh_until > now { return cached.set.find(kid).cloned().ok_or_else(|| { + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); CorporateIdentityError::Jwks(format!( "kid not found in fresh JWKS cache: {kid}" )) }); } + if cached.refresh_after > now { + if cached.hard_expires_at > now { + if let Some(jwk) = cached.set.find(kid) { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk.clone()); + } + } + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + return Err(CorporateIdentityError::Jwks( + "JWKS refresh is temporarily unavailable".to_string(), + )); + } + if cached.hard_expires_at > now { + cached.set.find(kid).cloned() + } else { + None + } + } else { + None } - } + }; - let set = self.fetch_jwks().await?; + metrics::counter!("buzz_jwks_refresh_total", "result" => "attempt").increment(1); + let set = match self.fetch_jwks().await { + Ok(set) => { + metrics::counter!("buzz_jwks_refresh_total", "result" => "success").increment(1); + set + } + Err(error) => { + metrics::counter!("buzz_jwks_refresh_total", "result" => "failure").increment(1); + let now = Instant::now(); + if let Some(cached) = self.jwks.write().await.as_mut() { + cached.refresh_after = now + JWKS_REFRESH_FAILURE_BACKOFF; + } + if let Some(jwk) = stale_known_key { + metrics::counter!("buzz_jwks_stale_key_uses_total").increment(1); + return Ok(jwk); + } + return Err(error); + } + }; let jwk = set.find(kid).cloned(); + let fetched_at = Instant::now(); *self.jwks.write().await = Some(CachedJwks { set, - expires_at: Instant::now() + JWKS_CACHE_TTL, + fetched_at, + fresh_until: fetched_at + JWKS_CACHE_TTL, + hard_expires_at: fetched_at + JWKS_CACHE_MAX_AGE, + refresh_after: fetched_at + JWKS_CACHE_TTL, }); - jwk.ok_or_else(|| CorporateIdentityError::Jwks(format!("kid not found: {kid}"))) + jwk.ok_or_else(|| { + metrics::counter!("buzz_jwks_unknown_kid_total").increment(1); + CorporateIdentityError::Jwks(format!("kid not found: {kid}")) + }) } async fn fetch_jwks(&self) -> Result { @@ -242,7 +385,7 @@ fn jwt_validation(algorithm: Algorithm, config: &CorporateIdentityConfig) -> Val /// Callers must complete admission/authorization before passing this proof to /// [`finalize_corporate_identity`]. This ordering prevents rejected requests /// from creating identity bindings or public assertions. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum CorporateIdentityProof { /// Corporate identity is disabled for this relay. NotRequired, @@ -252,6 +395,10 @@ pub enum CorporateIdentityProof { claims: CorporateJwtClaims, /// Binding source selected from the configured npub policy. source: &'static str, + /// Transport provenance proved by the deployment adapter. `None` is + /// retained only for the disabled legacy lane and can never be sealed + /// into protected runtime evidence. + assertion_transport: Option, }, /// A NIP-OA owner with an active binding authorized this agent. Delegated { @@ -264,13 +411,23 @@ pub enum CorporateIdentityProof { }, } +impl fmt::Debug for CorporateIdentityProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NotRequired => "CorporateIdentityProof::NotRequired", + Self::Direct { .. } => "CorporateIdentityProof::Direct([redacted])", + Self::Delegated { .. } => "CorporateIdentityProof::Delegated([redacted])", + }) + } +} + /// Borrow staged direct-identity data for an atomic admission transaction. pub fn binding_input_for_proof<'a>( proof: &'a CorporateIdentityProof, signer: &'a PublicKey, ) -> Option> { match proof { - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { Some(buzz_db::identity_binding::IdentityBindingInput { issuer: &claims.issuer, uid: &claims.uid, @@ -283,13 +440,85 @@ pub fn binding_input_for_proof<'a>( } } +/// Translate a read-only direct verifier result into sealed provider evidence. +pub fn verified_assertion_for_proof( + proof: &CorporateIdentityProof, + authorization_domain: CommunityId, + transport: buzz_auth::AuthTransport, + now_unix_seconds: u64, +) -> Result, CorporateIdentityError> { + let CorporateIdentityProof::Direct { + claims, + assertion_transport, + .. + } = proof + else { + return Ok(None); + }; + let assertion_transport = assertion_transport.ok_or_else(|| { + CorporateIdentityError::Evidence("verified ingress provenance is unavailable".to_owned()) + })?; + buzz_auth::VerifiedEvidenceAdapter::new() + .federated_assertion_from_validated_claims( + authorization_domain, + transport, + &claims.issuer, + &claims.uid, + claims.pubkey, + assertion_transport, + None, + claims.expires_at, + now_unix_seconds, + ) + .map(Some) + .map_err(|error| CorporateIdentityError::Evidence(error.to_string())) +} + +/// Seal current direct evidence with the same clock used by the configured +/// verifier. Non-direct proofs require neither a verifier instance nor time. +pub fn current_verified_assertion_for_proof( + state: &crate::state::AppState, + proof: &CorporateIdentityProof, + authorization_domain: CommunityId, + transport: buzz_auth::AuthTransport, +) -> Result, CorporateIdentityError> { + if !matches!(proof, CorporateIdentityProof::Direct { .. }) { + return Ok(None); + } + if matches!( + proof, + CorporateIdentityProof::Direct { + assertion_transport: None, + .. + } + ) && crate::authorization_runtime::transport::legacy_identity_lane( + state, + authorization_domain, + ) == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + // Off/absent keeps the inherited verifier/finalizer behavior, but an + // unproved header can never be promoted into protected evidence. + return Ok(None); + } + let verifier = state + .corporate_identity + .as_ref() + .ok_or(CorporateIdentityError::VerifierUnavailable)?; + verified_assertion_for_proof( + proof, + authorization_domain, + transport, + verifier.authorization_now()?, + ) +} + /// Whether this proof relies on a delegated owner rather than a direct JWT. pub fn proof_is_delegated(proof: &CorporateIdentityProof) -> bool { matches!(proof, CorporateIdentityProof::Delegated { .. }) } /// Outcome of corporate identity enforcement. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum CorporateIdentityDecision { /// Corporate identity is disabled for this relay. NotRequired, @@ -317,6 +546,16 @@ pub enum CorporateIdentityDecision { }, } +impl fmt::Debug for CorporateIdentityDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NotRequired => "CorporateIdentityDecision::NotRequired", + Self::Direct { .. } => "CorporateIdentityDecision::Direct([redacted])", + Self::Delegated { .. } => "CorporateIdentityDecision::Delegated([redacted])", + }) + } +} + struct SessionRevalidationPlan { binding_pubkey: PublicKey, expected_issuer: String, @@ -368,8 +607,8 @@ async fn cancel_session_at_expiry( async fn run_session_binding_revalidation( interval: Duration, - signer: PublicKey, - binding_pubkey: PublicKey, + _signer: PublicKey, + _binding_pubkey: PublicKey, expected_issuer: String, expected_uid: String, cancel: tokio_util::sync::CancellationToken, @@ -391,20 +630,12 @@ async fn run_session_binding_revalidation( Ok(Some(binding)) if binding.issuer == expected_issuer && binding.uid == expected_uid => {} Ok(Some(_)) | Ok(None) => { - warn!( - signer = %signer.to_hex(), - binding_pubkey = %binding_pubkey.to_hex(), - "corporate identity session evicted after binding revocation" - ); + warn!("corporate identity session evicted after binding revocation"); cancel.cancel(); return; } - Err(error) => { - warn!( - signer = %signer.to_hex(), - error = %error, - "corporate identity session revalidation failed closed" - ); + Err(_error) => { + warn!("corporate identity session revalidation failed closed"); cancel.cancel(); return; } @@ -465,8 +696,20 @@ pub fn spawn_session_revalidation( } /// Errors produced by corporate identity verification. -#[derive(Debug, Error)] +#[derive(Error)] pub enum CorporateIdentityError { + /// Direct identity evidence existed without its configured verifier. + #[error("relay identity verifier unavailable")] + VerifierUnavailable, + /// The configured identity header was duplicated, combined, or malformed. + #[error("relay identity header is ambiguous")] + AmbiguousIdentityHeader, + /// Protected identity evidence lacked deployment-verified provenance. + #[error("relay identity transport provenance is unavailable")] + UntrustedIdentityTransport, + /// The shared authorization clock could not provide verifier time. + #[error("corporate identity authorization clock unavailable")] + AuthorizationClock(#[from] AuthorizationClockError), /// No JWT was available and delegation did not apply. #[error("corporate identity JWT missing")] MissingJwt, @@ -502,32 +745,55 @@ pub enum CorporateIdentityError { /// NIP-OA delegation was present but did not satisfy corporate identity. #[error("corporate identity delegation denied")] DelegationDenied, + /// Verified claims could not be sealed as portable assertion evidence. + #[error("corporate identity evidence is inconsistent: {0}")] + Evidence(String), /// Database operation failed. #[error("corporate identity database error: {0}")] Db(#[from] buzz_db::DbError), } +impl fmt::Debug for CorporateIdentityError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CorporateIdentityError") + .field("reason", &self.reason_code()) + .finish() + } +} + impl CorporateIdentityError { /// HTTP status appropriate for this error. pub fn status_code(&self) -> StatusCode { match self { - Self::MissingJwt | Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { - StatusCode::UNAUTHORIZED - } + Self::AuthorizationClock(_) + | Self::AmbiguousIdentityHeader + | Self::UntrustedIdentityTransport + | Self::MissingJwt + | Self::MissingKid + | Self::InvalidJwt(_) + | Self::Jwks(_) => StatusCode::UNAUTHORIZED, Self::InvalidClaim { .. } | Self::NpubMismatch | Self::BindingConflict | Self::BindingRevoked | Self::BindingRequired - | Self::DelegationDenied => StatusCode::FORBIDDEN, - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + | Self::DelegationDenied + | Self::Evidence(_) => StatusCode::FORBIDDEN, + Self::VerifierUnavailable | Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, } } /// Sanitized message safe to return to clients. pub fn public_message(&self) -> &'static str { match self { + Self::VerifierUnavailable | Self::AuthorizationClock(_) => { + "relay identity verification unavailable" + } Self::MissingJwt => "relay-verified identity required", + Self::AmbiguousIdentityHeader | Self::UntrustedIdentityTransport => { + "relay identity verification failed" + } Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { "relay identity verification failed" } @@ -537,6 +803,7 @@ impl CorporateIdentityError { Self::BindingRevoked => "relay identity binding revoked", Self::BindingRequired => "relay identity binding required", Self::DelegationDenied => "relay identity delegation denied", + Self::Evidence(_) => "relay identity verification failed", Self::Db(_) => "relay identity unavailable", } } @@ -546,31 +813,137 @@ impl CorporateIdentityError { let status = self.status_code(); let message = self.public_message(); if status.is_server_error() { - warn!(error = %self, "corporate identity enforcement failed"); + warn!( + reason = self.reason_code(), + "corporate identity enforcement failed" + ); } (status, Json(serde_json::json!({ "error": message }))) } + + /// Stable, non-sensitive diagnostic class for metrics and runtime logs. + pub(crate) fn reason_code(&self) -> &'static str { + match self { + Self::VerifierUnavailable => "verifier_unavailable", + Self::AmbiguousIdentityHeader => "ambiguous_identity_header", + Self::UntrustedIdentityTransport => "untrusted_identity_transport", + Self::AuthorizationClock(_) => "authorization_clock", + Self::MissingJwt => "missing_jwt", + Self::MissingKid => "missing_kid", + Self::InvalidJwt(_) => "invalid_jwt", + Self::Jwks(_) => "jwks", + Self::InvalidClaim { .. } => "invalid_claim", + Self::NpubMismatch => "npub_mismatch", + Self::BindingConflict => "binding_conflict", + Self::BindingRevoked => "binding_revoked", + Self::BindingRequired => "binding_required", + Self::DelegationDenied => "delegation_denied", + Self::Evidence(_) => "evidence", + Self::Db(_) => "db", + } + } +} + +/// Deployment-owned proof that a direct assertion arrived through a reviewed +/// ingress boundary. +/// +/// The OSS relay deliberately does not infer provenance from the presence of +/// an identity header. A composition root must install an implementation that +/// validates immediate-caller transport evidence and confirms that inbound +/// copies were stripped before exactly one value was set. +pub trait IdentityAssertionProvenanceVerifier: Send + Sync { + /// Return the provider-neutral assertion transport proved for this request. + fn verify( + &self, + headers: &HeaderMap, + ) -> Result; +} + +/// One exact direct assertion captured at the request boundary. +#[derive(Clone, PartialEq, Eq)] +pub struct IdentityAssertionInput { + jwt: String, + assertion_transport: Option, +} + +impl IdentityAssertionInput { + /// Validated header payload passed only to the JWT verifier. + pub fn jwt(&self) -> &str { + &self.jwt + } } -/// Extract a corporate identity JWT from the configured request header. +impl fmt::Debug for IdentityAssertionInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IdentityAssertionInput") + .field("jwt", &"[redacted]") + .field("assertion_transport", &self.assertion_transport) + .finish() + } +} + +/// Extract exactly one corporate identity JWT from the configured header. +/// +/// RFC 9110 permits intermediaries to combine repeated field lines. Identity +/// evidence is not list-valued, so both multiple field lines and any comma are +/// rejected rather than applying a first/last-wins rule. pub fn identity_jwt_from_headers( headers: &HeaderMap, config: &CorporateIdentityConfig, -) -> Option { - headers - .get(config.jwt_header.as_str()) - .and_then(|v| v.to_str().ok()) - .map(str::trim) - .and_then(|raw| { - raw.strip_prefix("Bearer ") - .unwrap_or(raw) - .trim() - .split(',') - .next() - }) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) +) -> Result, CorporateIdentityError> { + let mut values = headers.get_all(config.jwt_header.as_str()).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + let raw = value + .to_str() + .map_err(|_| CorporateIdentityError::AmbiguousIdentityHeader)? + .trim(); + if raw.contains(',') { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + let jwt = raw.strip_prefix("Bearer ").unwrap_or(raw).trim(); + if jwt.is_empty() { + return Err(CorporateIdentityError::AmbiguousIdentityHeader); + } + Ok(Some(jwt.to_owned())) +} + +/// Extract direct identity evidence and attach only deployment-proved +/// transport provenance. Disabled/Off legacy behavior can retain the header +/// payload, but cannot turn it into protected runtime evidence. +pub fn identity_assertion_from_headers( + state: &AppState, + authorization_domain: CommunityId, + headers: &HeaderMap, +) -> Result, CorporateIdentityError> { + let Some(jwt) = identity_jwt_from_headers(headers, &state.config.corporate_identity)? else { + return Ok(None); + }; + let mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(authorization_domain)); + let assertion_transport = match mode { + None | Some(crate::authorization_runtime::finalization::AuthorizationMode::Off) => None, + Some(_) => { + let verifier = state + .identity_assertion_provenance() + .ok_or(CorporateIdentityError::UntrustedIdentityTransport)?; + let transport = verifier.verify(headers)?; + if transport != buzz_auth::AssertionTransport::TrustedProxy { + return Err(CorporateIdentityError::UntrustedIdentityTransport); + } + Some(transport) + } + }; + Ok(Some(IdentityAssertionInput { + jwt, + assertion_transport, + })) } /// Validate corporate identity without creating bindings or assertions. @@ -578,12 +951,17 @@ pub async fn verify_corporate_identity( state: &AppState, community_id: CommunityId, signer: PublicKey, - identity_jwt: Option<&str>, + identity_assertion: Option<&IdentityAssertionInput>, auth_tag_json: Option<&str>, ) -> Result { - let result = - verify_corporate_identity_inner(state, community_id, signer, identity_jwt, auth_tag_json) - .await; + let result = verify_corporate_identity_inner( + state, + community_id, + signer, + identity_assertion, + auth_tag_json, + ) + .await; if let Err(error) = &result { record_corporate_identity_denial(error); } @@ -594,20 +972,29 @@ async fn verify_corporate_identity_inner( state: &AppState, community_id: CommunityId, signer: PublicKey, - identity_jwt: Option<&str>, + identity_assertion: Option<&IdentityAssertionInput>, auth_tag_json: Option<&str>, ) -> Result { let Some(service) = state.corporate_identity.as_ref() else { return Ok(CorporateIdentityProof::NotRequired); }; + if !service.config.require + && crate::authorization_runtime::transport::legacy_identity_lane(state, community_id) + == crate::authorization_runtime::transport::LegacyIdentityLane::Legacy + { + return Ok(CorporateIdentityProof::NotRequired); + } // Requests can carry both a direct identity JWT and a cryptographically // verified NIP-OA owner declaration. The deployment selects which identity // source wins; the provider-neutral default treats the JWT as the signer's // identity. Delegated precedence supports identity-aware gateways that // attach an owner's token to requests made by that owner's agents. - if select_identity_auth_path(&service.config, identity_jwt, auth_tag_json) - == IdentityAuthPath::Delegated + if select_identity_auth_path( + &service.config, + identity_assertion.map(IdentityAssertionInput::jwt), + auth_tag_json, + ) == IdentityAuthPath::Delegated { return verify_delegated_corporate_identity( &state.db, @@ -619,10 +1006,14 @@ async fn verify_corporate_identity_inner( .await; } - if let Some(token) = identity_jwt { - let claims = service.validate_jwt(token).await?; + if let Some(assertion) = identity_assertion { + let claims = service.validate_jwt(assertion.jwt()).await?; let source = binding_source_for_signer(claims.pubkey, signer)?; - return Ok(CorporateIdentityProof::Direct { claims, source }); + return Ok(CorporateIdentityProof::Direct { + claims, + source, + assertion_transport: assertion.assertion_transport, + }); } verify_delegated_corporate_identity( @@ -670,7 +1061,7 @@ pub async fn finalize_atomic_corporate_identity_result( owner_issuer, owner_uid, }), - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { let binding = committed_binding.ok_or_else(|| { buzz_db::DbError::InvalidData( "atomic identity admission did not return a binding result".to_string(), @@ -703,7 +1094,7 @@ async fn finalize_corporate_identity_inner( owner_issuer, owner_uid, }), - CorporateIdentityProof::Direct { claims, source } => { + CorporateIdentityProof::Direct { claims, source, .. } => { let binding = state .db .bind_or_validate_identity( @@ -788,33 +1179,31 @@ async fn complete_direct_corporate_identity( ) .await; } - if let Err(error) = ensure_identity_assertion( - state, - community_id, - signer, - claims.public_display_name.as_deref(), - claims.expires_at, - ) - .await - { - // The binding remains the authorization authority. A projection - // failure removes the verified affordance but must not lock an - // otherwise authorized user out of the relay. - warn!( - signer = %signer.to_hex(), - error = %error, - "failed to publish corporate identity assertion" - ); - metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") - .increment(1); + let projection_mode = state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)); + if public_projection_mutation_enabled(projection_mode) { + if let Err(_error) = ensure_identity_assertion( + state, + community_id, + signer, + &claims.issuer, + &claims.uid, + claims.public_display_name.as_deref(), + claims.expires_at, + ) + .await + { + // The binding remains the authorization authority. A projection + // failure removes the verified affordance but must not lock an + // otherwise authorized user out of the relay. + warn!("failed to publish corporate identity assertion"); + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") + .increment(1); + } } - debug!( - uid = %claims.uid, - signer = %signer.to_hex(), - source, - "corporate identity verified" - ); + debug!(source, "corporate identity verified"); Ok(CorporateIdentityDecision::Direct { issuer: claims.issuer, uid: claims.uid, @@ -865,6 +1254,45 @@ fn identity_assertion_matches( subject: &str, display_name: Option<&str>, expires_at: u64, +) -> bool { + let exact_tag_count = |name: &str, value: &str| { + event + .tags + .iter() + .filter(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + .count() + }; + event.content.is_empty() + && event.tags.len() == if display_name.is_some() { 6 } else { 5 } + && exact_tag_count("d", subject) == 1 + && exact_tag_count("p", subject) == 1 + && exact_tag_count("verified", "relay") == 1 + && exact_tag_count( + "active", + if display_name.is_some() { + "true" + } else { + "false" + }, + ) == 1 + && exact_tag_count("expiration", &expires_at.to_string()) == 1 + && match display_name { + Some(name) => exact_tag_count("display_name", name) == 1, + None => !event.tags.iter().any(|tag| { + tag.as_slice() + .first() + .is_some_and(|part| part == "display_name") + }), + } +} + +fn identity_assertion_has_base_shape( + event: &Event, + relay_author: PublicKey, + subject: &str, ) -> bool { let has_tag = |name: &str, value: &str| { event.tags.iter().any(|tag| { @@ -872,43 +1300,38 @@ fn identity_assertion_matches( parts.len() == 2 && parts[0] == name && parts[1] == value }) }; - has_tag("d", subject) + event.content.is_empty() + && event.kind.as_u16() as u32 == KIND_USER_TRUSTED_ASSERTION + && event.pubkey == relay_author + && event.verify_id() + && event.verify_signature() + && 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)) } async fn ensure_identity_assertion( state: &AppState, community_id: CommunityId, subject: PublicKey, + issuer: &str, + uid: &str, display_name: Option<&str>, jwt_expires_at: u64, ) -> Result<(), String> { let subject_hex = subject.to_hex(); - let existing = state - .db - .query_events(&EventQuery { - kinds: Some(vec![KIND_USER_TRUSTED_ASSERTION as i32]), - pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), - d_tag: Some(subject_hex.clone()), - global_only: true, - limit: Some(1), - ..EventQuery::for_community(community_id) - }) - .await - .map_err(|error| error.to_string())? - .into_iter() - .next(); + let permit = buzz_db::public_projection::begin_active_public_projection( + &state.db, + community_id, + state.relay_keypair.public_key().as_bytes(), + issuer, + uid, + subject.as_bytes(), + ) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "identity binding changed before public projection".to_owned())?; + let existing = permit.current_projection().cloned(); // Privacy default: do not publish any assertion unless the operator opted // into a public label. An inactive replacement is emitted only to retire a @@ -919,9 +1342,20 @@ async fn ensure_identity_assertion( let now = Timestamp::now().as_secs(); let expires_at = identity_assertion_expiration(display_name, jwt_expires_at, now); - if existing.as_ref().is_some_and(|stored| { + if let Some(existing_match) = existing.as_ref().filter(|stored| { identity_assertion_matches(&stored.event, &subject_hex, display_name, expires_at) }) { + permit + .commit( + &existing_match.event, + if display_name.is_some() { + buzz_db::public_projection::ProjectionDisposition::Active + } else { + buzz_db::public_projection::ProjectionDisposition::Inactive + }, + ) + .await + .map_err(|error| error.to_string())?; return Ok(()); } @@ -938,9 +1372,15 @@ async fn ensure_identity_assertion( Timestamp::from(created_at), )?; - state - .db - .replace_parameterized_event(community_id, &event, &subject_hex, None) + permit + .commit( + &event, + if display_name.is_some() { + buzz_db::public_projection::ProjectionDisposition::Active + } else { + buzz_db::public_projection::ProjectionDisposition::Inactive + }, + ) .await .map_err(|error| error.to_string())?; metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "published") @@ -956,6 +1396,214 @@ fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64 } } +/// Internal O4 reconciliation failure. This carries no identity or token data. +#[derive(Debug, Error)] +pub(crate) enum PublicProjectionReconciliationError { + /// Durable projection state was unavailable or inconsistent. + #[error("public identity projection database state is unavailable")] + Database(#[from] buzz_db::DbError), + /// Shared authorization time was unavailable. + #[error(transparent)] + Clock(#[from] AuthorizationClockError), + /// A stored event did not satisfy the exact public projection contract. + #[error("stored public identity projection is invalid")] + InvalidProjection, + /// The relay could not construct the canonical inactive event. + #[error("failed to construct inactive public identity projection")] + Build, + /// A configured domain lacked its durable tenant mapping. + #[error("public identity projection domain is unavailable")] + DomainUnavailable, + /// Cross-replica withdrawal delivery failed and remains retryable. + #[error("public identity projection delivery is unavailable")] + DeliveryUnavailable, + /// Startup did not reach a complete reconciliation fixed point. + #[error("public identity projection reconciliation is incomplete")] + Incomplete, +} + +async fn reconcile_one_public_projection( + state: &AppState, + domains: &[CommunityId], +) -> Result { + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + buzz_db::public_projection::materialize_public_projection_retirements( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await?; + + if let Some(claim) = buzz_db::public_projection::claim_public_projection_retirement( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await? + { + let permit = + buzz_db::public_projection::begin_public_projection_retirement(&state.db, claim) + .await?; + let Some(current) = permit.current_projection().cloned() else { + permit.finish_no_projection().await?; + return Ok(true); + }; + let old_key = match PublicKey::from_slice(permit.old_pubkey()) { + Ok(key) => key, + Err(_) => { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + }; + let subject = old_key.to_hex(); + if !identity_assertion_has_base_shape( + ¤t.event, + state.relay_keypair.public_key(), + &subject, + ) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + let current_is_inactive = identity_assertion_matches(¤t.event, &subject, None, 0); + if permit.head().is_some_and(|head| { + head.event_id() != *current.event.id.as_bytes() + || head.disposition() + != if current_is_inactive { + buzz_db::public_projection::ProjectionDisposition::Inactive + } else { + buzz_db::public_projection::ProjectionDisposition::Active + } + }) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + if current_is_inactive { + permit.finish_existing_inactive().await?; + return Ok(true); + } + let head_origin = permit.head().and_then(|head| head.origin()); + if let Some(active_origin) = permit.active_origin() { + if permit.source_origin() == Some(active_origin) { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::InvalidProjection); + } + if head_origin == Some(active_origin) { + permit.finish_superseded(¤t.event).await?; + return Ok(true); + } + } + if let (Some(source), Some(origin)) = (permit.source_origin(), head_origin) { + let belongs_to_later_generation = if origin.binding_id() == source.binding_id() { + origin.binding_version() > source.binding_version() + } else { + true + }; + if belongs_to_later_generation { + permit.finish_newer_projection().await?; + return Ok(true); + } + } + let now = SystemAuthorizationClock.now()?.unix_seconds(); + let Some(next_created_at) = current.event.created_at.as_secs().checked_add(1) else { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::Build); + }; + let inactive = match build_identity_assertion( + &state.relay_keypair, + old_key, + None, + 0, + Timestamp::from(next_created_at.max(now)), + ) { + Ok(event) => event, + Err(_) => { + permit.defer().await?; + return Err(PublicProjectionReconciliationError::Build); + } + }; + permit.finish_inactive(&inactive).await?; + return Ok(true); + } + + let Some(delivery) = buzz_db::public_projection::begin_public_projection_delivery( + &state.db, + domains, + relay_pubkey.as_slice(), + ) + .await? + else { + return Ok(false); + }; + let domain = delivery.community_id(); + let tenant = state + .db + .usage_community_hosts() + .await? + .into_iter() + .find(|entry| CommunityId::from_uuid(entry.id) == domain) + .map(|entry| buzz_core::TenantContext::resolved(domain, entry.host)); + let Some(tenant) = tenant else { + delivery.defer().await?; + return Err(PublicProjectionReconciliationError::DomainUnavailable); + }; + state.mark_local_event(domain, &delivery.stored().event.id); + if state + .pubsub + .publish_event(&tenant, EventTopic::Global, &delivery.stored().event) + .await + .is_err() + { + state + .local_event_ids + .invalidate(&(domain, delivery.stored().event.id.to_bytes())); + delivery.defer().await?; + return Err(PublicProjectionReconciliationError::DeliveryUnavailable); + } + crate::handlers::event::fan_out_event_to_local_subscribers(state, domain, delivery.stored()) + .await; + delivery.complete().await?; + Ok(true) +} + +/// Drain every materialized retirement before protected routes become reachable. +pub(crate) async fn reconcile_public_projection_retirements_startup( + state: &AppState, + domains: &[CommunityId], +) -> Result<(), PublicProjectionReconciliationError> { + for _ in 0..PUBLIC_PROJECTION_STARTUP_LIMIT { + if !reconcile_one_public_projection(state, domains).await? { + break; + } + } + let unfinished = buzz_db::public_projection::unfinished_public_projection_retirements( + &state.db, + domains, + state.relay_keypair.public_key().as_bytes(), + ) + .await?; + if unfinished != 0 { + return Err(PublicProjectionReconciliationError::Incomplete); + } + Ok(()) +} + +/// Continuously discover committed lifecycle rows and retry withdrawal/delivery. +pub(crate) async fn run_public_projection_retirement_reconciliation( + state: Arc, + domains: Vec, +) { + loop { + match reconcile_one_public_projection(state.as_ref(), &domains).await { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!(%error, "public identity projection reconciliation deferred") + } + } + tokio::time::sleep(PUBLIC_PROJECTION_RECONCILIATION_INTERVAL).await; + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum IdentityAuthPath { Direct, @@ -985,16 +1633,12 @@ async fn verify_delegated_corporate_identity( auth_tag_json: Option<&str>, ) -> Result { if config.allow_delegation { - if let Some(owner_pubkey) = extract_unconditional_nip_oa_owner(signer, auth_tag_json) { + if let Some(owner_pubkey) = verify_unconditional_nip_oa_owner(signer, auth_tag_json) { let owner_binding = db .get_active_identity_binding_by_pubkey(community_id, owner_pubkey.as_bytes()) .await?; if let Some(owner_binding) = owner_binding { - debug!( - agent = %signer.to_hex(), - owner = %owner_pubkey.to_hex(), - "corporate identity granted via NIP-OA owner binding" - ); + debug!("corporate identity granted via NIP-OA owner binding"); return Ok(CorporateIdentityProof::Delegated { owner_pubkey, owner_issuer: owner_binding.issuer, @@ -1010,7 +1654,11 @@ async fn verify_delegated_corporate_identity( } } -fn extract_unconditional_nip_oa_owner( +/// Verify an unconditional NIP-OA owner attestation for transport-wide use. +/// +/// Conditional attestations are deliberately rejected because their narrower +/// event constraints cannot be promoted into connection or request authority. +pub fn verify_unconditional_nip_oa_owner( signer: PublicKey, auth_tag_json: Option<&str>, ) -> Option { @@ -1095,11 +1743,7 @@ fn binding_source_for_signer( match claim_pubkey { Some(claim_pubkey) => { if claim_pubkey != signer { - warn!( - signer = %signer.to_hex(), - claim_pubkey = %claim_pubkey.to_hex(), - "corporate identity JWT npub claim does not match signer" - ); + warn!("corporate identity JWT npub claim does not match signer"); return Err(CorporateIdentityError::NpubMismatch); } Ok(SOURCE_JWT_NPUB) @@ -1166,6 +1810,28 @@ fn claim_u64(claims: &Map, claim: &str) -> Result, + verifier_time: u64, +) -> Result<(), CorporateIdentityError> { + let Some(value) = claims.get("iat") else { + return Ok(()); + }; + let issued_at = value + .as_u64() + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: "iat".to_string(), + reason: "must be an unsigned integer when present".to_string(), + })?; + if issued_at > verifier_time.saturating_add(JWT_CLOCK_SKEW_LEEWAY_SECS) { + return Err(CorporateIdentityError::InvalidClaim { + claim: "iat".to_string(), + reason: "must not be later than verifier time plus bounded skew".to_string(), + }); + } + Ok(()) +} + fn parse_pubkey_claim(claim: &str, value: &str) -> Result { if value.starts_with("npub1") { PublicKey::from_bech32(value).map_err(|e| CorporateIdentityError::InvalidClaim { @@ -1185,7 +1851,7 @@ pub fn service_from_config( config: &CorporateIdentityConfig, ) -> Option> { config - .require + .verifier_configured() .then(|| Arc::new(CorporateIdentityService::new(config.clone()))) } @@ -1200,23 +1866,25 @@ fn record_identity_binding_metric(binding: &BindIdentityResult) { metrics::counter!("buzz_corporate_identity_bindings_total", "result" => result).increment(1); } +const fn public_projection_mutation_enabled( + mode: Option, +) -> bool { + use crate::authorization_runtime::finalization::AuthorizationMode; + + matches!( + mode, + None | Some(AuthorizationMode::Off) | Some(AuthorizationMode::Enforce) + ) +} + fn record_corporate_identity_denial(error: &CorporateIdentityError) { - let reason = match error { - CorporateIdentityError::MissingJwt => "missing_jwt", - CorporateIdentityError::MissingKid => "missing_kid", - CorporateIdentityError::InvalidJwt(_) => "invalid_jwt", - CorporateIdentityError::Jwks(_) => "jwks", - CorporateIdentityError::InvalidClaim { .. } => "invalid_claim", - CorporateIdentityError::NpubMismatch => "npub_mismatch", - CorporateIdentityError::BindingConflict => "binding_conflict", - CorporateIdentityError::BindingRevoked => "binding_revoked", - CorporateIdentityError::BindingRequired => "binding_required", - CorporateIdentityError::DelegationDenied => "delegation_denied", - CorporateIdentityError::Db(_) => "db", - }; metrics::counter!("buzz_auth_failures_total", "reason" => "corporate_identity_denied") .increment(1); - metrics::counter!("buzz_corporate_identity_denials_total", "reason" => reason).increment(1); + metrics::counter!( + "buzz_corporate_identity_denials_total", + "reason" => error.reason_code() + ) + .increment(1); } async fn record_identity_binding_audit( @@ -1228,6 +1896,16 @@ async fn record_identity_binding_audit( uid: &str, detail: serde_json::Value, ) { + if crate::protected_surface::require_effect_permit( + state + .protected_transport() + .and_then(|runtime| runtime.mode_for_domain(community_id)), + crate::protected_surface::EffectSurfaceId::LegacyAuditDelivery, + ) + .is_err() + { + return; + } let Some(audit_tx) = &state.audit_tx else { return; }; @@ -1256,6 +1934,7 @@ mod tests { use axum::http::{HeaderMap, HeaderName, HeaderValue}; use base64::Engine as _; + use buzz_auth::{AuthorizationClock, AuthorizationTime}; use jsonwebtoken::jwk::JwkSet; use jsonwebtoken::{encode, EncodingKey, Header}; use nostr::Keys; @@ -1266,6 +1945,131 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + struct FixedAuthorizationClock(u64); + + impl AuthorizationClock for FixedAuthorizationClock { + fn now(&self) -> Result { + Ok(AuthorizationTime::from_unix_seconds(self.0)) + } + } + + #[test] + fn observational_modes_never_mutate_the_public_projection() { + use crate::authorization_runtime::finalization::AuthorizationMode; + + assert!(public_projection_mutation_enabled(None)); + assert!(public_projection_mutation_enabled(Some( + AuthorizationMode::Off + ))); + assert!(public_projection_mutation_enabled(Some( + AuthorizationMode::Enforce + ))); + assert!(!public_projection_mutation_enabled(Some( + AuthorizationMode::Shadow + ))); + assert!(!public_projection_mutation_enabled(Some( + AuthorizationMode::VerifyOnly + ))); + } + + #[test] + fn identity_debug_never_exposes_provider_or_principal_data() { + let pubkey = Keys::generate().public_key(); + let claims = CorporateJwtClaims { + issuer: "https://private-issuer.invalid".into(), + uid: "private-subject".into(), + display_name: "Private Person".into(), + public_display_name: Some("Public Label".into()), + pubkey: Some(pubkey), + expires_at: 1_234_567, + }; + let proof = CorporateIdentityProof::Direct { + claims: claims.clone(), + source: "private-source", + assertion_transport: Some(buzz_auth::AssertionTransport::TrustedProxy), + }; + let decision = CorporateIdentityDecision::Delegated { + owner_pubkey: pubkey, + owner_issuer: claims.issuer.clone(), + owner_uid: claims.uid.clone(), + }; + let error = CorporateIdentityError::InvalidClaim { + claim: "private-claim-name".into(), + reason: "private-claim-value".into(), + }; + + for debug in [ + format!("{claims:?}"), + format!("{proof:?}"), + format!("{decision:?}"), + format!("{error:?}"), + ] { + for secret in [ + "private-issuer", + "private-subject", + "Private Person", + "Public Label", + "private-source", + "private-claim-name", + "private-claim-value", + &pubkey.to_hex(), + "1234567", + ] { + assert!( + !debug.contains(secret), + "debug output exposed {secret:?}: {debug}" + ); + } + } + } + + #[test] + fn direct_identity_requires_verified_transport_provenance_before_sealing() { + let claims = CorporateJwtClaims { + issuer: "https://issuer.example".into(), + uid: "synthetic-subject".into(), + display_name: "Synthetic User".into(), + public_display_name: None, + pubkey: Some(Keys::generate().public_key()), + expires_at: 2_000, + }; + let domain = CommunityId::from_uuid(Uuid::nil()); + let unproved = CorporateIdentityProof::Direct { + claims: claims.clone(), + source: SOURCE_JWT_NPUB, + assertion_transport: None, + }; + + assert!(matches!( + verified_assertion_for_proof( + &unproved, + domain, + buzz_auth::AuthTransport::RelayWebSocket, + 1_000, + ), + Err(CorporateIdentityError::Evidence(_)) + )); + + let proved = CorporateIdentityProof::Direct { + claims, + source: SOURCE_JWT_NPUB, + assertion_transport: Some(buzz_auth::AssertionTransport::TrustedProxy), + }; + let assertion = verified_assertion_for_proof( + &proved, + domain, + buzz_auth::AuthTransport::RelayWebSocket, + 1_000, + ) + .expect("synthetic proved assertion should seal") + .expect("direct identity should produce a sealed assertion"); + + assert_eq!( + assertion.transport(), + buzz_auth::AssertionTransport::TrustedProxy + ); + } + fn test_config() -> CorporateIdentityConfig { CorporateIdentityConfig { require: true, @@ -1519,6 +2323,59 @@ mod tests { .first() .is_some_and(|part| part == "display_name") })); + + let subject_hex = subject.to_hex(); + let malformed = EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + Tag::parse(["d", subject_hex.as_str()]).expect("d tag"), + Tag::parse(["p", subject_hex.as_str()]).expect("p tag"), + Tag::parse(["verified", "relay"]).expect("verified tag"), + Tag::parse(["active", "false"]).expect("inactive tag"), + Tag::parse(["expiration", "0"]).expect("expiration tag"), + Tag::parse(["display_name", "Forbidden stale label"]).expect("display tag"), + ]) + .custom_created_at(Timestamp::from(now + 1)) + .sign_with_keys(&relay) + .expect("sign malformed inactive assertion"); + assert!(!identity_assertion_matches( + &malformed, + &subject_hex, + None, + 0, + )); + + for (display_name, expires_at, at) in [ + (Some("Example User"), now + 60, now + 2), + (None, 0, now + 3), + ] { + let canonical = build_identity_assertion( + &relay, + subject, + display_name, + expires_at, + Timestamp::from(at), + ) + .expect("build canonical assertion"); + let nonempty = EventBuilder::new( + Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), + "private content must never be projected", + ) + .tags(canonical.tags) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(&relay) + .expect("sign non-canonical assertion"); + assert!(!identity_assertion_matches( + &nonempty, + &subject_hex, + display_name, + expires_at, + )); + assert!(!identity_assertion_has_base_shape( + &nonempty, + relay.public_key(), + &subject_hex, + )); + } } #[test] @@ -1588,6 +2445,38 @@ mod tests { assert_eq!(claims.display_name, "user@example.com"); } + #[tokio::test] + async fn validate_jwt_rejects_future_and_malformed_optional_iat() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let now = 2_000_000_000; + let clock: SharedAuthorizationClock = Arc::new(FixedAuthorizationClock(now)); + + let mut within_skew = valid_test_claims(now); + within_skew["iat"] = Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS); + let token = rsa_test_jwt_with_claims(&key, "rsa-key", &within_skew); + validate_rsa_jwt_at(&token, rsa_test_jwk(&key, "rsa-key"), Arc::clone(&clock)) + .await + .expect("optional iat within bounded skew validates"); + + for iat in [ + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS + 600), + Value::String("tomorrow".to_string()), + ] { + let mut claims = valid_test_claims(now); + claims["iat"] = iat; + let token = rsa_test_jwt_with_claims(&key, "rsa-key", &claims); + let error = + validate_rsa_jwt_at(&token, rsa_test_jwk(&key, "rsa-key"), Arc::clone(&clock)) + .await + .expect_err("future or malformed optional iat must fail closed"); + match error { + CorporateIdentityError::InvalidClaim { claim, .. } => assert_eq!(claim, "iat"), + CorporateIdentityError::InvalidJwt(_) => {} + other => panic!("unexpected optional iat error: {other:?}"), + } + } + } + #[tokio::test] async fn validate_jwt_rejects_rs256_token_signed_by_wrong_key() { let signing_key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); @@ -1704,6 +2593,36 @@ mod tests { assert_eq!(validation.leeway, JWT_CLOCK_SKEW_LEEWAY_SECS); } + #[test] + fn optional_iat_is_not_required_and_is_bounded_by_verifier_time() { + let now = 1_800_000_000; + let mut claims = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + assert!(validate_optional_iat(&claims, now).is_ok()); + + claims.insert( + "iat".to_string(), + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS), + ); + assert!(validate_optional_iat(&claims, now).is_ok()); + + for invalid in [ + Value::from(now + JWT_CLOCK_SKEW_LEEWAY_SECS + 1), + Value::String("tomorrow".to_string()), + Value::from(-1), + Value::from(1.5), + Value::Null, + ] { + claims.insert("iat".to_string(), invalid); + assert!(matches!( + validate_optional_iat(&claims, now), + Err(CorporateIdentityError::InvalidClaim { ref claim, .. }) if claim == "iat" + )); + } + } + #[test] fn jwt_validation_rejects_malformed_registered_claim_types() { let now = Timestamp::now().as_secs(); @@ -1799,7 +2718,7 @@ mod tests { } #[test] - fn extracts_bearer_token_from_comma_list_header() { + fn rejects_ambiguous_identity_header_values() { let config = test_config(); let mut headers = HeaderMap::new(); headers.insert( @@ -1807,10 +2726,18 @@ mod tests { HeaderValue::from_static("Bearer token-a, Bearer token-b"), ); - assert_eq!( - identity_jwt_from_headers(&headers, &config).as_deref(), - Some("token-a") + assert!(identity_jwt_from_headers(&headers, &config).is_err()); + + headers.remove("x-buzz-identity-token"); + headers.append( + HeaderName::from_static("x-buzz-identity-token"), + HeaderValue::from_static("Bearer token-a"), + ); + headers.append( + HeaderName::from_static("x-buzz-identity-token"), + HeaderValue::from_static("Bearer token-b"), ); + assert!(identity_jwt_from_headers(&headers, &config).is_err()); } #[test] @@ -1867,9 +2794,13 @@ mod tests { #[tokio::test] async fn fresh_jwks_cache_miss_does_not_refetch() { let service = CorporateIdentityService::new(test_config()); + let fetched_at = Instant::now(); *service.jwks.write().await = Some(CachedJwks { set: JwkSet { keys: Vec::new() }, - expires_at: Instant::now() + Duration::from_secs(60), + fetched_at, + fresh_until: fetched_at + Duration::from_secs(60), + hard_expires_at: fetched_at + JWKS_CACHE_MAX_AGE, + refresh_after: fetched_at + Duration::from_secs(60), }); let err = service @@ -1958,11 +2889,11 @@ mod tests { .expect("conditional auth tag"); assert_eq!( - extract_unconditional_nip_oa_owner(agent, Some(&unconditional)), + verify_unconditional_nip_oa_owner(agent, Some(&unconditional)), Some(owner.public_key()), ); assert_eq!( - extract_unconditional_nip_oa_owner(agent, Some(&conditional)), + verify_unconditional_nip_oa_owner(agent, Some(&conditional)), None, ); } @@ -2013,19 +2944,31 @@ mod tests { } fn rsa_test_jwt(private_key: &[u8], kid: &str) -> String { - let mut header = Header::new(Algorithm::RS256); - header.kid = Some(kid.to_string()); - encode( - &header, + rsa_test_jwt_with_claims( + private_key, + kid, &valid_test_claims(Timestamp::now().as_secs()), - &EncodingKey::from_rsa_der(private_key), ) - .expect("encode RSA test JWT") + } + + fn rsa_test_jwt_with_claims(private_key: &[u8], kid: &str, claims: &Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode(&header, claims, &EncodingKey::from_rsa_der(private_key)) + .expect("encode RSA test JWT") } async fn validate_rsa_jwt( token: &str, jwk: Jwk, + ) -> Result { + validate_rsa_jwt_at(token, jwk, Arc::new(SystemAuthorizationClock)).await + } + + async fn validate_rsa_jwt_at( + token: &str, + jwk: Jwk, + clock: SharedAuthorizationClock, ) -> Result { let body = serde_json::to_string(&JwkSet { keys: vec![jwk] }).expect("serialize RSA test JWKS"); @@ -2034,7 +2977,7 @@ mod tests { let mut config = test_config(); config.jwks_uri = uri; config.npub_claim = None; - let result = CorporateIdentityService::new(config) + let result = CorporateIdentityService::with_authorization_clock(config, clock) .validate_jwt(token) .await; server.abort(); @@ -2106,6 +3049,264 @@ mod tests { CommunityId::from_uuid(id) } + async fn projection_test_state( + db: buzz_db::Db, + pool: PgPool, + redis_url: &str, + relay_keys: Keys, + ) -> Arc { + let mut config = crate::config::Config::from_env().expect("default test config"); + config.redis_url = redis_url.to_owned(); + let redis_pool = deadpool_redis::Config::from_url(redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(redis_url, redis_pool.clone()) + .await + .expect("test pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("test media store"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + relay_keys, + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn projection_worker_retries_after_restart_and_fans_out_canonical_withdrawal() { + let (db, pool) = setup_db().await; + let community = make_community(&pool).await; + let relay_keys = Keys::generate(); + let subject_keys = Keys::generate(); + let subject = subject_keys.public_key(); + db.bind_or_validate_identity( + community, + "https://provider.example", + "synthetic-subject", + subject.as_bytes(), + Some("Synthetic Label"), + SOURCE_DB_BINDING, + ) + .await + .expect("create synthetic binding"); + let active = build_identity_assertion( + &relay_keys, + subject, + Some("Synthetic Label"), + Timestamp::now().as_secs().saturating_add(300), + Timestamp::now(), + ) + .expect("build active projection"); + buzz_db::public_projection::begin_active_public_projection( + &db, + community, + relay_keys.public_key().as_bytes(), + "https://provider.example", + "synthetic-subject", + subject.as_bytes(), + ) + .await + .expect("begin active projection") + .expect("active binding exists") + .commit( + &active, + buzz_db::public_projection::ProjectionDisposition::Active, + ) + .await + .expect("commit active projection"); + db.revoke_identity_key(community, subject.as_bytes(), None, "synthetic revocation") + .await + .expect("revoke synthetic key"); + + let observational = make_community(&pool).await; + let observational_keys = Keys::generate(); + let observational_subject = observational_keys.public_key(); + db.bind_or_validate_identity( + observational, + "https://observational-provider.example", + "observational-subject", + observational_subject.as_bytes(), + Some("Observational Label"), + SOURCE_DB_BINDING, + ) + .await + .expect("create observational binding"); + let observational_active = build_identity_assertion( + &relay_keys, + observational_subject, + Some("Observational Label"), + Timestamp::now().as_secs().saturating_add(300), + Timestamp::now(), + ) + .expect("build observational projection"); + buzz_db::public_projection::begin_active_public_projection( + &db, + observational, + relay_keys.public_key().as_bytes(), + "https://observational-provider.example", + "observational-subject", + observational_subject.as_bytes(), + ) + .await + .expect("begin observational projection") + .expect("observational binding exists") + .commit( + &observational_active, + buzz_db::public_projection::ProjectionDisposition::Active, + ) + .await + .expect("commit observational projection"); + db.revoke_identity_key( + observational, + observational_subject.as_bytes(), + None, + "observational revocation", + ) + .await + .expect("revoke observational key"); + + let unavailable = projection_test_state( + db.clone(), + pool.clone(), + "redis://127.0.0.1:1", + relay_keys.clone(), + ) + .await; + assert!(matches!( + reconcile_public_projection_retirements_startup(&unavailable, &[community]).await, + Err(PublicProjectionReconciliationError::DeliveryUnavailable) + )); + assert_eq!( + buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[community], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("retryable retirement remains"), + 1 + ); + drop(unavailable); + + let live_redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()); + let restarted = projection_test_state( + db.clone(), + pool.clone(), + &live_redis_url, + relay_keys.clone(), + ) + .await; + let tenant = restarted + .db + .usage_community_hosts() + .await + .expect("load synthetic tenant") + .into_iter() + .find(|entry| CommunityId::from_uuid(entry.id) == community) + .map(|entry| buzz_core::TenantContext::resolved(community, entry.host)) + .expect("synthetic tenant exists"); + let mut received = restarted.pubsub.subscribe_local(); + restarted + .pubsub + .retain_topic(&tenant, EventTopic::Global) + .await; + let subscriber_state = Arc::clone(&restarted.pubsub); + let subscriber = tokio::spawn(async move { subscriber_state.run_subscriber().await }); + tokio::time::sleep(Duration::from_millis(200)).await; + let worker_state = Arc::clone(&restarted); + let worker = tokio::spawn(async move { + run_public_projection_retirement_reconciliation(worker_state, vec![community]).await + }); + let withdrawal = tokio::time::timeout(Duration::from_secs(8), async { + loop { + let event = received + .recv() + .await + .expect("projection fan-out remains open"); + if event.community_id == community + && event.topic == EventTopic::Global + && event.event.kind.as_u16() as u32 == KIND_USER_TRUSTED_ASSERTION + { + break event.event; + } + } + }) + .await + .expect("restart retries and fans out the withdrawal"); + assert!(withdrawal.content.is_empty()); + assert!(identity_assertion_matches( + &withdrawal, + &subject.to_hex(), + None, + 0 + )); + let serialized_withdrawal = + serde_json::to_string(&withdrawal).expect("serialize withdrawal"); + assert!(!serialized_withdrawal.contains("provider.example")); + assert!(!serialized_withdrawal.contains("synthetic-subject")); + let mut completed = false; + for _ in 0..40 { + if buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[community], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("count completed retirement") + == 0 + { + completed = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + worker.abort(); + subscriber.abort(); + assert!( + completed, + "withdrawal delivery did not complete exactly once" + ); + let observational_head: String = sqlx::query_scalar( + "SELECT disposition FROM identity_public_projection_heads \ + WHERE community_id=$1 AND relay_pubkey=$2 AND subject_pubkey=$3", + ) + .bind(observational.as_uuid()) + .bind(relay_keys.public_key().as_bytes()) + .bind(observational_subject.as_bytes()) + .fetch_one(&pool) + .await + .expect("observational head remains"); + assert_eq!(observational_head, "active"); + assert_eq!( + buzz_db::public_projection::unfinished_public_projection_retirements( + &db, + &[observational], + relay_keys.public_key().as_bytes(), + ) + .await + .expect("observational mode has no retirement queue"), + 0 + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn delegation_requires_owner_identity_binding() { diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44e..ca53ab8a34 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -23,6 +23,19 @@ use axum::{ }; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use metrics_util::MetricKindMask; +use sha2::{Digest, Sha256}; + +/// Stable provider-neutral label that does not publish a tenant host. +/// +/// This label is for ephemeral runtime metrics only. Durable audit identity +/// and managed pseudonymization remain outside the metrics plane. +pub(crate) fn community_label(community_id: buzz_core::CommunityId) -> String { + let mut digest = Sha256::new(); + digest.update(b"buzz-runtime-community-metric-v1"); + digest.update(community_id.as_uuid().as_bytes()); + let encoded = hex::encode(digest.finalize()); + encoded[..16].to_owned() +} /// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`. const LATENCY_BUCKETS_MS: [f64; 11] = [ @@ -205,3 +218,18 @@ pub async fn track_metrics(req: Request, next: Next) -> Response { response } + +#[cfg(test)] +mod privacy_tests { + use super::*; + + #[test] + fn community_metric_label_is_stable_bounded_and_opaque() { + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(0xfeed)); + let label = community_label(community); + assert_eq!(label, community_label(community)); + assert_eq!(label.len(), 16); + assert!(!label.contains(&community.to_string())); + assert!(label.bytes().all(|byte| byte.is_ascii_hexdigit())); + } +}