diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs index 8a5733eaa..569da23de 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 c46f65877..56a8780bb 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]\")"