From 5a014bda25aa8919bbd6fa047fec0e2756acd1b0 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:23:30 -0500 Subject: [PATCH] fix(auth): seal authoritative adapter boundary Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/context/authority.rs | 621 ++++++++++++++++++++++ crates/buzz-auth/src/context/binding.rs | 93 +++- crates/buzz-auth/src/context/mod.rs | 96 +++- crates/buzz-auth/src/context/reason.rs | 6 + crates/buzz-auth/src/context/tests.rs | 287 ++++++++++ crates/buzz-auth/src/lib.rs | 11 +- 6 files changed, 1075 insertions(+), 39 deletions(-) create mode 100644 crates/buzz-auth/src/context/authority.rs diff --git a/crates/buzz-auth/src/context/authority.rs b/crates/buzz-auth/src/context/authority.rs new file mode 100644 index 000000000..e3941ea38 --- /dev/null +++ b/crates/buzz-auth/src/context/authority.rs @@ -0,0 +1,621 @@ +use std::{fmt, future::Future, pin::Pin}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use super::{ + AuthContextError, AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, + BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, + FederatedPolicyStamp, FederatedPrincipal, ResolvedFederatedPolicy, +}; + +/// Boxed asynchronous result returned by a federated authority adapter. +pub type AuthorityAdapterFuture<'a, T> = Pin + Send + 'a>>; + +/// Failure while invoking or validating a federated authority adapter. +#[derive(Debug, PartialEq, Eq)] +pub enum AuthorityAdapterError { + /// The storage adapter failed before producing authoritative state. + Adapter(E), + /// Adapter output violated the authorization contract. + Contract(AuthContextError), + /// Current policy no longer matches the atomic binding precondition. + PolicyChanged, +} + +impl AuthorityAdapterError { + /// Wrap a storage-adapter failure. + pub const fn adapter(error: E) -> Self { + Self::Adapter(error) + } + + /// Report that the policy identifier or epoch changed before binding resolution. + pub const fn policy_changed() -> Self { + Self::PolicyChanged + } +} + +impl From for AuthorityAdapterError { + fn from(error: AuthContextError) -> Self { + Self::Contract(error) + } +} + +/// Read-only request for the current enrollment policy of one authorization domain. +#[derive(Clone, PartialEq, Eq)] +pub struct CurrentPolicyRequest { + authorization_domain: CommunityId, + correlation_id: Uuid, + observed_at: u64, +} + +impl CurrentPolicyRequest { + /// Server-resolved authorization domain to read. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Correlation identifier for the decision being assembled. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Trusted server time for the policy read. + pub const fn observed_at(&self) -> u64 { + self.observed_at + } +} + +impl fmt::Debug for CurrentPolicyRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CurrentPolicyRequest") + .field("authorization_domain", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("observed_at", &"[redacted]") + .finish() + } +} + +/// Crate-owned capability for sealing one current policy read. +/// +/// The adapter receives this value from [`resolve_current_federated_policy`]; +/// downstream callers cannot construct it. Calling [`Self::resolved`] validates +/// the raw storage fields and returns an opaque policy value. +pub struct CurrentPolicyResolutionSink { + request: CurrentPolicyRequest, +} + +impl CurrentPolicyResolutionSink { + /// Validate and seal current policy fields read by the adapter. + #[allow(clippy::too_many_arguments)] + pub fn resolved( + self, + authorization_domain: CommunityId, + policy_id: Uuid, + epoch: u64, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, + ) -> Result { + if authorization_domain != self.request.authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch); + } + let stamp = FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + policy_id, + epoch, + self.request.correlation_id, + requirement, + effective_from, + effective_until, + )?; + if stamp.is_not_yet_effective_at(self.request.observed_at) { + return Err(AuthContextError::FederatedPolicyNotYetEffective); + } + if stamp.is_expired_at(self.request.observed_at) { + return Err(AuthContextError::FederatedPolicyExpired); + } + Ok(ResolvedFederatedPolicy::from_authoritative_resolution( + stamp, + )) + } +} + +impl fmt::Debug for CurrentPolicyResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CurrentPolicyResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Atomic binding request tied to an exact current enrollment-policy epoch. +#[derive(Clone, PartialEq, Eq)] +pub struct BindingResolutionRequest { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + policy_id: Uuid, + policy_epoch: u64, + policy_requirement: FederatedIdentityRequirement, + correlation_id: Uuid, + key_attested: bool, + effective_from: u64, + effective_until: u64, + observed_at: u64, +} + +impl BindingResolutionRequest { + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact issuer-qualified principal being resolved. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Authenticated Nostr key being resolved. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Stable current enrollment-policy identifier. + pub const fn policy_id(&self) -> Uuid { + self.policy_id + } + + /// Exact policy epoch that must still be current inside the binding transaction. + pub const fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + /// Enrollment requirement at the expected policy epoch. + pub const fn policy_requirement(&self) -> FederatedIdentityRequirement { + self.policy_requirement + } + + /// Correlation identifier for this decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Whether verifier-owned assertion evidence attested the exact bound key. + pub const fn key_attested(&self) -> bool { + self.key_attested + } + + /// Inclusive joined assertion, capability, and policy validity bound. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive joined assertion, capability, and policy validity bound. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Trusted server time for binding eligibility. + pub const fn observed_at(&self) -> u64 { + self.observed_at + } +} + +impl fmt::Debug for BindingResolutionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BindingResolutionRequest") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("bound_pubkey", &"[redacted]") + .field("policy_id", &"[redacted]") + .field("policy_epoch", &"[redacted]") + .field("policy_requirement", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("effective_from", &"[redacted]") + .field("effective_until", &"[redacted]") + .field("observed_at", &"[redacted]") + .finish() + } +} + +#[derive(Clone)] +struct BindingExpectation { + request: BindingResolutionRequest, +} + +impl BindingExpectation { + #[allow(clippy::too_many_arguments)] + fn evidence( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if authorization_domain != self.request.authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if principal != self.request.principal { + return Err(AuthContextError::AssertionPrincipalMismatch); + } + if bound_pubkey != self.request.bound_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch); + } + if expires_at.is_some_and(|bound| bound.is_expired_at(self.request.observed_at)) { + return Err(AuthContextError::BindingExpired); + } + AuthoritativeBindingEvidence::new( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + } +} + +/// Crate-owned capability for sealing direct binding state. +/// +/// Implementations may call [`Self::existing_active`] after a current active +/// read, or [`Self::atomically_enrolled`] only after enrollment commits in the +/// same transaction that compared the request's policy identifier and epoch. +pub struct DirectBindingResolutionSink { + expected: BindingExpectation, +} + +impl DirectBindingResolutionSink { + /// Seal an already-active binding returned by authoritative storage. + #[allow(clippy::too_many_arguments)] + pub fn existing_active( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::existing_active) + } + + /// Seal a binding created under the request's atomic policy precondition. + #[allow(clippy::too_many_arguments)] + pub fn atomically_enrolled( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + match self.expected.request.policy_requirement { + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey) + if !self.expected.request.key_attested => + { + return Err(AuthContextError::KeyAttestationRequired); + } + FederatedIdentityRequirement::Required( + EnrollmentMode::AttestedKey | EnrollmentMode::Tofu, + ) => {} + FederatedIdentityRequirement::NotRequired + | FederatedIdentityRequirement::Required(EnrollmentMode::Provisioned) => { + return Err(AuthContextError::InvalidAuthorizationReason); + } + } + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::atomically_enrolled) + } +} + +impl fmt::Debug for DirectBindingResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DirectBindingResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Crate-owned capability for sealing a read-only existing owner binding. +/// +/// This sink intentionally has no enrollment method, so delegated-owner +/// resolution cannot create or relabel a binding. +pub struct ExistingBindingResolutionSink { + expected: BindingExpectation, +} + +impl ExistingBindingResolutionSink { + /// Seal an already-active owner binding returned by authoritative storage. + #[allow(clippy::too_many_arguments)] + pub fn existing_active( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::existing_active) + } +} + +impl fmt::Debug for ExistingBindingResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ExistingBindingResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Trusted cross-crate adapter for current policy and binding state. +/// +/// The server must compose exactly one implementation backed by authoritative +/// storage; request and transport code must never select an implementation. +/// Binding methods must compare `policy_id` and `policy_epoch` and check +/// database time against `[effective_from, effective_until)` after lock +/// acquisition and immediately before commit, inside the same transaction as +/// the active read or enrollment. A mismatch or elapsed interval fails closed +/// without binding mutation; policy mismatch is +/// [`AuthorityAdapterError::PolicyChanged`]. +pub trait FederatedAuthorityAdapter: Send + Sync { + /// Storage-specific failure type. + type Error; + + /// Read the domain's current enrollment policy and seal it with `sink`. + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; + + /// Resolve or atomically enroll a direct binding under the exact policy precondition. + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; + + /// Resolve an already-active owner binding without enrollment or mutation. + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; +} + +/// Resolve and seal the current policy for one authorization decision. +pub async fn resolve_current_federated_policy( + adapter: &A, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result> { + let request = CurrentPolicyRequest { + authorization_domain, + correlation_id, + observed_at: now_unix_seconds, + }; + let sink = CurrentPolicyResolutionSink { + request: request.clone(), + }; + let policy = adapter.resolve_current_policy(request, sink).await?; + validate_returned_policy( + &policy, + authorization_domain, + correlation_id, + now_unix_seconds, + )?; + Ok(policy) +} + +/// Resolve or atomically enroll a direct binding under an exact current policy. +#[allow(dead_code)] +// Keep the verifier-derived attestation bit and joined interval explicit at +// this sealed boundary so storage adapters cannot infer or widen either fact. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn resolve_direct_binding( + adapter: &A, + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + key_attested: bool, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + let request = binding_request( + policy, + principal, + bound_pubkey, + key_attested, + effective_from, + effective_until, + now_unix_seconds, + )?; + let sink = DirectBindingResolutionSink { + expected: BindingExpectation { + request: request.clone(), + }, + }; + let resolution = adapter + .resolve_direct_binding(request.clone(), sink) + .await?; + validate_returned_binding(&resolution, &request, false)?; + Ok(resolution) +} + +/// Resolve an already-active owner binding under an exact current policy. +#[allow(dead_code)] +pub(crate) async fn resolve_existing_binding( + adapter: &A, + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + let request = binding_request( + policy, + principal, + bound_pubkey, + false, + effective_from, + effective_until, + now_unix_seconds, + )?; + let sink = ExistingBindingResolutionSink { + expected: BindingExpectation { + request: request.clone(), + }, + }; + let resolution = adapter + .resolve_existing_binding(request.clone(), sink) + .await?; + validate_returned_binding(&resolution, &request, true)?; + Ok(resolution) +} + +fn binding_request( + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + key_attested: bool, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + if effective_from < policy.stamp().effective_from() + || effective_until > policy.stamp().effective_until() + || effective_from >= effective_until + { + return Err(AuthContextError::InvalidFederatedPolicyInterval.into()); + } + if now_unix_seconds < effective_from { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if now_unix_seconds >= effective_until { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + Ok(BindingResolutionRequest { + authorization_domain: policy.authorization_domain(), + principal, + bound_pubkey, + policy_id: policy.stamp().policy_id(), + policy_epoch: policy.stamp().epoch(), + policy_requirement: policy.requirement(), + correlation_id: policy.stamp().correlation_id(), + key_attested, + effective_from, + effective_until, + observed_at: now_unix_seconds, + }) +} + +fn validate_returned_policy( + policy: &ResolvedFederatedPolicy, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result<(), AuthorityAdapterError> { + if policy.authorization_domain() != authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch.into()); + } + if policy.stamp().correlation_id() != correlation_id { + return Err(AuthContextError::FederatedPolicyCorrelationMismatch.into()); + } + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + Ok(()) +} + +fn validate_returned_binding( + resolution: &AuthoritativeBindingResolution, + request: &BindingResolutionRequest, + require_existing: bool, +) -> Result<(), AuthorityAdapterError> { + if require_existing && !resolution.is_existing_active() { + return Err(AuthContextError::DelegatedBindingNotExistingActive.into()); + } + if resolution.authorization_domain() != request.authorization_domain { + return Err(AuthContextError::BindingDomainMismatch.into()); + } + if resolution.principal() != &request.principal { + return Err(AuthContextError::AssertionPrincipalMismatch.into()); + } + if resolution.bound_pubkey() != request.bound_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch.into()); + } + if resolution + .expires_at() + .is_some_and(|bound| bound.is_expired_at(request.observed_at)) + { + return Err(AuthContextError::BindingExpired.into()); + } + Ok(()) +} diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index 2f3fba859..e9ca0e9c9 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -48,8 +48,8 @@ impl fmt::Debug for FederatedIdentityRequirement { /// /// This stamp is not provider capability-policy evidence. It names the /// server-owned federated enrollment policy that supplied the requirement and -/// its half-open effective interval. The constructor validates shape, while the -/// O3 authority adapter remains responsible for sourcing current policy state. +/// its half-open effective interval. The constructor validates shape, while a +/// crate-owned authority adapter remains responsible for sourcing current policy state. #[derive(Clone, PartialEq, Eq)] pub struct FederatedPolicyStamp { authorization_domain: CommunityId, @@ -62,12 +62,12 @@ pub struct FederatedPolicyStamp { } impl FederatedPolicyStamp { - /// Validate lineage read from current authoritative O3 policy state. + /// Validate lineage read from current authoritative policy state. /// /// This constructor enforces structural invariants only. Callers must not - /// source any field from transport input, and O3 must compare the epoch as - /// an atomic precondition before enrollment. - pub fn from_authoritative_state( + /// source any field from transport input, and the authority adapter must + /// compare the epoch as an atomic precondition before enrollment. + pub(crate) fn from_authoritative_state( authorization_domain: CommunityId, policy_id: Uuid, epoch: u64, @@ -181,8 +181,8 @@ impl fmt::Debug for ResolvedFederatedPolicy { } impl ResolvedFederatedPolicy { - /// Seal structurally validated current O3 policy lineage for finalization. - pub const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { + /// Seal structurally validated current policy lineage for finalization. + pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { Self { stamp } } @@ -235,6 +235,11 @@ impl ResolvedFederatedPolicy { pub const fn stamp(&self) -> &FederatedPolicyStamp { &self.stamp } + + #[allow(dead_code)] + pub(crate) fn into_stamp(self) -> FederatedPolicyStamp { + self.stamp + } } /// Provenance recorded when a binding is created. @@ -347,14 +352,14 @@ pub struct VersionedBindingRef { resolution_reason: AuthorizationReason, } -/// Structurally validated binding fields returned by authoritative O3 state. +/// Structurally validated binding fields returned by authoritative state. /// /// This is not authorization by itself. The crate-owned finalizer additionally /// requires a typed lifecycle outcome proving that the binding was already /// active or was atomically enrolled during this decision. It has no default or /// deserialization path. #[derive(PartialEq, Eq)] -pub struct AuthoritativeBindingEvidence { +pub(crate) struct AuthoritativeBindingEvidence { authorization_domain: CommunityId, binding_id: Uuid, principal: FederatedPrincipal, @@ -367,7 +372,7 @@ pub struct AuthoritativeBindingEvidence { impl AuthoritativeBindingEvidence { /// Validate typed fields read from authoritative binding state. #[allow(clippy::too_many_arguments)] - pub fn new( + pub(crate) fn new( authorization_domain: CommunityId, binding_id: Uuid, principal: FederatedPrincipal, @@ -447,9 +452,9 @@ enum BindingResolutionOutcome { AtomicallyEnrolled, } -/// Typed authoritative lifecycle result consumed by the O1 finalizer. +/// Typed authoritative lifecycle result consumed by the crate-owned finalizer. /// -/// It carries no caller-selected authorization reason; O1 derives that reason +/// It carries no caller-selected authorization reason; the finalizer derives that reason /// from the lifecycle outcome, persisted provenance, and current enrollment /// policy. #[derive(PartialEq, Eq)] @@ -459,21 +464,61 @@ pub struct AuthoritativeBindingResolution { } impl AuthoritativeBindingResolution { - /// Record O3's authoritative result that the binding already existed. - pub fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self { + /// Record the authoritative result that the binding already existed. + pub(crate) fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self { Self { evidence, outcome: BindingResolutionOutcome::ExistingActive, } } - /// Record O3's authoritative result that enrollment committed atomically. - pub fn atomically_enrolled(evidence: AuthoritativeBindingEvidence) -> Self { + /// Record the authoritative result that enrollment committed atomically. + pub(crate) fn atomically_enrolled(evidence: AuthoritativeBindingEvidence) -> Self { Self { evidence, outcome: BindingResolutionOutcome::AtomicallyEnrolled, } } + + /// Whether authoritative storage resolved an already-active binding. + pub const fn is_existing_active(&self) -> bool { + matches!(self.outcome, BindingResolutionOutcome::ExistingActive) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.evidence.authorization_domain() + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.evidence.binding_id() + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + self.evidence.principal() + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.evidence.bound_pubkey() + } + + /// Current local binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.evidence.binding_version() + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.evidence.expires_at() + } + + /// Persisted provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.evidence.source() + } } impl fmt::Debug for AuthoritativeBindingResolution { @@ -528,10 +573,16 @@ impl VersionedBindingRef { )) } - pub(super) fn from_existing_authoritative_evidence( - evidence: AuthoritativeBindingEvidence, - ) -> Self { - Self::from_authoritative_evidence(evidence, AuthorizationReason::ExistingBinding) + pub(super) fn from_existing_authoritative_resolution( + resolution: AuthoritativeBindingResolution, + ) -> Result { + if !resolution.is_existing_active() { + return Err(AuthContextError::DelegatedBindingNotExistingActive); + } + Ok(Self::from_authoritative_evidence( + resolution.evidence, + AuthorizationReason::ExistingBinding, + )) } fn from_authoritative_evidence( diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index 1bc23b9ff..ba52abc4f 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -13,14 +13,21 @@ use uuid::Uuid; use crate::Scope; +pub(crate) mod authority; mod binding; mod evidence; mod reason; +pub use authority::{ + resolve_current_federated_policy, AuthorityAdapterError, AuthorityAdapterFuture, + BindingResolutionRequest, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DirectBindingResolutionSink, ExistingBindingResolutionSink, FederatedAuthorityAdapter, +}; +pub(crate) use binding::AuthoritativeBindingEvidence; pub use binding::{ - AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource, - BindingVersion, EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, - ResolvedFederatedPolicy, VersionedBindingRef, + AuthoritativeBindingResolution, BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, + FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, + VersionedBindingRef, }; pub use evidence::{ AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, @@ -73,7 +80,7 @@ impl fmt::Debug for FederatedAuthorization { } } -/// Authoritative O3 result consumed by the public O1 production finalizer. +/// Authoritative result consumed by the production finalizer. /// /// Unlike [`FederatedAuthorization`], this input cannot contain a raw /// [`VersionedBindingRef`] or a caller-selected authorization reason. @@ -83,15 +90,15 @@ pub enum AuthoritativeFederatedResolution { NotRequired, /// Direct authority backed by an existing or atomically enrolled binding. Direct { - /// Typed authoritative O3 lifecycle result. + /// Typed authoritative lifecycle result. binding: AuthoritativeBindingResolution, /// Current verified assertion for the authenticated actor. assertion: VerifiedFederatedAssertion, }, /// Delegated authority backed by an already-active owner binding. Delegated { - /// Typed authoritative evidence for the existing owner binding. - owner: AuthoritativeBindingEvidence, + /// Typed authoritative result for the existing owner binding. + owner: AuthoritativeBindingResolution, /// Current admission resolved for the owner. admission: VerifiedOwnerAdmission, }, @@ -106,6 +113,17 @@ impl fmt::Debug for AuthoritativeFederatedResolution { } } +impl AuthoritativeFederatedResolution { + #[allow(dead_code)] + pub(crate) const fn principal(&self) -> Option<&FederatedPrincipal> { + match self { + Self::NotRequired => None, + Self::Direct { assertion, .. } => Some(assertion.principal()), + Self::Delegated { admission, .. } => Some(admission.principal()), + } + } +} + /// Initial shared authorization-context contract. #[derive(PartialEq, Eq)] pub struct AuthContextV1 { @@ -128,6 +146,31 @@ pub struct AuthContextInput { community_access: AuthorizedCommunityAccess, } +/// Opaque proof that a validated capability snapshot was consumed. +/// +/// Only the crate-owned provider finalizer can construct this value. It keeps +/// the low-level context finalizer public for a stacked contract while making +/// it impossible for downstream code to bypass capability authorization. +pub struct CapabilityFinalizationSeal { + _private: (), +} + +impl CapabilityFinalizationSeal { + #[allow(dead_code)] + pub(crate) const fn new() -> Self { + Self { _private: () } + } +} + +impl fmt::Debug for CapabilityFinalizationSeal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CapabilityFinalizationSeal") + .field(&"[redacted]") + .finish() + } +} + impl AuthContextInput { /// Collect evidence after cryptographic authentication and community /// admission have both succeeded. @@ -144,6 +187,26 @@ impl AuthContextInput { community_access, } } + + #[allow(dead_code)] + pub(crate) const fn authorization_domain(&self) -> CommunityId { + self.tenant.community() + } + + #[allow(dead_code)] + pub(crate) const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + #[allow(dead_code)] + pub(crate) const fn transport(&self) -> AuthTransport { + self.nostr_proof.authorized_transport() + } + + #[allow(dead_code)] + pub(crate) const fn actor_pubkey(&self) -> PublicKey { + self.nostr_proof.actor_pubkey() + } } impl fmt::Debug for AuthContextV1 { @@ -182,14 +245,19 @@ impl fmt::Debug for AuthContext { } impl AuthContext { - /// Finalize an immutable V1 context from authoritative O3 binding evidence. + /// Finalize an immutable V1 context from authoritative binding evidence. /// - /// This is the production O1↔O3 seam. O3 must resolve the enrollment-policy - /// stamp and binding lifecycle state from current authoritative storage, - /// use the policy epoch as a conditional precondition for any atomic - /// enrollment, and pass the resulting typed lifecycle outcome here. O1 - /// derives the authorization reason; transport code cannot select it. + /// A crate-owned authority adapter must resolve the enrollment-policy stamp + /// and binding lifecycle state from current authoritative storage, use the + /// policy epoch as a conditional precondition for any atomic enrollment, + /// and pass the resulting opaque lifecycle outcome here. The finalizer + /// derives the authorization reason; transport code cannot select it or + /// construct authoritative policy and binding outcomes. + /// + /// The opaque seal ensures a production caller first consumed the validated + /// capability decision supplied by the provider contract. pub fn finalize_authoritative_v1( + _capability: CapabilityFinalizationSeal, input: AuthContextInput, federated_policy: ResolvedFederatedPolicy, resolution: AuthoritativeFederatedResolution, @@ -209,7 +277,7 @@ impl AuthContext { } AuthoritativeFederatedResolution::Delegated { owner, admission } => { FederatedAuthorization::Delegated { - owner: VersionedBindingRef::from_existing_authoritative_evidence(owner), + owner: VersionedBindingRef::from_existing_authoritative_resolution(owner)?, admission, } } diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index 9a99cbc36..fe07e9a68 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -169,6 +169,9 @@ pub enum AuthContextError { /// Delegated authorization did not match the verified Nostr owner. #[error("delegated federated authorization does not match the verified Nostr owner")] DelegatedOwnerMismatch, + /// Delegated owner evidence did not resolve an already-active binding. + #[error("delegated federated authorization requires an existing active binding")] + DelegatedBindingNotExistingActive, } impl AuthContextError { @@ -216,6 +219,9 @@ impl AuthContextError { Self::DelegateKeyMismatch => "federated_delegate_key_mismatch", Self::DelegationRequired => "federated_delegation_required", Self::DelegatedOwnerMismatch => "federated_delegated_owner_mismatch", + Self::DelegatedBindingNotExistingActive => { + "federated_delegated_binding_not_existing_active" + } } } } diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs index 4540b34b0..2b5419736 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -132,6 +132,92 @@ fn authoritative_binding_evidence( .expect("synthetic authoritative binding evidence is valid") } +struct TestAuthorityAdapter; + +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 { + assert_eq!(request.authorization_domain(), authorization_domain(1)); + assert_eq!(request.correlation_id(), Uuid::from_u128(2)); + assert_eq!(request.observed_at(), 100); + assert!(!format!("{request:?}").contains("100")); + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + 7, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + 90, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + assert_eq!(request.policy_id(), Uuid::from_u128(40)); + assert_eq!(request.policy_epoch(), 7); + assert_eq!( + request.policy_requirement(), + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey) + ); + assert!(request.key_attested()); + assert_eq!(request.effective_from(), 90); + assert_eq!(request.effective_until(), 180); + assert_eq!(request.observed_at(), 100); + assert!(!format!("{request:?}").contains("subject-123")); + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(180).expect("synthetic binding expiry is valid")), + BindingSource::AttestedKey, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(180).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .map_err(AuthorityAdapterError::from) + }) + } +} + fn binding_in(domain: u128, pubkey: PublicKey) -> VersionedBindingRef { binding_with_source_in(domain, pubkey, BindingSource::AttestedKey) } @@ -1127,6 +1213,7 @@ fn authorization_error_codes_are_unique_and_provider_neutral() { AuthContextError::DelegateKeyMismatch, AuthContextError::DelegationRequired, AuthContextError::DelegatedOwnerMismatch, + AuthContextError::DelegatedBindingNotExistingActive, ]; let mut codes = errors .iter() @@ -1311,6 +1398,7 @@ fn federated_policy_stamp_rejects_invalid_lineage() { fn authoritative_finalizer_derives_binding_reason() { let existing_actor = Keys::generate(); let existing = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), input( existing_actor.public_key(), AuthTransport::RelayWebSocket, @@ -1336,6 +1424,7 @@ fn authoritative_finalizer_derives_binding_reason() { let attested_actor = Keys::generate(); let attested = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), input( attested_actor.public_key(), AuthTransport::RelayWebSocket, @@ -1366,6 +1455,7 @@ fn authoritative_finalizer_derives_binding_reason() { let tofu_actor = Keys::generate(); let tofu = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), input(tofu_actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Tofu), AuthoritativeFederatedResolution::Direct { @@ -1388,10 +1478,149 @@ fn authoritative_finalizer_derives_binding_reason() { ); } +#[tokio::test] +async fn cross_crate_authority_adapter_seals_policy_and_binding_outcome() { + let actor = Keys::generate(); + let adapter = TestAuthorityAdapter; + let policy = resolve_current_federated_policy( + &adapter, + authorization_domain(1), + Uuid::from_u128(2), + 100, + ) + .await + .expect("current authoritative policy is valid"); + let binding = authority::resolve_direct_binding( + &adapter, + &policy, + principal(), + actor.public_key(), + true, + 90, + 180, + 100, + ) + .await + .expect("atomic binding resolution is valid"); + let context = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy, + AuthoritativeFederatedResolution::Direct { + binding, + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 180, + actor.public_key(), + ), + }, + 100, + ) + .expect("sealed adapter output finalizes"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); +} + +#[tokio::test] +async fn binding_sink_rejects_missing_attestation_for_attested_enrollment() { + struct MissingAttestationAdapter; + + impl FederatedAuthorityAdapter for MissingAttestationAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + 7, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + 90, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + _request: BindingResolutionRequest, + _sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async { Err(AuthorityAdapterError::adapter("not called")) }) + } + } + + let actor = Keys::generate(); + let adapter = MissingAttestationAdapter; + let policy = resolve_current_federated_policy( + &adapter, + authorization_domain(1), + Uuid::from_u128(2), + 100, + ) + .await + .expect("current authoritative policy is valid"); + let error = authority::resolve_direct_binding( + &adapter, + &policy, + principal(), + actor.public_key(), + false, + 90, + 180, + 100, + ) + .await + .expect_err("attested-key enrollment requires sealed matching attestation"); + + assert_eq!( + error, + AuthorityAdapterError::Contract(AuthContextError::KeyAttestationRequired) + ); +} + #[test] fn authoritative_finalizer_rejects_incompatible_enrollment_result() { let actor = Keys::generate(); let error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Provisioned), AuthoritativeFederatedResolution::Direct { @@ -1421,6 +1650,7 @@ fn authoritative_finalizer_carries_binding_expiry() { ) .expect("synthetic authoritative binding evidence is valid"); let error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), input(actor.public_key(), AuthTransport::RelayWebSocket, None), policy_required(EnrollmentMode::Provisioned), AuthoritativeFederatedResolution::Direct { @@ -1434,6 +1664,63 @@ fn authoritative_finalizer_carries_binding_expiry() { assert_eq!(error, AuthContextError::BindingExpired); } +#[test] +fn authoritative_finalizer_requires_existing_active_delegated_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let admission = || { + VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ) + }; + let enrolled_error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Delegated { + owner: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(owner.public_key(), BindingSource::Provisioned), + ), + admission: admission(), + }, + 100, + ) + .expect_err("a newly enrolled record cannot be relabeled as an existing delegated owner"); + assert_eq!( + enrolled_error, + AuthContextError::DelegatedBindingNotExistingActive + ); + + let existing = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Delegated { + owner: AuthoritativeBindingResolution::existing_active(authoritative_binding_evidence( + owner.public_key(), + BindingSource::Provisioned, + )), + admission: admission(), + }, + 100, + ) + .expect("an existing active owner binding is eligible for delegated finalization"); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::DelegatedOwnerBinding + ); +} + #[test] fn tofu_enrollment_cannot_use_attested_key_policy_reason() { let actor = Keys::generate(); diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 441459251..df963bc4e 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -34,10 +34,13 @@ pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; pub use context::{ - AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthContext, - AuthContextError, AuthContextInput, AuthContextV1, AuthContextVersion, AuthMethod, - AuthTransport, AuthorizationReason, AuthorizedCommunityAccess, BindingSource, BindingVersion, - DelegationCapability, DelegationExpiry, EnrollmentMode, FederatedAuthorization, + resolve_current_federated_policy, AdmissionExpiry, AssertionExpiry, AssertionNotBefore, + AssertionTransport, AuthContext, AuthContextError, AuthContextInput, AuthContextV1, + AuthContextVersion, AuthMethod, AuthTransport, AuthorityAdapterError, AuthorityAdapterFuture, + AuthorizationReason, AuthorizedCommunityAccess, BindingResolutionRequest, BindingSource, + BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, + ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, VersionedBindingRef,