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] 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 e4d6e831e..169955583 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 fc4c1069c..9099d1027 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 99418b354..d8d5af3aa 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