diff --git a/crates/buzz-auth/src/context/authority.rs b/crates/buzz-auth/src/context/authority.rs new file mode 100644 index 0000000000..390273ecb3 --- /dev/null +++ b/crates/buzz-auth/src/context/authority.rs @@ -0,0 +1,636 @@ +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(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 fmt::Debug for AuthorityAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let variant = match self { + Self::Adapter(_) => "Adapter", + Self::Contract(_) => "Contract", + Self::PolicyChanged => "PolicyChanged", + }; + formatter + .debug_struct("AuthorityAdapterError") + .field("variant", &variant) + .field("detail", &"[redacted]") + .finish() + } +} + +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 new file mode 100644 index 0000000000..e9ca0e9c9e --- /dev/null +++ b/crates/buzz-auth/src/context/binding.rs @@ -0,0 +1,703 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use super::{AuthContextError, AuthorizationReason, FederatedPrincipal}; + +/// Policy used when no active binding exists for either principal or key. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentMode { + /// First use requires an assertion that attests the proven Nostr key. + AttestedKey, + /// Bindings must be created by an out-of-band administrative process. + Provisioned, + /// First use may bind the proven key without an asserted key claim. + Tofu, +} + +impl fmt::Debug for EnrollmentMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("EnrollmentMode") + .field(&"[redacted]") + .finish() + } +} + +/// Federated-identity requirement resolved for one authorization domain. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FederatedIdentityRequirement { + /// Federated identity is not required for this domain. + NotRequired, + /// Federated identity is required under the supplied enrollment policy. + Required(EnrollmentMode), +} + +impl fmt::Debug for FederatedIdentityRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedIdentityRequirement") + .field(&"[redacted]") + .finish() + } +} + +/// Exact authoritative enrollment-policy lineage for one decision. +/// +/// 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 a +/// crate-owned authority adapter remains responsible for sourcing current policy state. +#[derive(Clone, PartialEq, Eq)] +pub struct FederatedPolicyStamp { + authorization_domain: CommunityId, + policy_id: Uuid, + epoch: u64, + correlation_id: Uuid, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, +} + +impl FederatedPolicyStamp { + /// Validate lineage read from current authoritative policy state. + /// + /// This constructor enforces structural invariants only. Callers must not + /// 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, + correlation_id: Uuid, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, + ) -> Result { + if policy_id.is_nil() { + return Err(AuthContextError::InvalidFederatedPolicyId); + } + if epoch == 0 { + return Err(AuthContextError::InvalidFederatedPolicyEpoch); + } + if correlation_id.is_nil() { + return Err(AuthContextError::InvalidFederatedPolicyCorrelation); + } + if effective_from >= effective_until { + return Err(AuthContextError::InvalidFederatedPolicyInterval); + } + Ok(Self { + authorization_domain, + policy_id, + epoch, + correlation_id, + requirement, + effective_from, + effective_until, + }) + } + + /// Authorization domain whose enrollment policy was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable, non-nil identifier of the enrollment-policy namespace. + pub const fn policy_id(&self) -> Uuid { + self.policy_id + } + + /// Positive monotonic epoch within the enrollment-policy namespace. + pub const fn epoch(&self) -> u64 { + self.epoch + } + + /// Correlation identifier of the decision that resolved this policy. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Federated-identity requirement resolved at this epoch. + pub const fn requirement(&self) -> FederatedIdentityRequirement { + self.requirement + } + + /// Inclusive start of the policy's effective interval. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive end of the policy's effective interval. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Whether the policy is not yet effective at trusted server time. + pub const fn is_not_yet_effective_at(&self, now_unix_seconds: u64) -> bool { + now_unix_seconds < self.effective_from + } + + /// Whether the policy is expired at trusted server time. + pub const fn is_expired_at(&self, now_unix_seconds: u64) -> bool { + now_unix_seconds >= self.effective_until + } +} + +impl fmt::Debug for FederatedPolicyStamp { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FederatedPolicyStamp") + .field("authorization_domain", &"[redacted]") + .field("policy_id", &"[redacted]") + .field("epoch", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("requirement", &"[redacted]") + .field("effective_from", &"[redacted]") + .field("effective_until", &"[redacted]") + .finish() + } +} + +/// Server-resolved federated-identity policy for an authorization decision. +/// +/// A policy adapter must resolve the authorization domain's current +/// configuration before producing it; transport values are never authoritative +/// input. The evidence is intentionally move-only and has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct ResolvedFederatedPolicy { + stamp: FederatedPolicyStamp, +} + +impl fmt::Debug for ResolvedFederatedPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedFederatedPolicy") + .field("stamp", &self.stamp) + .finish() + } +} + +impl ResolvedFederatedPolicy { + /// Seal structurally validated current policy lineage for finalization. + pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { + Self { stamp } + } + + #[cfg(test)] + pub(crate) fn not_required(authorization_domain: CommunityId) -> Self { + Self::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + FederatedIdentityRequirement::NotRequired, + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + #[cfg(test)] + pub(crate) fn required( + authorization_domain: CommunityId, + enrollment_mode: EnrollmentMode, + ) -> Self { + Self::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + FederatedIdentityRequirement::Required(enrollment_mode), + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + /// Authorization domain whose configuration was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.stamp.authorization_domain() + } + + /// Resolved federated-identity requirement. + pub const fn requirement(&self) -> FederatedIdentityRequirement { + self.stamp.requirement() + } + + /// Exact authoritative enrollment-policy lineage for this decision. + 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. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + /// The identity provider attested the proven Nostr key. + AttestedKey, + /// An operator provisioned the binding out of band. + Provisioned, + /// The binding was established by trust on first use. + Tofu, +} + +impl fmt::Debug for BindingSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingSource") + .field(&"[redacted]") + .finish() + } +} + +/// Monotonically increasing version of an identity-to-key binding. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BindingVersion(u64); + +impl fmt::Debug for BindingVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingVersion") + .field(&"[redacted]") + .finish() + } +} + +impl BindingVersion { + /// Initial version assigned to a newly created binding. + pub const INITIAL: Self = Self(1); + + /// Build a non-zero binding version. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AuthContextError::InvalidBindingVersion); + } + Ok(Self(value)) + } + + /// Numeric binding version. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Optional authoritative expiry of a lifecycle-active identity binding. +/// +/// Expiry makes the binding ineligible for authorization but does not remove it +/// from lifecycle state or turn it into retirement or revocation evidence. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BindingExpiry(u64); + +impl fmt::Debug for BindingExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl BindingExpiry { + /// Build a non-zero binding expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidBindingExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the binding is no longer authorization-eligible. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Stable reference to one active identity-to-key binding. +/// +/// This reference is identity evidence. It is not an authorization lease and +/// does not by itself provide live-revocation +/// enforcement. Its optional authoritative expiry is a finalization and later +/// lease bound; expiry does not synthesize lifecycle state. An +/// authoritative binding adapter constructs this move-only value after checking +/// active lifecycle state; it has no default or deserialization path. +/// Production construction is available only through the crate-owned +/// authoritative-resolution finalizer. Pending, revoked, newly proposed, and +/// synthetic records must not cross that gate. +#[derive(PartialEq, Eq)] +pub struct VersionedBindingRef { + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + resolution_reason: AuthorizationReason, +} + +/// 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(crate) struct AuthoritativeBindingEvidence { + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, +} + +impl AuthoritativeBindingEvidence { + /// Validate typed fields read from authoritative binding state. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + }) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Current local binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.expires_at + } + + /// Persisted provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.source + } +} + +impl fmt::Debug for AuthoritativeBindingEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthoritativeBindingEvidence") + .field("authorization_domain", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("principal", &"[redacted]") + .field("bound_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("source", &"[redacted]") + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum BindingResolutionOutcome { + ExistingActive, + AtomicallyEnrolled, +} + +/// Typed authoritative lifecycle result consumed by the crate-owned finalizer. +/// +/// 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)] +pub struct AuthoritativeBindingResolution { + evidence: AuthoritativeBindingEvidence, + outcome: BindingResolutionOutcome, +} + +impl AuthoritativeBindingResolution { + /// Record the authoritative result that the binding already existed. + pub(crate) fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self { + Self { + evidence, + outcome: BindingResolutionOutcome::ExistingActive, + } + } + + /// 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 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthoritativeBindingResolution") + .field(&"[redacted]") + .finish() + } +} + +impl fmt::Debug for VersionedBindingRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VersionedBindingRef") + .field("authorization_domain", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("principal", &self.principal) + .field("bound_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("source", &"[redacted]") + .field("resolution_reason", &"[redacted]") + .finish() + } +} + +impl VersionedBindingRef { + pub(super) fn from_authoritative_resolution( + resolution: AuthoritativeBindingResolution, + requirement: FederatedIdentityRequirement, + ) -> Result { + let reason = match resolution.outcome { + BindingResolutionOutcome::ExistingActive => AuthorizationReason::ExistingBinding, + BindingResolutionOutcome::AtomicallyEnrolled => { + match (requirement, resolution.evidence.source) { + ( + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + BindingSource::AttestedKey, + ) => AuthorizationReason::EnrolledAttestedKey, + ( + FederatedIdentityRequirement::Required(EnrollmentMode::Tofu), + BindingSource::Tofu | BindingSource::AttestedKey, + ) => AuthorizationReason::EnrolledTofu, + _ => return Err(AuthContextError::InvalidAuthorizationReason), + } + } + }; + Ok(Self::from_authoritative_evidence( + resolution.evidence, + reason, + )) + } + + 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( + evidence: AuthoritativeBindingEvidence, + resolution_reason: AuthorizationReason, + ) -> Self { + Self { + authorization_domain: evidence.authorization_domain, + binding_id: evidence.binding_id, + principal: evidence.principal, + bound_pubkey: evidence.bound_pubkey, + binding_version: evidence.binding_version, + expires_at: evidence.expires_at, + source: evidence.source, + resolution_reason, + } + } + + /// Build a reference to a binding authoritatively resolved as already active. + #[cfg(test)] + pub(crate) fn new_existing_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason: AuthorizationReason::ExistingBinding, + }) + } + + /// Build a reference to a binding atomically enrolled in this decision. + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_enrolled_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + reason: AuthorizationReason, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + if !matches!( + reason, + AuthorizationReason::EnrolledAttestedKey | AuthorizationReason::EnrolledTofu + ) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason: reason, + }) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Current binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.expires_at + } + + /// Provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.source + } + + /// Stable reason proven by the authoritative binding lifecycle result. + pub(super) const fn authorization_reason(&self) -> AuthorizationReason { + self.resolution_reason + } +} diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs new file mode 100644 index 0000000000..c99018a4b0 --- /dev/null +++ b/crates/buzz-auth/src/context/evidence.rs @@ -0,0 +1,706 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use crate::Scope; + +#[cfg(test)] +use super::transport_accepts_proof; +use super::AuthContextError; + +/// Cryptographic proof used to authenticate the Nostr actor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMethod { + /// NIP-42 challenge/response over WebSocket. + Nip42, + /// NIP-98 signed HTTP request. + Nip98, + /// Blossom upload authorization. + Blossom, +} + +/// Entry point that produced the authorization context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthTransport { + /// Relay WebSocket protocol. + RelayWebSocket, + /// HTTP relay bridge. + HttpBridge, + /// Git-over-HTTP endpoint. + Git, + /// Media upload endpoint. + MediaUpload, + /// Authenticated media download endpoint, including `GET` and `HEAD`. + MediaDownload, + /// Huddle audio WebSocket. + Audio, +} + +/// Transport profile used to deliver a federated assertion. +/// +/// This records how the assertion reached its verifier. It is intentionally +/// independent of [`AuthTransport`]; each authentication adapter must verify +/// the delivery profile before constructing authorization evidence. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AssertionTransport { + /// A trusted proxy stripped inbound copies and injected the assertion. + TrustedProxy, + /// The client attached the assertion to the authorized request. + ClientAttached, +} + +impl fmt::Debug for AssertionTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionTransport") + .field(&"[redacted]") + .finish() + } +} + +/// Expiry of a validated federated assertion, expressed as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionExpiry(u64); + +impl fmt::Debug for AssertionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionExpiry { + /// Build a non-zero assertion expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAssertionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the assertion is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Earliest valid time from a validated federated assertion, as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionNotBefore(u64); + +impl fmt::Debug for AssertionNotBefore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionNotBefore") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionNotBefore { + /// Preserve a validated `nbf` timestamp for finalization checks. + pub const fn new(unix_seconds: u64) -> Self { + Self(unix_seconds) + } + + /// Earliest valid time as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` while the assertion is not yet valid at `now`. + pub const fn is_not_yet_valid_at(self, now_unix_seconds: u64) -> bool { + self.0 > now_unix_seconds + } +} + +/// Expiry imposed by a separately verified delegation proof. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DelegationExpiry(u64); + +impl fmt::Debug for DelegationExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl DelegationExpiry { + /// Build a non-zero delegation expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidDelegationExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the delegation is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Freshness bound imposed by current community or enterprise admission. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AdmissionExpiry(u64); + +impl fmt::Debug for AdmissionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AdmissionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AdmissionExpiry { + /// Build a non-zero admission freshness bound. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAdmissionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Freshness bound as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when admission is no longer current at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Server-verified Nostr authority for a request or connection. +#[derive(PartialEq, Eq)] +pub struct NostrAuthority { + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl fmt::Debug for NostrAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NostrAuthority") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.proof_method) + .field("verified_delegation", &"[redacted]") + .finish() + } +} + +impl NostrAuthority { + pub(super) fn new(proof: VerifiedNostrProof) -> Self { + Self { + actor_pubkey: proof.actor_pubkey, + proof_method: proof.proof_method, + verified_delegation: proof.verified_delegation, + } + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Proof method used to authenticate the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Cryptographically verified owner for a delegated Nostr actor. + pub const fn verified_owner_pubkey(&self) -> Option { + match &self.verified_delegation { + Some(delegation) => Some(delegation.owner_pubkey()), + None => None, + } + } + + /// Cryptographically verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +/// Stable identity-provider principal. +/// +/// Equality uses the exact validated issuer and subject bytes. Construction +/// does not trim, case-fold, parse, or otherwise normalize either value; the +/// assertion verifier owns canonical validation before crossing this boundary. +/// Neither value is suitable for public events or general-purpose logs. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct FederatedPrincipal { + issuer: String, + subject: String, +} + +impl FederatedPrincipal { + /// Build an issuer-qualified principal from validated assertion claims. + pub fn new( + issuer: impl Into, + subject: impl Into, + ) -> Result { + let issuer = issuer.into(); + let subject = subject.into(); + if issuer.is_empty() { + return Err(AuthContextError::EmptyIssuer); + } + if subject.is_empty() { + return Err(AuthContextError::EmptySubject); + } + Ok(Self { issuer, subject }) + } + + /// Validated identity-provider issuer. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Stable, non-reassignable subject within the issuer namespace. + pub fn subject(&self) -> &str { + &self.subject + } +} + +impl fmt::Debug for FederatedPrincipal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FederatedPrincipal") + .field("issuer", &"[redacted]") + .field("subject", &"[redacted]") + .finish() + } +} + +/// Identity-provider attestation of the Nostr key proven by this request. +/// +/// The assertion verifier may construct this evidence only after requiring the +/// configured key claim, parsing it successfully, and proving that it names +/// the exact Nostr key. Absence or mismatch must fail rather than silently +/// falling back to another enrollment mode. The evidence is intentionally +/// move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedKeyAttestation { + pubkey: PublicKey, +} + +impl VerifiedKeyAttestation { + #[cfg(test)] + pub(crate) const fn new(pubkey: PublicKey) -> Self { + Self { pubkey } + } + + /// Nostr key named by the verified assertion claim. + pub const fn pubkey(&self) -> PublicKey { + self.pubkey + } +} + +impl fmt::Debug for VerifiedKeyAttestation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedKeyAttestation") + .field("pubkey", &"[redacted]") + .finish() + } +} + +/// Federated assertion accepted by the configured assertion verifier. +/// +/// The verifier must enforce an allowed algorithm and key, require correctly +/// typed `exp`, `iss`, and `aud` claims, validate the issuer and audience, and +/// reject a malformed `nbf` before constructing this evidence. Finalization +/// independently enforces the preserved `nbf` against server time. Raw +/// assertion claims cannot construct it from outside `buzz-auth`. Private +/// identity attributes and public display labels are deliberately excluded. +/// The evidence is intentionally move-only and has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedFederatedAssertion { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, +} + +impl VerifiedFederatedAssertion { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, + ) -> Self { + Self { + authorization_domain, + authorized_transport, + principal, + key_attestation, + transport, + not_before, + expires_at, + } + } + + /// Authorization domain for which the assertion was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the verifier authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Issuer-qualified principal from the verified assertion. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key attested by the verified assertion claim, when present. + pub const fn key_attestation(&self) -> Option<&VerifiedKeyAttestation> { + self.key_attestation.as_ref() + } + + /// Verified assertion delivery profile. + pub const fn transport(&self) -> AssertionTransport { + self.transport + } + + /// Earliest valid time preserved from the verified assertion, when present. + pub const fn not_before(&self) -> Option { + self.not_before + } + + /// Upper time bound carried by the verified assertion. + pub const fn expires_at(&self) -> AssertionExpiry { + self.expires_at + } +} + +impl fmt::Debug for VerifiedFederatedAssertion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedFederatedAssertion") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("principal", &self.principal) + .field("key_attestation", &"[redacted]") + .field("transport", &"[redacted]") + .field("not_before", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +/// Current admission for the owner of a delegated Nostr actor. +/// +/// This evidence is independent of a federated assertion: a delegated request +/// need not possess the owner's token. A provider adapter will construct it +/// only after confirming that the bound owner is currently admitted in the +/// same authorization domain. Construction remains crate-private so only the +/// validated provider finalizer can turn a current capability decision into +/// this move-only evidence. +#[derive(PartialEq, Eq)] +pub struct VerifiedOwnerAdmission { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, +} + +impl VerifiedOwnerAdmission { + // Consumed by the provider finalizer in the stacked capability contract. + #[allow(dead_code)] + pub(crate) const fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, + ) -> Self { + Self { + authorization_domain, + principal, + fresh_until, + } + } + + /// Authorization domain for which owner admission was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Issuer-qualified owner admitted by the provider. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Upper bound after which provider admission must be resolved again. + pub const fn fresh_until(&self) -> AdmissionExpiry { + self.fresh_until + } +} + +impl fmt::Debug for VerifiedOwnerAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedOwnerAdmission") + .field("authorization_domain", &"[redacted]") + .field("principal", &self.principal) + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Capability represented by a verified owner-to-delegate proof. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DelegationCapability { + /// Authorizes the complete request or connection represented by the context. + TransportWide, +} + +impl fmt::Debug for DelegationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Transport-wide delegation from a bound owner to the authenticated key. +/// +/// A verifier may construct this only after proving the capability authorizes +/// the complete target request or connection. Time-only constraints may be +/// reduced to [`DelegationExpiry`], but operation-, event-kind-, or +/// request-specific constraints must not be discarded or promoted into this +/// transport-wide evidence. This move-only evidence has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedTransportDelegation { + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + capability: DelegationCapability, + expires_at: Option, +} + +impl fmt::Debug for VerifiedTransportDelegation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedTransportDelegation") + .field("owner_pubkey", &"[redacted]") + .field("delegate_pubkey", &"[redacted]") + .field("capability", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +impl VerifiedTransportDelegation { + /// Build transport-wide evidence after validating both keys and confirming + /// that no narrower capability constraint is being discarded. + #[cfg(test)] + pub(crate) fn new_unrestricted( + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + ) -> Result { + if owner_pubkey == delegate_pubkey { + return Err(AuthContextError::SelfDelegation); + } + Ok(Self { + owner_pubkey, + delegate_pubkey, + capability: DelegationCapability::TransportWide, + expires_at, + }) + } + + /// Bound owner that authorized the delegate. + pub const fn owner_pubkey(&self) -> PublicKey { + self.owner_pubkey + } + + /// Authenticated delegate key. + pub const fn delegate_pubkey(&self) -> PublicKey { + self.delegate_pubkey + } + + /// Verified capability scope. + pub const fn capability(&self) -> DelegationCapability { + self.capability + } + + /// Optional upper bound imposed by the delegation proof. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +/// Cryptographically verified Nostr proof for one request or connection. +/// +/// Transport verifiers inside `buzz-auth` produce this evidence after checking +/// the signature and transport-specific binding. Raw request keys and claimed +/// proof methods cannot construct it in relay call sites. Conditional +/// delegation may be attached only when it has been fully evaluated for the +/// target operation or safely reduced to transport-wide evidence. The evidence +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedNostrProof { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl VerifiedNostrProof { + #[cfg(test)] + pub(crate) fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, + ) -> Result { + if !transport_accepts_proof(authorized_transport, proof_method) { + return Err(AuthContextError::TransportProofMismatch); + } + if verified_delegation + .as_ref() + .is_some_and(|delegation| delegation.delegate_pubkey() != actor_pubkey) + { + return Err(AuthContextError::DelegateKeyMismatch); + } + Ok(Self { + authorization_domain, + authorized_transport, + actor_pubkey, + proof_method, + verified_delegation, + }) + } + + /// Authorization domain for which the Nostr proof was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the proof authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method accepted by the verifier. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +impl fmt::Debug for VerifiedNostrProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedNostrProof") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.proof_method) + .field("verified_delegation", &"[redacted]") + .finish() + } +} + +/// Successful community admission and permissions for one decision. +/// +/// An authorization adapter may construct this value only after membership, +/// invite, moderation, or equivalent community policy has allowed the actor. +/// Durable identity enrollment and public assertion publication must not occur +/// before this evidence exists; future binding adapters should require a borrow +/// of it before committing either side effect. Raw request scopes and channel +/// identifiers cannot construct this value in relay call sites. The resolution +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct AuthorizedCommunityAccess { + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, +} + +impl AuthorizedCommunityAccess { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Self { + Self { + authorization_domain, + scopes, + channel_ids, + } + } + + /// Authorization domain for which the permissions were resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Permission scopes resolved for the decision. + pub fn scopes(&self) -> &[Scope] { + &self.scopes + } + + /// Optional channel restriction resolved for the decision. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + self.channel_ids.as_deref() + } + + /// Consume verified admission into the final immutable permissions. + pub(super) fn into_permissions(self) -> (Vec, Option>) { + (self.scopes, self.channel_ids) + } +} + +impl fmt::Debug for AuthorizedCommunityAccess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizedCommunityAccess") + .field("authorization_domain", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs new file mode 100644 index 0000000000..95883be426 --- /dev/null +++ b/crates/buzz-auth/src/context/mod.rs @@ -0,0 +1,656 @@ +//! Versioned, transport-neutral authorization context. +//! +//! Authentication adapters produce this context after verifying Nostr proof. +//! Federated identity is optional, but when present it remains distinct from +//! the Nostr authority that signed the request. Raw assertions and mutable +//! display claims never enter this type. + +use std::fmt; + +use buzz_core::{tenant::TenantContext, CommunityId}; +use nostr::PublicKey; +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::{ + AuthoritativeBindingResolution, BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, + FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, + VersionedBindingRef, +}; +pub use evidence::{ + AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, + AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, + FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, +}; +pub use reason::{AuthContextError, AuthorizationReason}; + +/// Version of the authorization-context contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthContextVersion { + /// Initial shared authorization-context contract. + V1, +} + +/// Federated authorization attached to a Nostr-authenticated actor. +#[derive(PartialEq, Eq)] +pub enum FederatedAuthorization { + /// This deployment does not require federated identity. + /// + /// An independently verified Nostr owner may still be present in the + /// [`NostrAuthority`] without acquiring a federated binding. + NotRequired, + /// The actor directly owns the active federated binding. + Direct { + /// Active identity-to-key binding. + /// + /// The binding carries its authoritative lifecycle result, so a caller + /// cannot relabel a binding enrolled in this decision as pre-existing. + binding: VersionedBindingRef, + /// Current assertion accepted by the configured verifier. + assertion: VerifiedFederatedAssertion, + }, + /// The actor is delegated by the owner of an active federated binding. + Delegated { + /// Owner's active binding. + owner: VersionedBindingRef, + /// Current admission resolved for the bound owner. + admission: VerifiedOwnerAdmission, + }, +} + +impl fmt::Debug for FederatedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedAuthorization") + .field(&"[redacted]") + .finish() + } +} + +/// Authoritative result consumed by the production finalizer. +/// +/// Unlike [`FederatedAuthorization`], this input cannot contain a raw +/// [`VersionedBindingRef`] or a caller-selected authorization reason. +#[derive(PartialEq, Eq)] +pub enum AuthoritativeFederatedResolution { + /// This domain's current policy does not require federated identity. + NotRequired, + /// Direct authority backed by an existing or atomically enrolled binding. + Direct { + /// 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 result for the existing owner binding. + owner: AuthoritativeBindingResolution, + /// Current admission resolved for the owner. + admission: VerifiedOwnerAdmission, + }, +} + +impl fmt::Debug for AuthoritativeFederatedResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthoritativeFederatedResolution") + .field(&"[redacted]") + .finish() + } +} + +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 { + tenant: TenantContext, + correlation_id: Uuid, + transport: AuthTransport, + nostr: NostrAuthority, + federated_policy: ResolvedFederatedPolicy, + federated: FederatedAuthorization, + scopes: Vec, + channel_ids: Option>, +} + +/// Server-verified inputs consumed by the V1 authorization finalizer. +#[derive(PartialEq, Eq)] +pub struct AuthContextInput { + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + 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. + pub fn new( + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + community_access: AuthorizedCommunityAccess, + ) -> Self { + Self { + tenant, + correlation_id, + nostr_proof, + community_access, + } + } + + #[allow(dead_code)] + pub(crate) const fn authorization_domain(&self) -> CommunityId { + self.tenant.community() + } + + #[allow(dead_code)] + pub(crate) const fn nostr_proof_authorization_domain(&self) -> CommunityId { + self.nostr_proof.authorization_domain() + } + + #[allow(dead_code)] + pub(crate) const fn community_access_authorization_domain(&self) -> CommunityId { + self.community_access.authorization_domain() + } + + #[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 proof_method(&self) -> AuthMethod { + self.nostr_proof.proof_method() + } + + #[allow(dead_code)] + pub(crate) const fn actor_pubkey(&self) -> PublicKey { + self.nostr_proof.actor_pubkey() + } + + #[allow(dead_code)] + pub(crate) const fn verified_owner_pubkey(&self) -> Option { + match self.nostr_proof.verified_delegation() { + Some(delegation) => Some(delegation.owner_pubkey()), + None => None, + } + } +} + +impl fmt::Debug for AuthContextV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthContextV1") + .field("authorization_domain", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("transport", &self.transport) + .field("nostr", &self.nostr) + .field("federated_policy", &self.federated_policy) + .field("federated", &self.federated) + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} + +/// Versioned result of successful request or connection authorization. +/// +/// This security-boundary type intentionally has no default or deserialization +/// path. Persisted or transported data must be re-verified and finalized rather +/// than decoded directly into an authorized context. +#[derive(PartialEq, Eq)] +pub enum AuthContext { + /// Initial shared authorization-context contract. + V1(AuthContextV1), +} + +impl fmt::Debug for AuthContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::V1(context) => formatter.debug_tuple("V1").field(context).finish(), + } + } +} + +impl AuthContext { + /// Finalize an immutable V1 context from authoritative binding evidence. + /// + /// 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, + now_unix_seconds: u64, + ) -> Result { + validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; + let authorization = match resolution { + AuthoritativeFederatedResolution::NotRequired => FederatedAuthorization::NotRequired, + AuthoritativeFederatedResolution::Direct { binding, assertion } => { + FederatedAuthorization::Direct { + binding: VersionedBindingRef::from_authoritative_resolution( + binding, + federated_policy.requirement(), + )?, + assertion, + } + } + AuthoritativeFederatedResolution::Delegated { owner, admission } => { + FederatedAuthorization::Delegated { + owner: VersionedBindingRef::from_existing_authoritative_resolution(owner)?, + admission, + } + } + }; + Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds) + } + + /// Validate all authorization evidence and finalize an immutable V1 context. + /// + /// Adapters must preserve this phase order: cryptographic proof and + /// read-only assertion validation; community admission and capability + /// resolution; atomic binding/enrollment; then finalization. A denial before + /// admission must not create or refresh a binding, claim membership, or + /// publish a public identity assertion. + /// + /// `now_unix_seconds` must come from the server clock for the authorization + /// decision being finalized. + pub(crate) fn finalize_v1( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + now_unix_seconds: u64, + ) -> Result { + let authorization_domain = input.tenant.community(); + let transport = input.nostr_proof.authorized_transport(); + if input.nostr_proof.authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; + if input.community_access.authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { + return Err(AuthContextError::TransportProofMismatch); + } + validate_federated_authorization( + authorization_domain, + transport, + &input.nostr_proof, + &federated_policy, + &authorization, + now_unix_seconds, + )?; + let nostr = NostrAuthority::new(input.nostr_proof); + let (scopes, channel_ids) = input.community_access.into_permissions(); + Ok(Self::V1(AuthContextV1 { + tenant: input.tenant, + correlation_id: input.correlation_id, + transport, + nostr, + federated_policy, + federated: authorization, + scopes, + channel_ids, + })) + } + + /// Contract version represented by this context. + pub const fn version(&self) -> AuthContextVersion { + match self { + Self::V1(_) => AuthContextVersion::V1, + } + } + + /// Server-resolved tenant for the request or connection. + pub const fn tenant(&self) -> &TenantContext { + match self { + Self::V1(context) => &context.tenant, + } + } + + /// Request or connection correlation identifier. + pub const fn correlation_id(&self) -> Uuid { + match self { + Self::V1(context) => context.correlation_id, + } + } + + /// Transport that established this authorization context. + pub const fn transport(&self) -> AuthTransport { + match self { + Self::V1(context) => context.transport, + } + } + + /// Verified Nostr authority. + pub const fn nostr(&self) -> &NostrAuthority { + match self { + Self::V1(context) => &context.nostr, + } + } + + /// Authenticated Nostr actor. + pub const fn pubkey(&self) -> PublicKey { + self.nostr().actor_pubkey() + } + + /// Proof method used to authenticate the Nostr actor. + pub const fn auth_method(&self) -> AuthMethod { + self.nostr().proof_method() + } + + /// Cryptographically verified owner for a delegated Nostr actor. + pub const fn agent_owner_pubkey(&self) -> Option { + self.nostr().verified_owner_pubkey() + } + + /// Federated authorization associated with the Nostr actor. + pub const fn federated_authorization(&self) -> &FederatedAuthorization { + match self { + Self::V1(context) => &context.federated, + } + } + + /// Federated-identity policy resolved for this authorization decision. + pub const fn federated_policy(&self) -> &ResolvedFederatedPolicy { + match self { + Self::V1(context) => &context.federated_policy, + } + } + + /// Stable reason for the successful authorization decision. + pub const fn authorization_reason(&self) -> AuthorizationReason { + match self.federated_authorization() { + FederatedAuthorization::NotRequired => AuthorizationReason::NostrOnly, + FederatedAuthorization::Direct { binding, .. } => binding.authorization_reason(), + FederatedAuthorization::Delegated { .. } => AuthorizationReason::DelegatedOwnerBinding, + } + } + + /// Permission scopes granted to the context. + pub fn scopes(&self) -> &[Scope] { + match self { + Self::V1(context) => &context.scopes, + } + } + + /// Optional channel restriction. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + match self { + Self::V1(context) => context.channel_ids.as_deref(), + } + } + + /// Returns `true` if this context includes the given scope. + pub fn has_scope(&self, scope: &Scope) -> bool { + self.scopes().contains(scope) + } +} + +fn validate_federated_policy_stamp( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if federated_policy.authorization_domain() != input.tenant.community() { + return Err(AuthContextError::PolicyDomainMismatch); + } + if federated_policy.stamp().correlation_id() != input.correlation_id { + return Err(AuthContextError::FederatedPolicyCorrelationMismatch); + } + if federated_policy + .stamp() + .is_not_yet_effective_at(now_unix_seconds) + { + return Err(AuthContextError::FederatedPolicyNotYetEffective); + } + if federated_policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired); + } + Ok(()) +} + +pub(super) const fn transport_accepts_proof( + transport: AuthTransport, + proof_method: AuthMethod, +) -> bool { + match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => { + matches!(proof_method, AuthMethod::Nip42) + } + AuthTransport::HttpBridge | AuthTransport::Git => matches!(proof_method, AuthMethod::Nip98), + AuthTransport::MediaUpload => { + matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom) + } + AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98), + } +} + +#[cfg(test)] +mod tests; + +fn validate_federated_authorization( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + nostr_proof: &VerifiedNostrProof, + federated_policy: &ResolvedFederatedPolicy, + authorization: &FederatedAuthorization, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + match (federated_policy.requirement(), authorization) { + (FederatedIdentityRequirement::Required(_), FederatedAuthorization::NotRequired) => { + return Err(AuthContextError::FederatedIdentityRequired); + } + (FederatedIdentityRequirement::NotRequired, FederatedAuthorization::NotRequired) => {} + (FederatedIdentityRequirement::NotRequired, _) => { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + } + (FederatedIdentityRequirement::Required(_), _) => {} + } + + let actor_pubkey = nostr_proof.actor_pubkey(); + let verified_delegation = nostr_proof.verified_delegation(); + match authorization { + FederatedAuthorization::NotRequired => {} + FederatedAuthorization::Direct { binding, assertion } => { + if binding.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if assertion.authorization_domain() != authorization_domain { + return Err(AuthContextError::AssertionDomainMismatch); + } + if assertion.authorized_transport() != authorized_transport { + return Err(AuthContextError::AssertionTransportMismatch); + } + if assertion.principal() != binding.principal() { + return Err(AuthContextError::AssertionPrincipalMismatch); + } + if verified_delegation.is_some() { + return Err(AuthContextError::DirectAuthorizationHasOwner); + } + if binding.bound_pubkey() != actor_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch); + } + validate_binding_time(binding, now_unix_seconds)?; + validate_assertion_time(assertion, now_unix_seconds)?; + let FederatedIdentityRequirement::Required(enrollment_mode) = + federated_policy.requirement() + else { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + }; + let reason = binding.authorization_reason(); + if !direct_reason_is_valid(reason, enrollment_mode, binding.source()) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + validate_enrollment_key_attestation(reason, binding.source(), assertion, actor_pubkey)?; + } + FederatedAuthorization::Delegated { owner, admission } => { + if owner.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if admission.authorization_domain() != authorization_domain { + return Err(AuthContextError::OwnerAdmissionDomainMismatch); + } + if admission.principal() != owner.principal() { + return Err(AuthContextError::OwnerAdmissionPrincipalMismatch); + } + let Some(delegation) = verified_delegation else { + return Err(AuthContextError::DelegationRequired); + }; + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(AuthContextError::DelegatedOwnerMismatch); + } + validate_binding_time(owner, now_unix_seconds)?; + if admission.fresh_until().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::OwnerAdmissionExpired); + } + if delegation + .expires_at() + .is_some_and(|expiry| expiry.is_expired_at(now_unix_seconds)) + { + return Err(AuthContextError::DelegationExpired); + } + } + } + Ok(()) +} + +fn validate_binding_time( + binding: &VersionedBindingRef, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if binding + .expires_at() + .is_some_and(|expiry| expiry.is_expired_at(now_unix_seconds)) + { + return Err(AuthContextError::BindingExpired); + } + Ok(()) +} + +fn validate_assertion_time( + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if assertion + .not_before() + .is_some_and(|not_before| not_before.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(AuthContextError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::AssertionExpired); + } + Ok(()) +} + +const fn direct_reason_is_valid( + reason: AuthorizationReason, + enrollment_mode: EnrollmentMode, + binding_source: BindingSource, +) -> bool { + match reason { + AuthorizationReason::ExistingBinding => true, + AuthorizationReason::EnrolledAttestedKey => { + matches!(enrollment_mode, EnrollmentMode::AttestedKey) + && matches!(binding_source, BindingSource::AttestedKey) + } + AuthorizationReason::EnrolledTofu => { + matches!(enrollment_mode, EnrollmentMode::Tofu) + && matches!( + binding_source, + // An attested-key binding is stronger provenance than + // TOFU. The decision reason records the enrollment policy + // while the stored source remains truthful and is never + // downgraded to TOFU. + BindingSource::Tofu | BindingSource::AttestedKey + ) + } + AuthorizationReason::NostrOnly | AuthorizationReason::DelegatedOwnerBinding => false, + } +} + +fn validate_enrollment_key_attestation( + reason: AuthorizationReason, + binding_source: BindingSource, + assertion: &VerifiedFederatedAssertion, + actor_pubkey: PublicKey, +) -> Result<(), AuthContextError> { + let requires_attestation = matches!(reason, AuthorizationReason::EnrolledAttestedKey) + || (matches!(reason, AuthorizationReason::EnrolledTofu) + && matches!(binding_source, BindingSource::AttestedKey)); + if let Some(attestation) = assertion.key_attestation() { + if attestation.pubkey() != actor_pubkey { + return Err(AuthContextError::KeyAttestationMismatch); + } + return Ok(()); + } + if requires_attestation { + return Err(AuthContextError::KeyAttestationRequired); + } + Ok(()) +} diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs new file mode 100644 index 0000000000..fe07e9a68f --- /dev/null +++ b/crates/buzz-auth/src/context/reason.rs @@ -0,0 +1,227 @@ +use std::fmt; + +use thiserror::Error; + +/// Stable reason for an allowed authorization decision. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationReason { + /// Only the configured Nostr proof was required. + NostrOnly, + /// An existing direct federated binding matched. + /// + /// Enrollment policy governs creation of new bindings. Resolution of an + /// existing active binding, including future lease checks, is a separate + /// lifecycle decision. + ExistingBinding, + /// A direct binding was created under attested-key enrollment. + EnrolledAttestedKey, + /// A direct binding was created under trust-on-first-use enrollment. + EnrolledTofu, + /// A verified delegate derived authority from a bound owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for AuthorizationReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationReason") + .field(&"[redacted]") + .finish() + } +} + +impl AuthorizationReason { + /// Stable audit and metric code for this decision. + pub const fn code(self) -> &'static str { + match self { + Self::NostrOnly => "authorization_allow_001", + Self::ExistingBinding => "authorization_allow_002", + Self::EnrolledAttestedKey => "authorization_allow_003", + Self::EnrolledTofu => "authorization_allow_004", + Self::DelegatedOwnerBinding => "authorization_allow_005", + } + } +} + +/// Invalid authorization-context construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AuthContextError { + /// Issuer was empty. + #[error("federated principal issuer must not be empty")] + EmptyIssuer, + /// Subject was empty. + #[error("federated principal subject must not be empty")] + EmptySubject, + /// Binding version was zero. + #[error("identity binding version must be greater than zero")] + InvalidBindingVersion, + /// Binding identifier was the nil UUID. + #[error("identity binding identifier must not be nil")] + InvalidBindingId, + /// Binding expiry was not a valid Unix timestamp. + #[error("identity binding expiry must be greater than zero")] + InvalidBindingExpiry, + /// Enrollment-policy identifier was nil. + #[error("federated enrollment-policy identifier must not be nil")] + InvalidFederatedPolicyId, + /// Enrollment-policy epoch was zero. + #[error("federated enrollment-policy epoch must be greater than zero")] + InvalidFederatedPolicyEpoch, + /// Enrollment-policy correlation identifier was nil. + #[error("federated enrollment-policy correlation must not be nil")] + InvalidFederatedPolicyCorrelation, + /// Enrollment-policy effective interval was empty or reversed. + #[error("federated enrollment-policy effective interval is invalid")] + InvalidFederatedPolicyInterval, + /// Assertion expiry was not a valid Unix timestamp. + #[error("federated assertion expiry must be greater than zero")] + InvalidAssertionExpiry, + /// Delegation expiry was not a valid Unix timestamp. + #[error("delegation expiry must be greater than zero")] + InvalidDelegationExpiry, + /// Admission expiry was not a valid Unix timestamp. + #[error("admission expiry must be greater than zero")] + InvalidAdmissionExpiry, + /// Assertion had expired when authorization was evaluated. + #[error("federated assertion has expired")] + AssertionExpired, + /// Binding was no longer authorization-eligible when evaluated. + #[error("identity binding has expired")] + BindingExpired, + /// Enrollment policy was resolved for another authorization decision. + #[error("federated enrollment policy does not match the authorization decision")] + FederatedPolicyCorrelationMismatch, + /// Enrollment policy was used before its effective interval. + #[error("federated enrollment policy is not yet effective")] + FederatedPolicyNotYetEffective, + /// Enrollment policy was used after its effective interval. + #[error("federated enrollment policy has expired")] + FederatedPolicyExpired, + /// Assertion was used before its validated not-before bound. + #[error("federated assertion is not yet valid")] + AssertionNotYetValid, + /// Required key-attestation evidence was absent. + #[error("verified key attestation is required for this enrollment result")] + KeyAttestationRequired, + /// Key-attestation evidence named a different Nostr actor. + #[error("verified key attestation does not match the authenticated Nostr key")] + KeyAttestationMismatch, + /// Owner admission was no longer current when authorization was evaluated. + #[error("owner admission is no longer current")] + OwnerAdmissionExpired, + /// Resolved policy required federated identity, but none was supplied. + #[error("federated identity is required by the resolved authorization policy")] + FederatedIdentityRequired, + /// Federated authorization was supplied for a domain that does not use it. + #[error("federated authorization does not match the resolved authorization policy")] + UnexpectedFederatedAuthorization, + /// Delegation had expired when authorization was evaluated. + #[error("verified delegation has expired")] + DelegationExpired, + /// Owner and delegate were the same key. + #[error("delegation owner and delegate must be different keys")] + SelfDelegation, + /// Direct authorization reason did not match its enrollment policy or source. + #[error("federated authorization reason does not match binding provenance")] + InvalidAuthorizationReason, + /// Binding belonged to a different server-resolved authorization domain. + #[error("federated binding does not belong to the authorization domain")] + BindingDomainMismatch, + /// Nostr proof was verified for a different authorization domain. + #[error("Nostr proof does not belong to the authorization domain")] + NostrProofDomainMismatch, + /// Federated policy was resolved for a different authorization domain. + #[error("federated policy does not belong to the authorization domain")] + PolicyDomainMismatch, + /// Community admission was resolved for a different authorization domain. + #[error("community admission does not belong to the authorization domain")] + CommunityAccessDomainMismatch, + /// Assertion was verified for a different authorization domain. + #[error("federated assertion does not belong to the authorization domain")] + AssertionDomainMismatch, + /// Assertion was verified for a different transport. + #[error("federated assertion does not match the authorization transport")] + AssertionTransportMismatch, + /// Owner admission was resolved for a different authorization domain. + #[error("owner admission does not belong to the authorization domain")] + OwnerAdmissionDomainMismatch, + /// Owner admission represented a different bound principal. + #[error("owner admission principal does not match the active binding")] + OwnerAdmissionPrincipalMismatch, + /// Validated assertion principal did not match the active binding. + #[error("federated assertion principal does not match the active binding")] + AssertionPrincipalMismatch, + /// Proof method was not valid for the transport being authorized. + #[error("Nostr proof method does not match authorization transport")] + TransportProofMismatch, + /// Direct federated authorization was attached to a delegated Nostr actor. + #[error("direct federated authorization cannot include a delegated Nostr owner")] + DirectAuthorizationHasOwner, + /// Direct binding key did not match the authenticated actor. + #[error("direct federated binding does not match the authenticated Nostr key")] + DirectBindingKeyMismatch, + /// Delegated authorization named a different actor. + #[error("delegated federated authorization does not match the authenticated Nostr key")] + DelegateKeyMismatch, + /// Delegated federated authorization lacked verified Nostr delegation. + #[error("delegated federated authorization requires verified Nostr delegation")] + DelegationRequired, + /// 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 { + /// Stable audit and metric code for this rejected finalization. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyIssuer => "federated_principal_empty_issuer", + Self::EmptySubject => "federated_principal_empty_subject", + Self::InvalidBindingVersion => "federated_binding_invalid_version", + Self::InvalidBindingId => "federated_binding_invalid_id", + Self::InvalidBindingExpiry => "federated_binding_invalid_expiry", + Self::InvalidFederatedPolicyId => "federated_policy_invalid_id", + Self::InvalidFederatedPolicyEpoch => "federated_policy_invalid_epoch", + Self::InvalidFederatedPolicyCorrelation => "federated_policy_invalid_correlation", + Self::InvalidFederatedPolicyInterval => "federated_policy_invalid_interval", + Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry", + Self::InvalidDelegationExpiry => "delegation_invalid_expiry", + Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry", + Self::AssertionExpired => "federated_assertion_expired", + Self::BindingExpired => "federated_binding_expired", + Self::FederatedPolicyCorrelationMismatch => "federated_policy_correlation_mismatch", + Self::FederatedPolicyNotYetEffective => "federated_policy_not_yet_effective", + Self::FederatedPolicyExpired => "federated_policy_expired", + Self::AssertionNotYetValid => "federated_assertion_not_yet_valid", + Self::KeyAttestationRequired => "federated_key_attestation_required", + Self::KeyAttestationMismatch => "federated_key_attestation_mismatch", + Self::OwnerAdmissionExpired => "owner_admission_expired", + Self::FederatedIdentityRequired => "federated_identity_required", + Self::UnexpectedFederatedAuthorization => "federated_authorization_unexpected", + Self::DelegationExpired => "delegation_expired", + Self::SelfDelegation => "delegation_self_reference", + Self::InvalidAuthorizationReason => "federated_binding_invalid_reason", + Self::BindingDomainMismatch => "federated_binding_domain_mismatch", + Self::NostrProofDomainMismatch => "nostr_proof_domain_mismatch", + Self::PolicyDomainMismatch => "federated_policy_domain_mismatch", + Self::CommunityAccessDomainMismatch => "community_access_domain_mismatch", + Self::AssertionDomainMismatch => "federated_assertion_domain_mismatch", + Self::AssertionTransportMismatch => "federated_assertion_transport_mismatch", + Self::OwnerAdmissionDomainMismatch => "owner_admission_domain_mismatch", + Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch", + Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch", + Self::TransportProofMismatch => "nostr_transport_proof_mismatch", + Self::DirectAuthorizationHasOwner => "federated_direct_has_owner", + Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch", + 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 new file mode 100644 index 0000000000..528e04e5f5 --- /dev/null +++ b/crates/buzz-auth/src/context/tests.rs @@ -0,0 +1,2019 @@ +use super::*; +use buzz_core::CommunityId; +use nostr::Keys; + +fn tenant(value: u128) -> TenantContext { + TenantContext::resolved(authorization_domain(value), "relay.example") +} + +fn authorization_domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn assertion( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + assertion_in(1, authorized_transport, principal, transport, expiry) +} + +fn assertion_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + assertion_with_bounds_in( + domain, + authorized_transport, + principal, + transport, + None, + expiry, + ) +} + +fn assertion_with_bounds_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + not_before: Option, + expiry: u64, +) -> VerifiedFederatedAssertion { + VerifiedFederatedAssertion::new( + authorization_domain(domain), + authorized_transport, + principal, + None, + transport, + not_before.map(AssertionNotBefore::new), + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn assertion_with_attested_key( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, + attested_pubkey: PublicKey, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + VerifiedFederatedAssertion::new( + authorization_domain(1), + authorized_transport, + principal, + Some(VerifiedKeyAttestation::new(attested_pubkey)), + transport, + None, + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn policy_not_required() -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::not_required(authorization_domain(1)) +} + +fn policy_required(enrollment_mode: EnrollmentMode) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::required(authorization_domain(1), enrollment_mode) +} + +fn policy_with_lineage( + enrollment_mode: EnrollmentMode, + correlation_id: Uuid, + effective_from: u64, + effective_until: u64, +) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 7, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + effective_from, + effective_until, + ) + .expect("synthetic federated policy lineage is valid"), + ) +} + +fn binding(pubkey: PublicKey) -> VersionedBindingRef { + binding_in(1, pubkey) +} + +fn authoritative_binding_evidence( + pubkey: PublicKey, + source: BindingSource, +) -> AuthoritativeBindingEvidence { + AuthoritativeBindingEvidence::new( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + ) + .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) +} + +fn binding_with_source_in( + domain: u128, + pubkey: PublicKey, + source: BindingSource, +) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + authorization_domain(domain), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + ) + .expect("synthetic binding identifier is valid") +} + +fn enrolled_binding( + pubkey: PublicKey, + source: BindingSource, + reason: AuthorizationReason, +) -> VersionedBindingRef { + VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + reason, + ) + .expect("synthetic enrolled binding is valid") +} + +fn expiring_binding(pubkey: PublicKey, expires_at: u64) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + Some(BindingExpiry::new(expires_at).expect("synthetic binding expiry is valid")), + BindingSource::AttestedKey, + ) + .expect("synthetic binding identifier is valid") +} + +fn input( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, +) -> AuthContextInput { + input_with_delegation_expiry(actor_pubkey, transport, verified_owner_pubkey, 200) +} + +fn input_with_delegation_expiry( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, + delegation_expiry: u64, +) -> AuthContextInput { + let verified_delegation = verified_owner_pubkey.map(|owner_pubkey| { + VerifiedTransportDelegation::new_unrestricted( + owner_pubkey, + actor_pubkey, + Some( + DelegationExpiry::new(delegation_expiry) + .expect("synthetic delegation expiry is valid"), + ), + ) + .expect("synthetic owner and delegate are distinct") + }); + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + transport, + actor_pubkey, + proof_method, + verified_delegation, + ) + .expect("synthetic Nostr proof is internally consistent"), + AuthorizedCommunityAccess::new(authorization_domain(1), Scope::all_known(), None), + ) +} + +fn proof_in(domain: u128, transport: AuthTransport, actor_pubkey: PublicKey) -> VerifiedNostrProof { + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + VerifiedNostrProof::new( + authorization_domain(domain), + transport, + actor_pubkey, + proof_method, + None, + ) + .expect("synthetic Nostr proof is valid") +} + +fn community_access_in(domain: u128) -> AuthorizedCommunityAccess { + AuthorizedCommunityAccess::new(authorization_domain(domain), Scope::all_known(), None) +} + +fn delegated_authorization( + domain: u128, + owner_pubkey: PublicKey, + admission_principal: FederatedPrincipal, + admission_expiry: u64, +) -> FederatedAuthorization { + FederatedAuthorization::Delegated { + owner: binding_in(domain, owner_pubkey), + admission: VerifiedOwnerAdmission::new( + authorization_domain(domain), + admission_principal, + AdmissionExpiry::new(admission_expiry).expect("synthetic admission expiry is valid"), + ), + } +} + +#[test] +fn context_preserves_server_resolved_authority() { + let keys = Keys::generate(); + let correlation_id = Uuid::from_u128(2); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + correlation_id, + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + keys.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + None, + ), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr-only policy is final authorization"); + + assert_eq!(context.version(), AuthContextVersion::V1); + assert_eq!(context.tenant().community().as_uuid(), &Uuid::from_u128(1)); + assert_eq!(context.correlation_id(), correlation_id); + assert_eq!(context.transport(), AuthTransport::RelayWebSocket); + assert_eq!(context.pubkey(), keys.public_key()); + assert_eq!(context.auth_method(), AuthMethod::Nip42); + assert_eq!( + context.federated_policy().requirement(), + FederatedIdentityRequirement::NotRequired + ); + assert!(context.has_scope(&Scope::MessagesRead)); + assert_eq!( + context.federated_authorization(), + &FederatedAuthorization::NotRequired + ); +} + +#[test] +fn direct_authorization_requires_the_authenticated_key() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(other.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a direct binding for another key must be rejected"); + assert_eq!(error, AuthContextError::DirectBindingKeyMismatch); +} + +#[test] +fn delegated_authorization_requires_the_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect("verified owner and delegate match"); + + assert!(matches!( + context.federated_authorization(), + FederatedAuthorization::Delegated { .. } + )); +} + +#[test] +fn delegated_authorization_requires_verified_delegation() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization requires verifier-issued proof"); + + assert_eq!(error, AuthContextError::DelegationRequired); + assert_eq!(error.code(), "federated_delegation_required"); +} + +#[test] +fn principal_debug_output_redacts_claim_values() { + let principal = FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid"); + let output = format!("{principal:?}"); + + assert_eq!( + output, + "FederatedPrincipal { issuer: \"[redacted]\", subject: \"[redacted]\" }" + ); +} + +#[test] +fn principal_preserves_exact_validated_values() { + let principal = FederatedPrincipal::new(" HTTPS://IDP.EXAMPLE/ ", " Subject-123 ") + .expect("non-empty validated values are accepted exactly"); + + assert_eq!(principal.issuer(), " HTTPS://IDP.EXAMPLE/ "); + assert_eq!(principal.subject(), " Subject-123 "); + assert_eq!( + FederatedPrincipal::new("", "subject-123"), + Err(AuthContextError::EmptyIssuer) + ); + assert_eq!( + FederatedPrincipal::new("https://idp.example", ""), + Err(AuthContextError::EmptySubject) + ); +} + +#[test] +fn context_debug_output_omits_tenant_host() { + let actor = Keys::generate(); + let channel_id = Uuid::from_u128(20); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + Some(vec![channel_id]), + ), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("matching direct authorization is valid"); + + assert_eq!( + format!("{context:?}"), + concat!( + "V1(AuthContextV1 { authorization_domain: \"[redacted]\", ", + "correlation_id: \"[redacted]\", transport: RelayWebSocket, ", + "nostr: NostrAuthority { actor_pubkey: \"[redacted]\", ", + "proof_method: Nip42, verified_delegation: \"[redacted]\" }, ", + "federated_policy: ResolvedFederatedPolicy { ", + "stamp: FederatedPolicyStamp { authorization_domain: \"[redacted]\", ", + "policy_id: \"[redacted]\", epoch: \"[redacted]\", ", + "correlation_id: \"[redacted]\", requirement: \"[redacted]\", ", + "effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ", + "federated: FederatedAuthorization(\"[redacted]\"), ", + "scopes: \"[redacted]\", channel_ids: \"[redacted]\" })" + ) + ); +} + +#[test] +fn direct_authorization_rejects_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("direct authorization cannot derive authority from an owner"); + + assert_eq!(error, AuthContextError::DirectAuthorizationHasOwner); +} + +#[test] +fn delegated_authorization_requires_current_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 100), + 100, + ) + .expect_err("delegated authorization must not survive owner admission expiry"); + + assert_eq!(error, AuthContextError::OwnerAdmissionExpired); +} + +#[test] +fn delegated_authorization_rejects_cross_domain_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("delegated owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} + +#[test] +fn delegated_authorization_requires_the_owner_admission_principal() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), admission_principal, 200), + 100, + ) + .expect_err("current admission must identify the bound owner"); + + assert_eq!(error, AuthContextError::OwnerAdmissionPrincipalMismatch); +} + +#[test] +fn delegated_authorization_rejects_an_expired_proof() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input_with_delegation_expiry( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + 100, + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must not survive delegation expiry"); + + assert_eq!(error, AuthContextError::DelegationExpired); +} + +#[test] +fn verified_nostr_proof_requires_the_authenticated_delegate() { + let actor = Keys::generate(); + let other_delegate = Keys::generate(); + let owner = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + other_delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect_err("verified proof must name the authenticated actor"); + + assert_eq!(error, AuthContextError::DelegateKeyMismatch); +} + +#[test] +fn delegated_authorization_requires_the_bound_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let other_owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(other_owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must match the verified owner"); + + assert_eq!(error, AuthContextError::DelegatedOwnerMismatch); +} + +#[test] +fn delegated_binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(2, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("a delegated binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); +} + +#[test] +fn zero_binding_version_is_rejected() { + assert_eq!( + BindingVersion::new(0), + Err(AuthContextError::InvalidBindingVersion) + ); +} + +#[test] +fn zero_binding_expiry_is_rejected() { + assert_eq!( + BindingExpiry::new(0), + Err(AuthContextError::InvalidBindingExpiry) + ); +} + +#[test] +fn nil_binding_identifier_is_rejected() { + let actor = Keys::generate(); + let error = VersionedBindingRef::new_existing_active_for_test( + CommunityId::from_uuid(Uuid::from_u128(1)), + Uuid::nil(), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + ) + .expect_err("nil is not a stable binding identifier"); + + assert_eq!(error, AuthContextError::InvalidBindingId); + assert_eq!(error.code(), "federated_binding_invalid_id"); +} + +#[test] +fn evidence_value_debug_output_redacts_numeric_values() { + let assertion_expiry = AssertionExpiry::new(200).expect("synthetic expiry is valid"); + let assertion_not_before = AssertionNotBefore::new(100); + let delegation_expiry = DelegationExpiry::new(300).expect("synthetic expiry is valid"); + let admission_expiry = AdmissionExpiry::new(350).expect("synthetic expiry is valid"); + let binding_expiry = BindingExpiry::new(375).expect("synthetic expiry is valid"); + let binding_version = BindingVersion::new(400).expect("synthetic version is valid"); + + assert_eq!( + format!("{assertion_expiry:?}"), + "AssertionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{assertion_not_before:?}"), + "AssertionNotBefore(\"[redacted]\")" + ); + assert_eq!( + format!("{delegation_expiry:?}"), + "DelegationExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{admission_expiry:?}"), + "AdmissionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{binding_expiry:?}"), + "BindingExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{binding_version:?}"), + "BindingVersion(\"[redacted]\")" + ); +} + +#[test] +fn authority_adapter_error_debug_output_redacts_storage_detail() { + let error = AuthorityAdapterError::adapter("private-storage-detail"); + + let rendered = format!("{error:?}"); + assert_eq!( + rendered, + "AuthorityAdapterError { variant: \"Adapter\", detail: \"[redacted]\" }" + ); + assert!(!rendered.contains("private-storage-detail")); +} + +#[test] +fn zero_owner_admission_expiry_is_rejected() { + assert_eq!( + AdmissionExpiry::new(0), + Err(AuthContextError::InvalidAdmissionExpiry) + ); +} + +#[test] +fn owner_admission_debug_output_is_fully_redacted() { + let admission = VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ); + + assert_eq!( + format!("{admission:?}"), + concat!( + "VerifiedOwnerAdmission { authorization_domain: \"[redacted]\", ", + "principal: FederatedPrincipal { issuer: \"[redacted]\", ", + "subject: \"[redacted]\" }, fresh_until: \"[redacted]\" }" + ) + ); +} + +#[test] +fn verified_assertion_debug_output_is_fully_redacted() { + let actor = Keys::generate(); + let assertion = VerifiedFederatedAssertion::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(100)), + AssertionExpiry::new(200).expect("synthetic assertion expiry is valid"), + ); + + assert_eq!( + format!("{assertion:?}"), + concat!( + "VerifiedFederatedAssertion { authorization_domain: \"[redacted]\", ", + "authorized_transport: RelayWebSocket, principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "key_attestation: \"[redacted]\", transport: \"[redacted]\", ", + "not_before: \"[redacted]\", expires_at: \"[redacted]\" }" + ) + ); +} + +#[test] +fn direct_authorization_rejects_expired_assertions() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::ClientAttached, 100), + }, + 100, + ) + .expect_err("authorization must not survive assertion expiry"); + + assert_eq!(error, AuthContextError::AssertionExpired); + assert_eq!(error.code(), "federated_assertion_expired"); +} + +#[test] +fn direct_authorization_rejects_binding_at_exact_expiry() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: expiring_binding(actor.public_key(), 100), + assertion: assertion(principal(), AssertionTransport::ClientAttached, 200), + }, + 100, + ) + .expect_err("authorization must not survive binding expiry"); + + assert_eq!(error, AuthContextError::BindingExpired); + assert_eq!(error.code(), "federated_binding_expired"); +} + +#[test] +fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { + let owner = Keys::generate(); + let delegate = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + delegate.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: expiring_binding(owner.public_key(), 100), + admission: VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("delegated authorization must not survive owner-binding expiry"); + + assert_eq!(error, AuthContextError::BindingExpired); +} + +#[test] +fn direct_authorization_rejects_a_future_assertion() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_with_bounds_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::ClientAttached, + Some(101), + 200, + ), + }, + 100, + ) + .expect_err("authorization must enforce the assertion's not-before bound"); + + assert_eq!(error, AuthContextError::AssertionNotYetValid); + assert_eq!(error.code(), "federated_assertion_not_yet_valid"); +} + +#[test] +fn direct_authorization_requires_the_assertion_principal() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion( + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("the current assertion must identify the bound principal"); + + assert_eq!(error, AuthContextError::AssertionPrincipalMismatch); + assert_eq!(error.code(), "federated_assertion_principal_mismatch"); +} + +#[test] +fn enrolled_reason_must_match_policy_and_binding_source() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Provisioned), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }, + 100, + ) + .expect_err("provisioned mode cannot enroll during authorization"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn existing_active_bindings_are_independent_of_enrollment_mode() { + let actor = Keys::generate(); + let enrollment_modes = [ + EnrollmentMode::AttestedKey, + EnrollmentMode::Provisioned, + EnrollmentMode::Tofu, + ]; + let binding_sources = [ + BindingSource::AttestedKey, + BindingSource::Provisioned, + BindingSource::Tofu, + ]; + + for enrollment_mode in enrollment_modes { + for binding_source in binding_sources { + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(enrollment_mode), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), binding_source), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("enrollment mode governs new bindings, not existing active bindings"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + } + } +} + +#[test] +fn binding_lifecycle_result_owns_the_authorization_reason() { + let actor = Keys::generate(); + let existing = binding(actor.public_key()); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + + let enrolled = enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ); + assert_eq!( + enrolled.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); + + let error = VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + AuthorizationReason::ExistingBinding, + ) + .expect_err("fresh enrollment cannot be relabeled as an existing binding"); + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn attested_enrollment_requires_matching_verified_key_evidence() { + let actor = Keys::generate(); + let other = Keys::generate(); + + let missing = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("attested enrollment cannot silently accept a missing key claim"); + assert_eq!(missing, AuthContextError::KeyAttestationRequired); + + let mismatch = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("attested enrollment cannot accept another Nostr key"); + assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch); + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }, + 100, + ) + .expect("matching verified key evidence permits attested enrollment"); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); +} + +#[test] +fn present_key_attestation_never_ignores_an_actor_mismatch() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), BindingSource::Tofu), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("a present but mismatched key claim must never be ignored"); + + assert_eq!(error, AuthContextError::KeyAttestationMismatch); +} + +#[test] +fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() { + let actor = Keys::generate(); + let authorization = FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledTofu, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }; + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + authorization, + 100, + ) + .expect("TOFU policy may retain stronger attested-key provenance"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledTofu + ); +} + +#[test] +fn authorization_reason_codes_are_unique_and_redaction_safe() { + let reasons = [ + AuthorizationReason::NostrOnly, + AuthorizationReason::ExistingBinding, + AuthorizationReason::EnrolledAttestedKey, + AuthorizationReason::EnrolledTofu, + AuthorizationReason::DelegatedOwnerBinding, + ]; + let mut codes = reasons + .iter() + .copied() + .map(AuthorizationReason::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), reasons.len()); + for code in codes { + assert!(code.starts_with("authorization_allow_")); + assert!(!code.contains("tofu")); + assert!(!code.contains("attest")); + assert!(!code.contains("provision")); + assert!(!code.contains("provider")); + } +} + +#[test] +fn authorization_error_codes_are_unique_and_provider_neutral() { + let errors = [ + AuthContextError::EmptyIssuer, + AuthContextError::EmptySubject, + AuthContextError::InvalidBindingVersion, + AuthContextError::InvalidBindingId, + AuthContextError::InvalidBindingExpiry, + AuthContextError::InvalidFederatedPolicyId, + AuthContextError::InvalidFederatedPolicyEpoch, + AuthContextError::InvalidFederatedPolicyCorrelation, + AuthContextError::InvalidFederatedPolicyInterval, + AuthContextError::InvalidAssertionExpiry, + AuthContextError::InvalidDelegationExpiry, + AuthContextError::InvalidAdmissionExpiry, + AuthContextError::AssertionExpired, + AuthContextError::BindingExpired, + AuthContextError::FederatedPolicyCorrelationMismatch, + AuthContextError::FederatedPolicyNotYetEffective, + AuthContextError::FederatedPolicyExpired, + AuthContextError::AssertionNotYetValid, + AuthContextError::KeyAttestationRequired, + AuthContextError::KeyAttestationMismatch, + AuthContextError::OwnerAdmissionExpired, + AuthContextError::FederatedIdentityRequired, + AuthContextError::UnexpectedFederatedAuthorization, + AuthContextError::DelegationExpired, + AuthContextError::SelfDelegation, + AuthContextError::InvalidAuthorizationReason, + AuthContextError::BindingDomainMismatch, + AuthContextError::NostrProofDomainMismatch, + AuthContextError::PolicyDomainMismatch, + AuthContextError::CommunityAccessDomainMismatch, + AuthContextError::AssertionDomainMismatch, + AuthContextError::AssertionTransportMismatch, + AuthContextError::OwnerAdmissionDomainMismatch, + AuthContextError::OwnerAdmissionPrincipalMismatch, + AuthContextError::AssertionPrincipalMismatch, + AuthContextError::TransportProofMismatch, + AuthContextError::DirectAuthorizationHasOwner, + AuthContextError::DirectBindingKeyMismatch, + AuthContextError::DelegateKeyMismatch, + AuthContextError::DelegationRequired, + AuthContextError::DelegatedOwnerMismatch, + AuthContextError::DelegatedBindingNotExistingActive, + ]; + let mut codes = errors + .iter() + .copied() + .map(AuthContextError::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), errors.len()); + for code in codes { + assert!(!code.contains("registry")); + assert!(!code.contains("proxy")); + assert!(!code.contains("role")); + assert!(!code.contains("group")); + } +} + +#[test] +fn security_posture_debug_output_is_fully_redacted() { + assert_eq!( + format!("{:?}", EnrollmentMode::AttestedKey), + "EnrollmentMode(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + FederatedIdentityRequirement::Required(EnrollmentMode::Tofu) + ), + "FederatedIdentityRequirement(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", BindingSource::Provisioned), + "BindingSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AssertionTransport::TrustedProxy), + "AssertionTransport(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AuthorizationReason::EnrolledTofu), + "AuthorizationReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", DelegationCapability::TransportWide), + "DelegationCapability(\"[redacted]\")" + ); + let key = Keys::generate(); + assert_eq!( + format!("{:?}", VerifiedKeyAttestation::new(key.public_key())), + "VerifiedKeyAttestation { pubkey: \"[redacted]\" }" + ); + assert_eq!( + format!("{:?}", binding(key.public_key())), + concat!( + "VersionedBindingRef { authorization_domain: \"[redacted]\", ", + "binding_id: \"[redacted]\", principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "bound_pubkey: \"[redacted]\", binding_version: \"[redacted]\", ", + "expires_at: \"[redacted]\", source: \"[redacted]\", ", + "resolution_reason: \"[redacted]\" }" + ) + ); + assert_eq!( + format!("{:?}", FederatedAuthorization::NotRequired), + "FederatedAuthorization(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", policy_required(EnrollmentMode::AttestedKey)), + concat!( + "ResolvedFederatedPolicy { stamp: FederatedPolicyStamp { ", + "authorization_domain: \"[redacted]\", policy_id: \"[redacted]\", ", + "epoch: \"[redacted]\", correlation_id: \"[redacted]\", ", + "requirement: \"[redacted]\", effective_from: \"[redacted]\", ", + "effective_until: \"[redacted]\" } }" + ) + ); +} + +#[test] +fn federated_policy_must_match_the_authorization_correlation() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("policy evidence from another decision must not finalize"); + + assert_eq!(error, AuthContextError::FederatedPolicyCorrelationMismatch); +} + +#[test] +fn federated_policy_effective_interval_is_half_open() { + let actor = Keys::generate(); + let not_yet_effective = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300), + }, + 100, + ) + .expect_err("policy must not authorize before its effective interval"); + assert_eq!( + not_yet_effective, + AuthContextError::FederatedPolicyNotYetEffective + ); + + let expired = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300), + }, + 100, + ) + .expect_err("policy must deny at its exact exclusive bound"); + assert_eq!(expired, AuthContextError::FederatedPolicyExpired); +} + +#[test] +fn federated_policy_stamp_rejects_invalid_lineage() { + let requirement = FederatedIdentityRequirement::Required(EnrollmentMode::Provisioned); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::nil(), + 1, + Uuid::from_u128(2), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyId) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 0, + Uuid::from_u128(2), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyEpoch) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 1, + Uuid::nil(), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyCorrelation) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + requirement, + 200, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyInterval) + ); +} + +#[test] +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, + None, + ), + policy_required(EnrollmentMode::Tofu), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::existing_active( + authoritative_binding_evidence( + existing_actor.public_key(), + BindingSource::Provisioned, + ), + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("existing authoritative binding is eligible"); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + + let attested_actor = Keys::generate(); + let attested = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + attested_actor.public_key(), + AuthTransport::RelayWebSocket, + None, + ), + policy_required(EnrollmentMode::AttestedKey), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence( + attested_actor.public_key(), + BindingSource::AttestedKey, + ), + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + attested_actor.public_key(), + ), + }, + 100, + ) + .expect("attested enrollment result is eligible"); + assert_eq!( + attested.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); + + 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 { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(tofu_actor.public_key(), BindingSource::AttestedKey), + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + tofu_actor.public_key(), + ), + }, + 100, + ) + .expect("stronger attested provenance remains valid under TOFU enrollment"); + assert_eq!( + tofu.authorization_reason(), + AuthorizationReason::EnrolledTofu + ); +} + +#[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 { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(actor.public_key(), BindingSource::Provisioned), + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("ordinary finalization cannot relabel provisioned state as enrollment"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn authoritative_finalizer_carries_binding_expiry() { + let actor = Keys::generate(); + let evidence = AuthoritativeBindingEvidence::new( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(100).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .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 { + binding: AuthoritativeBindingResolution::existing_active(evidence), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("production finalization must preserve the authoritative binding bound"); + + 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(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("TOFU policy must emit the TOFU enrollment reason"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn transport_and_proof_method_must_agree() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip98, + None, + ) + .expect_err("HTTP proof must not authorize a relay WebSocket"); + assert_eq!(error, AuthContextError::TransportProofMismatch); +} + +#[test] +fn blossom_upload_proof_cannot_authorize_media_downloads() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaDownload, + actor.public_key(), + AuthMethod::Blossom, + None, + ) + .expect_err("upload-only proof must not be widened to media download authority"); + assert_eq!(error, AuthContextError::TransportProofMismatch); + + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaUpload, + actor.public_key(), + AuthMethod::Blossom, + None, + ) + .expect("Blossom proof may authorize the verified upload operation"); +} + +#[test] +fn binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding_in(2, actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); + assert_eq!(error.code(), "federated_binding_domain_mismatch"); +} + +#[test] +fn nostr_only_authorization_may_preserve_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr delegation remains independent of federated policy"); + + assert_eq!(context.agent_owner_pubkey(), Some(owner.public_key())); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::NostrOnly + ); +} + +#[test] +fn transport_delegation_rejects_self_reference() { + let actor = Keys::generate(); + let error = + VerifiedTransportDelegation::new_unrestricted(actor.public_key(), actor.public_key(), None) + .expect_err("an actor cannot be its own verified owner"); + + assert_eq!(error, AuthContextError::SelfDelegation); +} + +#[test] +fn transport_delegation_is_explicitly_transport_wide() { + let owner = Keys::generate(); + let delegate = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + + assert_eq!(delegation.capability(), DelegationCapability::TransportWide); +} + +#[test] +fn required_policy_rejects_nostr_only_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("required federated identity cannot be bypassed by the caller"); + + assert_eq!(error, AuthContextError::FederatedIdentityRequired); + assert_eq!(error.code(), "federated_identity_required"); +} + +#[test] +fn not_required_policy_rejects_federated_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_not_required(), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("federated evidence cannot override the resolved domain policy"); + + assert_eq!(error, AuthContextError::UnexpectedFederatedAuthorization); + assert_eq!(error.code(), "federated_authorization_unexpected"); +} + +#[test] +fn nostr_proof_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(2, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(1), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("a Nostr proof from another domain must be rejected"); + + assert_eq!(error, AuthContextError::NostrProofDomainMismatch); + assert_eq!(error.code(), "nostr_proof_domain_mismatch"); +} + +#[test] +fn federated_policy_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + ResolvedFederatedPolicy::not_required(authorization_domain(2)), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("policy from another domain must be rejected"); + + assert_eq!(error, AuthContextError::PolicyDomainMismatch); + assert_eq!(error.code(), "federated_policy_domain_mismatch"); +} + +#[test] +fn community_admission_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(1, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(2), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("community admission from another domain must be rejected"); + + assert_eq!(error, AuthContextError::CommunityAccessDomainMismatch); + assert_eq!(error.code(), "community_access_domain_mismatch"); +} + +#[test] +fn assertion_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 2, + AuthTransport::RelayWebSocket, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion from another domain must be rejected"); + + assert_eq!(error, AuthContextError::AssertionDomainMismatch); + assert_eq!(error.code(), "federated_assertion_domain_mismatch"); +} + +#[test] +fn assertion_must_match_the_authorized_transport() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion verified for another transport must be rejected"); + + assert_eq!(error, AuthContextError::AssertionTransportMismatch); + assert_eq!(error.code(), "federated_assertion_transport_mismatch"); +} + +#[test] +fn delegated_owner_admission_must_match_the_authorization_domain() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index aed9624d9d..df963bc4e0 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,6 +17,8 @@ /// Channel access checking trait and helpers. pub mod access; +/// Versioned, transport-neutral authorization context. +pub mod context; /// Authentication error types. pub mod error; /// NIP-42 challenge–response authentication. @@ -31,6 +33,18 @@ pub mod rate_limit; pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; +pub use context::{ + 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, +}; pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; @@ -43,49 +57,51 @@ pub use rate_limit::{ }; pub use scope::{parse_scopes, Scope}; -#[cfg(any(test, feature = "test-utils"))] -pub use access::MockAccessChecker; -#[cfg(any(test, feature = "test-utils"))] -pub use nip98_replay::AlwaysFreshReplayGuard; -#[cfg(any(test, feature = "test-utils"))] -pub use rate_limit::AlwaysAllowRateLimiter; - -/// How the connection was authenticated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthMethod { - /// NIP-42 challenge/response — Schnorr signature over kind:22242. - Nip42, - /// NIP-98 HTTP Auth — Schnorr signature over kind:27235. - Nip98, -} - -/// The result of a successful authentication, bound to a connection. -#[derive(Debug, Clone)] -pub struct AuthContext { +/// Existing NIP authentication result stored on a relay connection. +/// +/// This remains separate from [`AuthContext`], which is finalized only after +/// transport authentication and every configured authorization policy pass. +#[derive(Clone)] +pub struct ConnectionAuthContext { /// The authenticated Nostr public key. pub pubkey: nostr::PublicKey, /// Permission scopes granted to this connection. pub scopes: Vec, - /// Channel restriction (reserved for future per-channel access control). - /// - /// `None` means unrestricted. + /// Channel restriction (`None` means unrestricted). pub channel_ids: Option>, /// How the connection was authenticated. pub auth_method: AuthMethod, - /// NIP-OA verified owner pubkey (if authenticated via owner attestation). - /// - /// `None` for direct relay members or non-NIP-OA auth paths. - /// Set by the relay membership gate when NIP-OA fallback succeeds. + /// NIP-OA verified owner pubkey, when present. pub agent_owner_pubkey: Option, } -impl AuthContext { +impl std::fmt::Debug for ConnectionAuthContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConnectionAuthContext") + .field("pubkey", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .field("auth_method", &self.auth_method) + .field("agent_owner_pubkey", &"[redacted]") + .finish() + } +} + +impl ConnectionAuthContext { /// Returns `true` if this context includes the given [`Scope`]. pub fn has_scope(&self, scope: &Scope) -> bool { self.scopes.contains(scope) } } +#[cfg(any(test, feature = "test-utils"))] +pub use access::MockAccessChecker; +#[cfg(any(test, feature = "test-utils"))] +pub use nip98_replay::AlwaysFreshReplayGuard; +#[cfg(any(test, feature = "test-utils"))] +pub use rate_limit::AlwaysAllowRateLimiter; + /// Top-level authentication configuration, typically loaded from the relay's TOML config file. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct AuthConfig { @@ -112,7 +128,7 @@ impl AuthService { &self.config } - /// Verify a NIP-42 AUTH event and return an [`AuthContext`]. + /// Verify a NIP-42 AUTH event and return a [`ConnectionAuthContext`]. /// /// Pure cryptographic verification — no network calls, no JWT, no tokens. pub async fn verify_auth_event( @@ -120,7 +136,7 @@ impl AuthService { auth_event: nostr::Event, expected_challenge: &str, relay_url: &str, - ) -> Result { + ) -> Result { // Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify) let event_clone = auth_event.clone(); let challenge_owned = expected_challenge.to_string(); @@ -133,12 +149,12 @@ impl AuthService { // In pure Nostr mode, all authenticated connections get full scopes. // Per-channel access is enforced by the relay's membership checks (NIP-29). - Ok(AuthContext { + Ok(ConnectionAuthContext { pubkey: auth_event.pubkey, scopes: Scope::all_known(), channel_ids: None, auth_method: AuthMethod::Nip42, - agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA + agent_owner_pubkey: None, }) } } @@ -183,17 +199,41 @@ mod tests { } #[test] - fn auth_context_scope_check() { + fn connection_auth_context_scope_check() { let keys = Keys::generate(); - let ctx = AuthContext { + let context = ConnectionAuthContext { pubkey: keys.public_key(), scopes: vec![Scope::MessagesRead, Scope::ChannelsRead], channel_ids: None, auth_method: AuthMethod::Nip42, agent_owner_pubkey: None, }; - assert!(ctx.has_scope(&Scope::MessagesRead)); - assert!(!ctx.has_scope(&Scope::MessagesWrite)); + + assert!(context.has_scope(&Scope::MessagesRead)); + assert!(!context.has_scope(&Scope::MessagesWrite)); + } + + #[test] + fn connection_auth_context_debug_redacts_authorization_data() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let channel_id = uuid::Uuid::new_v4(); + let context = ConnectionAuthContext { + pubkey: actor.public_key(), + scopes: vec![Scope::MessagesRead], + channel_ids: Some(vec![channel_id]), + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: Some(owner.public_key()), + }; + + assert_eq!( + format!("{context:?}"), + concat!( + "ConnectionAuthContext { pubkey: \"[redacted]\", scopes: \"[redacted]\", ", + "channel_ids: \"[redacted]\", auth_method: Nip42, ", + "agent_owner_pubkey: \"[redacted]\" }" + ) + ); } #[tokio::test] diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 1b536271a3..55de214e78 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,7 +14,7 @@ use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{generate_challenge, ConnectionAuthContext, LimitType}; use buzz_core::tenant::TenantContext; use nostr::Filter; @@ -41,7 +41,7 @@ pub enum AuthState { challenge: String, }, /// Client has successfully authenticated. - Authenticated(AuthContext), + Authenticated(ConnectionAuthContext), /// Authentication attempt was rejected. Failed, } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 2c2f5b02fd..288129fd62 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1388,7 +1388,7 @@ mod tests { remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), corporate_identity_jwt: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( - buzz_auth::AuthContext { + buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), scopes: vec![], channel_ids: None,