mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(auth): enforce binding expiry contract
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user