From c3c17cf850977c5d742e6abbdb4d65136cba1adf Mon Sep 17 00:00:00 2001 From: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:57:33 -0500 Subject: [PATCH] fix(auth): enforce binding expiry contract Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com> --- crates/buzz-auth/src/context/binding.rs | 52 +++++++++++++++- crates/buzz-auth/src/context/mod.rs | 17 +++++- crates/buzz-auth/src/context/reason.rs | 8 +++ crates/buzz-auth/src/context/tests.rs | 79 ++++++++++++++++++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs index 352eb65b5..e5fce7bb8 100644 --- a/crates/buzz-auth/src/context/binding.rs +++ b/crates/buzz-auth/src/context/binding.rs @@ -147,10 +147,48 @@ impl BindingVersion { } } +/// 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 expiry or live-revocation enforcement. An +/// 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 intentionally unavailable in this phase. A @@ -167,6 +205,7 @@ pub struct VersionedBindingRef { principal: FederatedPrincipal, bound_pubkey: PublicKey, binding_version: BindingVersion, + expires_at: Option, source: BindingSource, resolution_reason: AuthorizationReason, } @@ -180,6 +219,7 @@ impl fmt::Debug for VersionedBindingRef { .field("principal", &self.principal) .field("bound_pubkey", &"[redacted]") .field("binding_version", &"[redacted]") + .field("expires_at", &"[redacted]") .field("source", &"[redacted]") .field("resolution_reason", &"[redacted]") .finish() @@ -195,6 +235,7 @@ impl VersionedBindingRef { principal: FederatedPrincipal, bound_pubkey: PublicKey, binding_version: BindingVersion, + expires_at: Option, source: BindingSource, ) -> Result { if binding_id.is_nil() { @@ -206,6 +247,7 @@ impl VersionedBindingRef { principal, bound_pubkey, binding_version, + expires_at, source, resolution_reason: AuthorizationReason::ExistingBinding, }) @@ -213,12 +255,14 @@ impl VersionedBindingRef { /// 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 { @@ -237,6 +281,7 @@ impl VersionedBindingRef { principal, bound_pubkey, binding_version, + expires_at, source, resolution_reason: reason, }) @@ -267,6 +312,11 @@ impl VersionedBindingRef { 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 diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs index 2649b61e4..8b3e17ee0 100644 --- a/crates/buzz-auth/src/context/mod.rs +++ b/crates/buzz-auth/src/context/mod.rs @@ -18,7 +18,7 @@ mod evidence; mod reason; pub use binding::{ - BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, + BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, ResolvedFederatedPolicy, VersionedBindingRef, }; pub use evidence::{ @@ -354,6 +354,7 @@ fn validate_federated_authorization( 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() @@ -382,6 +383,7 @@ fn validate_federated_authorization( 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); } @@ -396,6 +398,19 @@ fn validate_federated_authorization( 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, diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs index c071dc73d..83fc200af 100644 --- a/crates/buzz-auth/src/context/reason.rs +++ b/crates/buzz-auth/src/context/reason.rs @@ -58,6 +58,9 @@ pub enum AuthContextError { /// 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, /// Assertion expiry was not a valid Unix timestamp. #[error("federated assertion expiry must be greater than zero")] InvalidAssertionExpiry, @@ -70,6 +73,9 @@ pub enum AuthContextError { /// 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, /// Assertion was used before its validated not-before bound. #[error("federated assertion is not yet valid")] AssertionNotYetValid, @@ -152,10 +158,12 @@ impl AuthContextError { 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::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::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 e204336f3..f6171ddd7 100644 --- a/crates/buzz-auth/src/context/tests.rs +++ b/crates/buzz-auth/src/context/tests.rs @@ -111,6 +111,7 @@ fn binding_with_source_in( principal(), pubkey, BindingVersion::INITIAL, + None, source, ) .expect("synthetic binding identifier is valid") @@ -127,12 +128,26 @@ fn enrolled_binding( 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, @@ -565,6 +580,14 @@ fn zero_binding_version_is_rejected() { ); } +#[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(); @@ -574,6 +597,7 @@ fn nil_binding_identifier_is_rejected() { principal(), actor.public_key(), BindingVersion::INITIAL, + None, BindingSource::AttestedKey, ) .expect_err("nil is not a stable binding identifier"); @@ -588,6 +612,7 @@ fn evidence_value_debug_output_redacts_numeric_values() { 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!( @@ -606,6 +631,10 @@ fn evidence_value_debug_output_redacts_numeric_values() { format!("{admission_expiry:?}"), "AdmissionExpiry(\"[redacted]\")" ); + assert_eq!( + format!("{binding_expiry:?}"), + "BindingExpiry(\"[redacted]\")" + ); assert_eq!( format!("{binding_version:?}"), "BindingVersion(\"[redacted]\")" @@ -681,6 +710,50 @@ fn direct_authorization_rejects_expired_assertions() { 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(); @@ -810,6 +883,7 @@ fn binding_lifecycle_result_owns_the_authorization_reason() { principal(), actor.public_key(), BindingVersion::INITIAL, + None, BindingSource::AttestedKey, AuthorizationReason::ExistingBinding, ) @@ -972,10 +1046,12 @@ fn authorization_error_codes_are_unique_and_provider_neutral() { AuthContextError::EmptySubject, AuthContextError::InvalidBindingVersion, AuthContextError::InvalidBindingId, + AuthContextError::InvalidBindingExpiry, AuthContextError::InvalidAssertionExpiry, AuthContextError::InvalidDelegationExpiry, AuthContextError::InvalidAdmissionExpiry, AuthContextError::AssertionExpired, + AuthContextError::BindingExpired, AuthContextError::AssertionNotYetValid, AuthContextError::KeyAttestationRequired, AuthContextError::KeyAttestationMismatch, @@ -1059,7 +1135,8 @@ fn security_posture_debug_output_is_fully_redacted() { "binding_id: \"[redacted]\", principal: FederatedPrincipal { ", "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", "bound_pubkey: \"[redacted]\", binding_version: \"[redacted]\", ", - "source: \"[redacted]\", resolution_reason: \"[redacted]\" }" + "expires_at: \"[redacted]\", source: \"[redacted]\", ", + "resolution_reason: \"[redacted]\" }" ) ); assert_eq!(