From edf28cca46453cfd7eac1026d2df2a3a5871951b Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:02:43 -0500 Subject: [PATCH 1/7] feat(auth): add authorization provider contract Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> (cherry picked from commit 93428020c84f477186f400952d403e33e6f2ab04) Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/lib.rs | 10 + crates/buzz-auth/src/provider/mod.rs | 1031 ++++++++++++++++++++++++ crates/buzz-auth/src/provider/tests.rs | 970 ++++++++++++++++++++++ 3 files changed, 2011 insertions(+) create mode 100644 crates/buzz-auth/src/provider/mod.rs create mode 100644 crates/buzz-auth/src/provider/tests.rs diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index df963bc4e0..e4d6e831eb 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -27,6 +27,8 @@ pub mod nip42; pub mod nip98; /// NIP-98 replay protection — shared, community-scoped, atomic seen-set. pub mod nip98_replay; +/// Provider-neutral authorization policy and validated capability snapshots. +pub mod provider; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -52,6 +54,14 @@ pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, }; +pub use provider::{ + resolve_authorization, AuthorizationAuthority, AuthorizationCapability, AuthorizationDenial, + AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, + AuthorizationProviderFuture, AuthorizationRequest, CapabilitySet, CapabilitySnapshot, + DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, ProviderContractError, + ProviderDecision, ProviderTimeout, ProviderUnavailable, ProviderUnavailableReason, RetryAfter, + MAX_PROVIDER_FRESHNESS_SECONDS, MAX_PROVIDER_TIMEOUT, +}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs new file mode 100644 index 0000000000..970e2bac20 --- /dev/null +++ b/crates/buzz-auth/src/provider/mod.rs @@ -0,0 +1,1031 @@ +//! Provider-neutral authorization decisions. +//! +//! This module defines a runtime-neutral boundary between verified identity +//! evidence and deployment-specific policy. It does not select or configure a +//! provider, construct identity evidence, or change any relay handler. + +use std::{fmt, future::Future, pin::Pin, time::Duration}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::context::{ + AuthMethod, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedNostrProof, + VersionedBindingRef, +}; + +const MAX_OPAQUE_ID_BYTES: usize = 256; +const MAX_RETRY_AFTER_SECONDS: u32 = 3_600; +/// Maximum freshness window accepted from an authorization provider. +pub const MAX_PROVIDER_FRESHNESS_SECONDS: u64 = 86_400; +/// Maximum deadline accepted for one authorization-provider call. +pub const MAX_PROVIDER_TIMEOUT: Duration = Duration::from_secs(60); + +/// Portable capability evaluated by an [`AuthorizationProvider`]. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum AuthorizationCapability { + /// Read community content. + CommunityRead, + /// Publish community content. + CommunityWrite, + /// Perform moderation operations. + Moderate, + /// Mint or claim invitations. + Invite, + /// Read authenticated media. + MediaRead, + /// Upload media. + MediaWrite, + /// Read Git content. + GitRead, + /// Write Git content. + GitWrite, + /// Join an audio session. + AudioJoin, +} + +impl fmt::Debug for AuthorizationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Non-empty, normalized set of portable capabilities. +#[derive(Clone, PartialEq, Eq)] +pub struct CapabilitySet(Vec); + +impl CapabilitySet { + /// Build a non-empty set, sorting and removing duplicate capabilities. + pub fn new( + mut capabilities: Vec, + ) -> Result { + capabilities.sort_unstable(); + capabilities.dedup(); + if capabilities.is_empty() { + return Err(ProviderContractError::EmptyCapabilitySet); + } + Ok(Self(capabilities)) + } + + /// Normalized capabilities in stable order. + pub fn as_slice(&self) -> &[AuthorizationCapability] { + &self.0 + } + + fn contains_all(&self, requested: &Self) -> bool { + requested + .as_slice() + .iter() + .all(|capability| self.0.binary_search(capability).is_ok()) + } +} + +impl fmt::Debug for CapabilitySet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CapabilitySet") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque identifier for the server-resolved authorization profile. +/// +/// Production construction is intentionally unavailable until a sealed policy +/// adapter can prove that the profile came from server-owned configuration. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct AuthorizationProfileId(String); + +impl AuthorizationProfileId { + /// Preserve a non-empty, bounded profile identifier exactly as configured. + #[cfg(test)] + pub(crate) fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyProfileId); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::ProfileIdTooLong); + } + Ok(Self(value)) + } + + /// Exact profile identifier for provider routing. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AuthorizationProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationProfileId") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque, equality-comparable policy version returned by a provider. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct PolicyVersion(String); + +impl PolicyVersion { + /// Preserve a non-empty, bounded policy version without interpreting it. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyPolicyVersion); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::PolicyVersionTooLong); + } + Ok(Self(value)) + } + + /// Exact opaque version bytes. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PolicyVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PolicyVersion") + .field(&"[redacted]") + .finish() + } +} + +/// Authority whose provider admission is requested. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationAuthority { + /// The authenticated actor matches the admitted principal's key attestation. + Direct, + /// The authenticated actor derives authority from a bound owner. + Delegated { + /// Cryptographically verified and actively bound owner key. + owner_pubkey: PublicKey, + }, +} + +impl fmt::Debug for AuthorizationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationAuthority") + .field(&"[redacted]") + .finish() + } +} + +/// Redaction-safe description of how the provider request was derived. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DecisionSource { + /// Current verified assertion for the authenticated actor. + DirectAssertion, + /// Current active binding for a cryptographically verified owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for DecisionSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DecisionSource") + .field(&"[redacted]") + .finish() + } +} + +/// Provider request derived from server-verified identity evidence. +#[derive(PartialEq, Eq)] +pub struct AuthorizationRequest { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + authority: AuthorizationAuthority, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + decision_source: DecisionSource, + evidence_valid_until: Option, +} + +impl AuthorizationRequest { + /// Build a direct request from a current key-attested assertion and Nostr proof. + /// + /// An unattested assertion is intentionally insufficient in this phase. A + /// future trust-on-first-use path must also consume authoritative active or + /// atomic-enrollment binding evidence before it can produce direct authority. + pub fn direct( + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + if proof.verified_delegation().is_some() { + return Err(ProviderContractError::DirectRequestHasOwner); + } + if proof.authorization_domain() != assertion.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if proof.authorized_transport() != assertion.authorized_transport() { + return Err(ProviderContractError::TransportMismatch); + } + let Some(key_attestation) = assertion.key_attestation() else { + return Err(ProviderContractError::MissingKeyAttestation); + }; + if key_attestation.pubkey() != proof.actor_pubkey() { + return Err(ProviderContractError::KeyAttestationMismatch); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(ProviderContractError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::AssertionExpired); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Direct, + principal: assertion.principal().clone(), + profile_id, + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DirectAssertion, + evidence_valid_until: Some(assertion.expires_at().unix_seconds()), + }) + } + + /// Build a delegated request for a cryptographically verified bound owner. + /// + /// This path does not require an owner assertion. The provider resolves + /// current admission for the exact issuer-qualified bound owner. + pub fn delegated( + proof: &VerifiedNostrProof, + owner: &VersionedBindingRef, + profile_id: AuthorizationProfileId, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + if proof.authorization_domain() != owner.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired); + }; + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(ProviderContractError::DelegatedOwnerMismatch); + } + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::DelegationExpired); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Delegated { + owner_pubkey: owner.bound_pubkey(), + }, + principal: owner.principal().clone(), + profile_id, + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DelegatedOwnerBinding, + evidence_valid_until: delegation.expires_at().map(|bound| bound.unix_seconds()), + }) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method used for the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Direct or delegated authority whose admission is requested. + pub const fn authority(&self) -> &AuthorizationAuthority { + &self.authority + } + + /// Exact issuer-qualified principal whose admission is requested. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Server-resolved provider profile. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Portable capabilities requested for this decision. + pub const fn requested_capabilities(&self) -> &CapabilitySet { + &self.requested_capabilities + } + + /// Correlation identifier for this request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Verified source from which this request was derived. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } +} + +impl fmt::Debug for AuthorizationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRequest") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("authority", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("requested_capabilities", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("evidence_valid_until", &"[redacted]") + .finish() + } +} + +/// Provider-produced allowed capability data before crate-owned validation. +#[derive(PartialEq, Eq)] +pub struct ProviderAllow { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, +} + +impl ProviderAllow { + /// Build a provider allow result with mandatory policy and freshness data. + pub fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + ) -> Result { + if issued_at == 0 { + return Err(ProviderContractError::InvalidIssuedAt); + } + if fresh_until <= issued_at { + return Err(ProviderContractError::InvalidFreshnessBound); + } + if fresh_until - issued_at > MAX_PROVIDER_FRESHNESS_SECONDS { + return Err(ProviderContractError::FreshnessWindowTooLong); + } + Ok(Self { + authorization_domain, + principal, + profile_id, + capabilities, + policy_version, + issued_at, + fresh_until, + }) + } +} + +impl fmt::Debug for ProviderAllow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderAllow") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Stable reason for a denied provider authorization. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationDenialReason { + /// The configured provider denied the request. + ProviderDenied, + /// The provider response named another authorization domain. + AuthorizationDomainMismatch, + /// The provider response named another principal. + PrincipalMismatch, + /// The provider response named another authorization profile. + AuthorizationProfileMismatch, + /// The provider response omitted a requested capability. + MissingCapability, + /// The provider response was already stale. + StaleDecision, + /// The provider response was issued in the future. + FutureDecision, + /// Verified identity evidence expired before the decision became effective. + IdentityEvidenceExpired, +} + +impl AuthorizationDenialReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::ProviderDenied => "authorization_provider_deny_001", + Self::AuthorizationDomainMismatch => "authorization_provider_deny_002", + Self::PrincipalMismatch => "authorization_provider_deny_003", + Self::MissingCapability => "authorization_provider_deny_004", + Self::StaleDecision => "authorization_provider_deny_005", + Self::FutureDecision => "authorization_provider_deny_006", + Self::IdentityEvidenceExpired => "authorization_provider_deny_007", + Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", + } + } +} + +impl fmt::Debug for AuthorizationDenialReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationDenialReason") + .field(&"[redacted]") + .finish() + } +} + +/// Provider-neutral denial returned to an authorization caller. +#[derive(PartialEq, Eq)] +pub struct AuthorizationDenial { + reason: AuthorizationDenialReason, +} + +impl AuthorizationDenial { + /// Build a denial with a stable provider-neutral reason. + pub const fn new(reason: AuthorizationDenialReason) -> Self { + Self { reason } + } + + /// Stable reason for the denial. + pub const fn reason(&self) -> AuthorizationDenialReason { + self.reason + } +} + +impl fmt::Debug for AuthorizationDenial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationDenial") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Stable provider-unavailability reason. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderUnavailableReason { + /// The provider is temporarily unavailable. + TemporarilyUnavailable, + /// The provider call exceeded its bounded deadline. + Timeout, + /// A provider dependency is unavailable. + DependencyUnavailable, +} + +impl ProviderUnavailableReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::TemporarilyUnavailable => "authorization_provider_unavailable_001", + Self::Timeout => "authorization_provider_unavailable_002", + Self::DependencyUnavailable => "authorization_provider_unavailable_003", + } + } +} + +impl fmt::Debug for ProviderUnavailableReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderUnavailableReason") + .field(&"[redacted]") + .finish() + } +} + +/// Bounded provider retry hint in seconds. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RetryAfter(u32); + +impl RetryAfter { + /// Build a non-zero retry hint no greater than one hour. + pub const fn new(seconds: u32) -> Result { + if seconds == 0 || seconds > MAX_RETRY_AFTER_SECONDS { + return Err(ProviderContractError::InvalidRetryAfter); + } + Ok(Self(seconds)) + } + + /// Retry hint in seconds. + pub const fn seconds(self) -> u32 { + self.0 + } +} + +impl fmt::Debug for RetryAfter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RetryAfter") + .field(&"[redacted]") + .finish() + } +} + +/// Explicit finite deadline for one provider call. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProviderTimeout(Duration); + +impl ProviderTimeout { + /// Build a provider-call deadline no greater than one minute. + pub fn new(duration: Duration) -> Result { + if duration.is_zero() || duration > MAX_PROVIDER_TIMEOUT { + return Err(ProviderContractError::InvalidProviderTimeout); + } + Ok(Self(duration)) + } + + /// Configured provider-call deadline. + pub const fn duration(self) -> Duration { + self.0 + } +} + +impl fmt::Debug for ProviderTimeout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderTimeout") + .field(&"[redacted]") + .finish() + } +} + +/// Fail-closed provider unavailability. +#[derive(PartialEq, Eq)] +pub struct ProviderUnavailable { + reason: ProviderUnavailableReason, + retry_after: Option, +} + +impl ProviderUnavailable { + /// Build an unavailable result with optional bounded retry metadata. + pub const fn new(reason: ProviderUnavailableReason, retry_after: Option) -> Self { + Self { + reason, + retry_after, + } + } + + /// Stable reason for unavailability. + pub const fn reason(&self) -> ProviderUnavailableReason { + self.reason + } + + /// Optional bounded retry hint. + pub const fn retry_after(&self) -> Option { + self.retry_after + } +} + +impl fmt::Debug for ProviderUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderUnavailable") + .field("reason", &"[redacted]") + .field("retry_after", &"[redacted]") + .finish() + } +} + +/// Raw decision returned by an [`AuthorizationProvider`]. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderDecision { + /// Provider policy allowed a capability set. + Allow(ProviderAllow), + /// Provider policy denied the request. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for ProviderDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderDecision") + .field(&"[redacted]") + .finish() + } +} + +/// Boxed provider future used to keep [`AuthorizationProvider`] object-safe. +pub type AuthorizationProviderFuture<'a> = + Pin + Send + 'a>>; + +/// Object-safe, asynchronous, provider-neutral authorization policy. +pub trait AuthorizationProvider: Send + Sync { + /// Evaluate one request without mutating identity or community state. + /// + /// Implementations must yield while waiting for I/O and must not block the + /// async executor. The returned future must be cancellation-safe: the + /// caller drops it on timeout, so dropping at any await point must release + /// resources through RAII and must not leave shared state partially + /// updated. Provider evaluation is read-only; cache updates, if any, must + /// become visible atomically. The deadline bounds future polling and cannot + /// preempt blocking synchronous work inside this method. + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a>; +} + +/// Stable reason for a validated allowed decision. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderAllowReason { + /// Current provider policy granted the exact requested capabilities. + CurrentPolicy, +} + +impl ProviderAllowReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::CurrentPolicy => "authorization_provider_allow_001", + } + } +} + +impl fmt::Debug for ProviderAllowReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderAllowReason") + .field(&"[redacted]") + .finish() + } +} + +/// Validated, request-scoped capability snapshot. +/// +/// This type has no public constructor, default, or deserialization path. Only +/// [`resolve_authorization`] can create it after checking the provider response. +#[derive(PartialEq, Eq)] +pub struct CapabilitySnapshot { + authorization_domain: CommunityId, + actor_pubkey: PublicKey, + owner_pubkey: Option, + proof_method: AuthMethod, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + effective_until: u64, + decision_source: DecisionSource, + correlation_id: Uuid, + reason: ProviderAllowReason, +} + +impl CapabilitySnapshot { + /// Authorization domain for this decision. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact authenticated Nostr actor for this decision. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Exact verified owner for delegated authority, when present. + pub const fn owner_pubkey(&self) -> Option { + self.owner_pubkey + } + + /// Cryptographic proof method for the authenticated actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Exact admitted issuer-qualified principal. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Server-resolved authorization profile for this decision. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Exact request-scoped portable capabilities. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Provider decision issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Provider freshness bound in Unix seconds. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Earliest effective bound across provider and identity evidence. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Verified request source for this snapshot. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } + + /// Correlation identifier binding the snapshot to its request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable reason for this allowed decision. + pub const fn reason(&self) -> ProviderAllowReason { + self.reason + } +} + +impl fmt::Debug for CapabilitySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilitySnapshot") + .field("authorization_domain", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("effective_until", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Fail-closed result of validating a provider decision. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationOutcome { + /// Provider policy allowed the exact requested capabilities. + Allow(Box), + /// Provider policy or response validation denied authorization. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated; callers must not fall back. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for AuthorizationOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationOutcome") + .field(&"[redacted]") + .finish() + } +} + +/// Resolve and validate one provider authorization decision. +/// +/// Unavailability is preserved as a fail-closed outcome. This function never +/// falls back to Nostr-only authorization or applies an implicit grace period. +pub async fn resolve_authorization( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + now_unix_seconds: u64, + timeout: ProviderTimeout, +) -> AuthorizationOutcome { + let decision = match tokio::time::timeout(timeout.duration(), provider.authorize(request)).await + { + Ok(decision) => decision, + Err(_) => { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::Timeout, + None, + )); + } + }; + let allow = match decision { + ProviderDecision::Allow(allow) => allow, + ProviderDecision::Deny(denial) => return AuthorizationOutcome::Deny(denial), + ProviderDecision::Unavailable(unavailable) => { + return AuthorizationOutcome::Unavailable(unavailable); + } + }; + + if allow.authorization_domain != request.authorization_domain { + return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); + } + if allow.principal != request.principal { + return deny(AuthorizationDenialReason::PrincipalMismatch); + } + if allow.profile_id != request.profile_id { + return deny(AuthorizationDenialReason::AuthorizationProfileMismatch); + } + if allow.issued_at > now_unix_seconds { + return deny(AuthorizationDenialReason::FutureDecision); + } + if allow.fresh_until <= now_unix_seconds { + return deny(AuthorizationDenialReason::StaleDecision); + } + if !allow + .capabilities + .contains_all(&request.requested_capabilities) + { + return deny(AuthorizationDenialReason::MissingCapability); + } + + let effective_until = request + .evidence_valid_until + .map_or(allow.fresh_until, |bound| bound.min(allow.fresh_until)); + if effective_until <= now_unix_seconds { + return deny(AuthorizationDenialReason::IdentityEvidenceExpired); + } + + AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { + authorization_domain: allow.authorization_domain, + actor_pubkey: request.actor_pubkey, + owner_pubkey: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { owner_pubkey } => Some(*owner_pubkey), + }, + proof_method: request.proof_method, + principal: allow.principal, + profile_id: allow.profile_id, + capabilities: request.requested_capabilities.clone(), + policy_version: allow.policy_version, + issued_at: allow.issued_at, + fresh_until: allow.fresh_until, + effective_until, + decision_source: request.decision_source, + correlation_id: request.correlation_id, + reason: ProviderAllowReason::CurrentPolicy, + })) +} + +const fn deny(reason: AuthorizationDenialReason) -> AuthorizationOutcome { + AuthorizationOutcome::Deny(AuthorizationDenial::new(reason)) +} + +/// Invalid provider request or response construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ProviderContractError { + /// A capability set was empty. + #[error("authorization capability set must not be empty")] + EmptyCapabilitySet, + /// The authorization profile identifier was empty. + #[error("authorization profile identifier must not be empty")] + EmptyProfileId, + /// The authorization profile identifier exceeded its size bound. + #[error("authorization profile identifier exceeds the size bound")] + ProfileIdTooLong, + /// The policy version was empty. + #[error("authorization policy version must not be empty")] + EmptyPolicyVersion, + /// The policy version exceeded its size bound. + #[error("authorization policy version exceeds the size bound")] + PolicyVersionTooLong, + /// Provider decision issue time was zero. + #[error("provider decision issue time must be greater than zero")] + InvalidIssuedAt, + /// Provider freshness did not follow issue time. + #[error("provider freshness bound must follow its issue time")] + InvalidFreshnessBound, + /// Provider freshness exceeded the public maximum window. + #[error("provider freshness window exceeds its public bound")] + FreshnessWindowTooLong, + /// Retry metadata was zero or exceeded its public bound. + #[error("provider retry hint is outside its public bound")] + InvalidRetryAfter, + /// Provider call deadline was zero or exceeded its public bound. + #[error("provider call deadline is outside its public bound")] + InvalidProviderTimeout, + /// Correlation identifier was nil. + #[error("provider request correlation identifier must not be nil")] + InvalidCorrelationId, + /// Direct evidence contained delegated authority. + #[error("direct provider request cannot contain a delegated owner")] + DirectRequestHasOwner, + /// Verified evidence belonged to different authorization domains. + #[error("provider request evidence does not share an authorization domain")] + AuthorizationDomainMismatch, + /// Verified assertion and Nostr proof authorized different transports. + #[error("provider request evidence does not share an authorization transport")] + TransportMismatch, + /// Assertion was not yet valid at server time. + #[error("provider request assertion is not yet valid")] + AssertionNotYetValid, + /// Assertion was expired at server time. + #[error("provider request assertion has expired")] + AssertionExpired, + /// Assertion key attestation named another actor. + #[error("provider request key attestation does not match the Nostr actor")] + KeyAttestationMismatch, + /// Direct assertion omitted a key attestation. + #[error("direct provider request requires key attestation")] + MissingKeyAttestation, + /// Delegated request lacked verified delegation. + #[error("delegated provider request requires verified delegation")] + DelegationRequired, + /// Delegated request named another bound owner. + #[error("delegated provider request does not match the bound owner")] + DelegatedOwnerMismatch, + /// Delegation was expired at server time. + #[error("delegated provider request has expired")] + DelegationExpired, +} + +impl ProviderContractError { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyCapabilitySet => "authorization_provider_contract_001", + Self::EmptyProfileId => "authorization_provider_contract_002", + Self::ProfileIdTooLong => "authorization_provider_contract_003", + Self::EmptyPolicyVersion => "authorization_provider_contract_004", + Self::PolicyVersionTooLong => "authorization_provider_contract_005", + Self::InvalidIssuedAt => "authorization_provider_contract_006", + Self::InvalidFreshnessBound => "authorization_provider_contract_007", + Self::InvalidRetryAfter => "authorization_provider_contract_008", + Self::DirectRequestHasOwner => "authorization_provider_contract_009", + Self::AuthorizationDomainMismatch => "authorization_provider_contract_010", + Self::TransportMismatch => "authorization_provider_contract_011", + Self::AssertionNotYetValid => "authorization_provider_contract_012", + Self::AssertionExpired => "authorization_provider_contract_013", + Self::KeyAttestationMismatch => "authorization_provider_contract_014", + Self::DelegationRequired => "authorization_provider_contract_015", + Self::DelegatedOwnerMismatch => "authorization_provider_contract_016", + Self::DelegationExpired => "authorization_provider_contract_017", + Self::InvalidProviderTimeout => "authorization_provider_contract_018", + Self::InvalidCorrelationId => "authorization_provider_contract_019", + Self::MissingKeyAttestation => "authorization_provider_contract_020", + Self::FreshnessWindowTooLong => "authorization_provider_contract_021", + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs new file mode 100644 index 0000000000..733ff520a1 --- /dev/null +++ b/crates/buzz-auth/src/provider/tests.rs @@ -0,0 +1,970 @@ +use std::{ + future::pending, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use nostr::Keys; + +use super::*; +use crate::context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingSource, + BindingVersion, DelegationExpiry, VerifiedKeyAttestation, VerifiedTransportDelegation, +}; + +const NOW: u64 = 100; + +fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn profile() -> AuthorizationProfileId { + AuthorizationProfileId::new("profile-1").expect("synthetic profile is valid") +} + +fn policy_version(value: &str) -> PolicyVersion { + PolicyVersion::new(value).expect("synthetic policy version is valid") +} + +fn provider_timeout() -> ProviderTimeout { + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") +} + +fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { + CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") +} + +fn direct_request_with_expiry( + actor: &Keys, + expiry: u64, + requested: CapabilitySet, +) -> AuthorizationRequest { + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ); + AuthorizationRequest::direct( + &proof, + &assertion, + profile(), + requested, + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic direct request is valid") +} + +fn direct_request(actor: &Keys) -> AuthorizationRequest { + direct_request_with_expiry( + actor, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) +} + +fn existing_binding(owner: &Keys) -> VersionedBindingRef { + existing_binding_in(1, owner) +} + +fn existing_binding_in(domain_value: u128, owner: &Keys) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + domain(domain_value), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + BindingSource::Provisioned, + ) + .expect("synthetic binding is valid") +} + +fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic delegated proof is valid"); + AuthorizationRequest::delegated( + &proof, + &existing_binding(owner), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic delegated request is valid") +} + +fn allow_for( + request: &AuthorizationRequest, + granted: CapabilitySet, + version: &str, + issued_at: u64, + fresh_until: u64, +) -> ProviderDecision { + ProviderDecision::Allow( + ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + request.profile_id().clone(), + granted, + policy_version(version), + issued_at, + fresh_until, + ) + .expect("synthetic provider allow is structurally valid"), + ) +} + +struct FakeProvider { + decision: Mutex>, +} + +impl FakeProvider { + fn returning(decision: ProviderDecision) -> Self { + Self { + decision: Mutex::new(Some(decision)), + } + } +} + +impl AuthorizationProvider for FakeProvider { + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + self.decision + .lock() + .expect("synthetic provider mutex is not poisoned") + .take() + .expect("synthetic provider is called exactly once") + }) + } +} + +struct PendingProvider { + calls: Arc, + dropped: Arc, +} + +struct CancellationMarker(Arc); + +impl Drop for CancellationMarker { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl AuthorizationProvider for PendingProvider { + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + self.calls.fetch_add(1, Ordering::SeqCst); + let marker = CancellationMarker(Arc::clone(&self.dropped)); + Box::pin(async move { + let _marker = marker; + pending().await + }) + } +} + +#[tokio::test] +async fn current_allow_returns_request_scoped_snapshot() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + ]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_eq!(snapshot.authorization_domain(), domain(1)); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), None); + assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); + assert_eq!(snapshot.principal(), request.principal()); + assert_eq!(snapshot.profile_id(), request.profile_id()); + assert_eq!( + snapshot.capabilities().as_slice(), + &[AuthorizationCapability::CommunityRead] + ); + assert_eq!(snapshot.policy_version().as_str(), "version-a"); + assert_eq!(snapshot.issued_at(), 90); + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 180); + assert_eq!(snapshot.decision_source(), DecisionSource::DirectAssertion); + assert_eq!(snapshot.correlation_id(), request.correlation_id()); + assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy); +} + +#[tokio::test] +async fn explicit_denial_is_preserved() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider denial must fail closed"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::ProviderDenied); +} + +#[tokio::test] +async fn provider_unavailability_never_falls_back_to_allow() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let retry_after = RetryAfter::new(30).expect("synthetic retry hint is bounded"); + let provider = + FakeProvider::returning(ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::TemporarilyUnavailable, + Some(retry_after), + ))); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider unavailability must remain fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::TemporarilyUnavailable + ); + assert_eq!(unavailable.retry_after(), Some(retry_after)); +} + +#[tokio::test] +async fn provider_call_deadline_returns_timeout_unavailability() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let provider = PendingProvider { + calls: Arc::clone(&calls), + dropped: Arc::clone(&dropped), + }; + let timeout = + ProviderTimeout::new(Duration::from_millis(1)).expect("synthetic timeout is finite"); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_authorization(&provider, &request, NOW, timeout).await + else { + panic!("provider timeout must remain fail closed"); + }; + assert_eq!(unavailable.reason(), ProviderUnavailableReason::Timeout); + assert_eq!(unavailable.retry_after(), None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn stale_and_future_provider_decisions_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let stale = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 80, + 90, + )); + let AuthorizationOutcome::Deny(stale_denial) = + resolve_authorization(&stale, &request, NOW, provider_timeout()).await + else { + panic!("stale decision must deny"); + }; + assert_eq!( + stale_denial.reason(), + AuthorizationDenialReason::StaleDecision + ); + + let future = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(future_denial) = + resolve_authorization(&future, &request, NOW, provider_timeout()).await + else { + panic!("future decision must deny"); + }; + assert_eq!( + future_denial.reason(), + AuthorizationDenialReason::FutureDecision + ); +} + +#[tokio::test] +async fn domain_principal_and_capability_mismatches_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + + let wrong_domain = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(2), + request.principal().clone(), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_domain, &request, NOW, provider_timeout()).await + else { + panic!("cross-domain decision must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationDomainMismatch + ); + + let wrong_principal = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_principal, &request, NOW, provider_timeout()).await + else { + panic!("principal mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::PrincipalMismatch + ); + + let wrong_profile = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + request.principal().clone(), + AuthorizationProfileId::new("other-profile").expect("synthetic profile is valid"), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&wrong_profile, &request, NOW, provider_timeout()).await + else { + panic!("profile mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationProfileMismatch + ); + + let missing_capability = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::CommunityWrite]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&missing_capability, &request, NOW, provider_timeout()).await + else { + panic!("missing capability must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[tokio::test] +async fn assertion_expiry_bounds_provider_freshness() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 120); +} + +#[tokio::test] +async fn delegated_owner_admission_does_not_require_owner_assertion() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let request = delegated_request(&actor, &owner, 140); + assert!(matches!( + request.authority(), + AuthorizationAuthority::Delegated { owner_pubkey } + if *owner_pubkey == owner.public_key() + )); + assert_eq!( + request.decision_source(), + DecisionSource::DelegatedOwnerBinding + ); + + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current owner admission must allow delegated authority"); + }; + assert_eq!(snapshot.effective_until(), 140); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), Some(owner.public_key())); +} + +#[tokio::test] +async fn policy_versions_detect_equality_and_change_without_ordering() { + let actor = Keys::generate(); + let request_a = direct_request(&actor); + let provider_a = FakeProvider::returning(allow_for( + &request_a, + request_a.requested_capabilities().clone(), + "opaque-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_a) = + resolve_authorization(&provider_a, &request_a, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + let request_b = direct_request(&actor); + let provider_b = FakeProvider::returning(allow_for( + &request_b, + request_b.requested_capabilities().clone(), + "opaque-b", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_b) = + resolve_authorization(&provider_b, &request_b, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_ne!(snapshot_a.policy_version(), snapshot_b.policy_version()); + assert_eq!(snapshot_a.policy_version(), &policy_version("opaque-a")); +} + +#[test] +fn provider_contract_rejects_malformed_values() { + assert_eq!( + CapabilitySet::new(Vec::new()), + Err(ProviderContractError::EmptyCapabilitySet) + ); + assert_eq!( + AuthorizationProfileId::new(""), + Err(ProviderContractError::EmptyProfileId) + ); + assert_eq!( + AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::ProfileIdTooLong) + ); + assert_eq!( + PolicyVersion::new(""), + Err(ProviderContractError::EmptyPolicyVersion) + ); + assert_eq!( + RetryAfter::new(0), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + ProviderTimeout::new(Duration::ZERO), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT + Duration::from_nanos(1)), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 0, + 180, + ), + Err(ProviderContractError::InvalidIssuedAt) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100, + ), + Err(ProviderContractError::InvalidFreshnessBound) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS + 1, + ), + Err(ProviderContractError::FreshnessWindowTooLong) + ); +} + +#[test] +fn request_construction_rechecks_verified_bounds_and_relationships() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let expired = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionExpired) + ); + + let future = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(NOW + 1)), + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &future, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionNotYetValid) + ); +} + +#[test] +fn request_construction_rejects_mismatched_verified_evidence() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let other = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + + let assertion_in_domain = + |domain_value, transport, attested_pubkey: Option| { + VerifiedFederatedAssertion::new( + domain(domain_value), + transport, + principal(), + attested_pubkey.map(VerifiedKeyAttestation::new), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ) + }; + let request = |proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion| { + AuthorizationRequest::direct( + proof, + assertion, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + assert_eq!( + request( + &proof, + &assertion_in_domain(2, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::HttpBridge, None), + ), + Err(ProviderContractError::TransportMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, Some(other.public_key()),), + ), + Err(ProviderContractError::KeyAttestationMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::MissingKeyAttestation) + ); + + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW + 20).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let delegated_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + request( + &delegated_proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::DirectRequestHasOwner) + ); + + let delegated_request_from = |proof: &VerifiedNostrProof, binding: &VersionedBindingRef| { + AuthorizationRequest::delegated( + proof, + binding, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + assert_eq!( + AuthorizationRequest::delegated( + &delegated_proof, + &existing_binding(&owner), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + delegated_request_from(&proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationRequired) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding(&other)), + Err(ProviderContractError::DelegatedOwnerMismatch) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding_in(2, &owner)), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + + let expired_delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let expired_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(expired_delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + delegated_request_from(&expired_proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationExpired) + ); +} + +#[tokio::test] +async fn request_decision_snapshot_and_errors_are_redaction_safe() { + let actor = Keys::generate(); + let request = direct_request(&actor); + assert_eq!( + format!("{request:?}"), + concat!( + "AuthorizationRequest { authorization_domain: \"[redacted]\", ", + "actor_pubkey: \"[redacted]\", proof_method: \"[redacted]\", ", + "authority: \"[redacted]\", principal: \"[redacted]\", ", + "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", + "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_until: \"[redacted]\" }" + ) + ); + + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + request.profile_id().clone(), + request.requested_capabilities().clone(), + policy_version("private-policy-version"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"); + assert_eq!( + format!("{allow:?}"), + concat!( + "ProviderAllow { authorization_domain: \"[redacted]\", ", + "principal: \"[redacted]\", profile_id: \"[redacted]\", ", + "capabilities: \"[redacted]\", policy_version: \"[redacted]\", ", + "issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }" + ) + ); + let decision = ProviderDecision::Allow(allow); + assert_eq!(format!("{decision:?}"), "ProviderDecision(\"[redacted]\")"); + let provider = FakeProvider::returning(decision); + let outcome = resolve_authorization(&provider, &request, NOW, provider_timeout()).await; + assert_eq!( + format!("{outcome:?}"), + "AuthorizationOutcome(\"[redacted]\")" + ); + let AuthorizationOutcome::Allow(snapshot) = outcome else { + panic!("current provider policy must allow"); + }; + assert_eq!( + format!("{snapshot:?}"), + concat!( + "CapabilitySnapshot { authorization_domain: \"[redacted]\", ", + "actor_pubkey: \"[redacted]\", owner_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", principal: \"[redacted]\", ", + "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", + "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", + "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", + "decision_source: \"[redacted]\", correlation_id: \"[redacted]\", ", + "reason: \"[redacted]\" }" + ) + ); + + let denial = AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied); + assert_eq!( + format!("{denial:?}"), + "AuthorizationDenial { reason: \"[redacted]\" }" + ); + let unavailable = ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + Some(RetryAfter::new(30).expect("synthetic retry hint is bounded")), + ); + assert_eq!( + format!("{unavailable:?}"), + concat!( + "ProviderUnavailable { reason: \"[redacted]\", ", + "retry_after: \"[redacted]\" }" + ) + ); + assert_eq!( + format!("{:?}", provider_timeout()), + "ProviderTimeout(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", request.profile_id()), + "AuthorizationProfileId(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.policy_version()), + "PolicyVersion(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.capabilities()), + "CapabilitySet(\"[redacted]\")" + ); + + for error in [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::DelegatedOwnerMismatch, + ] { + let rendered = error.to_string(); + assert!(!rendered.contains("idp.example")); + assert!(!rendered.contains("subject-123")); + assert!(!rendered.contains("private-policy-version")); + } +} + +#[test] +fn provider_trait_is_object_safe_and_codes_are_unique() { + let provider: Arc = + Arc::new(FakeProvider::returning(ProviderDecision::Deny( + AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied), + ))); + assert!(Arc::strong_count(&provider) == 1); + + let mut codes = vec![ + ProviderAllowReason::CurrentPolicy.code(), + AuthorizationDenialReason::ProviderDenied.code(), + AuthorizationDenialReason::AuthorizationDomainMismatch.code(), + AuthorizationDenialReason::PrincipalMismatch.code(), + AuthorizationDenialReason::AuthorizationProfileMismatch.code(), + AuthorizationDenialReason::MissingCapability.code(), + AuthorizationDenialReason::StaleDecision.code(), + AuthorizationDenialReason::FutureDecision.code(), + AuthorizationDenialReason::IdentityEvidenceExpired.code(), + ProviderUnavailableReason::TemporarilyUnavailable.code(), + ProviderUnavailableReason::Timeout.code(), + ProviderUnavailableReason::DependencyUnavailable.code(), + ]; + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), 12); + + let contract_errors = [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::ProfileIdTooLong, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::PolicyVersionTooLong, + ProviderContractError::InvalidIssuedAt, + ProviderContractError::InvalidFreshnessBound, + ProviderContractError::FreshnessWindowTooLong, + ProviderContractError::InvalidRetryAfter, + ProviderContractError::InvalidProviderTimeout, + ProviderContractError::InvalidCorrelationId, + ProviderContractError::DirectRequestHasOwner, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::TransportMismatch, + ProviderContractError::AssertionNotYetValid, + ProviderContractError::AssertionExpired, + ProviderContractError::KeyAttestationMismatch, + ProviderContractError::MissingKeyAttestation, + ProviderContractError::DelegationRequired, + ProviderContractError::DelegatedOwnerMismatch, + ProviderContractError::DelegationExpired, + ]; + let mut contract_codes = contract_errors + .iter() + .copied() + .map(ProviderContractError::code) + .collect::>(); + contract_codes.sort_unstable(); + contract_codes.dedup(); + assert_eq!(contract_codes.len(), contract_errors.len()); +} From 33b11db767ee4442a5f49ccb72de1c9e6dd5cfc2 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:35:38 -0500 Subject: [PATCH 2/7] fix(auth): preserve provider decision scope Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> (cherry picked from commit 26f64a507c3fe1cd4db3352998b2804b0cb80644) Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/mod.rs | 75 ++++++++++++++++++++++++-- crates/buzz-auth/src/provider/tests.rs | 44 +++++++++++++-- 2 files changed, 110 insertions(+), 9 deletions(-) diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 970e2bac20..8a5733eaa2 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -12,8 +12,8 @@ use thiserror::Error; use uuid::Uuid; use crate::context::{ - AuthMethod, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedNostrProof, - VersionedBindingRef, + AuthMethod, AuthTransport, BindingVersion, FederatedPrincipal, VerifiedFederatedAssertion, + VerifiedNostrProof, VersionedBindingRef, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -33,8 +33,10 @@ pub enum AuthorizationCapability { CommunityWrite, /// Perform moderation operations. Moderate, - /// Mint or claim invitations. - Invite, + /// Mint invitations. + InviteMint, + /// Claim an invitation before membership exists. + InviteClaim, /// Read authenticated media. MediaRead, /// Upload media. @@ -132,6 +134,9 @@ impl fmt::Debug for AuthorizationProfileId { } /// Opaque, equality-comparable policy version returned by a provider. +/// +/// This is the typed policy-change seam that later lease and invalidation code +/// can use without assuming a provider-specific numeric ordering. #[derive(Clone, PartialEq, Eq, Hash)] pub struct PolicyVersion(String); @@ -173,6 +178,10 @@ pub enum AuthorizationAuthority { Delegated { /// Cryptographically verified and actively bound owner key. owner_pubkey: PublicKey, + /// Stable identifier of the active owner binding. + binding_id: Uuid, + /// Exact active owner-binding version used for this decision. + binding_version: BindingVersion, }, } @@ -208,6 +217,7 @@ impl fmt::Debug for DecisionSource { #[derive(PartialEq, Eq)] pub struct AuthorizationRequest { authorization_domain: CommunityId, + transport: AuthTransport, actor_pubkey: PublicKey, proof_method: AuthMethod, authority: AuthorizationAuthority, @@ -225,6 +235,7 @@ impl AuthorizationRequest { /// An unattested assertion is intentionally insufficient in this phase. A /// future trust-on-first-use path must also consume authoritative active or /// atomic-enrollment binding evidence before it can produce direct authority. + /// `now_unix_seconds` must come from the server clock. pub fn direct( proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion, @@ -262,6 +273,7 @@ impl AuthorizationRequest { } Ok(Self { authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), actor_pubkey: proof.actor_pubkey(), proof_method: proof.proof_method(), authority: AuthorizationAuthority::Direct, @@ -278,6 +290,7 @@ impl AuthorizationRequest { /// /// This path does not require an owner assertion. The provider resolves /// current admission for the exact issuer-qualified bound owner. + /// `now_unix_seconds` must come from the server clock. pub fn delegated( proof: &VerifiedNostrProof, owner: &VersionedBindingRef, @@ -306,10 +319,13 @@ impl AuthorizationRequest { } Ok(Self { authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), actor_pubkey: proof.actor_pubkey(), proof_method: proof.proof_method(), authority: AuthorizationAuthority::Delegated { owner_pubkey: owner.bound_pubkey(), + binding_id: owner.binding_id(), + binding_version: owner.binding_version(), }, principal: owner.principal().clone(), profile_id, @@ -325,6 +341,11 @@ impl AuthorizationRequest { self.authorization_domain } + /// Exact protected transport authorized by the verified proof. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + /// Authenticated Nostr actor. pub const fn actor_pubkey(&self) -> PublicKey { self.actor_pubkey @@ -364,6 +385,11 @@ impl AuthorizationRequest { pub const fn decision_source(&self) -> DecisionSource { self.decision_source } + + /// Earliest validity bound supplied by verified assertion or delegation evidence. + pub const fn evidence_valid_until(&self) -> Option { + self.evidence_valid_until + } } impl fmt::Debug for AuthorizationRequest { @@ -371,6 +397,7 @@ impl fmt::Debug for AuthorizationRequest { formatter .debug_struct("AuthorizationRequest") .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") .field("actor_pubkey", &"[redacted]") .field("proof_method", &"[redacted]") .field("authority", &"[redacted]") @@ -714,11 +741,16 @@ impl fmt::Debug for ProviderAllowReason { /// /// This type has no public constructor, default, or deserialization path. Only /// [`resolve_authorization`] can create it after checking the provider response. +/// The move-only snapshot is the private finalizer evidence for a later phase; +/// callers may inspect its bounded metadata but cannot recreate trusted state. #[derive(PartialEq, Eq)] pub struct CapabilitySnapshot { authorization_domain: CommunityId, + transport: AuthTransport, actor_pubkey: PublicKey, owner_pubkey: Option, + binding_id: Option, + binding_version: Option, proof_method: AuthMethod, principal: FederatedPrincipal, profile_id: AuthorizationProfileId, @@ -738,6 +770,11 @@ impl CapabilitySnapshot { self.authorization_domain } + /// Exact protected transport for which this snapshot was resolved. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + /// Exact authenticated Nostr actor for this decision. pub const fn actor_pubkey(&self) -> PublicKey { self.actor_pubkey @@ -748,6 +785,19 @@ impl CapabilitySnapshot { self.owner_pubkey } + /// Stable active binding identifier for delegated authority. + pub const fn binding_id(&self) -> Option { + self.binding_id + } + + /// Exact active binding version for delegated authority. + /// + /// This is not a lease: later consumers must compare it with current + /// authoritative binding state before reusing a cached snapshot. + pub const fn binding_version(&self) -> Option { + self.binding_version + } + /// Cryptographic proof method for the authenticated actor. pub const fn proof_method(&self) -> AuthMethod { self.proof_method @@ -809,8 +859,11 @@ impl fmt::Debug for CapabilitySnapshot { formatter .debug_struct("CapabilitySnapshot") .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") .field("actor_pubkey", &"[redacted]") .field("owner_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") .field("proof_method", &"[redacted]") .field("principal", &"[redacted]") .field("profile_id", &"[redacted]") @@ -851,6 +904,7 @@ impl fmt::Debug for AuthorizationOutcome { /// /// Unavailability is preserved as a fail-closed outcome. This function never /// falls back to Nostr-only authorization or applies an implicit grace period. +/// `now_unix_seconds` must come from the server clock. pub async fn resolve_authorization( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, @@ -906,10 +960,21 @@ pub async fn resolve_authorization( AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { authorization_domain: allow.authorization_domain, + transport: request.transport, actor_pubkey: request.actor_pubkey, owner_pubkey: match &request.authority { AuthorizationAuthority::Direct => None, - AuthorizationAuthority::Delegated { owner_pubkey } => Some(*owner_pubkey), + AuthorizationAuthority::Delegated { owner_pubkey, .. } => Some(*owner_pubkey), + }, + binding_id: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { binding_id, .. } => Some(*binding_id), + }, + binding_version: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { + binding_version, .. + } => Some(*binding_version), }, proof_method: request.proof_method, principal: allow.principal, diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 733ff520a1..e0deba136a 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -222,8 +222,11 @@ async fn current_allow_returns_request_scoped_snapshot() { }; assert_eq!(snapshot.authorization_domain(), domain(1)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); assert_eq!(snapshot.actor_pubkey(), actor.public_key()); assert_eq!(snapshot.owner_pubkey(), None); + assert_eq!(snapshot.binding_id(), None); + assert_eq!(snapshot.binding_version(), None); assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); assert_eq!(snapshot.principal(), request.principal()); assert_eq!(snapshot.profile_id(), request.profile_id()); @@ -432,6 +435,33 @@ async fn domain_principal_and_capability_mismatches_deny() { ); } +#[tokio::test] +async fn invite_mint_does_not_authorize_invite_claim() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 200, + capabilities(&[AuthorizationCapability::InviteClaim]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::InviteMint]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("invitation minting must not authorize a claim"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + #[tokio::test] async fn assertion_expiry_bounds_provider_freshness() { let actor = Keys::generate(); @@ -464,7 +494,7 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { let request = delegated_request(&actor, &owner, 140); assert!(matches!( request.authority(), - AuthorizationAuthority::Delegated { owner_pubkey } + AuthorizationAuthority::Delegated { owner_pubkey, .. } if *owner_pubkey == owner.public_key() )); assert_eq!( @@ -487,6 +517,9 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { assert_eq!(snapshot.effective_until(), 140); assert_eq!(snapshot.actor_pubkey(), actor.public_key()); assert_eq!(snapshot.owner_pubkey(), Some(owner.public_key())); + assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); + assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); } #[tokio::test] @@ -811,7 +844,8 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { format!("{request:?}"), concat!( "AuthorizationRequest { authorization_domain: \"[redacted]\", ", - "actor_pubkey: \"[redacted]\", proof_method: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", ", "authority: \"[redacted]\", principal: \"[redacted]\", ", "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", @@ -853,8 +887,10 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { format!("{snapshot:?}"), concat!( "CapabilitySnapshot { authorization_domain: \"[redacted]\", ", - "actor_pubkey: \"[redacted]\", owner_pubkey: \"[redacted]\", ", - "proof_method: \"[redacted]\", principal: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", + "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", + "principal: \"[redacted]\", ", "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", From d82afe9012efd8d86a1facbf677efea362aa2abe Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:20:34 -0500 Subject: [PATCH 3/7] test(auth): expand provider negative coverage Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> (cherry picked from commit 12f6d2ec639ece2a669f7ea76d74ea4e72912209) Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/tests.rs | 485 ++++++++++++++++++++++--- 1 file changed, 430 insertions(+), 55 deletions(-) diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index e0deba136a..c46f658773 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -42,26 +42,91 @@ fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") } -fn direct_request_with_expiry( +fn all_capabilities() -> [AuthorizationCapability; 10] { + [ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + AuthorizationCapability::Moderate, + AuthorizationCapability::InviteMint, + AuthorizationCapability::InviteClaim, + AuthorizationCapability::MediaRead, + AuthorizationCapability::MediaWrite, + AuthorizationCapability::GitRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::AudioJoin, + ] +} + +fn capability_coverage_is_exhaustive(capability: AuthorizationCapability) { + match capability { + AuthorizationCapability::CommunityRead + | AuthorizationCapability::CommunityWrite + | AuthorizationCapability::Moderate + | AuthorizationCapability::InviteMint + | AuthorizationCapability::InviteClaim + | AuthorizationCapability::MediaRead + | AuthorizationCapability::MediaWrite + | AuthorizationCapability::GitRead + | AuthorizationCapability::GitWrite + | AuthorizationCapability::AudioJoin => {} + } +} + +fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { + match transport { + AuthTransport::RelayWebSocket => AuthMethod::Nip42, + AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaDownload => { + AuthMethod::Nip98 + } + AuthTransport::MediaUpload => AuthMethod::Blossom, + AuthTransport::Audio => AuthMethod::Nip42, + } +} + +fn all_contract_errors() -> [ProviderContractError; 21] { + [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::ProfileIdTooLong, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::PolicyVersionTooLong, + ProviderContractError::InvalidIssuedAt, + ProviderContractError::InvalidFreshnessBound, + ProviderContractError::FreshnessWindowTooLong, + ProviderContractError::InvalidRetryAfter, + ProviderContractError::InvalidProviderTimeout, + ProviderContractError::InvalidCorrelationId, + ProviderContractError::DirectRequestHasOwner, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::TransportMismatch, + ProviderContractError::AssertionNotYetValid, + ProviderContractError::AssertionExpired, + ProviderContractError::KeyAttestationMismatch, + ProviderContractError::MissingKeyAttestation, + ProviderContractError::DelegationRequired, + ProviderContractError::DelegatedOwnerMismatch, + ProviderContractError::DelegationExpired, + ] +} + +fn direct_request_for_transport( actor: &Keys, + transport: AuthTransport, + proof_method: AuthMethod, + not_before: Option, expiry: u64, requested: CapabilitySet, -) -> AuthorizationRequest { - let proof = VerifiedNostrProof::new( - domain(1), - AuthTransport::RelayWebSocket, - actor.public_key(), - AuthMethod::Nip42, - None, - ) - .expect("synthetic proof is valid"); +) -> Result { + let proof = + VerifiedNostrProof::new(domain(1), transport, actor.public_key(), proof_method, None) + .expect("synthetic proof is valid"); let assertion = VerifiedFederatedAssertion::new( domain(1), - AuthTransport::RelayWebSocket, + transport, principal(), Some(VerifiedKeyAttestation::new(actor.public_key())), AssertionTransport::TrustedProxy, - None, + not_before.map(AssertionNotBefore::new), AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), ); AuthorizationRequest::direct( @@ -72,6 +137,21 @@ fn direct_request_with_expiry( Uuid::from_u128(20), NOW, ) +} + +fn direct_request_with_expiry( + actor: &Keys, + expiry: u64, + requested: CapabilitySet, +) -> AuthorizationRequest { + direct_request_for_transport( + actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + None, + expiry, + requested, + ) .expect("synthetic direct request is valid") } @@ -243,6 +323,48 @@ async fn current_allow_returns_request_scoped_snapshot() { assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy); } +#[tokio::test] +async fn allowed_snapshot_preserves_every_requested_transport_scope() { + let transports = [ + AuthTransport::RelayWebSocket, + AuthTransport::HttpBridge, + AuthTransport::Git, + AuthTransport::MediaUpload, + AuthTransport::MediaDownload, + AuthTransport::Audio, + ]; + + for transport in transports { + let proof_method = proof_method_for_transport(transport); + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + transport, + proof_method, + None, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("synthetic direct request is valid"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow every transport profile"); + }; + assert_eq!(snapshot.transport(), transport); + assert_eq!(snapshot.proof_method(), proof_method); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + } +} + #[tokio::test] async fn explicit_denial_is_preserved() { let actor = Keys::generate(); @@ -345,6 +467,46 @@ async fn stale_and_future_provider_decisions_deny() { ); } +#[tokio::test] +async fn provider_time_boundaries_and_current_assertion_are_exact() { + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + Some(NOW), + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("assertion with not-before equal to server time is current"); + + let issued_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + )); + assert!(matches!( + resolve_authorization(&issued_now, &request, NOW, provider_timeout()).await, + AuthorizationOutcome::Allow(_) + )); + + let stale_at_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + NOW, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&stale_at_now, &request, NOW, provider_timeout()).await + else { + panic!("freshness ending at server time must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); +} + #[tokio::test] async fn domain_principal_and_capability_mismatches_deny() { let actor = Keys::generate(); @@ -462,6 +624,55 @@ async fn invite_mint_does_not_authorize_invite_claim() { ); } +#[test] +fn capability_sets_are_normalized_and_deduplicated() { + let normalized = CapabilitySet::new(vec![ + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + ]) + .expect("synthetic capabilities are non-empty"); + + assert_eq!( + normalized.as_slice(), + &[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + ] + ); +} + +#[tokio::test] +async fn no_distinct_capability_authorizes_another_capability() { + let actor = Keys::generate(); + for requested in all_capabilities() { + for granted in all_capabilities() { + if requested == granted { + continue; + } + + let request = direct_request_with_expiry(&actor, 200, capabilities(&[requested])); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[granted]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, NOW, provider_timeout()).await + else { + panic!("a distinct capability must not widen provider authority"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); + } + } +} + #[tokio::test] async fn assertion_expiry_bounds_provider_freshness() { let actor = Keys::generate(); @@ -487,6 +698,52 @@ async fn assertion_expiry_bounds_provider_freshness() { assert_eq!(snapshot.effective_until(), 120); } +#[tokio::test] +async fn identity_evidence_expiring_during_provider_resolution_denies() { + let actor = Keys::generate(); + let direct = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let direct_provider = FakeProvider::returning(allow_for( + &direct, + direct.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(direct_denial) = + resolve_authorization(&direct_provider, &direct, 120, provider_timeout()).await + else { + panic!("assertion expiring during provider resolution must deny"); + }; + assert_eq!( + direct_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated = delegated_request(&delegate, &owner, 140); + let delegated_provider = FakeProvider::returning(allow_for( + &delegated, + delegated.requested_capabilities().clone(), + "version-a", + 130, + 180, + )); + let AuthorizationOutcome::Deny(delegated_denial) = + resolve_authorization(&delegated_provider, &delegated, 140, provider_timeout()).await + else { + panic!("delegation expiring during provider resolution must deny"); + }; + assert_eq!( + delegated_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + #[tokio::test] async fn delegated_owner_admission_does_not_require_owner_assertion() { let actor = Keys::generate(); @@ -571,10 +828,16 @@ fn provider_contract_rejects_malformed_values() { AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), Err(ProviderContractError::ProfileIdTooLong) ); + assert!(AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()); assert_eq!( PolicyVersion::new(""), Err(ProviderContractError::EmptyPolicyVersion) ); + assert_eq!( + PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::PolicyVersionTooLong) + ); + assert!(PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()); assert_eq!( RetryAfter::new(0), Err(ProviderContractError::InvalidRetryAfter) @@ -583,6 +846,12 @@ fn provider_contract_rejects_malformed_values() { RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1), Err(ProviderContractError::InvalidRetryAfter) ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS) + .expect("maximum retry hint is valid") + .seconds(), + MAX_RETRY_AFTER_SECONDS + ); assert_eq!( ProviderTimeout::new(Duration::ZERO), Err(ProviderContractError::InvalidProviderTimeout) @@ -591,6 +860,12 @@ fn provider_contract_rejects_malformed_values() { ProviderTimeout::new(MAX_PROVIDER_TIMEOUT + Duration::from_nanos(1)), Err(ProviderContractError::InvalidProviderTimeout) ); + assert_eq!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT) + .expect("maximum provider timeout is valid") + .duration(), + MAX_PROVIDER_TIMEOUT + ); assert_eq!( ProviderAllow::new( domain(1), @@ -615,6 +890,18 @@ fn provider_contract_rejects_malformed_values() { ), Err(ProviderContractError::InvalidFreshnessBound) ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 99, + ), + Err(ProviderContractError::InvalidFreshnessBound) + ); assert_eq!( ProviderAllow::new( domain(1), @@ -627,6 +914,16 @@ fn provider_contract_rejects_malformed_values() { ), Err(ProviderContractError::FreshnessWindowTooLong) ); + assert!(ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS, + ) + .is_ok()); } #[test] @@ -840,17 +1137,38 @@ fn request_construction_rejects_mismatched_verified_evidence() { async fn request_decision_snapshot_and_errors_are_redaction_safe() { let actor = Keys::generate(); let request = direct_request(&actor); + let request_debug = concat!( + "AuthorizationRequest { authorization_domain: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", ", + "authority: \"[redacted]\", principal: \"[redacted]\", ", + "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", + "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_until: \"[redacted]\" }" + ); + // Keep this exact-shape assertion deliberately: adding a field must fail until + // the disclosure contract explicitly confirms that the new field is redacted. + assert_eq!(format!("{request:?}"), request_debug); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated_request = delegated_request(&delegate, &owner, 180); + assert_eq!(format!("{delegated_request:?}"), request_debug); assert_eq!( - format!("{request:?}"), - concat!( - "AuthorizationRequest { authorization_domain: \"[redacted]\", ", - "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", - "proof_method: \"[redacted]\", ", - "authority: \"[redacted]\", principal: \"[redacted]\", ", - "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", - "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", - "evidence_valid_until: \"[redacted]\" }" - ) + format!("{:?}", request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", request.decision_source()), + "DecisionSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.decision_source()), + "DecisionSource(\"[redacted]\")" ); let allow = ProviderAllow::new( @@ -915,6 +1233,44 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "retry_after: \"[redacted]\" }" ) ); + assert_eq!( + format!( + "{:?}", + ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); assert_eq!( format!("{:?}", provider_timeout()), "ProviderTimeout(\"[redacted]\")" @@ -932,17 +1288,58 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "CapabilitySet(\"[redacted]\")" ); - for error in [ - ProviderContractError::EmptyCapabilitySet, - ProviderContractError::EmptyProfileId, - ProviderContractError::EmptyPolicyVersion, - ProviderContractError::AuthorizationDomainMismatch, - ProviderContractError::DelegatedOwnerMismatch, + for capability in all_capabilities() { + capability_coverage_is_exhaustive(capability); + assert_eq!( + format!("{capability:?}"), + "AuthorizationCapability(\"[redacted]\")" + ); + } + for reason in [ + AuthorizationDenialReason::ProviderDenied, + AuthorizationDenialReason::AuthorizationDomainMismatch, + AuthorizationDenialReason::PrincipalMismatch, + AuthorizationDenialReason::AuthorizationProfileMismatch, + AuthorizationDenialReason::MissingCapability, + AuthorizationDenialReason::StaleDecision, + AuthorizationDenialReason::FutureDecision, + AuthorizationDenialReason::IdentityEvidenceExpired, ] { - let rendered = error.to_string(); - assert!(!rendered.contains("idp.example")); - assert!(!rendered.contains("subject-123")); - assert!(!rendered.contains("private-policy-version")); + assert_eq!( + format!("{reason:?}"), + "AuthorizationDenialReason(\"[redacted]\")" + ); + } + for reason in [ + ProviderUnavailableReason::TemporarilyUnavailable, + ProviderUnavailableReason::Timeout, + ProviderUnavailableReason::DependencyUnavailable, + ] { + assert_eq!( + format!("{reason:?}"), + "ProviderUnavailableReason(\"[redacted]\")" + ); + } + assert_eq!( + format!("{:?}", ProviderAllowReason::CurrentPolicy), + "ProviderAllowReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", RetryAfter::new(30).expect("retry hint is valid")), + "RetryAfter(\"[redacted]\")" + ); + + for error in all_contract_errors() { + for rendered in [error.to_string(), format!("{error:?}")] { + for private_value in [ + "idp.example", + "subject-123", + "profile-1", + "private-policy-version", + ] { + assert!(!rendered.contains(private_value)); + } + } } } @@ -972,29 +1369,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() { codes.dedup(); assert_eq!(codes.len(), 12); - let contract_errors = [ - ProviderContractError::EmptyCapabilitySet, - ProviderContractError::EmptyProfileId, - ProviderContractError::ProfileIdTooLong, - ProviderContractError::EmptyPolicyVersion, - ProviderContractError::PolicyVersionTooLong, - ProviderContractError::InvalidIssuedAt, - ProviderContractError::InvalidFreshnessBound, - ProviderContractError::FreshnessWindowTooLong, - ProviderContractError::InvalidRetryAfter, - ProviderContractError::InvalidProviderTimeout, - ProviderContractError::InvalidCorrelationId, - ProviderContractError::DirectRequestHasOwner, - ProviderContractError::AuthorizationDomainMismatch, - ProviderContractError::TransportMismatch, - ProviderContractError::AssertionNotYetValid, - ProviderContractError::AssertionExpired, - ProviderContractError::KeyAttestationMismatch, - ProviderContractError::MissingKeyAttestation, - ProviderContractError::DelegationRequired, - ProviderContractError::DelegatedOwnerMismatch, - ProviderContractError::DelegationExpired, - ]; + let contract_errors = all_contract_errors(); let mut contract_codes = contract_errors .iter() .copied() From 23f98df72d48d41ddc659656fe5fd22ae4710d76 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:05:37 -0500 Subject: [PATCH 4/7] fix(auth): revalidate provider decisions after I/O Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/mod.rs | 47 +++- crates/buzz-auth/src/provider/tests.rs | 338 ++++++++++++++++++++++--- 2 files changed, 352 insertions(+), 33 deletions(-) diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 8a5733eaa2..569da23deb 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -317,6 +317,20 @@ impl AuthorizationRequest { { return Err(ProviderContractError::DelegationExpired); } + if owner + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::BindingExpired); + } + let evidence_valid_until = match (delegation.expires_at(), owner.expires_at()) { + (Some(delegation), Some(binding)) => { + Some(delegation.unix_seconds().min(binding.unix_seconds())) + } + (Some(delegation), None) => Some(delegation.unix_seconds()), + (None, Some(binding)) => Some(binding.unix_seconds()), + (None, None) => None, + }; Ok(Self { authorization_domain: proof.authorization_domain(), transport: proof.authorized_transport(), @@ -332,7 +346,7 @@ impl AuthorizationRequest { requested_capabilities, correlation_id, decision_source: DecisionSource::DelegatedOwnerBinding, - evidence_valid_until: delegation.expires_at().map(|bound| bound.unix_seconds()), + evidence_valid_until, }) } @@ -386,7 +400,8 @@ impl AuthorizationRequest { self.decision_source } - /// Earliest validity bound supplied by verified assertion or delegation evidence. + /// Earliest validity bound supplied by verified assertion, owner-binding, + /// or delegation evidence. pub const fn evidence_valid_until(&self) -> Option { self.evidence_valid_until } @@ -632,6 +647,17 @@ impl fmt::Debug for ProviderTimeout { } } +/// Trusted server time used to validate a provider result. +/// +/// The resolver samples this source exactly once for an allowed decision. A +/// source must return current Unix time without reusing a value captured before +/// provider I/O, and must not block the async executor. Returning `None` fails +/// closed as dependency unavailability. +pub trait AuthorizationClock: Send + Sync { + /// Current trusted Unix time, or `None` when it cannot be obtained. + fn now_unix_seconds(&self) -> Option; +} + /// Fail-closed provider unavailability. #[derive(PartialEq, Eq)] pub struct ProviderUnavailable { @@ -904,11 +930,14 @@ impl fmt::Debug for AuthorizationOutcome { /// /// Unavailability is preserved as a fail-closed outcome. This function never /// falls back to Nostr-only authorization or applies an implicit grace period. -/// `now_unix_seconds` must come from the server clock. +/// `clock` must be the server's trusted time source. After provider I/O +/// completes, an allowed decision is checked against exactly one fresh sample. +/// Provider freshness and all effective evidence bounds use that same value; +/// callers must not precompute and pass a decision-start timestamp. pub async fn resolve_authorization( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, - now_unix_seconds: u64, + clock: &dyn AuthorizationClock, timeout: ProviderTimeout, ) -> AuthorizationOutcome { let decision = match tokio::time::timeout(timeout.duration(), provider.authorize(request)).await @@ -928,6 +957,12 @@ pub async fn resolve_authorization( return AuthorizationOutcome::Unavailable(unavailable); } }; + let Some(now_unix_seconds) = clock.now_unix_seconds() else { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )); + }; if allow.authorization_domain != request.authorization_domain { return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); @@ -1061,6 +1096,9 @@ pub enum ProviderContractError { /// Delegation was expired at server time. #[error("delegated provider request has expired")] DelegationExpired, + /// Owner binding was expired at server time. + #[error("delegated provider request owner binding has expired")] + BindingExpired, } impl ProviderContractError { @@ -1088,6 +1126,7 @@ impl ProviderContractError { Self::InvalidCorrelationId => "authorization_provider_contract_019", Self::MissingKeyAttestation => "authorization_provider_contract_020", Self::FreshnessWindowTooLong => "authorization_provider_contract_021", + Self::BindingExpired => "authorization_provider_contract_022", } } } diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index c46f658773..56a8780bb5 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -1,7 +1,7 @@ use std::{ future::pending, sync::{ - atomic::{AtomicBool, AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, Arc, Mutex, }, time::Duration, @@ -11,8 +11,9 @@ use nostr::Keys; use super::*; use crate::context::{ - AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingSource, - BindingVersion, DelegationExpiry, VerifiedKeyAttestation, VerifiedTransportDelegation, + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingExpiry, + BindingSource, BindingVersion, DelegationExpiry, VerifiedKeyAttestation, + VerifiedTransportDelegation, }; const NOW: u64 = 100; @@ -38,6 +39,53 @@ fn provider_timeout() -> ProviderTimeout { ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") } +#[derive(Clone)] +struct TestClock { + now: Arc, + available: Arc, + reads: Arc, +} + +impl TestClock { + fn at(now: u64) -> Self { + Self { + now: Arc::new(AtomicU64::new(now)), + available: Arc::new(AtomicBool::new(true)), + reads: Arc::new(AtomicUsize::new(0)), + } + } + + fn set(&self, now: u64) { + self.now.store(now, Ordering::SeqCst); + } + + fn set_available(&self, available: bool) { + self.available.store(available, Ordering::SeqCst); + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } +} + +impl AuthorizationClock for TestClock { + fn now_unix_seconds(&self) -> Option { + self.reads.fetch_add(1, Ordering::SeqCst); + self.available + .load(Ordering::SeqCst) + .then(|| self.now.load(Ordering::SeqCst)) + } +} + +async fn resolve_at( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + now: u64, + timeout: ProviderTimeout, +) -> AuthorizationOutcome { + resolve_authorization(provider, request, &TestClock::at(now), timeout).await +} + fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") } @@ -83,7 +131,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { } } -fn all_contract_errors() -> [ProviderContractError; 21] { +fn all_contract_errors() -> [ProviderContractError; 22] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -106,6 +154,7 @@ fn all_contract_errors() -> [ProviderContractError; 21] { ProviderContractError::DelegationRequired, ProviderContractError::DelegatedOwnerMismatch, ProviderContractError::DelegationExpired, + ProviderContractError::BindingExpired, ] } @@ -168,32 +217,46 @@ fn existing_binding(owner: &Keys) -> VersionedBindingRef { } fn existing_binding_in(domain_value: u128, owner: &Keys) -> VersionedBindingRef { + existing_binding_with_expiry_in(domain_value, owner, None) +} + +fn existing_binding_with_expiry_in( + domain_value: u128, + owner: &Keys, + expires_at: Option, +) -> VersionedBindingRef { VersionedBindingRef::new_existing_active_for_test( domain(domain_value), Uuid::from_u128(10), principal(), owner.public_key(), BindingVersion::INITIAL, + expires_at + .map(|expiry| BindingExpiry::new(expiry).expect("synthetic binding expiry is valid")), BindingSource::Provisioned, ) .expect("synthetic binding is valid") } -fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { +fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProof { let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), actor.public_key(), Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), ) .expect("synthetic delegation is valid"); - let proof = VerifiedNostrProof::new( + VerifiedNostrProof::new( domain(1), AuthTransport::RelayWebSocket, actor.public_key(), AuthMethod::Nip42, Some(delegation), ) - .expect("synthetic delegated proof is valid"); + .expect("synthetic delegated proof is valid") +} + +fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { + let proof = delegated_proof(actor, owner, expiry); AuthorizationRequest::delegated( &proof, &existing_binding(owner), @@ -253,6 +316,56 @@ impl AuthorizationProvider for FakeProvider { } } +struct AdvancingProvider { + decision: Mutex>, + clock: TestClock, + decision_time: u64, + clock_available: bool, +} + +impl AdvancingProvider { + fn returning_at(decision: ProviderDecision, clock: TestClock, decision_time: u64) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time, + clock_available: true, + } + } + + fn returning_with_clock_failure(decision: ProviderDecision, clock: TestClock) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time: 0, + clock_available: false, + } + } +} + +impl AuthorizationProvider for AdvancingProvider { + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + tokio::task::yield_now().await; + assert_eq!( + self.clock.reads(), + 0, + "decision time must not be sampled before provider I/O completes" + ); + self.clock.set(self.decision_time); + self.clock.set_available(self.clock_available); + self.decision + .lock() + .expect("synthetic provider mutex is not poisoned") + .take() + .expect("synthetic provider is called exactly once") + }) + } +} + struct PendingProvider { calls: Arc, dropped: Arc, @@ -296,7 +409,7 @@ async fn current_allow_returns_request_scoped_snapshot() { )); let AuthorizationOutcome::Allow(snapshot) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("current provider policy must allow"); }; @@ -355,7 +468,7 @@ async fn allowed_snapshot_preserves_every_requested_transport_scope() { )); let AuthorizationOutcome::Allow(snapshot) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("current provider policy must allow every transport profile"); }; @@ -374,7 +487,7 @@ async fn explicit_denial_is_preserved() { ))); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("provider denial must fail closed"); }; @@ -393,7 +506,7 @@ async fn provider_unavailability_never_falls_back_to_allow() { ))); let AuthorizationOutcome::Unavailable(unavailable) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("provider unavailability must remain fail closed"); }; @@ -418,7 +531,7 @@ async fn provider_call_deadline_returns_timeout_unavailability() { ProviderTimeout::new(Duration::from_millis(1)).expect("synthetic timeout is finite"); let AuthorizationOutcome::Unavailable(unavailable) = - resolve_authorization(&provider, &request, NOW, timeout).await + resolve_at(&provider, &request, NOW, timeout).await else { panic!("provider timeout must remain fail closed"); }; @@ -428,6 +541,173 @@ async fn provider_call_deadline_returns_timeout_unavailability() { assert!(dropped.load(Ordering::SeqCst)); } +#[tokio::test] +async fn provider_freshness_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 105, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, &clock, provider_timeout()).await + else { + panic!("a provider decision stale after I/O must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn identity_evidence_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 105, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, &clock, provider_timeout()).await + else { + panic!("identity evidence expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[tokio::test] +async fn owner_binding_expiry_is_evaluated_after_async_io() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(105)); + let request = AuthorizationRequest::delegated( + &proof, + &binding, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("owner binding is current at request construction"); + assert_eq!(request.evidence_valid_until(), Some(105)); + + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, &clock, provider_timeout()).await + else { + panic!("owner binding expired after provider I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[test] +fn delegated_request_rejects_owner_binding_at_exact_expiry() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(NOW)); + + let error = AuthorizationRequest::delegated( + &proof, + &binding, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect_err("expired owner binding must not enter provider evaluation"); + assert_eq!(error, ProviderContractError::BindingExpired); +} + +#[tokio::test] +async fn decision_issued_during_async_io_is_not_false_future() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 104, + 180, + ), + clock.clone(), + 105, + ); + + assert!(matches!( + resolve_authorization(&provider, &request, &clock, provider_timeout()).await, + AuthorizationOutcome::Allow(_) + )); +} + +#[tokio::test] +async fn clock_failure_after_provider_io_is_unavailable() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_with_clock_failure( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + ); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_authorization(&provider, &request, &clock, provider_timeout()).await + else { + panic!("unavailable decision time must fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::DependencyUnavailable + ); +} + #[tokio::test] async fn stale_and_future_provider_decisions_deny() { let actor = Keys::generate(); @@ -440,7 +720,7 @@ async fn stale_and_future_provider_decisions_deny() { 90, )); let AuthorizationOutcome::Deny(stale_denial) = - resolve_authorization(&stale, &request, NOW, provider_timeout()).await + resolve_at(&stale, &request, NOW, provider_timeout()).await else { panic!("stale decision must deny"); }; @@ -457,7 +737,7 @@ async fn stale_and_future_provider_decisions_deny() { 180, )); let AuthorizationOutcome::Deny(future_denial) = - resolve_authorization(&future, &request, NOW, provider_timeout()).await + resolve_at(&future, &request, NOW, provider_timeout()).await else { panic!("future decision must deny"); }; @@ -488,7 +768,7 @@ async fn provider_time_boundaries_and_current_assertion_are_exact() { 180, )); assert!(matches!( - resolve_authorization(&issued_now, &request, NOW, provider_timeout()).await, + resolve_at(&issued_now, &request, NOW, provider_timeout()).await, AuthorizationOutcome::Allow(_) )); @@ -500,7 +780,7 @@ async fn provider_time_boundaries_and_current_assertion_are_exact() { NOW, )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&stale_at_now, &request, NOW, provider_timeout()).await + resolve_at(&stale_at_now, &request, NOW, provider_timeout()).await else { panic!("freshness ending at server time must deny"); }; @@ -525,7 +805,7 @@ async fn domain_principal_and_capability_mismatches_deny() { .expect("synthetic provider allow is structurally valid"), )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&wrong_domain, &request, NOW, provider_timeout()).await + resolve_at(&wrong_domain, &request, NOW, provider_timeout()).await else { panic!("cross-domain decision must deny"); }; @@ -548,7 +828,7 @@ async fn domain_principal_and_capability_mismatches_deny() { .expect("synthetic provider allow is structurally valid"), )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&wrong_principal, &request, NOW, provider_timeout()).await + resolve_at(&wrong_principal, &request, NOW, provider_timeout()).await else { panic!("principal mismatch must deny"); }; @@ -570,7 +850,7 @@ async fn domain_principal_and_capability_mismatches_deny() { .expect("synthetic provider allow is structurally valid"), )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&wrong_profile, &request, NOW, provider_timeout()).await + resolve_at(&wrong_profile, &request, NOW, provider_timeout()).await else { panic!("profile mismatch must deny"); }; @@ -587,7 +867,7 @@ async fn domain_principal_and_capability_mismatches_deny() { 180, )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&missing_capability, &request, NOW, provider_timeout()).await + resolve_at(&missing_capability, &request, NOW, provider_timeout()).await else { panic!("missing capability must deny"); }; @@ -614,7 +894,7 @@ async fn invite_mint_does_not_authorize_invite_claim() { )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("invitation minting must not authorize a claim"); }; @@ -661,7 +941,7 @@ async fn no_distinct_capability_authorizes_another_capability() { 180, )); let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("a distinct capability must not widen provider authority"); }; @@ -690,7 +970,7 @@ async fn assertion_expiry_bounds_provider_freshness() { )); let AuthorizationOutcome::Allow(snapshot) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("current bounded policy must allow"); }; @@ -714,7 +994,7 @@ async fn identity_evidence_expiring_during_provider_resolution_denies() { 180, )); let AuthorizationOutcome::Deny(direct_denial) = - resolve_authorization(&direct_provider, &direct, 120, provider_timeout()).await + resolve_at(&direct_provider, &direct, 120, provider_timeout()).await else { panic!("assertion expiring during provider resolution must deny"); }; @@ -734,7 +1014,7 @@ async fn identity_evidence_expiring_during_provider_resolution_denies() { 180, )); let AuthorizationOutcome::Deny(delegated_denial) = - resolve_authorization(&delegated_provider, &delegated, 140, provider_timeout()).await + resolve_at(&delegated_provider, &delegated, 140, provider_timeout()).await else { panic!("delegation expiring during provider resolution must deny"); }; @@ -767,7 +1047,7 @@ async fn delegated_owner_admission_does_not_require_owner_assertion() { 180, )); let AuthorizationOutcome::Allow(snapshot) = - resolve_authorization(&provider, &request, NOW, provider_timeout()).await + resolve_at(&provider, &request, NOW, provider_timeout()).await else { panic!("current owner admission must allow delegated authority"); }; @@ -791,7 +1071,7 @@ async fn policy_versions_detect_equality_and_change_without_ordering() { 180, )); let AuthorizationOutcome::Allow(snapshot_a) = - resolve_authorization(&provider_a, &request_a, NOW, provider_timeout()).await + resolve_at(&provider_a, &request_a, NOW, provider_timeout()).await else { panic!("current provider policy must allow"); }; @@ -805,7 +1085,7 @@ async fn policy_versions_detect_equality_and_change_without_ordering() { 180, )); let AuthorizationOutcome::Allow(snapshot_b) = - resolve_authorization(&provider_b, &request_b, NOW, provider_timeout()).await + resolve_at(&provider_b, &request_b, NOW, provider_timeout()).await else { panic!("current provider policy must allow"); }; @@ -1193,7 +1473,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { let decision = ProviderDecision::Allow(allow); assert_eq!(format!("{decision:?}"), "ProviderDecision(\"[redacted]\")"); let provider = FakeProvider::returning(decision); - let outcome = resolve_authorization(&provider, &request, NOW, provider_timeout()).await; + let outcome = resolve_at(&provider, &request, NOW, provider_timeout()).await; assert_eq!( format!("{outcome:?}"), "AuthorizationOutcome(\"[redacted]\")" From 4bd64092bcb083646791ed926ec5640bfc801764 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:48:30 -0500 Subject: [PATCH 5/7] fix(auth): bind capability snapshots to enrollment policy Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/mod.rs | 125 +++++++-- crates/buzz-auth/src/provider/tests.rs | 353 +++++++++++++++++++++++-- 2 files changed, 443 insertions(+), 35 deletions(-) diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 569da23deb..fc4c1069cf 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -12,8 +12,8 @@ use thiserror::Error; use uuid::Uuid; use crate::context::{ - AuthMethod, AuthTransport, BindingVersion, FederatedPrincipal, VerifiedFederatedAssertion, - VerifiedNostrProof, VersionedBindingRef, + AuthMethod, AuthTransport, AuthoritativeBindingEvidence, BindingVersion, FederatedPolicyStamp, + FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -133,10 +133,12 @@ impl fmt::Debug for AuthorizationProfileId { } } -/// Opaque, equality-comparable policy version returned by a provider. +/// Opaque, equality-comparable capability-policy version returned by a provider. /// /// This is the typed policy-change seam that later lease and invalidation code -/// can use without assuming a provider-specific numeric ordering. +/// can use without assuming a provider-specific numeric ordering. It is a +/// distinct namespace from [`FederatedPolicyStamp::epoch`] and must never be +/// used as enrollment-policy currency evidence. #[derive(Clone, PartialEq, Eq, Hash)] pub struct PolicyVersion(String); @@ -222,6 +224,7 @@ pub struct AuthorizationRequest { proof_method: AuthMethod, authority: AuthorizationAuthority, principal: FederatedPrincipal, + federated_policy: FederatedPolicyStamp, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -239,6 +242,7 @@ impl AuthorizationRequest { pub fn direct( proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion, + federated_policy: &ResolvedFederatedPolicy, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -247,6 +251,12 @@ impl AuthorizationRequest { if correlation_id.is_nil() { return Err(ProviderContractError::InvalidCorrelationId); } + validate_federated_policy( + federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; if proof.verified_delegation().is_some() { return Err(ProviderContractError::DirectRequestHasOwner); } @@ -278,11 +288,17 @@ impl AuthorizationRequest { proof_method: proof.proof_method(), authority: AuthorizationAuthority::Direct, principal: assertion.principal().clone(), + federated_policy: federated_policy.stamp().clone(), profile_id, requested_capabilities, correlation_id, decision_source: DecisionSource::DirectAssertion, - evidence_valid_until: Some(assertion.expires_at().unix_seconds()), + evidence_valid_until: Some( + assertion + .expires_at() + .unix_seconds() + .min(federated_policy.stamp().effective_until()), + ), }) } @@ -293,7 +309,8 @@ impl AuthorizationRequest { /// `now_unix_seconds` must come from the server clock. pub fn delegated( proof: &VerifiedNostrProof, - owner: &VersionedBindingRef, + owner: &AuthoritativeBindingEvidence, + federated_policy: &ResolvedFederatedPolicy, profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, @@ -302,6 +319,12 @@ impl AuthorizationRequest { if correlation_id.is_nil() { return Err(ProviderContractError::InvalidCorrelationId); } + validate_federated_policy( + federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; if proof.authorization_domain() != owner.authorization_domain() { return Err(ProviderContractError::AuthorizationDomainMismatch); } @@ -323,14 +346,13 @@ impl AuthorizationRequest { { return Err(ProviderContractError::BindingExpired); } - let evidence_valid_until = match (delegation.expires_at(), owner.expires_at()) { - (Some(delegation), Some(binding)) => { - Some(delegation.unix_seconds().min(binding.unix_seconds())) - } - (Some(delegation), None) => Some(delegation.unix_seconds()), - (None, Some(binding)) => Some(binding.unix_seconds()), - (None, None) => None, - }; + let mut evidence_valid_until = federated_policy.stamp().effective_until(); + if let Some(delegation) = delegation.expires_at() { + evidence_valid_until = evidence_valid_until.min(delegation.unix_seconds()); + } + if let Some(binding) = owner.expires_at() { + evidence_valid_until = evidence_valid_until.min(binding.unix_seconds()); + } Ok(Self { authorization_domain: proof.authorization_domain(), transport: proof.authorized_transport(), @@ -342,11 +364,12 @@ impl AuthorizationRequest { binding_version: owner.binding_version(), }, principal: owner.principal().clone(), + federated_policy: federated_policy.stamp().clone(), profile_id, requested_capabilities, correlation_id, decision_source: DecisionSource::DelegatedOwnerBinding, - evidence_valid_until, + evidence_valid_until: Some(evidence_valid_until), }) } @@ -380,6 +403,11 @@ impl AuthorizationRequest { &self.principal } + /// Exact authoritative enrollment-policy lineage bound to this request. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + /// Server-resolved provider profile. pub const fn profile_id(&self) -> &AuthorizationProfileId { &self.profile_id @@ -401,7 +429,7 @@ impl AuthorizationRequest { } /// Earliest validity bound supplied by verified assertion, owner-binding, - /// or delegation evidence. + /// delegation, or authoritative enrollment-policy evidence. pub const fn evidence_valid_until(&self) -> Option { self.evidence_valid_until } @@ -417,6 +445,7 @@ impl fmt::Debug for AuthorizationRequest { .field("proof_method", &"[redacted]") .field("authority", &"[redacted]") .field("principal", &"[redacted]") + .field("federated_policy", &"[redacted]") .field("profile_id", &"[redacted]") .field("requested_capabilities", &"[redacted]") .field("correlation_id", &"[redacted]") @@ -505,6 +534,8 @@ pub enum AuthorizationDenialReason { FutureDecision, /// Verified identity evidence expired before the decision became effective. IdentityEvidenceExpired, + /// The bound federated enrollment policy was not current after provider I/O. + FederatedPolicyNotCurrent, } impl AuthorizationDenialReason { @@ -519,6 +550,7 @@ impl AuthorizationDenialReason { Self::FutureDecision => "authorization_provider_deny_006", Self::IdentityEvidenceExpired => "authorization_provider_deny_007", Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", + Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009", } } } @@ -779,6 +811,7 @@ pub struct CapabilitySnapshot { binding_version: Option, proof_method: AuthMethod, principal: FederatedPrincipal, + federated_policy: FederatedPolicyStamp, profile_id: AuthorizationProfileId, capabilities: CapabilitySet, policy_version: PolicyVersion, @@ -834,6 +867,19 @@ impl CapabilitySnapshot { &self.principal } + /// Exact authoritative enrollment-policy lineage bound to this decision. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + + /// Whether a freshly resolved O3 policy is exactly the policy used here. + /// + /// O3 must additionally compare this stamp with current authoritative state + /// and use its epoch as an atomic enrollment precondition. + pub fn is_bound_to_federated_policy(&self, policy: &ResolvedFederatedPolicy) -> bool { + self.federated_policy == *policy.stamp() + } + /// Server-resolved authorization profile for this decision. pub const fn profile_id(&self) -> &AuthorizationProfileId { &self.profile_id @@ -892,6 +938,7 @@ impl fmt::Debug for CapabilitySnapshot { .field("binding_version", &"[redacted]") .field("proof_method", &"[redacted]") .field("principal", &"[redacted]") + .field("federated_policy", &"[redacted]") .field("profile_id", &"[redacted]") .field("capabilities", &"[redacted]") .field("policy_version", &"[redacted]") @@ -964,6 +1011,14 @@ pub async fn resolve_authorization( )); }; + if request + .federated_policy + .is_not_yet_effective_at(now_unix_seconds) + || request.federated_policy.is_expired_at(now_unix_seconds) + { + return deny(AuthorizationDenialReason::FederatedPolicyNotCurrent); + } + if allow.authorization_domain != request.authorization_domain { return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); } @@ -1013,6 +1068,7 @@ pub async fn resolve_authorization( }, proof_method: request.proof_method, principal: allow.principal, + federated_policy: request.federated_policy.clone(), profile_id: allow.profile_id, capabilities: request.requested_capabilities.clone(), policy_version: allow.policy_version, @@ -1099,6 +1155,18 @@ pub enum ProviderContractError { /// Owner binding was expired at server time. #[error("delegated provider request owner binding has expired")] BindingExpired, + /// Enrollment policy belonged to another authorization domain. + #[error("provider request enrollment policy does not match the authorization domain")] + FederatedPolicyDomainMismatch, + /// Enrollment policy belonged to another correlated decision. + #[error("provider request enrollment policy does not match the correlation identifier")] + FederatedPolicyCorrelationMismatch, + /// Enrollment policy was not yet effective at server time. + #[error("provider request enrollment policy is not yet effective")] + FederatedPolicyNotYetEffective, + /// Enrollment policy was expired at server time. + #[error("provider request enrollment policy has expired")] + FederatedPolicyExpired, } impl ProviderContractError { @@ -1127,9 +1195,34 @@ impl ProviderContractError { Self::MissingKeyAttestation => "authorization_provider_contract_020", Self::FreshnessWindowTooLong => "authorization_provider_contract_021", Self::BindingExpired => "authorization_provider_contract_022", + Self::FederatedPolicyDomainMismatch => "authorization_provider_contract_023", + Self::FederatedPolicyCorrelationMismatch => "authorization_provider_contract_024", + Self::FederatedPolicyNotYetEffective => "authorization_provider_contract_025", + Self::FederatedPolicyExpired => "authorization_provider_contract_026", } } } +fn validate_federated_policy( + policy: &ResolvedFederatedPolicy, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result<(), ProviderContractError> { + if policy.authorization_domain() != authorization_domain { + return Err(ProviderContractError::FederatedPolicyDomainMismatch); + } + if policy.stamp().correlation_id() != correlation_id { + return Err(ProviderContractError::FederatedPolicyCorrelationMismatch); + } + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyNotYetEffective); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyExpired); + } + Ok(()) +} + #[cfg(test)] mod tests; diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 56a8780bb5..99418b354c 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -11,9 +11,10 @@ use nostr::Keys; use super::*; use crate::context::{ - AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, BindingExpiry, - BindingSource, BindingVersion, DelegationExpiry, VerifiedKeyAttestation, - VerifiedTransportDelegation, + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, + AuthoritativeBindingEvidence, BindingExpiry, BindingSource, BindingVersion, DelegationExpiry, + EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, + VerifiedKeyAttestation, VerifiedTransportDelegation, }; const NOW: u64 = 100; @@ -35,6 +36,39 @@ fn policy_version(value: &str) -> PolicyVersion { PolicyVersion::new(value).expect("synthetic policy version is valid") } +fn federated_policy_with( + domain_value: u128, + correlation_id: Uuid, + epoch: u64, + enrollment_mode: EnrollmentMode, + effective_from: u64, + effective_until: u64, +) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(domain_value), + Uuid::from_u128(40), + epoch, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + effective_from, + effective_until, + ) + .expect("synthetic federated policy lineage is valid"), + ) +} + +fn federated_policy() -> ResolvedFederatedPolicy { + federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 200, + ) +} + fn provider_timeout() -> ProviderTimeout { ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") } @@ -131,7 +165,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { } } -fn all_contract_errors() -> [ProviderContractError; 22] { +fn all_contract_errors() -> [ProviderContractError; 26] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -155,6 +189,10 @@ fn all_contract_errors() -> [ProviderContractError; 22] { ProviderContractError::DelegatedOwnerMismatch, ProviderContractError::DelegationExpired, ProviderContractError::BindingExpired, + ProviderContractError::FederatedPolicyDomainMismatch, + ProviderContractError::FederatedPolicyCorrelationMismatch, + ProviderContractError::FederatedPolicyNotYetEffective, + ProviderContractError::FederatedPolicyExpired, ] } @@ -181,6 +219,7 @@ fn direct_request_for_transport( AuthorizationRequest::direct( &proof, &assertion, + &federated_policy(), profile(), requested, Uuid::from_u128(20), @@ -212,11 +251,11 @@ fn direct_request(actor: &Keys) -> AuthorizationRequest { ) } -fn existing_binding(owner: &Keys) -> VersionedBindingRef { +fn existing_binding(owner: &Keys) -> AuthoritativeBindingEvidence { existing_binding_in(1, owner) } -fn existing_binding_in(domain_value: u128, owner: &Keys) -> VersionedBindingRef { +fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingEvidence { existing_binding_with_expiry_in(domain_value, owner, None) } @@ -224,8 +263,8 @@ fn existing_binding_with_expiry_in( domain_value: u128, owner: &Keys, expires_at: Option, -) -> VersionedBindingRef { - VersionedBindingRef::new_existing_active_for_test( +) -> AuthoritativeBindingEvidence { + AuthoritativeBindingEvidence::new( domain(domain_value), Uuid::from_u128(10), principal(), @@ -260,6 +299,7 @@ fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRe AuthorizationRequest::delegated( &proof, &existing_binding(owner), + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -567,6 +607,186 @@ async fn provider_freshness_is_evaluated_after_async_io() { assert_eq!(clock.reads(), 1); } +#[tokio::test] +async fn federated_policy_expiry_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 105, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + &policy, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("federated policy is current when provider I/O begins"); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = + resolve_authorization(&provider, &request, &clock, provider_timeout()).await + else { + panic!("federated enrollment policy expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::FederatedPolicyNotCurrent + ); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn snapshot_requires_exact_enrollment_policy_lineage() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let current_policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + ¤t_policy, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "6", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider and enrollment policy must allow"); + }; + + let stale_tofu_policy = + federated_policy_with(1, Uuid::from_u128(20), 6, EnrollmentMode::Tofu, 1, 160); + assert!(snapshot.is_bound_to_federated_policy(¤t_policy)); + assert!(!snapshot.is_bound_to_federated_policy(&stale_tofu_policy)); + assert_eq!(snapshot.policy_version().as_str(), "6"); + assert_ne!( + snapshot.policy_version().as_str(), + snapshot.federated_policy().epoch().to_string() + ); +} + +#[tokio::test] +async fn enrollment_policy_bounds_snapshot_effective_interval() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 150, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + &policy, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + 90, + 170, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + + assert_eq!(request.evidence_valid_until(), Some(150)); + assert_eq!(snapshot.effective_until(), 150); +} + #[tokio::test] async fn identity_evidence_is_evaluated_after_async_io() { let actor = Keys::generate(); @@ -608,6 +828,7 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() { let request = AuthorizationRequest::delegated( &proof, &binding, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -649,6 +870,7 @@ fn delegated_request_rejects_owner_binding_at_exact_expiry() { let error = AuthorizationRequest::delegated( &proof, &binding, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1230,6 +1452,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), @@ -1241,6 +1464,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1262,6 +1486,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &future, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1271,6 +1496,88 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { ); } +#[test] +fn request_construction_rejects_non_current_or_mismatched_federated_policy() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let request_with = |policy: &ResolvedFederatedPolicy| { + AuthorizationRequest::direct( + &proof, + &assertion, + policy, + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + let wrong_domain = federated_policy_with( + 2, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(&wrong_domain), + Err(ProviderContractError::FederatedPolicyDomainMismatch) + ); + let wrong_correlation = federated_policy_with( + 1, + Uuid::from_u128(21), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(&wrong_correlation), + Err(ProviderContractError::FederatedPolicyCorrelationMismatch) + ); + let future = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + NOW + 1, + 180, + ); + assert_eq!( + request_with(&future), + Err(ProviderContractError::FederatedPolicyNotYetEffective) + ); + let expired = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + NOW, + ); + assert_eq!( + request_with(&expired), + Err(ProviderContractError::FederatedPolicyExpired) + ); +} + #[test] fn request_construction_rejects_mismatched_verified_evidence() { let actor = Keys::generate(); @@ -1301,6 +1608,7 @@ fn request_construction_rejects_mismatched_verified_evidence() { AuthorizationRequest::direct( proof, assertion, + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), @@ -1359,20 +1667,23 @@ fn request_construction_rejects_mismatched_verified_evidence() { Err(ProviderContractError::DirectRequestHasOwner) ); - let delegated_request_from = |proof: &VerifiedNostrProof, binding: &VersionedBindingRef| { - AuthorizationRequest::delegated( - proof, - binding, - profile(), - capabilities(&[AuthorizationCapability::CommunityRead]), - Uuid::from_u128(20), - NOW, - ) - }; + let delegated_request_from = + |proof: &VerifiedNostrProof, binding: &AuthoritativeBindingEvidence| { + AuthorizationRequest::delegated( + proof, + binding, + &federated_policy(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; assert_eq!( AuthorizationRequest::delegated( &delegated_proof, &existing_binding(&owner), + &federated_policy(), profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), @@ -1422,6 +1733,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", "proof_method: \"[redacted]\", ", "authority: \"[redacted]\", principal: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", "evidence_valid_until: \"[redacted]\" }" @@ -1489,6 +1801,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", "principal: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", @@ -1584,6 +1897,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { AuthorizationDenialReason::StaleDecision, AuthorizationDenialReason::FutureDecision, AuthorizationDenialReason::IdentityEvidenceExpired, + AuthorizationDenialReason::FederatedPolicyNotCurrent, ] { assert_eq!( format!("{reason:?}"), @@ -1641,13 +1955,14 @@ fn provider_trait_is_object_safe_and_codes_are_unique() { AuthorizationDenialReason::StaleDecision.code(), AuthorizationDenialReason::FutureDecision.code(), AuthorizationDenialReason::IdentityEvidenceExpired.code(), + AuthorizationDenialReason::FederatedPolicyNotCurrent.code(), ProviderUnavailableReason::TemporarilyUnavailable.code(), ProviderUnavailableReason::Timeout.code(), ProviderUnavailableReason::DependencyUnavailable.code(), ]; codes.sort_unstable(); codes.dedup(); - assert_eq!(codes.len(), 12); + assert_eq!(codes.len(), 13); let contract_errors = all_contract_errors(); let mut contract_codes = contract_errors From cc9bc5361d06ea5ff7529aaa59f4a035ef92a476 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:50:42 -0500 Subject: [PATCH 6/7] fix(auth): seal capability finalization runtime Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/lib.rs | 11 +- crates/buzz-auth/src/provider/mod.rs | 664 ++++++++++++++++++++++--- crates/buzz-auth/src/provider/tests.rs | 632 ++++++++++++++++++++--- 3 files changed, 1162 insertions(+), 145 deletions(-) diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index e4d6e831eb..1699555831 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -55,12 +55,13 @@ pub use nip98_replay::{ MAX_REPLAY_TTL_SECS, }; pub use provider::{ - resolve_authorization, AuthorizationAuthority, AuthorizationCapability, AuthorizationDenial, + AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial, AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, - AuthorizationProviderFuture, AuthorizationRequest, CapabilitySet, CapabilitySnapshot, - DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, ProviderContractError, - ProviderDecision, ProviderTimeout, ProviderUnavailable, ProviderUnavailableReason, RetryAfter, - MAX_PROVIDER_FRESHNESS_SECONDS, MAX_PROVIDER_TIMEOUT, + AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, + CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, + ProviderAuthorizationError, ProviderContractError, ProviderDecision, ProviderTimeout, + ProviderUnavailable, ProviderUnavailableReason, RetryAfter, MAX_PROVIDER_FRESHNESS_SECONDS, + MAX_PROVIDER_TIMEOUT, }; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index fc4c1069cf..9099d10273 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -12,8 +12,13 @@ use thiserror::Error; use uuid::Uuid; use crate::context::{ - AuthMethod, AuthTransport, AuthoritativeBindingEvidence, BindingVersion, FederatedPolicyStamp, + authority::{resolve_direct_binding, resolve_existing_binding}, + resolve_current_federated_policy, AdmissionExpiry, AssertionTransport, AuthContext, + AuthContextError, AuthContextInput, AuthMethod, AuthTransport, AuthoritativeBindingResolution, + AuthoritativeFederatedResolution, AuthorityAdapterError, BindingVersion, + CapabilityFinalizationSeal, FederatedAuthorityAdapter, FederatedPolicyStamp, FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, + VerifiedOwnerAdmission, }; const MAX_OPAQUE_ID_BYTES: usize = 256; @@ -99,15 +104,17 @@ impl fmt::Debug for CapabilitySet { /// Opaque identifier for the server-resolved authorization profile. /// -/// Production construction is intentionally unavailable until a sealed policy -/// adapter can prove that the profile came from server-owned configuration. +/// Transport input and provider responses must never select this identifier. +/// Production callers construct it only while loading trusted server +/// configuration, before request handling begins. #[derive(Clone, PartialEq, Eq, Hash)] pub struct AuthorizationProfileId(String); impl AuthorizationProfileId { /// Preserve a non-empty, bounded profile identifier exactly as configured. - #[cfg(test)] - pub(crate) fn new(value: impl Into) -> Result { + pub fn from_server_configuration( + value: impl Into, + ) -> Result { let value = value.into(); if value.is_empty() { return Err(ProviderContractError::EmptyProfileId); @@ -117,7 +124,6 @@ impl AuthorizationProfileId { } Ok(Self(value)) } - /// Exact profile identifier for provider routing. pub fn as_str(&self) -> &str { &self.0 @@ -224,26 +230,30 @@ pub struct AuthorizationRequest { proof_method: AuthMethod, authority: AuthorizationAuthority, principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, federated_policy: FederatedPolicyStamp, - profile_id: AuthorizationProfileId, requested_capabilities: CapabilitySet, correlation_id: Uuid, decision_source: DecisionSource, - evidence_valid_until: Option, + evidence_valid_from: u64, + evidence_valid_until: u64, } impl AuthorizationRequest { - /// Build a direct request from a current key-attested assertion and Nostr proof. + /// Build a direct request from a current assertion and Nostr proof. /// - /// An unattested assertion is intentionally insufficient in this phase. A - /// future trust-on-first-use path must also consume authoritative active or - /// atomic-enrollment binding evidence before it can produce direct authority. + /// A matching key claim is preserved for later enrollment, but its absence + /// does not block provider evaluation: an existing active binding can still + /// authorize. Any atomic attested-key enrollment fails closed later unless + /// this assertion carried the exact authenticated key. /// `now_unix_seconds` must come from the server clock. pub fn direct( proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion, - federated_policy: &ResolvedFederatedPolicy, - profile_id: AuthorizationProfileId, + federated_policy: ResolvedFederatedPolicy, requested_capabilities: CapabilitySet, correlation_id: Uuid, now_unix_seconds: u64, @@ -252,7 +262,7 @@ impl AuthorizationRequest { return Err(ProviderContractError::InvalidCorrelationId); } validate_federated_policy( - federated_policy, + &federated_policy, proof.authorization_domain(), correlation_id, now_unix_seconds, @@ -266,10 +276,10 @@ impl AuthorizationRequest { if proof.authorized_transport() != assertion.authorized_transport() { return Err(ProviderContractError::TransportMismatch); } - let Some(key_attestation) = assertion.key_attestation() else { - return Err(ProviderContractError::MissingKeyAttestation); - }; - if key_attestation.pubkey() != proof.actor_pubkey() { + if assertion + .key_attestation() + .is_some_and(|attestation| attestation.pubkey() != proof.actor_pubkey()) + { return Err(ProviderContractError::KeyAttestationMismatch); } if assertion @@ -281,6 +291,18 @@ impl AuthorizationRequest { if assertion.expires_at().is_expired_at(now_unix_seconds) { return Err(ProviderContractError::AssertionExpired); } + let evidence_valid_from = + assertion + .not_before() + .map_or(federated_policy.stamp().effective_from(), |bound| { + bound + .unix_seconds() + .max(federated_policy.stamp().effective_from()) + }); + let evidence_valid_until = assertion + .expires_at() + .unix_seconds() + .min(federated_policy.stamp().effective_until()); Ok(Self { authorization_domain: proof.authorization_domain(), transport: proof.authorized_transport(), @@ -288,17 +310,16 @@ impl AuthorizationRequest { proof_method: proof.proof_method(), authority: AuthorizationAuthority::Direct, principal: assertion.principal().clone(), - federated_policy: federated_policy.stamp().clone(), - profile_id, + key_attested: assertion.key_attestation().is_some(), + assertion_transport: Some(assertion.transport()), + assertion_not_before: assertion.not_before().map(|bound| bound.unix_seconds()), + assertion_expires_at: Some(assertion.expires_at().unix_seconds()), + federated_policy: federated_policy.into_stamp(), requested_capabilities, correlation_id, decision_source: DecisionSource::DirectAssertion, - evidence_valid_until: Some( - assertion - .expires_at() - .unix_seconds() - .min(federated_policy.stamp().effective_until()), - ), + evidence_valid_from, + evidence_valid_until, }) } @@ -307,11 +328,10 @@ impl AuthorizationRequest { /// This path does not require an owner assertion. The provider resolves /// current admission for the exact issuer-qualified bound owner. /// `now_unix_seconds` must come from the server clock. - pub fn delegated( + pub(crate) fn delegated( proof: &VerifiedNostrProof, - owner: &AuthoritativeBindingEvidence, - federated_policy: &ResolvedFederatedPolicy, - profile_id: AuthorizationProfileId, + owner: &AuthoritativeBindingResolution, + federated_policy: ResolvedFederatedPolicy, requested_capabilities: CapabilitySet, correlation_id: Uuid, now_unix_seconds: u64, @@ -320,7 +340,7 @@ impl AuthorizationRequest { return Err(ProviderContractError::InvalidCorrelationId); } validate_federated_policy( - federated_policy, + &federated_policy, proof.authorization_domain(), correlation_id, now_unix_seconds, @@ -328,6 +348,9 @@ impl AuthorizationRequest { if proof.authorization_domain() != owner.authorization_domain() { return Err(ProviderContractError::AuthorizationDomainMismatch); } + if !owner.is_existing_active() { + return Err(ProviderContractError::DelegatedBindingNotExistingActive); + } let Some(delegation) = proof.verified_delegation() else { return Err(ProviderContractError::DelegationRequired); }; @@ -346,6 +369,7 @@ impl AuthorizationRequest { { return Err(ProviderContractError::BindingExpired); } + let evidence_valid_from = federated_policy.stamp().effective_from(); let mut evidence_valid_until = federated_policy.stamp().effective_until(); if let Some(delegation) = delegation.expires_at() { evidence_valid_until = evidence_valid_until.min(delegation.unix_seconds()); @@ -364,12 +388,16 @@ impl AuthorizationRequest { binding_version: owner.binding_version(), }, principal: owner.principal().clone(), - federated_policy: federated_policy.stamp().clone(), - profile_id, + key_attested: false, + assertion_transport: None, + assertion_not_before: None, + assertion_expires_at: None, + federated_policy: federated_policy.into_stamp(), requested_capabilities, correlation_id, decision_source: DecisionSource::DelegatedOwnerBinding, - evidence_valid_until: Some(evidence_valid_until), + evidence_valid_from, + evidence_valid_until, }) } @@ -408,11 +436,6 @@ impl AuthorizationRequest { &self.federated_policy } - /// Server-resolved provider profile. - pub const fn profile_id(&self) -> &AuthorizationProfileId { - &self.profile_id - } - /// Portable capabilities requested for this decision. pub const fn requested_capabilities(&self) -> &CapabilitySet { &self.requested_capabilities @@ -428,9 +451,13 @@ impl AuthorizationRequest { self.decision_source } - /// Earliest validity bound supplied by verified assertion, owner-binding, - /// delegation, or authoritative enrollment-policy evidence. - pub const fn evidence_valid_until(&self) -> Option { + /// Inclusive joined lower validity bound supplied by verified evidence. + pub const fn evidence_valid_from(&self) -> u64 { + self.evidence_valid_from + } + + /// Exclusive joined upper validity bound supplied by verified evidence. + pub const fn evidence_valid_until(&self) -> u64 { self.evidence_valid_until } } @@ -445,16 +472,86 @@ impl fmt::Debug for AuthorizationRequest { .field("proof_method", &"[redacted]") .field("authority", &"[redacted]") .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") .field("federated_policy", &"[redacted]") - .field("profile_id", &"[redacted]") .field("requested_capabilities", &"[redacted]") .field("correlation_id", &"[redacted]") .field("decision_source", &"[redacted]") + .field("evidence_valid_from", &"[redacted]") .field("evidence_valid_until", &"[redacted]") .finish() } } +/// Resolve an existing delegated owner and build a provider request. +/// +/// The policy is consumed, the owner lifecycle outcome is produced only by the +/// configured authority adapter, and server time is sampled again after the +/// binding read. This path cannot enroll or relabel an owner binding. +#[allow(clippy::too_many_arguments)] +async fn resolve_delegated_authorization_request( + adapter: &A, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + clock: &dyn AuthorizationClock, +) -> Result> { + let Some(before_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + before_io, + )?; + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired.into()); + }; + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(before_io)) + { + return Err(ProviderContractError::DelegationExpired.into()); + } + let effective_from = federated_policy.stamp().effective_from(); + let effective_until = + delegation + .expires_at() + .map_or(federated_policy.stamp().effective_until(), |bound| { + bound + .unix_seconds() + .min(federated_policy.stamp().effective_until()) + }); + let owner = resolve_existing_binding( + adapter, + &federated_policy, + principal, + delegation.owner_pubkey(), + effective_from, + effective_until, + before_io, + ) + .await?; + let Some(after_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + AuthorizationRequest::delegated( + proof, + &owner, + federated_policy, + requested_capabilities, + correlation_id, + after_io, + ) + .map_err(ProviderAuthorizationError::from) +} + /// Provider-produced allowed capability data before crate-owned validation. #[derive(PartialEq, Eq)] pub struct ProviderAllow { @@ -534,6 +631,8 @@ pub enum AuthorizationDenialReason { FutureDecision, /// Verified identity evidence expired before the decision became effective. IdentityEvidenceExpired, + /// Trusted time moved before the joined evidence interval. + IdentityEvidenceNotYetValid, /// The bound federated enrollment policy was not current after provider I/O. FederatedPolicyNotCurrent, } @@ -551,6 +650,7 @@ impl AuthorizationDenialReason { Self::IdentityEvidenceExpired => "authorization_provider_deny_007", Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009", + Self::IdentityEvidenceNotYetValid => "authorization_provider_deny_010", } } } @@ -754,6 +854,13 @@ pub type AuthorizationProviderFuture<'a> = /// Object-safe, asynchronous, provider-neutral authorization policy. pub trait AuthorizationProvider: Send + Sync { + /// Profile fixed by trusted server configuration for this provider. + /// + /// Request and transport data must never influence this value. Returning it + /// from the configured provider keeps route selection out of + /// [`AuthorizationRequest`]. + fn profile_id(&self) -> AuthorizationProfileId; + /// Evaluate one request without mutating identity or community state. /// /// Implementations must yield while waiting for I/O and must not block the @@ -795,14 +902,99 @@ impl fmt::Debug for ProviderAllowReason { } } +/// Fail-closed error while joining provider and authoritative state. +#[derive(PartialEq, Eq)] +pub enum ProviderAuthorizationError { + /// Trusted server time was unavailable. + ClockUnavailable, + /// Provider evidence or snapshot shape violated the contract. + Contract(ProviderContractError), + /// Current policy or binding resolution failed. + Authority(AuthorityAdapterError), + /// Final immutable context validation failed. + Context(AuthContextError), +} + +/// Server-configured provider, authority adapter, and trusted clock. +/// +/// Construct exactly one runtime during server startup and inject it into +/// request handling. Every capability snapshot is privately bound to the +/// runtime that performed provider I/O, so a caller cannot substitute another +/// adapter or clock during finalization. +pub struct AuthorizationRuntime { + authority: A, + clock: C, + provider: P, + binding: Uuid, +} + +impl AuthorizationRuntime { + /// Bind trusted startup configuration into one authorization runtime. + pub fn from_server_configuration(authority: A, clock: C, provider: P) -> Self { + Self { + authority, + clock, + provider, + binding: Uuid::new_v4(), + } + } +} + +impl fmt::Debug for AuthorizationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRuntime") + .field("authority", &"[redacted]") + .field("clock", &"[redacted]") + .field("provider", &"[redacted]") + .field("binding", &"[redacted]") + .finish() + } +} + +impl From for ProviderAuthorizationError { + fn from(error: ProviderContractError) -> Self { + Self::Contract(error) + } +} + +impl From> for ProviderAuthorizationError { + fn from(error: AuthorityAdapterError) -> Self { + Self::Authority(error) + } +} + +impl From for ProviderAuthorizationError { + fn from(error: AuthContextError) -> Self { + Self::Context(error) + } +} + +impl fmt::Debug for ProviderAuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let variant = match self { + Self::ClockUnavailable => "ClockUnavailable", + Self::Contract(_) => "Contract", + Self::Authority(_) => "Authority", + Self::Context(_) => "Context", + }; + formatter + .debug_struct("ProviderAuthorizationError") + .field("variant", &variant) + .field("detail", &"[redacted]") + .finish() + } +} + /// Validated, request-scoped capability snapshot. /// /// This type has no public constructor, default, or deserialization path. Only -/// [`resolve_authorization`] can create it after checking the provider response. -/// The move-only snapshot is the private finalizer evidence for a later phase; +/// [`AuthorizationRuntime::resolve_authorization`] can create it after checking +/// the provider response. The move-only snapshot is private finalizer evidence; /// callers may inspect its bounded metadata but cannot recreate trusted state. #[derive(PartialEq, Eq)] pub struct CapabilitySnapshot { + runtime_binding: Uuid, authorization_domain: CommunityId, transport: AuthTransport, actor_pubkey: PublicKey, @@ -811,12 +1003,17 @@ pub struct CapabilitySnapshot { binding_version: Option, proof_method: AuthMethod, principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, federated_policy: FederatedPolicyStamp, profile_id: AuthorizationProfileId, capabilities: CapabilitySet, policy_version: PolicyVersion, issued_at: u64, fresh_until: u64, + effective_from: u64, effective_until: u64, decision_source: DecisionSource, correlation_id: Uuid, @@ -824,6 +1021,13 @@ pub struct CapabilitySnapshot { } impl CapabilitySnapshot { + fn validate_runtime(&self, runtime_binding: Uuid) -> Result<(), ProviderContractError> { + if self.runtime_binding != runtime_binding { + return Err(ProviderContractError::AuthorizationRuntimeMismatch); + } + Ok(()) + } + /// Authorization domain for this decision. pub const fn authorization_domain(&self) -> CommunityId { self.authorization_domain @@ -872,10 +1076,10 @@ impl CapabilitySnapshot { &self.federated_policy } - /// Whether a freshly resolved O3 policy is exactly the policy used here. + /// Whether a freshly resolved authoritative policy is exactly the policy used here. /// - /// O3 must additionally compare this stamp with current authoritative state - /// and use its epoch as an atomic enrollment precondition. + /// The authority adapter must additionally compare this stamp with current + /// state and use its epoch as an atomic enrollment precondition. pub fn is_bound_to_federated_policy(&self, policy: &ResolvedFederatedPolicy) -> bool { self.federated_policy == *policy.stamp() } @@ -905,7 +1109,12 @@ impl CapabilitySnapshot { self.fresh_until } - /// Earliest effective bound across provider and identity evidence. + /// Inclusive joined lower bound across provider and identity evidence. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive joined upper bound across provider and identity evidence. pub const fn effective_until(&self) -> u64 { self.effective_until } @@ -924,12 +1133,300 @@ impl CapabilitySnapshot { pub const fn reason(&self) -> ProviderAllowReason { self.reason } + + /// Consume a direct capability decision and finalize authoritative context. + /// + /// The current enrollment policy is reread after provider I/O, then the + /// exact assertion, policy, capability interval, and authenticated key are + /// supplied to the configured binding adapter. Server time is resampled + /// after each awaited authority operation. + async fn finalize_direct_v1( + self, + adapter: &A, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + clock: &dyn AuthorizationClock, + ) -> Result> { + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + self.validate_direct_shape(&input, &assertion, before_policy)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_direct_shape(&input, &assertion, after_policy)?; + self.validate_current_policy(&policy)?; + + let binding = resolve_direct_binding( + adapter, + &policy, + self.principal.clone(), + self.actor_pubkey, + self.key_attested, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_direct_shape(&input, &assertion, after_binding)?; + self.validate_current_policy(&policy)?; + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Direct { binding, assertion }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + /// Consume a delegated capability decision and finalize authoritative context. + /// + /// The bound owner is reread without enrollment after a fresh exact policy + /// read. Binding identifier and version must match the provider decision; + /// provider admission is derived from this snapshot's joined interval. + async fn finalize_delegated_v1( + self, + adapter: &A, + input: AuthContextInput, + clock: &dyn AuthorizationClock, + ) -> Result> { + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + let owner_pubkey = self.validate_delegated_shape(&input)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + + let owner = resolve_existing_binding( + adapter, + &policy, + self.principal.clone(), + owner_pubkey, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + if Some(owner.binding_id()) != self.binding_id + || Some(owner.binding_version()) != self.binding_version + || owner + .expires_at() + .is_some_and(|bound| bound.unix_seconds() < self.effective_until) + { + return Err(ProviderContractError::CapabilityBindingChanged.into()); + } + let admission = VerifiedOwnerAdmission::new( + self.authorization_domain, + self.principal, + AdmissionExpiry::new(self.effective_until)?, + ); + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Delegated { owner, admission }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + fn validate_common( + &self, + input: &AuthContextInput, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if input.authorization_domain() != self.authorization_domain + || input.correlation_id() != self.correlation_id + || input.transport() != self.transport + || input.proof_method() != self.proof_method + || input.actor_pubkey() != self.actor_pubkey + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if now_unix_seconds < self.effective_from { + return Err(ProviderContractError::CapabilityNotYetEffective); + } + if now_unix_seconds >= self.effective_until { + return Err(ProviderContractError::CapabilityExpired); + } + Ok(()) + } + + fn validate_direct_shape( + &self, + input: &AuthContextInput, + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if self.decision_source != DecisionSource::DirectAssertion + || self.owner_pubkey.is_some() + || self.binding_id.is_some() + || self.binding_version.is_some() + || input.verified_owner_pubkey().is_some() + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + if assertion.authorization_domain() != self.authorization_domain + || assertion.authorized_transport() != self.transport + || Some(assertion.transport()) != self.assertion_transport + || assertion.not_before().map(|bound| bound.unix_seconds()) != self.assertion_not_before + || Some(assertion.expires_at().unix_seconds()) != self.assertion_expires_at + || assertion.key_attestation().is_some() != self.key_attested + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if assertion.principal() != &self.principal { + return Err(ProviderContractError::CapabilityPrincipalMismatch); + } + if assertion + .key_attestation() + .is_some_and(|attestation| attestation.pubkey() != self.actor_pubkey) + { + return Err(ProviderContractError::KeyAttestationMismatch); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(ProviderContractError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::AssertionExpired); + } + Ok(()) + } + + fn validate_delegated_shape( + &self, + input: &AuthContextInput, + ) -> Result { + let Some(owner_pubkey) = self.owner_pubkey else { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + }; + if self.decision_source != DecisionSource::DelegatedOwnerBinding + || self.binding_id.is_none() + || self.binding_version.is_none() + || self.key_attested + || self.assertion_transport.is_some() + || self.assertion_not_before.is_some() + || self.assertion_expires_at.is_some() + || input.verified_owner_pubkey() != Some(owner_pubkey) + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + Ok(owner_pubkey) + } + + fn validate_current_policy( + &self, + policy: &ResolvedFederatedPolicy, + ) -> Result<(), ProviderContractError> { + if !self.is_bound_to_federated_policy(policy) { + return Err(ProviderContractError::FederatedPolicyChanged); + } + Ok(()) + } +} + +fn finalization_time( + clock: &dyn AuthorizationClock, +) -> Result> { + clock + .now_unix_seconds() + .ok_or(ProviderAuthorizationError::ClockUnavailable) +} + +impl AuthorizationRuntime +where + A: FederatedAuthorityAdapter, + C: AuthorizationClock, + P: AuthorizationProvider, +{ + /// Resolve a provider decision using this runtime's fixed provider and clock. + pub async fn resolve_authorization( + &self, + request: &AuthorizationRequest, + timeout: ProviderTimeout, + ) -> AuthorizationOutcome { + resolve_authorization(&self.provider, request, &self.clock, timeout, self.binding).await + } + + /// Resolve an existing delegated owner and build a provider request. + pub async fn resolve_delegated_authorization_request( + &self, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result> { + resolve_delegated_authorization_request( + &self.authority, + proof, + principal, + federated_policy, + requested_capabilities, + correlation_id, + &self.clock, + ) + .await + } + + /// Consume a runtime-bound direct capability snapshot. + pub async fn finalize_direct_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_direct_v1(&self.authority, input, assertion, &self.clock) + .await + } + + /// Consume a runtime-bound delegated capability snapshot. + pub async fn finalize_delegated_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_delegated_v1(&self.authority, input, &self.clock) + .await + } } impl fmt::Debug for CapabilitySnapshot { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CapabilitySnapshot") + .field("runtime_binding", &"[redacted]") .field("authorization_domain", &"[redacted]") .field("transport", &"[redacted]") .field("actor_pubkey", &"[redacted]") @@ -938,12 +1435,17 @@ impl fmt::Debug for CapabilitySnapshot { .field("binding_version", &"[redacted]") .field("proof_method", &"[redacted]") .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") .field("federated_policy", &"[redacted]") .field("profile_id", &"[redacted]") .field("capabilities", &"[redacted]") .field("policy_version", &"[redacted]") .field("issued_at", &"[redacted]") .field("fresh_until", &"[redacted]") + .field("effective_from", &"[redacted]") .field("effective_until", &"[redacted]") .field("decision_source", &"[redacted]") .field("correlation_id", &"[redacted]") @@ -981,12 +1483,14 @@ impl fmt::Debug for AuthorizationOutcome { /// completes, an allowed decision is checked against exactly one fresh sample. /// Provider freshness and all effective evidence bounds use that same value; /// callers must not precompute and pass a decision-start timestamp. -pub async fn resolve_authorization( +async fn resolve_authorization( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, clock: &dyn AuthorizationClock, timeout: ProviderTimeout, + runtime_binding: Uuid, ) -> AuthorizationOutcome { + let configured_profile = provider.profile_id(); let decision = match tokio::time::timeout(timeout.duration(), provider.authorize(request)).await { Ok(decision) => decision, @@ -1025,7 +1529,7 @@ pub async fn resolve_authorization( if allow.principal != request.principal { return deny(AuthorizationDenialReason::PrincipalMismatch); } - if allow.profile_id != request.profile_id { + if allow.profile_id != configured_profile { return deny(AuthorizationDenialReason::AuthorizationProfileMismatch); } if allow.issued_at > now_unix_seconds { @@ -1041,14 +1545,17 @@ pub async fn resolve_authorization( return deny(AuthorizationDenialReason::MissingCapability); } - let effective_until = request - .evidence_valid_until - .map_or(allow.fresh_until, |bound| bound.min(allow.fresh_until)); - if effective_until <= now_unix_seconds { + let effective_from = request.evidence_valid_from.max(allow.issued_at); + let effective_until = request.evidence_valid_until.min(allow.fresh_until); + if now_unix_seconds < effective_from { + return deny(AuthorizationDenialReason::IdentityEvidenceNotYetValid); + } + if effective_until <= now_unix_seconds || effective_from >= effective_until { return deny(AuthorizationDenialReason::IdentityEvidenceExpired); } AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { + runtime_binding, authorization_domain: allow.authorization_domain, transport: request.transport, actor_pubkey: request.actor_pubkey, @@ -1068,12 +1575,17 @@ pub async fn resolve_authorization( }, proof_method: request.proof_method, principal: allow.principal, + key_attested: request.key_attested, + assertion_transport: request.assertion_transport, + assertion_not_before: request.assertion_not_before, + assertion_expires_at: request.assertion_expires_at, federated_policy: request.federated_policy.clone(), profile_id: allow.profile_id, capabilities: request.requested_capabilities.clone(), policy_version: allow.policy_version, issued_at: allow.issued_at, fresh_until: allow.fresh_until, + effective_from, effective_until, decision_source: request.decision_source, correlation_id: request.correlation_id, @@ -1140,9 +1652,9 @@ pub enum ProviderContractError { /// Assertion key attestation named another actor. #[error("provider request key attestation does not match the Nostr actor")] KeyAttestationMismatch, - /// Direct assertion omitted a key attestation. - #[error("direct provider request requires key attestation")] - MissingKeyAttestation, + /// Delegated owner resolution did not represent an already-active binding. + #[error("delegated provider request requires an existing active binding")] + DelegatedBindingNotExistingActive, /// Delegated request lacked verified delegation. #[error("delegated provider request requires verified delegation")] DelegationRequired, @@ -1167,6 +1679,30 @@ pub enum ProviderContractError { /// Enrollment policy was expired at server time. #[error("provider request enrollment policy has expired")] FederatedPolicyExpired, + /// A capability snapshot was used before its joined effective interval. + #[error("provider capability snapshot is not yet effective")] + CapabilityNotYetEffective, + /// A capability snapshot reached its joined exclusive expiry. + #[error("provider capability snapshot has expired")] + CapabilityExpired, + /// A capability snapshot did not match immutable request context. + #[error("provider capability snapshot does not match authorization context")] + CapabilityContextMismatch, + /// A capability snapshot did not match direct or delegated authority shape. + #[error("provider capability snapshot authority shape is invalid")] + CapabilityAuthorityMismatch, + /// A capability snapshot did not match the sealed assertion principal. + #[error("provider capability snapshot principal is invalid")] + CapabilityPrincipalMismatch, + /// The delegated binding identifier, version, or expiry changed. + #[error("provider capability snapshot binding is no longer current")] + CapabilityBindingChanged, + /// Fresh authoritative policy lineage differed from the capability snapshot. + #[error("provider capability snapshot enrollment policy changed")] + FederatedPolicyChanged, + /// A capability snapshot was presented to a different configured runtime. + #[error("provider capability snapshot does not belong to this authorization runtime")] + AuthorizationRuntimeMismatch, } impl ProviderContractError { @@ -1192,13 +1728,21 @@ impl ProviderContractError { Self::DelegationExpired => "authorization_provider_contract_017", Self::InvalidProviderTimeout => "authorization_provider_contract_018", Self::InvalidCorrelationId => "authorization_provider_contract_019", - Self::MissingKeyAttestation => "authorization_provider_contract_020", + Self::DelegatedBindingNotExistingActive => "authorization_provider_contract_020", Self::FreshnessWindowTooLong => "authorization_provider_contract_021", Self::BindingExpired => "authorization_provider_contract_022", Self::FederatedPolicyDomainMismatch => "authorization_provider_contract_023", Self::FederatedPolicyCorrelationMismatch => "authorization_provider_contract_024", Self::FederatedPolicyNotYetEffective => "authorization_provider_contract_025", Self::FederatedPolicyExpired => "authorization_provider_contract_026", + Self::CapabilityNotYetEffective => "authorization_provider_contract_027", + Self::CapabilityExpired => "authorization_provider_contract_028", + Self::CapabilityContextMismatch => "authorization_provider_contract_029", + Self::CapabilityAuthorityMismatch => "authorization_provider_contract_030", + Self::CapabilityPrincipalMismatch => "authorization_provider_contract_031", + Self::CapabilityBindingChanged => "authorization_provider_contract_032", + Self::FederatedPolicyChanged => "authorization_provider_contract_033", + Self::AuthorizationRuntimeMismatch => "authorization_provider_contract_034", } } } diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index 99418b354c..d8d5af3aa3 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -12,9 +12,15 @@ use nostr::Keys; use super::*; use crate::context::{ AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, - AuthoritativeBindingEvidence, BindingExpiry, BindingSource, BindingVersion, DelegationExpiry, - EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, - VerifiedKeyAttestation, VerifiedTransportDelegation, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource, + BindingVersion, DelegationExpiry, EnrollmentMode, FederatedIdentityRequirement, + FederatedPolicyStamp, ResolvedFederatedPolicy, VerifiedKeyAttestation, + VerifiedTransportDelegation, +}; +use crate::{ + AuthorityAdapterFuture, AuthorizedCommunityAccess, BindingResolutionRequest, + CurrentPolicyRequest, CurrentPolicyResolutionSink, DirectBindingResolutionSink, + ExistingBindingResolutionSink, Scope, }; const NOW: u64 = 100; @@ -29,7 +35,8 @@ fn principal() -> FederatedPrincipal { } fn profile() -> AuthorizationProfileId { - AuthorizationProfileId::new("profile-1").expect("synthetic profile is valid") + AuthorizationProfileId::from_server_configuration("profile-1") + .expect("synthetic profile is valid") } fn policy_version(value: &str) -> PolicyVersion { @@ -111,13 +118,185 @@ impl AuthorizationClock for TestClock { } } +#[derive(Clone)] +struct TestAuthorityAdapter { + policy_epoch: u64, + enrollment_mode: EnrollmentMode, + enroll_direct: bool, + policy_reads: Arc, + direct_calls: Arc, + existing_calls: Arc, + committed_enrollments: Arc, +} + +impl TestAuthorityAdapter { + fn new(policy_epoch: u64, enrollment_mode: EnrollmentMode, enroll_direct: bool) -> Self { + Self { + policy_epoch, + enrollment_mode, + enroll_direct, + policy_reads: Arc::new(AtomicUsize::new(0)), + direct_calls: Arc::new(AtomicUsize::new(0)), + existing_calls: Arc::new(AtomicUsize::new(0)), + committed_enrollments: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl FederatedAuthorityAdapter for TestAuthorityAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.policy_reads.fetch_add(1, Ordering::SeqCst); + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + self.policy_epoch, + FederatedIdentityRequirement::Required(self.enrollment_mode), + 1, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.direct_calls.fetch_add(1, Ordering::SeqCst); + let result = if self.enroll_direct { + let source = match self.enrollment_mode { + EnrollmentMode::AttestedKey => BindingSource::AttestedKey, + EnrollmentMode::Tofu => BindingSource::Tofu, + EnrollmentMode::Provisioned => BindingSource::Provisioned, + }; + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + source, + ) + } else { + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + }; + let resolution = result.map_err(AuthorityAdapterError::from)?; + if self.enroll_direct { + self.committed_enrollments.fetch_add(1, Ordering::SeqCst); + } + Ok(resolution) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.existing_calls.fetch_add(1, Ordering::SeqCst); + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .map_err(AuthorityAdapterError::from) + }) + } +} + +fn direct_evidence( + actor: &Keys, + enrollment_mode: EnrollmentMode, + key_attested: bool, +) -> ( + VerifiedNostrProof, + VerifiedFederatedAssertion, + AuthorizationRequest, +) { + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + key_attested.then(|| VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(90)), + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + federated_policy_with(1, Uuid::from_u128(20), 1, enrollment_mode, 1, 200), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic direct request is valid"); + (proof, assertion, request) +} + +fn finalization_input(proof: VerifiedNostrProof) -> AuthContextInput { + AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(1), Scope::all_known(), None), + ) +} + async fn resolve_at( provider: &dyn AuthorizationProvider, request: &AuthorizationRequest, now: u64, timeout: ProviderTimeout, ) -> AuthorizationOutcome { - resolve_authorization(provider, request, &TestClock::at(now), timeout).await + resolve_authorization( + provider, + request, + &TestClock::at(now), + timeout, + Uuid::from_u128(99), + ) + .await } fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { @@ -165,7 +344,7 @@ fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { } } -fn all_contract_errors() -> [ProviderContractError; 26] { +fn all_contract_errors() -> [ProviderContractError; 34] { [ ProviderContractError::EmptyCapabilitySet, ProviderContractError::EmptyProfileId, @@ -184,7 +363,7 @@ fn all_contract_errors() -> [ProviderContractError; 26] { ProviderContractError::AssertionNotYetValid, ProviderContractError::AssertionExpired, ProviderContractError::KeyAttestationMismatch, - ProviderContractError::MissingKeyAttestation, + ProviderContractError::DelegatedBindingNotExistingActive, ProviderContractError::DelegationRequired, ProviderContractError::DelegatedOwnerMismatch, ProviderContractError::DelegationExpired, @@ -193,6 +372,14 @@ fn all_contract_errors() -> [ProviderContractError; 26] { ProviderContractError::FederatedPolicyCorrelationMismatch, ProviderContractError::FederatedPolicyNotYetEffective, ProviderContractError::FederatedPolicyExpired, + ProviderContractError::CapabilityNotYetEffective, + ProviderContractError::CapabilityExpired, + ProviderContractError::CapabilityContextMismatch, + ProviderContractError::CapabilityAuthorityMismatch, + ProviderContractError::CapabilityPrincipalMismatch, + ProviderContractError::CapabilityBindingChanged, + ProviderContractError::FederatedPolicyChanged, + ProviderContractError::AuthorizationRuntimeMismatch, ] } @@ -219,8 +406,7 @@ fn direct_request_for_transport( AuthorizationRequest::direct( &proof, &assertion, - &federated_policy(), - profile(), + federated_policy(), requested, Uuid::from_u128(20), NOW, @@ -251,11 +437,11 @@ fn direct_request(actor: &Keys) -> AuthorizationRequest { ) } -fn existing_binding(owner: &Keys) -> AuthoritativeBindingEvidence { +fn existing_binding(owner: &Keys) -> AuthoritativeBindingResolution { existing_binding_in(1, owner) } -fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingEvidence { +fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingResolution { existing_binding_with_expiry_in(domain_value, owner, None) } @@ -263,8 +449,8 @@ fn existing_binding_with_expiry_in( domain_value: u128, owner: &Keys, expires_at: Option, -) -> AuthoritativeBindingEvidence { - AuthoritativeBindingEvidence::new( +) -> AuthoritativeBindingResolution { + let evidence = AuthoritativeBindingEvidence::new( domain(domain_value), Uuid::from_u128(10), principal(), @@ -274,7 +460,8 @@ fn existing_binding_with_expiry_in( .map(|expiry| BindingExpiry::new(expiry).expect("synthetic binding expiry is valid")), BindingSource::Provisioned, ) - .expect("synthetic binding is valid") + .expect("synthetic binding is valid"); + AuthoritativeBindingResolution::existing_active(evidence) } fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProof { @@ -299,8 +486,7 @@ fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRe AuthorizationRequest::delegated( &proof, &existing_binding(owner), - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -319,7 +505,7 @@ fn allow_for( ProviderAllow::new( request.authorization_domain(), request.principal().clone(), - request.profile_id().clone(), + profile(), granted, policy_version(version), issued_at, @@ -342,6 +528,10 @@ impl FakeProvider { } impl AuthorizationProvider for FakeProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + fn authorize<'a>( &'a self, _request: &'a AuthorizationRequest, @@ -356,6 +546,29 @@ impl AuthorizationProvider for FakeProvider { } } +struct EchoAllowProvider; + +impl AuthorizationProvider for EchoAllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + allow_for( + request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + ) + }) + } +} + struct AdvancingProvider { decision: Mutex>, clock: TestClock, @@ -384,6 +597,10 @@ impl AdvancingProvider { } impl AuthorizationProvider for AdvancingProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + fn authorize<'a>( &'a self, _request: &'a AuthorizationRequest, @@ -420,6 +637,10 @@ impl Drop for CancellationMarker { } impl AuthorizationProvider for PendingProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + fn authorize<'a>( &'a self, _request: &'a AuthorizationRequest, @@ -462,7 +683,7 @@ async fn current_allow_returns_request_scoped_snapshot() { assert_eq!(snapshot.binding_version(), None); assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); assert_eq!(snapshot.principal(), request.principal()); - assert_eq!(snapshot.profile_id(), request.profile_id()); + assert_eq!(snapshot.profile_id(), &profile()); assert_eq!( snapshot.capabilities().as_slice(), &[AuthorizationCapability::CommunityRead] @@ -476,6 +697,212 @@ async fn current_allow_returns_request_scoped_snapshot() { assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy); } +#[tokio::test] +async fn runtime_finalizer_allows_existing_binding_without_key_claim() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Provisioned, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + + let context = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect("an existing active binding does not require a later key claim"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::ExistingBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn attested_enrollment_without_sealed_key_claim_fails_before_commit() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::AttestedKey, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::AttestedKey, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("provider evaluation may allow before binding resolution"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("attested-key enrollment requires the sealed matching key claim"); + + assert_eq!( + error, + ProviderAuthorizationError::Authority(AuthorityAdapterError::Contract( + AuthContextError::KeyAttestationRequired + )) + ); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn fresh_policy_epoch_drift_blocks_binding_mutation() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(2, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("request-time policy is current during provider evaluation"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("fresh authoritative policy drift must fail before binding I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::FederatedPolicyChanged) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn runtime_binding_rejects_forged_adapter_and_clock_substitution() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let genuine_provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let genuine_runtime = AuthorizationRuntime::from_server_configuration( + TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true), + TestClock::at(NOW), + genuine_provider, + ); + let AuthorizationOutcome::Allow(snapshot) = genuine_runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("genuine runtime must issue the capability snapshot"); + }; + + let forged_authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let forged_runtime = AuthorizationRuntime::from_server_configuration( + forged_authority.clone(), + TestClock::at(NOW), + FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))), + ); + let error = forged_runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("a legitimate snapshot cannot be spliced to a caller-selected runtime"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::AuthorizationRuntimeMismatch) + ); + assert_eq!(forged_authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(forged_authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!( + forged_authority + .committed_enrollments + .load(Ordering::SeqCst), + 0 + ); +} + +#[tokio::test] +async fn runtime_resolves_and_refinalizes_existing_delegated_owner() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 180); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + EchoAllowProvider, + ); + let request = runtime + .resolve_delegated_authorization_request( + &proof, + principal(), + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + ) + .await + .expect("the configured adapter resolves an existing delegated owner"); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("the current owner admission must allow"); + }; + + let context = runtime + .finalize_delegated_v1(*snapshot, finalization_input(proof)) + .await + .expect("the owner is reread and finalized without enrollment"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::DelegatedOwnerBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.existing_calls.load(Ordering::SeqCst), 2); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn allowed_snapshot_preserves_every_requested_transport_scope() { let transports = [ @@ -598,8 +1025,14 @@ async fn provider_freshness_is_evaluated_after_async_io() { 105, ); - let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, &clock, provider_timeout()).await + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await else { panic!("a provider decision stale after I/O must deny"); }; @@ -638,8 +1071,7 @@ async fn federated_policy_expiry_is_evaluated_after_async_io() { let request = AuthorizationRequest::direct( &proof, &assertion, - &policy, - profile(), + policy, capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -658,8 +1090,14 @@ async fn federated_policy_expiry_is_evaluated_after_async_io() { 105, ); - let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, &clock, provider_timeout()).await + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await else { panic!("federated enrollment policy expired after I/O must deny"); }; @@ -701,8 +1139,7 @@ async fn snapshot_requires_exact_enrollment_policy_lineage() { let request = AuthorizationRequest::direct( &proof, &assertion, - ¤t_policy, - profile(), + current_policy, capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -723,7 +1160,15 @@ async fn snapshot_requires_exact_enrollment_policy_lineage() { let stale_tofu_policy = federated_policy_with(1, Uuid::from_u128(20), 6, EnrollmentMode::Tofu, 1, 160); - assert!(snapshot.is_bound_to_federated_policy(¤t_policy)); + let current_policy_for_comparison = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + assert!(snapshot.is_bound_to_federated_policy(¤t_policy_for_comparison)); assert!(!snapshot.is_bound_to_federated_policy(&stale_tofu_policy)); assert_eq!(snapshot.policy_version().as_str(), "6"); assert_ne!( @@ -763,8 +1208,7 @@ async fn enrollment_policy_bounds_snapshot_effective_interval() { let request = AuthorizationRequest::direct( &proof, &assertion, - &policy, - profile(), + policy, capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -783,7 +1227,7 @@ async fn enrollment_policy_bounds_snapshot_effective_interval() { panic!("current bounded policy must allow"); }; - assert_eq!(request.evidence_valid_until(), Some(150)); + assert_eq!(request.evidence_valid_until(), 150); assert_eq!(snapshot.effective_until(), 150); } @@ -808,8 +1252,14 @@ async fn identity_evidence_is_evaluated_after_async_io() { 105, ); - let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, &clock, provider_timeout()).await + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await else { panic!("identity evidence expired after I/O must deny"); }; @@ -828,14 +1278,13 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() { let request = AuthorizationRequest::delegated( &proof, &binding, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, ) .expect("owner binding is current at request construction"); - assert_eq!(request.evidence_valid_until(), Some(105)); + assert_eq!(request.evidence_valid_until(), 105); let clock = TestClock::at(NOW); let provider = AdvancingProvider::returning_at( @@ -849,8 +1298,14 @@ async fn owner_binding_expiry_is_evaluated_after_async_io() { clock.clone(), 105, ); - let AuthorizationOutcome::Deny(denial) = - resolve_authorization(&provider, &request, &clock, provider_timeout()).await + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await else { panic!("owner binding expired after provider I/O must deny"); }; @@ -870,8 +1325,7 @@ fn delegated_request_rejects_owner_binding_at_exact_expiry() { let error = AuthorizationRequest::delegated( &proof, &binding, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -898,7 +1352,14 @@ async fn decision_issued_during_async_io_is_not_false_future() { ); assert!(matches!( - resolve_authorization(&provider, &request, &clock, provider_timeout()).await, + resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99) + ) + .await, AuthorizationOutcome::Allow(_) )); } @@ -919,8 +1380,14 @@ async fn clock_failure_after_provider_io_is_unavailable() { clock.clone(), ); - let AuthorizationOutcome::Unavailable(unavailable) = - resolve_authorization(&provider, &request, &clock, provider_timeout()).await + let AuthorizationOutcome::Unavailable(unavailable) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await else { panic!("unavailable decision time must fail closed"); }; @@ -1018,7 +1485,7 @@ async fn domain_principal_and_capability_mismatches_deny() { ProviderAllow::new( domain(2), request.principal().clone(), - request.profile_id().clone(), + profile(), request.requested_capabilities().clone(), policy_version("version-a"), 90, @@ -1041,7 +1508,7 @@ async fn domain_principal_and_capability_mismatches_deny() { domain(1), FederatedPrincipal::new("https://idp.example", "other-subject") .expect("synthetic principal is valid"), - request.profile_id().clone(), + profile(), request.requested_capabilities().clone(), policy_version("version-a"), 90, @@ -1063,7 +1530,8 @@ async fn domain_principal_and_capability_mismatches_deny() { ProviderAllow::new( domain(1), request.principal().clone(), - AuthorizationProfileId::new("other-profile").expect("synthetic profile is valid"), + AuthorizationProfileId::from_server_configuration("other-profile") + .expect("synthetic profile is valid"), request.requested_capabilities().clone(), policy_version("version-a"), 90, @@ -1323,14 +1791,16 @@ fn provider_contract_rejects_malformed_values() { Err(ProviderContractError::EmptyCapabilitySet) ); assert_eq!( - AuthorizationProfileId::new(""), + AuthorizationProfileId::from_server_configuration(""), Err(ProviderContractError::EmptyProfileId) ); assert_eq!( - AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), Err(ProviderContractError::ProfileIdTooLong) ); - assert!(AuthorizationProfileId::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()); + assert!( + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok() + ); assert_eq!( PolicyVersion::new(""), Err(ProviderContractError::EmptyPolicyVersion) @@ -1452,8 +1922,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), NOW, @@ -1464,8 +1933,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &expired, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -1486,8 +1954,7 @@ fn request_construction_rechecks_verified_bounds_and_relationships() { AuthorizationRequest::direct( &proof, &future, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -1516,12 +1983,11 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() { None, AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), ); - let request_with = |policy: &ResolvedFederatedPolicy| { + let request_with = |policy: ResolvedFederatedPolicy| { AuthorizationRequest::direct( &proof, &assertion, policy, - profile(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -1537,7 +2003,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() { 180, ); assert_eq!( - request_with(&wrong_domain), + request_with(wrong_domain), Err(ProviderContractError::FederatedPolicyDomainMismatch) ); let wrong_correlation = federated_policy_with( @@ -1549,7 +2015,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() { 180, ); assert_eq!( - request_with(&wrong_correlation), + request_with(wrong_correlation), Err(ProviderContractError::FederatedPolicyCorrelationMismatch) ); let future = federated_policy_with( @@ -1561,7 +2027,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() { 180, ); assert_eq!( - request_with(&future), + request_with(future), Err(ProviderContractError::FederatedPolicyNotYetEffective) ); let expired = federated_policy_with( @@ -1573,7 +2039,7 @@ fn request_construction_rejects_non_current_or_mismatched_federated_policy() { NOW, ); assert_eq!( - request_with(&expired), + request_with(expired), Err(ProviderContractError::FederatedPolicyExpired) ); } @@ -1608,8 +2074,7 @@ fn request_construction_rejects_mismatched_verified_evidence() { AuthorizationRequest::direct( proof, assertion, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -1637,13 +2102,11 @@ fn request_construction_rejects_mismatched_verified_evidence() { ), Err(ProviderContractError::KeyAttestationMismatch) ); - assert_eq!( - request( - &proof, - &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), - ), - Err(ProviderContractError::MissingKeyAttestation) - ); + assert!(request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ) + .is_ok()); let delegation = VerifiedTransportDelegation::new_unrestricted( owner.public_key(), @@ -1668,12 +2131,11 @@ fn request_construction_rejects_mismatched_verified_evidence() { ); let delegated_request_from = - |proof: &VerifiedNostrProof, binding: &AuthoritativeBindingEvidence| { + |proof: &VerifiedNostrProof, binding: &AuthoritativeBindingResolution| { AuthorizationRequest::delegated( proof, binding, - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::from_u128(20), NOW, @@ -1683,8 +2145,7 @@ fn request_construction_rejects_mismatched_verified_evidence() { AuthorizationRequest::delegated( &delegated_proof, &existing_binding(&owner), - &federated_policy(), - profile(), + federated_policy(), capabilities(&[AuthorizationCapability::CommunityRead]), Uuid::nil(), NOW, @@ -1733,9 +2194,13 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", "proof_method: \"[redacted]\", ", "authority: \"[redacted]\", principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", "federated_policy: \"[redacted]\", ", - "profile_id: \"[redacted]\", requested_capabilities: \"[redacted]\", ", + "requested_capabilities: \"[redacted]\", ", "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_from: \"[redacted]\", ", "evidence_valid_until: \"[redacted]\" }" ); // Keep this exact-shape assertion deliberately: adding a field must fail until @@ -1766,7 +2231,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { let allow = ProviderAllow::new( request.authorization_domain(), request.principal().clone(), - request.profile_id().clone(), + profile(), request.requested_capabilities().clone(), policy_version("private-policy-version"), 90, @@ -1796,15 +2261,20 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { assert_eq!( format!("{snapshot:?}"), concat!( - "CapabilitySnapshot { authorization_domain: \"[redacted]\", ", + "CapabilitySnapshot { runtime_binding: \"[redacted]\", ", + "authorization_domain: \"[redacted]\", ", "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", "principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", "federated_policy: \"[redacted]\", ", "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", - "fresh_until: \"[redacted]\", effective_until: \"[redacted]\", ", + "fresh_until: \"[redacted]\", effective_from: \"[redacted]\", ", + "effective_until: \"[redacted]\", ", "decision_source: \"[redacted]\", correlation_id: \"[redacted]\", ", "reason: \"[redacted]\" }" ) @@ -1869,7 +2339,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { "ProviderTimeout(\"[redacted]\")" ); assert_eq!( - format!("{:?}", request.profile_id()), + format!("{:?}", &profile()), "AuthorizationProfileId(\"[redacted]\")" ); assert_eq!( @@ -1897,6 +2367,7 @@ async fn request_decision_snapshot_and_errors_are_redaction_safe() { AuthorizationDenialReason::StaleDecision, AuthorizationDenialReason::FutureDecision, AuthorizationDenialReason::IdentityEvidenceExpired, + AuthorizationDenialReason::IdentityEvidenceNotYetValid, AuthorizationDenialReason::FederatedPolicyNotCurrent, ] { assert_eq!( @@ -1955,6 +2426,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() { AuthorizationDenialReason::StaleDecision.code(), AuthorizationDenialReason::FutureDecision.code(), AuthorizationDenialReason::IdentityEvidenceExpired.code(), + AuthorizationDenialReason::IdentityEvidenceNotYetValid.code(), AuthorizationDenialReason::FederatedPolicyNotCurrent.code(), ProviderUnavailableReason::TemporarilyUnavailable.code(), ProviderUnavailableReason::Timeout.code(), @@ -1962,7 +2434,7 @@ fn provider_trait_is_object_safe_and_codes_are_unique() { ]; codes.sort_unstable(); codes.dedup(); - assert_eq!(codes.len(), 13); + assert_eq!(codes.len(), 14); let contract_errors = all_contract_errors(); let mut contract_codes = contract_errors From 5571b1cc41646697091bb8cd902c9188e5b567c6 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:05:52 -0500 Subject: [PATCH 7/7] fix(auth): reject domain drift before authority I/O Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/provider/mod.rs | 13 ++++ crates/buzz-auth/src/provider/tests.rs | 90 ++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 9099d10273..7b3e032962 100644 --- a/crates/buzz-auth/src/provider/mod.rs +++ b/crates/buzz-auth/src/provider/mod.rs @@ -1147,6 +1147,7 @@ impl CapabilitySnapshot { assertion: VerifiedFederatedAssertion, clock: &dyn AuthorizationClock, ) -> Result> { + self.validate_embedded_domains(&input)?; let before_policy = finalization_time(clock)?; self.validate_common(&input, before_policy)?; self.validate_direct_shape(&input, &assertion, before_policy)?; @@ -1200,6 +1201,7 @@ impl CapabilitySnapshot { input: AuthContextInput, clock: &dyn AuthorizationClock, ) -> Result> { + self.validate_embedded_domains(&input)?; let before_policy = finalization_time(clock)?; self.validate_common(&input, before_policy)?; let owner_pubkey = self.validate_delegated_shape(&input)?; @@ -1276,6 +1278,17 @@ impl CapabilitySnapshot { Ok(()) } + fn validate_embedded_domains(&self, input: &AuthContextInput) -> Result<(), AuthContextError> { + let authorization_domain = input.authorization_domain(); + if input.nostr_proof_authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + if input.community_access_authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + Ok(()) + } + fn validate_direct_shape( &self, input: &AuthContextInput, diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs index d8d5af3aa3..190cdf7da6 100644 --- a/crates/buzz-auth/src/provider/tests.rs +++ b/crates/buzz-auth/src/provider/tests.rs @@ -734,6 +734,96 @@ async fn runtime_finalizer_allows_existing_binding_without_key_claim() { assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); } +#[tokio::test] +async fn mismatched_embedded_proof_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (_, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let mismatched_proof = VerifiedNostrProof::new( + domain(2), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic mismatched proof is structurally valid"); + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(mismatched_proof), assertion) + .await + .expect_err("embedded proof domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::NostrProofDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn mismatched_community_access_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let input = AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(2), Scope::all_known(), None), + ); + + let error = runtime + .finalize_direct_v1(*snapshot, input, assertion) + .await + .expect_err("embedded admission domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::CommunityAccessDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn attested_enrollment_without_sealed_key_claim_fails_before_commit() { let actor = Keys::generate();