fix(auth): enforce binding expiry contract

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-03 15:05:16 -05:00
parent f5115171a3
commit c3c17cf850
4 changed files with 153 additions and 3 deletions
+51 -1
View File
@@ -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<Self, AuthContextError> {
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<BindingExpiry>,
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<BindingExpiry>,
source: BindingSource,
) -> Result<Self, AuthContextError> {
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<BindingExpiry>,
source: BindingSource,
reason: AuthorizationReason,
) -> Result<Self, AuthContextError> {
@@ -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<BindingExpiry> {
self.expires_at
}
/// Provenance of the active binding.
pub const fn source(&self) -> BindingSource {
self.source
+16 -1
View File
@@ -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,
+8
View File
@@ -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",
+78 -1
View File
@@ -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!(