fix(auth): revalidate provider decisions after I/O

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-03 17:03:52 -05:00
parent d82afe9012
commit 23f98df72d
2 changed files with 352 additions and 33 deletions
+43 -4
View File
@@ -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<u64> {
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<u64>;
}
/// 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",
}
}
}
+309 -29
View File
@@ -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<AtomicU64>,
available: Arc<AtomicBool>,
reads: Arc<AtomicUsize>,
}
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<u64> {
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<u64>,
) -> 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<Option<ProviderDecision>>,
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<AtomicUsize>,
dropped: Arc<AtomicBool>,
@@ -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]\")"