From 8ea116a5d0c009aa16dd2f7c7730e674d6724446 Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:31:57 -0500 Subject: [PATCH] fix(auth): bind authoritative policy and binding evidence Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/context/binding.rs | 377 ++++++++++++++++++++++-- crates/buzz-auth/src/context/mod.rs | 100 ++++++- crates/buzz-auth/src/context/reason.rs | 28 ++ crates/buzz-auth/src/context/tests.rs | 290 +++++++++++++++++- 4 files changed, 759 insertions(+), 36 deletions(-) diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index e5fce7bb8..2f3fba859 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -44,55 +44,196 @@ impl fmt::Debug for FederatedIdentityRequirement { } } +/// 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 the +/// O3 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 O3 policy state. + /// + /// This constructor enforces structural invariants only. Callers must not + /// source any field from transport input, and O3 must compare the epoch as + /// an atomic precondition before enrollment. + pub fn from_authoritative_state( + 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. /// -/// Raw request data cannot construct this value. A policy adapter must resolve -/// the authorization domain's configuration before producing it. The evidence -/// is intentionally move-only and has no default or deserialization path. +/// 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 { - authorization_domain: CommunityId, - requirement: FederatedIdentityRequirement, + stamp: FederatedPolicyStamp, } impl fmt::Debug for ResolvedFederatedPolicy { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ResolvedFederatedPolicy") - .field("authorization_domain", &"[redacted]") - .field("requirement", &"[redacted]") + .field("stamp", &self.stamp) .finish() } } impl ResolvedFederatedPolicy { - #[cfg(test)] - pub(crate) const fn not_required(authorization_domain: CommunityId) -> Self { - Self { - authorization_domain, - requirement: FederatedIdentityRequirement::NotRequired, - } + /// Seal structurally validated current O3 policy lineage for finalization. + pub const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { + Self { stamp } } #[cfg(test)] - pub(crate) const fn required( + 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 { - authorization_domain, - requirement: FederatedIdentityRequirement::Required(enrollment_mode), - } + 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.authorization_domain + self.stamp.authorization_domain() } /// Resolved federated-identity requirement. pub const fn requirement(&self) -> FederatedIdentityRequirement { - self.requirement + self.stamp.requirement() + } + + /// Exact authoritative enrollment-policy lineage for this decision. + pub const fn stamp(&self) -> &FederatedPolicyStamp { + &self.stamp } } @@ -191,13 +332,9 @@ impl BindingExpiry { /// 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 intentionally unavailable in this phase. A -/// future crate-owned, sealed finalizer must require a durable non-nil ID, a -/// positive version, authoritative active-state evidence, and a typed database -/// result distinguishing a binding that already existed from one atomically -/// enrolled during this decision. Transport callers must never select that -/// lifecycle result. Pending, revoked, newly proposed, and synthetic records -/// must not cross that gate. +/// 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, @@ -210,6 +347,144 @@ pub struct VersionedBindingRef { resolution_reason: AuthorizationReason, } +/// Structurally validated binding fields returned by authoritative O3 state. +/// +/// This is not authorization by itself. The crate-owned finalizer additionally +/// requires a typed lifecycle outcome proving that the binding was already +/// active or was atomically enrolled during this decision. It has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct AuthoritativeBindingEvidence { + 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 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 O1 finalizer. +/// +/// It carries no caller-selected authorization reason; O1 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 O3's authoritative result that the binding already existed. + pub fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self { + Self { + evidence, + outcome: BindingResolutionOutcome::ExistingActive, + } + } + + /// Record O3's authoritative result that enrollment committed atomically. + pub fn atomically_enrolled(evidence: AuthoritativeBindingEvidence) -> Self { + Self { + evidence, + outcome: BindingResolutionOutcome::AtomicallyEnrolled, + } + } +} + +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 @@ -227,6 +502,54 @@ impl fmt::Debug for VersionedBindingRef { } 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_evidence( + evidence: AuthoritativeBindingEvidence, + ) -> Self { + Self::from_authoritative_evidence(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( diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index 8b3e17ee0..1bc23b9ff 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -18,7 +18,8 @@ mod evidence; mod reason; pub use binding::{ - BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource, + BindingVersion, EnrollmentMode, FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, VersionedBindingRef, }; pub use evidence::{ @@ -72,6 +73,39 @@ impl fmt::Debug for FederatedAuthorization { } } +/// Authoritative O3 result consumed by the public O1 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 O3 lifecycle result. + binding: AuthoritativeBindingResolution, + /// Current verified assertion for the authenticated actor. + assertion: VerifiedFederatedAssertion, + }, + /// Delegated authority backed by an already-active owner binding. + Delegated { + /// Typed authoritative evidence for the existing owner binding. + owner: AuthoritativeBindingEvidence, + /// 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() + } +} + /// Initial shared authorization-context contract. #[derive(PartialEq, Eq)] pub struct AuthContextV1 { @@ -148,6 +182,41 @@ impl fmt::Debug for AuthContext { } impl AuthContext { + /// Finalize an immutable V1 context from authoritative O3 binding evidence. + /// + /// This is the production O1↔O3 seam. O3 must resolve the enrollment-policy + /// stamp and binding lifecycle state from current authoritative storage, + /// use the policy epoch as a conditional precondition for any atomic + /// enrollment, and pass the resulting typed lifecycle outcome here. O1 + /// derives the authorization reason; transport code cannot select it. + pub fn finalize_authoritative_v1( + 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_evidence(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 @@ -158,7 +227,7 @@ impl AuthContext { /// /// `now_unix_seconds` must come from the server clock for the authorization /// decision being finalized. - pub fn finalize_v1( + pub(crate) fn finalize_v1( input: AuthContextInput, federated_policy: ResolvedFederatedPolicy, authorization: FederatedAuthorization, @@ -169,9 +238,7 @@ impl AuthContext { if input.nostr_proof.authorization_domain() != authorization_domain { return Err(AuthContextError::NostrProofDomainMismatch); } - if federated_policy.authorization_domain() != authorization_domain { - return Err(AuthContextError::PolicyDomainMismatch); - } + validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; if input.community_access.authorization_domain() != authorization_domain { return Err(AuthContextError::CommunityAccessDomainMismatch); } @@ -293,6 +360,29 @@ impl AuthContext { } } +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, diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index 83fc200af..9a99cbc36 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -61,6 +61,18 @@ pub enum AuthContextError { /// 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, @@ -76,6 +88,15 @@ pub enum AuthContextError { /// 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, @@ -159,11 +180,18 @@ impl AuthContextError { 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", diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs index f6171ddd7..4540b34b0 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -92,10 +92,46 @@ 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") +} + fn binding_in(domain: u128, pubkey: PublicKey) -> VersionedBindingRef { binding_with_source_in(domain, pubkey, BindingSource::AttestedKey) } @@ -395,7 +431,10 @@ fn context_debug_output_omits_tenant_host() { "nostr: NostrAuthority { actor_pubkey: \"[redacted]\", ", "proof_method: Nip42, verified_delegation: \"[redacted]\" }, ", "federated_policy: ResolvedFederatedPolicy { ", - "authorization_domain: \"[redacted]\", requirement: \"[redacted]\" }, ", + "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]\" })" ) @@ -814,7 +853,12 @@ fn enrolled_reason_must_match_policy_and_binding_source() { BindingSource::AttestedKey, AuthorizationReason::EnrolledAttestedKey, ), - assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), }, 100, ) @@ -1047,11 +1091,18 @@ fn authorization_error_codes_are_unique_and_provider_neutral() { 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, @@ -1146,12 +1197,243 @@ fn security_posture_debug_output_is_fully_redacted() { assert_eq!( format!("{:?}", policy_required(EnrollmentMode::AttestedKey)), concat!( - "ResolvedFederatedPolicy { authorization_domain: \"[redacted]\", ", - "requirement: \"[redacted]\" }" + "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( + 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( + 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( + 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 + ); +} + +#[test] +fn authoritative_finalizer_rejects_incompatible_enrollment_result() { + let actor = Keys::generate(); + let error = AuthContext::finalize_authoritative_v1( + 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( + 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 tofu_enrollment_cannot_use_attested_key_policy_reason() { let actor = Keys::generate();