fix(auth): seal capability finalization runtime

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:53 -05:00
parent 4bd64092bc
commit cc9bc5361d
3 changed files with 1162 additions and 145 deletions
+6 -5
View File
@@ -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,
+604 -60
View File
@@ -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<String>) -> Result<Self, ProviderContractError> {
pub fn from_server_configuration(
value: impl Into<String>,
) -> Result<Self, ProviderContractError> {
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<AssertionTransport>,
assertion_not_before: Option<u64>,
assertion_expires_at: Option<u64>,
federated_policy: FederatedPolicyStamp,
profile_id: AuthorizationProfileId,
requested_capabilities: CapabilitySet,
correlation_id: Uuid,
decision_source: DecisionSource,
evidence_valid_until: Option<u64>,
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<u64> {
/// 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<A: FederatedAuthorityAdapter + ?Sized>(
adapter: &A,
proof: &VerifiedNostrProof,
principal: FederatedPrincipal,
federated_policy: ResolvedFederatedPolicy,
requested_capabilities: CapabilitySet,
correlation_id: Uuid,
clock: &dyn AuthorizationClock,
) -> Result<AuthorizationRequest, ProviderAuthorizationError<A::Error>> {
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<E> {
/// Trusted server time was unavailable.
ClockUnavailable,
/// Provider evidence or snapshot shape violated the contract.
Contract(ProviderContractError),
/// Current policy or binding resolution failed.
Authority(AuthorityAdapterError<E>),
/// 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<A, C, P> {
authority: A,
clock: C,
provider: P,
binding: Uuid,
}
impl<A, C, P> AuthorizationRuntime<A, C, P> {
/// 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<A, C, P> fmt::Debug for AuthorizationRuntime<A, C, P> {
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<E> From<ProviderContractError> for ProviderAuthorizationError<E> {
fn from(error: ProviderContractError) -> Self {
Self::Contract(error)
}
}
impl<E> From<AuthorityAdapterError<E>> for ProviderAuthorizationError<E> {
fn from(error: AuthorityAdapterError<E>) -> Self {
Self::Authority(error)
}
}
impl<E> From<AuthContextError> for ProviderAuthorizationError<E> {
fn from(error: AuthContextError) -> Self {
Self::Context(error)
}
}
impl<E> fmt::Debug for ProviderAuthorizationError<E> {
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<BindingVersion>,
proof_method: AuthMethod,
principal: FederatedPrincipal,
key_attested: bool,
assertion_transport: Option<AssertionTransport>,
assertion_not_before: Option<u64>,
assertion_expires_at: Option<u64>,
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<A: FederatedAuthorityAdapter + ?Sized>(
self,
adapter: &A,
input: AuthContextInput,
assertion: VerifiedFederatedAssertion,
clock: &dyn AuthorizationClock,
) -> Result<AuthContext, ProviderAuthorizationError<A::Error>> {
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<A: FederatedAuthorityAdapter + ?Sized>(
self,
adapter: &A,
input: AuthContextInput,
clock: &dyn AuthorizationClock,
) -> Result<AuthContext, ProviderAuthorizationError<A::Error>> {
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<PublicKey, ProviderContractError> {
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<E>(
clock: &dyn AuthorizationClock,
) -> Result<u64, ProviderAuthorizationError<E>> {
clock
.now_unix_seconds()
.ok_or(ProviderAuthorizationError::ClockUnavailable)
}
impl<A, C, P> AuthorizationRuntime<A, C, P>
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<AuthorizationRequest, ProviderAuthorizationError<A::Error>> {
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<AuthContext, ProviderAuthorizationError<A::Error>> {
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<AuthContext, ProviderAuthorizationError<A::Error>> {
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",
}
}
}
+552 -80
View File
@@ -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<AtomicUsize>,
direct_calls: Arc<AtomicUsize>,
existing_calls: Arc<AtomicUsize>,
committed_enrollments: Arc<AtomicUsize>,
}
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<ResolvedFederatedPolicy, AuthorityAdapterError<Self::Error>>,
> {
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<AuthoritativeBindingResolution, AuthorityAdapterError<Self::Error>>,
> {
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<AuthoritativeBindingResolution, AuthorityAdapterError<Self::Error>>,
> {
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<u64>,
) -> 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<Option<ProviderDecision>>,
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,
&current_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(&current_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(&current_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