fix(auth): harden assertion and provider evidence

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-04 15:45:02 -05:00
parent 0b0c9cd372
commit dc43209fd2
14 changed files with 6031 additions and 652 deletions

View File

@@ -601,6 +601,43 @@ impl VersionedBindingRef {
}
}
pub(crate) fn from_evidence_adapter(
authorization_domain: CommunityId,
binding_id: Uuid,
principal: FederatedPrincipal,
bound_pubkey: PublicKey,
binding_version: BindingVersion,
expires_at: Option<BindingExpiry>,
source: BindingSource,
resolution_reason: AuthorizationReason,
) -> Result<Self, AuthContextError> {
if binding_id.is_nil() {
return Err(AuthContextError::InvalidBindingId);
}
let valid_reason = matches!(
(source, resolution_reason),
(_, AuthorizationReason::ExistingBinding)
| (
BindingSource::AttestedKey,
AuthorizationReason::EnrolledAttestedKey
)
| (BindingSource::Tofu, AuthorizationReason::EnrolledTofu)
);
if !valid_reason {
return Err(AuthContextError::InvalidAuthorizationReason);
}
Ok(Self {
authorization_domain,
binding_id,
principal,
bound_pubkey,
binding_version,
expires_at,
source,
resolution_reason,
})
}
/// Build a reference to a binding authoritatively resolved as already active.
#[cfg(test)]
pub(crate) fn new_existing_active_for_test(

View File

@@ -1,14 +1,12 @@
use std::fmt;
use std::{fmt, sync::Arc};
use buzz_core::CommunityId;
use nostr::PublicKey;
use uuid::Uuid;
use crate::Scope;
use crate::{provider::AuthorizationProfileId, provider::CapabilitySet, Scope};
#[cfg(test)]
use super::transport_accepts_proof;
use super::AuthContextError;
use super::{transport_accepts_proof, AuthContextError};
/// Cryptographic proof used to authenticate the Nostr actor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,7 +15,7 @@ pub enum AuthMethod {
Nip42,
/// NIP-98 signed HTTP request.
Nip98,
/// Blossom upload authorization.
/// Blossom media authorization for the exact verified operation.
Blossom,
}
@@ -192,9 +190,7 @@ impl AdmissionExpiry {
/// Server-verified Nostr authority for a request or connection.
#[derive(PartialEq, Eq)]
pub struct NostrAuthority {
actor_pubkey: PublicKey,
proof_method: AuthMethod,
verified_delegation: Option<VerifiedTransportDelegation>,
verified_proof: Arc<VerifiedNostrProof>,
}
impl fmt::Debug for NostrAuthority {
@@ -202,42 +198,42 @@ impl fmt::Debug for NostrAuthority {
formatter
.debug_struct("NostrAuthority")
.field("actor_pubkey", &"[redacted]")
.field("proof_method", &self.proof_method)
.field("proof_method", &self.verified_proof.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,
}
pub(super) fn new(verified_proof: Arc<VerifiedNostrProof>) -> Self {
Self { verified_proof }
}
/// Authenticated Nostr actor.
pub const fn actor_pubkey(&self) -> PublicKey {
self.actor_pubkey
pub fn actor_pubkey(&self) -> PublicKey {
self.verified_proof.actor_pubkey()
}
/// Proof method used to authenticate the actor.
pub const fn proof_method(&self) -> AuthMethod {
self.proof_method
pub fn proof_method(&self) -> AuthMethod {
self.verified_proof.proof_method()
}
/// Cryptographically verified owner for a delegated Nostr actor.
pub const fn verified_owner_pubkey(&self) -> Option<PublicKey> {
match &self.verified_delegation {
Some(delegation) => Some(delegation.owner_pubkey()),
None => None,
}
pub fn verified_owner_pubkey(&self) -> Option<PublicKey> {
self.verified_proof
.verified_delegation()
.map(VerifiedTransportDelegation::owner_pubkey)
}
/// Cryptographically verified owner-to-actor delegation, when present.
pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> {
self.verified_delegation.as_ref()
pub fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> {
self.verified_proof.verified_delegation()
}
/// Sealed proof retained from transport verification.
pub fn verified_proof(&self) -> &Arc<VerifiedNostrProof> {
&self.verified_proof
}
}
@@ -304,6 +300,10 @@ pub struct VerifiedKeyAttestation {
}
impl VerifiedKeyAttestation {
pub(crate) const fn from_evidence_adapter(pubkey: PublicKey) -> Self {
Self { pubkey }
}
#[cfg(test)]
pub(crate) const fn new(pubkey: PublicKey) -> Self {
Self { pubkey }
@@ -346,6 +346,26 @@ pub struct VerifiedFederatedAssertion {
}
impl VerifiedFederatedAssertion {
pub(crate) const fn from_evidence_adapter(
authorization_domain: CommunityId,
authorized_transport: AuthTransport,
principal: FederatedPrincipal,
key_attestation: Option<VerifiedKeyAttestation>,
transport: AssertionTransport,
not_before: Option<AssertionNotBefore>,
expires_at: AssertionExpiry,
) -> Self {
Self {
authorization_domain,
authorized_transport,
principal,
key_attestation,
transport,
not_before,
expires_at,
}
}
#[cfg(test)]
pub(crate) const fn new(
authorization_domain: CommunityId,
@@ -418,6 +438,140 @@ impl fmt::Debug for VerifiedFederatedAssertion {
}
}
/// Provider-neutral identity and capability evidence from a verified adapter.
///
/// This move-only value can be created only from an already sealed federated
/// assertion. It carries the installed server profile, normalized portable
/// capabilities, and a hard freshness window without retaining raw headers,
/// tokens, or provider-specific claims.
#[derive(PartialEq, Eq)]
pub struct VerifiedProviderEvidence {
assertion: VerifiedFederatedAssertion,
profile_id: AuthorizationProfileId,
capabilities: CapabilitySet,
issued_at: u64,
fresh_until: u64,
}
impl VerifiedProviderEvidence {
pub(crate) const fn from_evidence_adapter(
assertion: VerifiedFederatedAssertion,
profile_id: AuthorizationProfileId,
capabilities: CapabilitySet,
issued_at: u64,
fresh_until: u64,
) -> Self {
Self {
assertion,
profile_id,
capabilities,
issued_at,
fresh_until,
}
}
/// Authorization domain for which the provider evidence was verified.
pub const fn authorization_domain(&self) -> CommunityId {
self.assertion.authorization_domain()
}
/// Protected transport for which the provider evidence was verified.
pub const fn authorized_transport(&self) -> AuthTransport {
self.assertion.authorized_transport()
}
/// Normalized issuer-qualified principal from the verified provenance.
pub const fn principal(&self) -> &FederatedPrincipal {
self.assertion.principal()
}
/// Installed server profile that produced this evidence.
pub const fn profile_id(&self) -> &AuthorizationProfileId {
&self.profile_id
}
/// Normalized capabilities granted by the verified provider result.
pub const fn capabilities(&self) -> &CapabilitySet {
&self.capabilities
}
/// Server-observed issuance time for this provider result.
pub const fn issued_at(&self) -> u64 {
self.issued_at
}
/// Exclusive hard freshness bound for this provider result.
pub const fn fresh_until(&self) -> u64 {
self.fresh_until
}
/// Revalidate this evidence for one exact protected request.
pub fn validate_for(
&self,
authorization_domain: CommunityId,
transport: AuthTransport,
installed_profile: &AuthorizationProfileId,
required_capabilities: &CapabilitySet,
now_unix_seconds: u64,
) -> Result<(), ProviderEvidenceValidationError> {
if self.authorization_domain() != authorization_domain {
return Err(ProviderEvidenceValidationError::AuthorizationDomainMismatch);
}
if self.authorized_transport() != transport {
return Err(ProviderEvidenceValidationError::TransportMismatch);
}
if self.profile_id() != installed_profile {
return Err(ProviderEvidenceValidationError::ProfileMismatch);
}
if !required_capabilities
.as_slice()
.iter()
.all(|capability| self.capabilities.contains(*capability))
{
return Err(ProviderEvidenceValidationError::CapabilityMismatch);
}
if self.issued_at > now_unix_seconds {
return Err(ProviderEvidenceValidationError::NotYetValid);
}
if self.fresh_until <= now_unix_seconds {
return Err(ProviderEvidenceValidationError::Expired);
}
Ok(())
}
}
impl fmt::Debug for VerifiedProviderEvidence {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VerifiedProviderEvidence")
.field("authorization_domain", &"[redacted]")
.field("authorized_transport", &"[redacted]")
.field("principal", &"[redacted]")
.field("profile_id", &"[redacted]")
.field("capabilities", &"[redacted]")
.field("issued_at", &"[redacted]")
.field("fresh_until", &"[redacted]")
.finish()
}
}
/// Fail-closed reason provider evidence cannot authorize an exact request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderEvidenceValidationError {
/// Evidence belongs to another authorization domain.
AuthorizationDomainMismatch,
/// Evidence was verified for another protected transport.
TransportMismatch,
/// Evidence was produced by another installed provider profile.
ProfileMismatch,
/// Evidence does not grant every capability required by the request.
CapabilityMismatch,
/// Evidence claims an issuance time after the server clock.
NotYetValid,
/// Evidence has reached its hard freshness bound.
Expired,
}
/// Current admission for the owner of a delegated Nostr actor.
///
/// This evidence is independent of a federated assertion: a delegated request
@@ -434,9 +588,7 @@ pub struct VerifiedOwnerAdmission {
}
impl VerifiedOwnerAdmission {
// Consumed by the provider finalizer in the stacked capability contract.
#[allow(dead_code)]
pub(crate) const fn new(
pub(crate) const fn from_capability_snapshot(
authorization_domain: CommunityId,
principal: FederatedPrincipal,
fresh_until: AdmissionExpiry,
@@ -448,6 +600,15 @@ impl VerifiedOwnerAdmission {
}
}
#[cfg(test)]
pub(crate) const fn new(
authorization_domain: CommunityId,
principal: FederatedPrincipal,
fresh_until: AdmissionExpiry,
) -> Self {
Self::from_capability_snapshot(authorization_domain, principal, fresh_until)
}
/// Authorization domain for which owner admission was resolved.
pub const fn authorization_domain(&self) -> CommunityId {
self.authorization_domain
@@ -491,6 +652,13 @@ impl fmt::Debug for DelegationCapability {
}
}
impl DelegationCapability {
/// Whether the verifier proved authority for the complete transport.
pub const fn is_transport_wide(self) -> bool {
matches!(self, Self::TransportWide)
}
}
/// Transport-wide delegation from a bound owner to the authenticated key.
///
/// A verifier may construct this only after proving the capability authorizes
@@ -522,7 +690,6 @@ impl fmt::Debug for VerifiedTransportDelegation {
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,
@@ -560,6 +727,78 @@ impl VerifiedTransportDelegation {
}
}
/// Class of exact verifier input retained by a sealed Nostr proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifiedOperationBindingKind {
/// NIP-42 challenge and relay URL for a WebSocket session.
NostrSession,
/// NIP-98 method, URL, and payload for an HTTP request.
HttpRequest,
/// Blossom upload verb, blob hash, server, and signed event.
BlossomUpload,
/// Blossom download verb, blob hash, server, and signed event.
BlossomDownload,
}
/// Opaque exact-operation fingerprint produced only by a trusted verifier.
///
/// It has no public constructor or serialization path. The fingerprint keeps
/// sensitive URLs, challenges, and payloads out of the authorization context
/// while preventing a proof from being silently widened to another verifier
/// operation class.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct VerifiedOperationBinding {
kind: VerifiedOperationBindingKind,
fingerprint: [u8; 32],
}
impl VerifiedOperationBinding {
pub(crate) const fn from_evidence_adapter(
kind: VerifiedOperationBindingKind,
fingerprint: [u8; 32],
) -> Self {
Self { kind, fingerprint }
}
#[cfg(test)]
fn for_transport(transport: AuthTransport) -> Self {
let kind = match transport {
AuthTransport::RelayWebSocket | AuthTransport::Audio => {
VerifiedOperationBindingKind::NostrSession
}
AuthTransport::HttpBridge | AuthTransport::Git => {
VerifiedOperationBindingKind::HttpRequest
}
AuthTransport::MediaUpload => VerifiedOperationBindingKind::BlossomUpload,
AuthTransport::MediaDownload => VerifiedOperationBindingKind::BlossomDownload,
};
Self {
kind,
fingerprint: [0; 32],
}
}
/// Exact verifier-operation class.
pub const fn kind(self) -> VerifiedOperationBindingKind {
self.kind
}
/// Opaque fingerprint of the exact verified operation inputs.
pub const fn fingerprint(self) -> [u8; 32] {
self.fingerprint
}
}
impl fmt::Debug for VerifiedOperationBinding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("VerifiedOperationBinding")
.field("kind", &self.kind)
.field("fingerprint", &"[redacted]")
.finish()
}
}
/// Cryptographically verified Nostr proof for one request or connection.
///
/// Transport verifiers inside `buzz-auth` produce this evidence after checking
@@ -574,10 +813,41 @@ pub struct VerifiedNostrProof {
authorized_transport: AuthTransport,
actor_pubkey: PublicKey,
proof_method: AuthMethod,
operation_binding: VerifiedOperationBinding,
verified_delegation: Option<VerifiedTransportDelegation>,
}
impl VerifiedNostrProof {
pub(crate) fn from_evidence_adapter(
authorization_domain: CommunityId,
authorized_transport: AuthTransport,
actor_pubkey: PublicKey,
proof_method: AuthMethod,
operation_binding: VerifiedOperationBinding,
verified_delegation: Option<VerifiedTransportDelegation>,
) -> Result<Self, AuthContextError> {
if !transport_accepts_proof(authorized_transport, proof_method) {
return Err(AuthContextError::TransportProofMismatch);
}
if !operation_binding_matches_transport(operation_binding.kind(), authorized_transport) {
return Err(AuthContextError::OperationProofMismatch);
}
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,
operation_binding,
verified_delegation,
})
}
#[cfg(test)]
pub(crate) fn new(
authorization_domain: CommunityId,
@@ -600,6 +870,7 @@ impl VerifiedNostrProof {
authorized_transport,
actor_pubkey,
proof_method,
operation_binding: VerifiedOperationBinding::for_transport(authorized_transport),
verified_delegation,
})
}
@@ -624,6 +895,11 @@ impl VerifiedNostrProof {
self.proof_method
}
/// Opaque binding to the exact verifier operation.
pub const fn operation_binding(&self) -> VerifiedOperationBinding {
self.operation_binding
}
/// Verified owner-to-actor delegation, when present.
pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> {
self.verified_delegation.as_ref()
@@ -638,11 +914,34 @@ impl fmt::Debug for VerifiedNostrProof {
.field("authorized_transport", &self.authorized_transport)
.field("actor_pubkey", &"[redacted]")
.field("proof_method", &self.proof_method)
.field("operation_binding", &self.operation_binding)
.field("verified_delegation", &"[redacted]")
.finish()
}
}
const fn operation_binding_matches_transport(
kind: VerifiedOperationBindingKind,
transport: AuthTransport,
) -> bool {
matches!(
(kind, transport),
(
VerifiedOperationBindingKind::NostrSession,
AuthTransport::RelayWebSocket | AuthTransport::Audio
) | (
VerifiedOperationBindingKind::HttpRequest,
AuthTransport::HttpBridge | AuthTransport::Git
) | (
VerifiedOperationBindingKind::BlossomUpload,
AuthTransport::MediaUpload
) | (
VerifiedOperationBindingKind::BlossomDownload,
AuthTransport::MediaDownload
)
)
}
/// Successful community admission and permissions for one decision.
///
/// An authorization adapter may construct this value only after membership,
@@ -660,6 +959,18 @@ pub struct AuthorizedCommunityAccess {
}
impl AuthorizedCommunityAccess {
pub(crate) const fn from_evidence_adapter(
authorization_domain: CommunityId,
scopes: Vec<Scope>,
channel_ids: Option<Vec<Uuid>>,
) -> Self {
Self {
authorization_domain,
scopes,
channel_ids,
}
}
#[cfg(test)]
pub(crate) const fn new(
authorization_domain: CommunityId,

View File

@@ -5,13 +5,19 @@
//! the Nostr authority that signed the request. Raw assertions and mutable
//! display claims never enter this type.
use std::fmt;
use std::{fmt, sync::Arc};
use buzz_core::{tenant::TenantContext, CommunityId};
use nostr::PublicKey;
use uuid::Uuid;
use crate::Scope;
use crate::{
lease::{
AuthorizationLease, AuthorizationLeaseValidator, AuthorizationOperationGuard,
LeaseUseRequirement, LeaseValidationError,
},
Scope,
};
pub(crate) mod authority;
mod binding;
@@ -32,8 +38,10 @@ pub use binding::{
pub use evidence::{
AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod,
AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry,
FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation,
VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation,
FederatedPrincipal, NostrAuthority, ProviderEvidenceValidationError,
VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof,
VerifiedOperationBinding, VerifiedOperationBindingKind, VerifiedOwnerAdmission,
VerifiedProviderEvidence, VerifiedTransportDelegation,
};
pub use reason::{AuthContextError, AuthorizationReason};
@@ -133,6 +141,7 @@ pub struct AuthContextV1 {
nostr: NostrAuthority,
federated_policy: ResolvedFederatedPolicy,
federated: FederatedAuthorization,
authorization_lease: Option<AuthorizationLease>,
scopes: Vec<Scope>,
channel_ids: Option<Vec<Uuid>>,
}
@@ -142,7 +151,7 @@ pub struct AuthContextV1 {
pub struct AuthContextInput {
tenant: TenantContext,
correlation_id: Uuid,
nostr_proof: VerifiedNostrProof,
nostr_proof: Arc<VerifiedNostrProof>,
community_access: AuthorizedCommunityAccess,
}
@@ -177,24 +186,23 @@ impl AuthContextInput {
pub fn new(
tenant: TenantContext,
correlation_id: Uuid,
nostr_proof: VerifiedNostrProof,
nostr_proof: impl Into<Arc<VerifiedNostrProof>>,
community_access: AuthorizedCommunityAccess,
) -> Self {
Self {
tenant,
correlation_id,
nostr_proof,
nostr_proof: nostr_proof.into(),
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 {
pub(crate) fn nostr_proof_authorization_domain(&self) -> CommunityId {
self.nostr_proof.authorization_domain()
}
@@ -204,32 +212,47 @@ impl AuthContextInput {
}
#[allow(dead_code)]
pub(crate) const fn correlation_id(&self) -> Uuid {
self.correlation_id
}
#[allow(dead_code)]
pub(crate) const fn transport(&self) -> AuthTransport {
pub(crate) fn transport(&self) -> AuthTransport {
self.nostr_proof.authorized_transport()
}
#[allow(dead_code)]
pub(crate) const fn proof_method(&self) -> AuthMethod {
pub(crate) fn proof_method(&self) -> AuthMethod {
self.nostr_proof.proof_method()
}
#[allow(dead_code)]
pub(crate) const fn actor_pubkey(&self) -> PublicKey {
pub(crate) fn actor_pubkey(&self) -> PublicKey {
self.nostr_proof.actor_pubkey()
}
#[allow(dead_code)]
pub(crate) const fn verified_owner_pubkey(&self) -> Option<PublicKey> {
pub(crate) fn verified_owner_pubkey(&self) -> Option<PublicKey> {
match self.nostr_proof.verified_delegation() {
Some(delegation) => Some(delegation.owner_pubkey()),
None => None,
}
}
/// Server-resolved tenant carried by the finalization input.
pub const fn tenant(&self) -> &TenantContext {
&self.tenant
}
/// Correlation identifier for this authorization decision.
pub const fn correlation_id(&self) -> Uuid {
self.correlation_id
}
/// Cryptographically verified Nostr proof.
pub fn nostr_proof(&self) -> &VerifiedNostrProof {
&self.nostr_proof
}
/// Current community admission and permissions.
pub const fn community_access(&self) -> &AuthorizedCommunityAccess {
&self.community_access
}
}
impl fmt::Debug for AuthContextV1 {
@@ -242,6 +265,7 @@ impl fmt::Debug for AuthContextV1 {
.field("nostr", &self.nostr)
.field("federated_policy", &self.federated_policy)
.field("federated", &self.federated)
.field("authorization_lease", &"[redacted]")
.field("scopes", &"[redacted]")
.field("channel_ids", &"[redacted]")
.finish()
@@ -305,7 +329,13 @@ impl AuthContext {
}
}
};
Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds)
Self::finalize_v1_inner(
input,
federated_policy,
authorization,
None,
now_unix_seconds,
)
}
/// Validate all authorization evidence and finalize an immutable V1 context.
@@ -324,26 +354,75 @@ impl AuthContext {
authorization: FederatedAuthorization,
now_unix_seconds: u64,
) -> Result<Self, AuthContextError> {
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,
Self::finalize_v1_inner(
input,
federated_policy,
authorization,
None,
now_unix_seconds,
)?;
)
}
pub(crate) fn finalize_v1_with_lease(
input: AuthContextInput,
federated_policy: ResolvedFederatedPolicy,
authorization: FederatedAuthorization,
authorization_lease: AuthorizationLease,
now_unix_seconds: u64,
) -> Result<Self, AuthContextError> {
if !matches!(
federated_policy.requirement(),
FederatedIdentityRequirement::Required(_)
) || matches!(authorization, FederatedAuthorization::NotRequired)
{
return Err(AuthContextError::FinalizedLeaseMismatch);
}
let active_binding = authorization
.active_binding()
.ok_or(AuthContextError::FinalizedLeaseMismatch)?;
if authorization_lease.authorization_domain() != input.tenant.community()
|| authorization_lease.transport() != input.nostr_proof.authorized_transport()
|| authorization_lease.actor_pubkey() != input.nostr_proof.actor_pubkey()
|| authorization_lease.binding_id() != active_binding.binding_id()
|| authorization_lease.binding_version() != active_binding.binding_version()
|| authorization_lease.correlation_id() != input.correlation_id
{
return Err(AuthContextError::FinalizedLeaseMismatch);
}
Self::finalize_v1_inner(
input,
federated_policy,
authorization,
Some(authorization_lease),
now_unix_seconds,
)
}
#[cfg(test)]
pub(super) fn finalize_v1_evidence_for_test(
input: AuthContextInput,
federated_policy: ResolvedFederatedPolicy,
authorization: FederatedAuthorization,
now_unix_seconds: u64,
) -> Result<Self, AuthContextError> {
Self::finalize_v1_inner(
input,
federated_policy,
authorization,
None,
now_unix_seconds,
)
}
fn finalize_v1_inner(
input: AuthContextInput,
federated_policy: ResolvedFederatedPolicy,
authorization: FederatedAuthorization,
authorization_lease: Option<AuthorizationLease>,
now_unix_seconds: u64,
) -> Result<Self, AuthContextError> {
validate_context_evidence(&input, &federated_policy, &authorization, now_unix_seconds)?;
let transport = input.nostr_proof.authorized_transport();
let nostr = NostrAuthority::new(input.nostr_proof);
let (scopes, channel_ids) = input.community_access.into_permissions();
Ok(Self::V1(AuthContextV1 {
@@ -353,6 +432,7 @@ impl AuthContext {
nostr,
federated_policy,
federated: authorization,
authorization_lease,
scopes,
channel_ids,
}))
@@ -394,17 +474,17 @@ impl AuthContext {
}
/// Authenticated Nostr actor.
pub const fn pubkey(&self) -> PublicKey {
pub fn pubkey(&self) -> PublicKey {
self.nostr().actor_pubkey()
}
/// Proof method used to authenticate the Nostr actor.
pub const fn auth_method(&self) -> AuthMethod {
pub 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<PublicKey> {
pub fn agent_owner_pubkey(&self) -> Option<PublicKey> {
self.nostr().verified_owner_pubkey()
}
@@ -422,6 +502,43 @@ impl AuthContext {
}
}
/// Bounded federated access lease, when this is an enforcing context.
pub const fn authorization_lease(&self) -> Option<&AuthorizationLease> {
match self {
Self::V1(context) => context.authorization_lease.as_ref(),
}
}
/// Validate this context's access lease for one protected operation.
///
/// Nostr-only contexts have no federated lease and therefore fail closed
/// when presented to a federated-protected operation.
pub fn authorize_lease_use(
&self,
validator: &AuthorizationLeaseValidator,
requirement: &LeaseUseRequirement,
) -> Result<(), LeaseValidationError> {
let lease = self
.authorization_lease()
.ok_or(LeaseValidationError::MissingLease)?;
validator.authorize(lease, requirement)
}
/// Validate and retain a per-capability guard for an in-flight operation.
///
/// Callers must revalidate the returned guard immediately before a
/// protected commit or stream emission.
pub fn operation_guard<'a>(
&'a self,
validator: &AuthorizationLeaseValidator,
requirement: LeaseUseRequirement,
) -> Result<AuthorizationOperationGuard<'a>, LeaseValidationError> {
let lease = self
.authorization_lease()
.ok_or(LeaseValidationError::MissingLease)?;
validator.operation_guard(lease, requirement)
}
/// Stable reason for the successful authorization decision.
pub const fn authorization_reason(&self) -> AuthorizationReason {
match self.federated_authorization() {
@@ -474,6 +591,46 @@ fn validate_federated_policy_stamp(
Ok(())
}
pub(crate) fn validate_context_evidence(
input: &AuthContextInput,
federated_policy: &ResolvedFederatedPolicy,
authorization: &FederatedAuthorization,
now_unix_seconds: u64,
) -> Result<(), AuthContextError> {
validate_federated_policy_stamp(input, federated_policy, now_unix_seconds)?;
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);
}
if federated_policy.authorization_domain() != authorization_domain {
return Err(AuthContextError::PolicyDomainMismatch);
}
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,
)
}
impl FederatedAuthorization {
pub(crate) const fn active_binding(&self) -> Option<&VersionedBindingRef> {
match self {
Self::NotRequired => None,
Self::Direct { binding, .. } => Some(binding),
Self::Delegated { owner, .. } => Some(owner),
}
}
}
pub(super) const fn transport_accepts_proof(
transport: AuthTransport,
proof_method: AuthMethod,
@@ -486,7 +643,7 @@ pub(super) const fn transport_accepts_proof(
AuthTransport::MediaUpload => {
matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom)
}
AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98),
AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Blossom),
}
}

View File

@@ -154,6 +154,9 @@ pub enum AuthContextError {
/// Proof method was not valid for the transport being authorized.
#[error("Nostr proof method does not match authorization transport")]
TransportProofMismatch,
/// Exact verifier-operation evidence was not valid for the transport.
#[error("Nostr operation proof does not match authorization transport")]
OperationProofMismatch,
/// Direct federated authorization was attached to a delegated Nostr actor.
#[error("direct federated authorization cannot include a delegated Nostr owner")]
DirectAuthorizationHasOwner,
@@ -172,6 +175,12 @@ pub enum AuthContextError {
/// Delegated owner evidence did not resolve an already-active binding.
#[error("delegated federated authorization requires an existing active binding")]
DelegatedBindingNotExistingActive,
/// Federated finalization did not consume a current provider decision.
#[error("federated authorization requires a current provider decision")]
ProviderDecisionRequired,
/// An issued lease did not match the context evidence being finalized.
#[error("authorization lease does not match finalized context evidence")]
FinalizedLeaseMismatch,
}
impl AuthContextError {
@@ -214,6 +223,7 @@ impl AuthContextError {
Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch",
Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch",
Self::TransportProofMismatch => "nostr_transport_proof_mismatch",
Self::OperationProofMismatch => "nostr_operation_proof_mismatch",
Self::DirectAuthorizationHasOwner => "federated_direct_has_owner",
Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch",
Self::DelegateKeyMismatch => "federated_delegate_key_mismatch",
@@ -222,6 +232,8 @@ impl AuthContextError {
Self::DelegatedBindingNotExistingActive => {
"federated_delegated_binding_not_existing_active"
}
Self::ProviderDecisionRequired => "authorization_provider_decision_required",
Self::FinalizedLeaseMismatch => "authorization_finalized_lease_mismatch",
}
}
}

View File

@@ -353,7 +353,7 @@ fn delegated_authorization(
fn context_preserves_server_resolved_authority() {
let keys = Keys::generate();
let correlation_id = Uuid::from_u128(2);
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
AuthContextInput::new(
tenant(1),
correlation_id,
@@ -398,7 +398,7 @@ fn context_preserves_server_resolved_authority() {
fn direct_authorization_requires_the_authenticated_key() {
let actor = Keys::generate();
let other = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -415,7 +415,7 @@ fn direct_authorization_requires_the_authenticated_key() {
fn delegated_authorization_requires_the_verified_owner() {
let actor = Keys::generate();
let owner = Keys::generate();
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -437,7 +437,7 @@ fn delegated_authorization_requires_the_verified_owner() {
fn delegated_authorization_requires_verified_delegation() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
delegated_authorization(1, owner.public_key(), principal(), 200),
@@ -482,7 +482,7 @@ fn principal_preserves_exact_validated_values() {
fn context_debug_output_omits_tenant_host() {
let actor = Keys::generate();
let channel_id = Uuid::from_u128(20);
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
AuthContextInput::new(
tenant(1),
Uuid::from_u128(2),
@@ -522,6 +522,7 @@ fn context_debug_output_omits_tenant_host() {
"correlation_id: \"[redacted]\", requirement: \"[redacted]\", ",
"effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ",
"federated: FederatedAuthorization(\"[redacted]\"), ",
"authorization_lease: \"[redacted]\", ",
"scopes: \"[redacted]\", channel_ids: \"[redacted]\" })"
)
);
@@ -531,7 +532,7 @@ fn context_debug_output_omits_tenant_host() {
fn direct_authorization_rejects_a_verified_owner() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -553,7 +554,7 @@ fn direct_authorization_rejects_a_verified_owner() {
fn delegated_authorization_requires_current_owner_admission() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -572,7 +573,7 @@ fn delegated_authorization_requires_current_owner_admission() {
fn delegated_authorization_rejects_cross_domain_owner_admission() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -600,7 +601,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() {
let owner = Keys::generate();
let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject")
.expect("synthetic principal is valid");
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -619,7 +620,7 @@ fn delegated_authorization_requires_the_owner_admission_principal() {
fn delegated_authorization_rejects_an_expired_proof() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input_with_delegation_expiry(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -663,7 +664,7 @@ 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(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -682,7 +683,7 @@ fn delegated_authorization_requires_the_bound_owner() {
fn delegated_binding_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -832,7 +833,7 @@ fn verified_assertion_debug_output_is_fully_redacted() {
#[test]
fn direct_authorization_rejects_expired_assertions() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::HttpBridge, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -850,7 +851,7 @@ fn direct_authorization_rejects_expired_assertions() {
#[test]
fn direct_authorization_rejects_binding_at_exact_expiry() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::HttpBridge, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -869,7 +870,7 @@ fn direct_authorization_rejects_binding_at_exact_expiry() {
fn delegated_authorization_rejects_owner_binding_at_exact_expiry() {
let owner = Keys::generate();
let delegate = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
delegate.public_key(),
AuthTransport::RelayWebSocket,
@@ -894,7 +895,7 @@ fn delegated_authorization_rejects_owner_binding_at_exact_expiry() {
#[test]
fn direct_authorization_rejects_a_future_assertion() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::HttpBridge, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -919,7 +920,7 @@ fn direct_authorization_rejects_a_future_assertion() {
#[test]
fn direct_authorization_requires_the_assertion_principal() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -942,7 +943,7 @@ fn direct_authorization_requires_the_assertion_principal() {
#[test]
fn enrolled_reason_must_match_policy_and_binding_source() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::Provisioned),
FederatedAuthorization::Direct {
@@ -981,7 +982,7 @@ fn existing_active_bindings_are_independent_of_enrollment_mode() {
for enrollment_mode in enrollment_modes {
for binding_source in binding_sources {
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(enrollment_mode),
FederatedAuthorization::Direct {
@@ -1038,7 +1039,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() {
let actor = Keys::generate();
let other = Keys::generate();
let missing = AuthContext::finalize_v1(
let missing = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1054,7 +1055,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() {
.expect_err("attested enrollment cannot silently accept a missing key claim");
assert_eq!(missing, AuthContextError::KeyAttestationRequired);
let mismatch = AuthContext::finalize_v1(
let mismatch = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1075,7 +1076,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() {
.expect_err("attested enrollment cannot accept another Nostr key");
assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch);
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1104,7 +1105,7 @@ fn attested_enrollment_requires_matching_verified_key_evidence() {
fn present_key_attestation_never_ignores_an_actor_mismatch() {
let actor = Keys::generate();
let other = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::Tofu),
FederatedAuthorization::Direct {
@@ -1140,7 +1141,7 @@ fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() {
),
};
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::Tofu),
authorization,
@@ -1308,7 +1309,7 @@ fn security_posture_debug_output_is_fully_redacted() {
#[test]
fn federated_policy_must_match_the_authorization_correlation() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200),
FederatedAuthorization::Direct {
@@ -1325,7 +1326,7 @@ fn federated_policy_must_match_the_authorization_correlation() {
#[test]
fn federated_policy_effective_interval_is_half_open() {
let actor = Keys::generate();
let not_yet_effective = AuthContext::finalize_v1(
let not_yet_effective = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200),
FederatedAuthorization::Direct {
@@ -1340,7 +1341,7 @@ fn federated_policy_effective_interval_is_half_open() {
AuthContextError::FederatedPolicyNotYetEffective
);
let expired = AuthContext::finalize_v1(
let expired = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100),
FederatedAuthorization::Direct {
@@ -1736,7 +1737,7 @@ fn authoritative_finalizer_requires_existing_active_delegated_owner() {
#[test]
fn tofu_enrollment_cannot_use_attested_key_policy_reason() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::Tofu),
FederatedAuthorization::Direct {
@@ -1769,17 +1770,37 @@ fn transport_and_proof_method_must_agree() {
}
#[test]
fn blossom_upload_proof_cannot_authorize_media_downloads() {
fn verified_blossom_proof_authorizes_media_download_profile() {
let actor = Keys::generate();
let error = VerifiedNostrProof::new(
let proof = 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");
.expect("verified Blossom GET/HEAD proof matches the media download profile");
assert_eq!(proof.authorized_transport(), AuthTransport::MediaDownload);
assert_eq!(proof.proof_method(), AuthMethod::Blossom);
}
#[test]
fn nip98_proof_cannot_substitute_for_blossom_media_download() {
let actor = Keys::generate();
let error = VerifiedNostrProof::new(
authorization_domain(1),
AuthTransport::MediaDownload,
actor.public_key(),
AuthMethod::Nip98,
None,
)
.expect_err("no implemented NIP-98 media GET/HEAD verifier exists");
assert_eq!(error, AuthContextError::TransportProofMismatch);
}
#[test]
fn blossom_media_proof_remains_bound_to_its_exact_operation() {
let actor = Keys::generate();
VerifiedNostrProof::new(
authorization_domain(1),
@@ -1788,13 +1809,13 @@ fn blossom_upload_proof_cannot_authorize_media_downloads() {
AuthMethod::Blossom,
None,
)
.expect("Blossom proof may authorize the verified upload operation");
.expect("Blossom upload proof may authorize only its verified upload operation");
}
#[test]
fn binding_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1813,7 +1834,7 @@ fn binding_cannot_cross_authorization_domains() {
fn nostr_only_authorization_may_preserve_a_verified_owner() {
let actor = Keys::generate();
let owner = Keys::generate();
let context = AuthContext::finalize_v1(
let context = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,
@@ -1859,7 +1880,7 @@ fn transport_delegation_is_explicitly_transport_wide() {
#[test]
fn required_policy_rejects_nostr_only_authorization() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::NotRequired,
@@ -1874,7 +1895,7 @@ fn required_policy_rejects_nostr_only_authorization() {
#[test]
fn not_required_policy_rejects_federated_authorization() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_not_required(),
FederatedAuthorization::Direct {
@@ -1892,7 +1913,7 @@ fn not_required_policy_rejects_federated_authorization() {
#[test]
fn nostr_proof_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
AuthContextInput::new(
tenant(1),
Uuid::from_u128(2),
@@ -1912,7 +1933,7 @@ fn nostr_proof_cannot_cross_authorization_domains() {
#[test]
fn federated_policy_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
ResolvedFederatedPolicy::not_required(authorization_domain(2)),
FederatedAuthorization::NotRequired,
@@ -1927,7 +1948,7 @@ fn federated_policy_cannot_cross_authorization_domains() {
#[test]
fn community_admission_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
AuthContextInput::new(
tenant(1),
Uuid::from_u128(2),
@@ -1947,7 +1968,7 @@ fn community_admission_cannot_cross_authorization_domains() {
#[test]
fn assertion_cannot_cross_authorization_domains() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1971,7 +1992,7 @@ fn assertion_cannot_cross_authorization_domains() {
#[test]
fn assertion_must_match_the_authorized_transport() {
let actor = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(actor.public_key(), AuthTransport::RelayWebSocket, None),
policy_required(EnrollmentMode::AttestedKey),
FederatedAuthorization::Direct {
@@ -1996,7 +2017,7 @@ fn assertion_must_match_the_authorized_transport() {
fn delegated_owner_admission_must_match_the_authorization_domain() {
let actor = Keys::generate();
let owner = Keys::generate();
let error = AuthContext::finalize_v1(
let error = AuthContext::finalize_v1_evidence_for_test(
input(
actor.public_key(),
AuthTransport::RelayWebSocket,

View File

@@ -0,0 +1,676 @@
//! Narrow trusted-workspace adapter for sealed authorization evidence.
//!
//! Wire data cannot deserialize into any type in this module. The adapter is
//! used only after the existing cryptographic verifier, assertion verifier,
//! community policy, or typed binding store has returned success. It repeats
//! exact domain, transport, actor, time, identifier, version, and provenance
//! checks before crossing the crate's sealed evidence boundary.
use buzz_core::{tenant::TenantContext, CommunityId};
use nostr::{Event, PublicKey};
use sha2::{Digest, Sha256};
use thiserror::Error;
use uuid::Uuid;
use crate::{
context::{
AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthContextError, AuthMethod,
AuthTransport, AuthorizedCommunityAccess, BindingExpiry, BindingSource, BindingVersion,
DelegationExpiry, FederatedPrincipal, VerifiedFederatedAssertion, VerifiedKeyAttestation,
VerifiedNostrProof, VerifiedOperationBinding, VerifiedOperationBindingKind,
VerifiedProviderEvidence, VerifiedTransportDelegation, VersionedBindingRef,
},
nip42::verify_nip42_event,
nip98::verify_nip98_event,
AuthorizationProfileId, AuthorizationReason, CapabilitySet, Scope,
};
/// Binding lifecycle result returned by an authoritative store adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ActiveBindingResolution {
/// Exact active binding already existed.
Existing,
/// Binding was atomically enrolled during this decision.
Enrolled,
}
/// Existing-verifier output for transport-wide Nostr delegation.
///
/// Construction is deliberately explicit and non-serializable. The relay may
/// create it only by translating a successful NIP-OA verifier result whose
/// constraints were proved transport-wide; raw `auth` tag data is not such a
/// result.
pub struct VerifiedDelegationOutput {
owner_pubkey: PublicKey,
delegate_pubkey: PublicKey,
expires_at: Option<u64>,
transport_wide: bool,
}
impl VerifiedDelegationOutput {
/// Translate an existing verifier's exact output.
pub const fn from_workspace_verifier(
owner_pubkey: PublicKey,
delegate_pubkey: PublicKey,
expires_at: Option<u64>,
transport_wide: bool,
) -> Self {
Self {
owner_pubkey,
delegate_pubkey,
expires_at,
transport_wide,
}
}
}
/// Stateless factory for sealed evidence.
///
/// This value carries no configuration and cannot select a domain, provider,
/// or capability. Every method requires the server-resolved values again and
/// rejects mismatches in the translated verifier/store output.
#[derive(Debug, Default, Clone, Copy)]
pub struct VerifiedEvidenceAdapter;
fn operation_binding(
kind: VerifiedOperationBindingKind,
parts: &[&[u8]],
) -> VerifiedOperationBinding {
let mut hasher = Sha256::new();
hasher.update(b"buzz-auth:verified-operation-binding:v1");
for part in parts {
hasher.update((part.len() as u64).to_be_bytes());
hasher.update(part);
}
VerifiedOperationBinding::from_evidence_adapter(kind, hasher.finalize().into())
}
impl VerifiedEvidenceAdapter {
/// Create the trusted-workspace adapter.
pub const fn new() -> Self {
Self
}
/// Attach exact transport-wide delegation output to an already sealed
/// cryptographic proof.
///
/// This consumes the original proof, rejects replacement of an existing
/// delegation, and rechecks that the verified delegate is the proof actor.
pub fn attach_transport_delegation(
&self,
proof: VerifiedNostrProof,
delegation: VerifiedDelegationOutput,
) -> Result<VerifiedNostrProof, EvidenceAdapterError> {
if proof.verified_delegation().is_some() {
return Err(EvidenceAdapterError::DelegationAlreadyPresent);
}
let verified_delegation = self.delegation(proof.actor_pubkey(), delegation)?;
VerifiedNostrProof::from_evidence_adapter(
proof.authorization_domain(),
proof.authorized_transport(),
proof.actor_pubkey(),
proof.proof_method(),
proof.operation_binding(),
Some(verified_delegation),
)
.map_err(Into::into)
}
/// Verify NIP-42 and bind its result to relay or audio transport.
pub fn verify_nip42(
&self,
authorization_domain: CommunityId,
transport: AuthTransport,
event: &Event,
expected_challenge: &str,
relay_url: &str,
delegation: Option<VerifiedDelegationOutput>,
) -> Result<VerifiedNostrProof, EvidenceAdapterError> {
if !matches!(
transport,
AuthTransport::RelayWebSocket | AuthTransport::Audio
) {
return Err(EvidenceAdapterError::TransportMethodMismatch);
}
verify_nip42_event(event, expected_challenge, relay_url)?;
let delegation = delegation
.map(|delegation| self.delegation(event.pubkey, delegation))
.transpose()?;
let binding = operation_binding(
VerifiedOperationBindingKind::NostrSession,
&[expected_challenge.as_bytes(), relay_url.as_bytes()],
);
VerifiedNostrProof::from_evidence_adapter(
authorization_domain,
transport,
event.pubkey,
AuthMethod::Nip42,
binding,
delegation,
)
.map_err(Into::into)
}
/// Verify NIP-98 and bind it to one exact HTTP transport operation.
// The individual arguments are intentional trust-boundary inputs: folding
// them into an unverified request bag would make it easier to omit an exact
// domain, transport, method, body, or delegation cross-check.
#[allow(clippy::too_many_arguments)]
pub fn verify_nip98(
&self,
authorization_domain: CommunityId,
transport: AuthTransport,
event_json: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
delegation: Option<VerifiedDelegationOutput>,
) -> Result<VerifiedNostrProof, EvidenceAdapterError> {
if !matches!(
transport,
AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaUpload
) {
return Err(EvidenceAdapterError::TransportMethodMismatch);
}
let actor = verify_nip98_event(event_json, expected_url, expected_method, body)?;
let delegation = delegation
.map(|delegation| self.delegation(actor, delegation))
.transpose()?;
let body_presence = [u8::from(body.is_some())];
let binding = operation_binding(
VerifiedOperationBindingKind::HttpRequest,
&[
expected_method.as_bytes(),
expected_url.as_bytes(),
&body_presence,
body.unwrap_or_default(),
],
);
VerifiedNostrProof::from_evidence_adapter(
authorization_domain,
transport,
actor,
AuthMethod::Nip98,
binding,
delegation,
)
.map_err(Into::into)
}
fn delegation(
&self,
actor: PublicKey,
output: VerifiedDelegationOutput,
) -> Result<VerifiedTransportDelegation, EvidenceAdapterError> {
if !output.transport_wide {
return Err(EvidenceAdapterError::NarrowDelegation);
}
if output.delegate_pubkey != actor {
return Err(EvidenceAdapterError::DelegatedActorMismatch);
}
let expires_at = output.expires_at.map(DelegationExpiry::new).transpose()?;
VerifiedTransportDelegation::new_unrestricted(
output.owner_pubkey,
output.delegate_pubkey,
expires_at,
)
.map_err(Into::into)
}
/// Translate validated assertion claims into sealed, exact-bound evidence.
///
/// `now_unix_seconds` and every claim argument must be copied from the
/// successful configured assertion-verifier result, never decoded again
/// from request data at this boundary.
#[allow(clippy::too_many_arguments)]
pub fn federated_assertion_from_validated_claims(
&self,
authorization_domain: CommunityId,
transport: AuthTransport,
issuer: &str,
subject: &str,
attested_pubkey: Option<PublicKey>,
assertion_transport: AssertionTransport,
not_before: Option<u64>,
expires_at: u64,
now_unix_seconds: u64,
) -> Result<VerifiedFederatedAssertion, EvidenceAdapterError> {
let principal = FederatedPrincipal::new(issuer, subject)?;
let expires_at = AssertionExpiry::new(expires_at)?;
if expires_at.is_expired_at(now_unix_seconds) {
return Err(EvidenceAdapterError::AssertionExpired);
}
let not_before = not_before.map(AssertionNotBefore::new);
if not_before.is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) {
return Err(EvidenceAdapterError::AssertionNotYetValid);
}
let key_attestation = attested_pubkey.map(VerifiedKeyAttestation::from_evidence_adapter);
Ok(VerifiedFederatedAssertion::from_evidence_adapter(
authorization_domain,
transport,
principal,
key_attestation,
assertion_transport,
not_before,
expires_at,
))
}
/// Seal provider-neutral evidence from an already verified assertion.
///
/// The adapter rejects malformed or stale freshness bounds before the
/// evidence can reach a protected route. The profile and capabilities are
/// typed server-side values and cannot be selected by raw request input.
pub fn provider_evidence_from_verified_assertion(
&self,
assertion: VerifiedFederatedAssertion,
profile_id: AuthorizationProfileId,
capabilities: CapabilitySet,
issued_at: u64,
fresh_until: u64,
now_unix_seconds: u64,
) -> Result<VerifiedProviderEvidence, EvidenceAdapterError> {
if issued_at == 0 || issued_at > now_unix_seconds {
return Err(EvidenceAdapterError::ProviderEvidenceNotYetValid);
}
if fresh_until <= issued_at
|| fresh_until <= now_unix_seconds
|| fresh_until > assertion.expires_at().unix_seconds()
{
return Err(EvidenceAdapterError::ProviderEvidenceExpired);
}
if assertion
.not_before()
.is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds))
{
return Err(EvidenceAdapterError::AssertionNotYetValid);
}
if assertion.expires_at().is_expired_at(now_unix_seconds) {
return Err(EvidenceAdapterError::AssertionExpired);
}
Ok(VerifiedProviderEvidence::from_evidence_adapter(
assertion,
profile_id,
capabilities,
issued_at,
fresh_until,
))
}
/// Translate a typed active binding-store result.
///
/// The adapter derives the authorization reason from lifecycle resolution
/// and provenance. Callers cannot label an enrolled row as pre-existing or
/// turn a provisioned row into a first-use enrollment.
#[allow(clippy::too_many_arguments)]
pub fn active_binding_from_store(
&self,
authorization_domain: CommunityId,
binding_domain: CommunityId,
binding_id: Uuid,
issuer: &str,
subject: &str,
bound_pubkey: PublicKey,
binding_version: u64,
expires_at: Option<u64>,
source: BindingSource,
resolution: ActiveBindingResolution,
assertion: Option<&VerifiedFederatedAssertion>,
) -> Result<VersionedBindingRef, EvidenceAdapterError> {
if binding_domain != authorization_domain {
return Err(EvidenceAdapterError::BindingDomainMismatch);
}
let binding_version = BindingVersion::new(binding_version)?;
let expires_at = expires_at.map(BindingExpiry::new).transpose()?;
let principal = FederatedPrincipal::new(issuer, subject)?;
if let Some(assertion) = assertion {
if assertion.authorization_domain() != authorization_domain
|| assertion.principal() != &principal
{
return Err(EvidenceAdapterError::AssertionBindingMismatch);
}
if assertion
.key_attestation()
.is_some_and(|key| key.pubkey() != bound_pubkey)
{
return Err(EvidenceAdapterError::AssertionBindingMismatch);
}
}
let reason = match (resolution, source) {
(ActiveBindingResolution::Existing, _) => AuthorizationReason::ExistingBinding,
(ActiveBindingResolution::Enrolled, BindingSource::AttestedKey) => {
if assertion
.and_then(VerifiedFederatedAssertion::key_attestation)
.is_none_or(|key| key.pubkey() != bound_pubkey)
{
return Err(EvidenceAdapterError::AttestationRequired);
}
AuthorizationReason::EnrolledAttestedKey
}
(ActiveBindingResolution::Enrolled, BindingSource::Tofu) => {
AuthorizationReason::EnrolledTofu
}
(ActiveBindingResolution::Enrolled, BindingSource::Provisioned) => {
return Err(EvidenceAdapterError::InvalidBindingResolution)
}
};
VersionedBindingRef::from_evidence_adapter(
authorization_domain,
binding_id,
principal,
bound_pubkey,
binding_version,
expires_at,
source,
reason,
)
.map_err(Into::into)
}
/// Translate successful local community policy into sealed admission.
pub fn community_access_from_policy(
&self,
tenant: &TenantContext,
resolved_domain: CommunityId,
scopes: Vec<Scope>,
channel_ids: Option<Vec<Uuid>>,
) -> Result<AuthorizedCommunityAccess, EvidenceAdapterError> {
if tenant.community() != resolved_domain {
return Err(EvidenceAdapterError::AdmissionDomainMismatch);
}
Ok(AuthorizedCommunityAccess::from_evidence_adapter(
resolved_domain,
scopes,
channel_ids,
))
}
}
/// Rejection while translating trusted-workspace verifier/store output.
#[derive(Debug, Error)]
pub enum EvidenceAdapterError {
/// Existing cryptographic proof failed.
#[error(transparent)]
Authentication(#[from] crate::AuthError),
/// Sealed context evidence was inconsistent.
#[error(transparent)]
Context(#[from] AuthContextError),
/// Proof method cannot establish the requested transport.
#[error("verified proof method does not match requested transport")]
TransportMethodMismatch,
/// Delegation output named a different actor.
#[error("verified delegation output does not match authenticated actor")]
DelegatedActorMismatch,
/// Operation-scoped delegation cannot be promoted to transport-wide.
#[error("verified delegation output is narrower than the transport")]
NarrowDelegation,
/// A sealed proof cannot have its verified delegation replaced.
#[error("verified transport delegation is already present")]
DelegationAlreadyPresent,
/// Validated assertion was already expired.
#[error("validated assertion is expired")]
AssertionExpired,
/// Validated assertion is not yet current.
#[error("validated assertion is not yet valid")]
AssertionNotYetValid,
/// Provider evidence claims an issuance time after the server clock.
#[error("verified provider evidence is not yet valid")]
ProviderEvidenceNotYetValid,
/// Provider evidence has an invalid or expired hard freshness bound.
#[error("verified provider evidence is expired")]
ProviderEvidenceExpired,
/// Binding store result came from another exact domain.
#[error("active binding output belongs to another authorization domain")]
BindingDomainMismatch,
/// Binding does not match the validated assertion.
#[error("active binding output does not match validated assertion")]
AssertionBindingMismatch,
/// Attested-key enrollment did not carry the exact attested key.
#[error("attested-key enrollment lacks matching attestation")]
AttestationRequired,
/// Store provenance cannot produce the supplied lifecycle result.
#[error("active binding resolution and provenance are inconsistent")]
InvalidBindingResolution,
/// Local policy result belongs to another exact domain.
#[error("community admission belongs to another authorization domain")]
AdmissionDomainMismatch,
}
#[cfg(test)]
mod tests {
use nostr::{EventBuilder, Keys, RelayUrl};
use super::*;
fn domain(value: u128) -> CommunityId {
CommunityId::from_uuid(Uuid::from_u128(value))
}
fn neutral_capabilities() -> CapabilitySet {
CapabilitySet::new(vec![crate::AuthorizationCapability::CommunityRead])
.expect("synthetic capability set")
}
fn neutral_profile(value: &str) -> AuthorizationProfileId {
AuthorizationProfileId::from_server_configuration(value)
.expect("synthetic provider profile")
}
#[test]
fn neutral_verified_evidence_accepts_and_rejects_exact_parts() {
let adapter = VerifiedEvidenceAdapter::new();
let assertion = adapter
.federated_assertion_from_validated_claims(
domain(1),
AuthTransport::HttpBridge,
"https://issuer.example",
"subject",
None,
AssertionTransport::TrustedProxy,
Some(90),
200,
100,
)
.expect("synthetic verified assertion");
let evidence = adapter
.provider_evidence_from_verified_assertion(
assertion,
neutral_profile("profile-a"),
neutral_capabilities(),
100,
180,
100,
)
.expect("matching verified provider evidence");
assert_eq!(
evidence.validate_for(
domain(1),
AuthTransport::HttpBridge,
&neutral_profile("profile-a"),
&neutral_capabilities(),
100,
),
Ok(())
);
assert_eq!(
evidence.validate_for(
domain(2),
AuthTransport::HttpBridge,
&neutral_profile("profile-a"),
&neutral_capabilities(),
100,
),
Err(crate::ProviderEvidenceValidationError::AuthorizationDomainMismatch)
);
assert_eq!(
evidence.validate_for(
domain(1),
AuthTransport::Git,
&neutral_profile("profile-a"),
&neutral_capabilities(),
100,
),
Err(crate::ProviderEvidenceValidationError::TransportMismatch)
);
assert_eq!(
evidence.validate_for(
domain(1),
AuthTransport::HttpBridge,
&neutral_profile("profile-b"),
&neutral_capabilities(),
100,
),
Err(crate::ProviderEvidenceValidationError::ProfileMismatch)
);
assert_eq!(
evidence.validate_for(
domain(1),
AuthTransport::HttpBridge,
&neutral_profile("profile-a"),
&CapabilitySet::new(vec![crate::AuthorizationCapability::GitRead])
.expect("synthetic capability set"),
100,
),
Err(crate::ProviderEvidenceValidationError::CapabilityMismatch)
);
assert_eq!(
evidence.validate_for(
domain(1),
AuthTransport::HttpBridge,
&neutral_profile("profile-a"),
&neutral_capabilities(),
180,
),
Err(crate::ProviderEvidenceValidationError::Expired)
);
assert_eq!(
format!("{evidence:?}"),
"VerifiedProviderEvidence { authorization_domain: \"[redacted]\", authorized_transport: \"[redacted]\", principal: \"[redacted]\", profile_id: \"[redacted]\", capabilities: \"[redacted]\", issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }"
);
}
#[test]
fn nip42_factory_reverifies_signature_challenge_transport_and_actor() {
let adapter = VerifiedEvidenceAdapter::new();
let actor = Keys::generate();
let owner = Keys::generate();
let challenge = "challenge";
let relay = "wss://relay.example";
let event = EventBuilder::auth(challenge, RelayUrl::parse(relay).expect("relay url"))
.sign_with_keys(&actor)
.expect("auth event");
let proof = adapter
.verify_nip42(
domain(1),
AuthTransport::RelayWebSocket,
&event,
challenge,
relay,
Some(VerifiedDelegationOutput::from_workspace_verifier(
owner.public_key(),
actor.public_key(),
None,
true,
)),
)
.expect("verified NIP-42 proof");
assert_eq!(
proof.operation_binding().kind(),
VerifiedOperationBindingKind::NostrSession
);
let substituted = adapter
.verify_nip42(
domain(1),
AuthTransport::RelayWebSocket,
&event,
challenge,
"wss://other.example",
None,
)
.expect_err("relay URL substitution must fail full verification");
assert!(matches!(
substituted,
EvidenceAdapterError::Authentication(_)
));
assert!(matches!(
adapter.verify_nip42(
domain(1),
AuthTransport::Git,
&event,
challenge,
relay,
None,
),
Err(EvidenceAdapterError::TransportMethodMismatch)
));
assert!(matches!(
adapter.verify_nip42(
domain(1),
AuthTransport::RelayWebSocket,
&event,
challenge,
relay,
Some(VerifiedDelegationOutput::from_workspace_verifier(
owner.public_key(),
Keys::generate().public_key(),
None,
true,
)),
),
Err(EvidenceAdapterError::DelegatedActorMismatch)
));
}
#[test]
fn assertion_and_binding_factories_reject_cross_domain_and_forged_provenance() {
let adapter = VerifiedEvidenceAdapter::new();
let actor = Keys::generate().public_key();
let assertion = adapter
.federated_assertion_from_validated_claims(
domain(1),
AuthTransport::HttpBridge,
"https://issuer.example",
"subject",
Some(actor),
AssertionTransport::TrustedProxy,
None,
200,
100,
)
.expect("assertion");
assert!(matches!(
adapter.active_binding_from_store(
domain(1),
domain(2),
Uuid::new_v4(),
"https://issuer.example",
"subject",
actor,
1,
None,
BindingSource::AttestedKey,
ActiveBindingResolution::Existing,
Some(&assertion),
),
Err(EvidenceAdapterError::BindingDomainMismatch)
));
assert!(matches!(
adapter.active_binding_from_store(
domain(1),
domain(1),
Uuid::new_v4(),
"https://issuer.example",
"subject",
actor,
1,
None,
BindingSource::Provisioned,
ActiveBindingResolution::Enrolled,
Some(&assertion),
),
Err(EvidenceAdapterError::InvalidBindingResolution)
));
}
}

View File

@@ -0,0 +1,834 @@
//! Bounded, versioned authorization leases.
//!
//! A lease is issued only by the crate-owned federated finalizer after all
//! identity, provider, delegation, and binding evidence has been validated.
//! Lease consumers must supply the current typed context, lease, binding,
//! provider profile, and policy versions plus the exact capability being
//! exercised.
use std::{fmt, sync::Arc, time::SystemTime};
use buzz_core::CommunityId;
use nostr::PublicKey;
use thiserror::Error;
use uuid::Uuid;
use crate::{
context::{AuthContextVersion, AuthTransport, BindingVersion, VersionedBindingRef},
provider::{AuthorizationCapability, AuthorizationProfileId, CapabilitySet, PolicyVersion},
};
/// Largest application lease duration accepted by the portable runtime.
pub const MAX_APPLICATION_LEASE_SECONDS: u64 = 86_400;
/// Largest explicit clock-skew allowance accepted by the portable runtime.
pub const MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS: u64 = 300;
/// Largest renewal lead time accepted by the portable runtime.
pub const MAX_LEASE_RENEWAL_LEAD_SECONDS: u64 = 3_600;
/// Centrally supplied authorization time in Unix seconds.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct AuthorizationTime(u64);
impl AuthorizationTime {
/// Preserve a Unix timestamp supplied by an authorization clock.
pub const fn from_unix_seconds(unix_seconds: u64) -> Self {
Self(unix_seconds)
}
/// Timestamp as seconds since the Unix epoch.
pub const fn unix_seconds(self) -> u64 {
self.0
}
}
impl fmt::Debug for AuthorizationTime {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("AuthorizationTime")
.field(&"[redacted]")
.finish()
}
}
/// Centrally injected time source for authorization decisions and lease use.
pub trait AuthorizationClock: Send + Sync {
/// Return the current authorization time.
fn now(&self) -> Result<AuthorizationTime, AuthorizationClockError>;
}
/// System-backed authorization clock used outside deterministic tests.
#[derive(Debug, Default)]
pub struct SystemAuthorizationClock;
impl AuthorizationClock for SystemAuthorizationClock {
fn now(&self) -> Result<AuthorizationTime, AuthorizationClockError> {
let elapsed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|_| AuthorizationClockError::BeforeUnixEpoch)?;
Ok(AuthorizationTime::from_unix_seconds(elapsed.as_secs()))
}
}
/// Failure to obtain central authorization time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum AuthorizationClockError {
/// The system clock was earlier than the Unix epoch.
#[error("authorization clock is earlier than the Unix epoch")]
BeforeUnixEpoch,
/// The injected clock could not provide a current value.
#[error("authorization clock is unavailable")]
Unavailable,
}
/// Shared injected authorization clock.
pub type SharedAuthorizationClock = Arc<dyn AuthorizationClock>;
/// Explicit conservative clock-skew allowance.
///
/// Skew is subtracted from every evidence bound when authority is issued; it
/// never extends a lease past an assertion, provider, delegation, binding, or
/// application expiry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorizationClockSkew(u64);
impl AuthorizationClockSkew {
/// Build a bounded skew allowance in seconds.
pub const fn from_seconds(seconds: u64) -> Result<Self, LeasePolicyError> {
if seconds > MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS {
return Err(LeasePolicyError::ClockSkewTooLarge);
}
Ok(Self(seconds))
}
/// Configured conservative skew allowance in seconds.
pub const fn seconds(self) -> u64 {
self.0
}
}
/// Maximum application lifetime for an access lease or verification status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ApplicationLeaseLimit(u64);
impl ApplicationLeaseLimit {
/// Build a non-zero application lifetime no greater than one day.
pub const fn from_seconds(seconds: u64) -> Result<Self, LeasePolicyError> {
if seconds == 0 {
return Err(LeasePolicyError::ZeroApplicationLimit);
}
if seconds > MAX_APPLICATION_LEASE_SECONDS {
return Err(LeasePolicyError::ApplicationLimitTooLarge);
}
Ok(Self(seconds))
}
/// Configured maximum lifetime in seconds.
pub const fn seconds(self) -> u64 {
self.0
}
}
/// Server-owned access-lease bounds for one authorization domain.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccessLeasePolicy {
application_limit: ApplicationLeaseLimit,
clock_skew: AuthorizationClockSkew,
}
impl AccessLeasePolicy {
/// Combine an application maximum with an explicit conservative skew.
pub const fn new(
application_limit: ApplicationLeaseLimit,
clock_skew: AuthorizationClockSkew,
) -> Self {
Self {
application_limit,
clock_skew,
}
}
/// Configured application maximum.
pub const fn application_limit(self) -> ApplicationLeaseLimit {
self.application_limit
}
/// Configured conservative clock skew.
pub const fn clock_skew(self) -> AuthorizationClockSkew {
self.clock_skew
}
}
/// Server-owned display-status bounds for verification-only mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerificationStatusPolicy {
application_limit: ApplicationLeaseLimit,
clock_skew: AuthorizationClockSkew,
}
impl VerificationStatusPolicy {
/// Combine a short display maximum with an explicit conservative skew.
pub const fn new(
application_limit: ApplicationLeaseLimit,
clock_skew: AuthorizationClockSkew,
) -> Self {
Self {
application_limit,
clock_skew,
}
}
/// Configured display-status maximum.
pub const fn application_limit(self) -> ApplicationLeaseLimit {
self.application_limit
}
/// Configured conservative clock skew.
pub const fn clock_skew(self) -> AuthorizationClockSkew {
self.clock_skew
}
}
/// Invalid server-owned lease policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum LeasePolicyError {
/// An application maximum was zero.
#[error("application authorization limit must be greater than zero")]
ZeroApplicationLimit,
/// An application maximum exceeded the portable upper bound.
#[error("application authorization limit exceeds the portable maximum")]
ApplicationLimitTooLarge,
/// The clock-skew allowance exceeded the portable upper bound.
#[error("authorization clock skew exceeds the portable maximum")]
ClockSkewTooLarge,
/// A renewal lead time was zero.
#[error("authorization lease renewal lead must be greater than zero")]
ZeroRenewalLead,
/// A renewal lead time exceeded the portable upper bound.
#[error("authorization lease renewal lead exceeds the portable maximum")]
RenewalLeadTooLarge,
}
/// Monotonic version of an issued authorization lease.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct LeaseVersion(u64);
impl LeaseVersion {
/// Initial lease version for a newly authorized session.
pub const INITIAL: Self = Self(1);
/// Build a positive lease version.
pub const fn new(value: u64) -> Result<Self, LeaseIssueError> {
if value == 0 {
return Err(LeaseIssueError::InvalidLeaseVersion);
}
Ok(Self(value))
}
/// Numeric lease version.
pub const fn get(self) -> u64 {
self.0
}
}
impl fmt::Debug for LeaseVersion {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_tuple("LeaseVersion")
.field(&"[redacted]")
.finish()
}
}
/// Freshness bound obtained with an authoritative active binding.
///
/// The identifier and version prevent a bound from one binding snapshot from
/// being reused with another binding or lifecycle version.
#[derive(PartialEq, Eq)]
pub struct BindingLeaseBound {
binding_id: Uuid,
binding_version: BindingVersion,
valid_until: u64,
}
impl BindingLeaseBound {
/// Attach a non-zero freshness bound to an authoritative active binding.
pub fn new(binding: &VersionedBindingRef, valid_until: u64) -> Result<Self, LeaseIssueError> {
if valid_until == 0 {
return Err(LeaseIssueError::InvalidBindingBound);
}
Ok(Self {
binding_id: binding.binding_id(),
binding_version: binding.binding_version(),
valid_until,
})
}
/// Stable binding identifier represented by this bound.
pub const fn binding_id(&self) -> Uuid {
self.binding_id
}
/// Exact active binding version represented by this bound.
pub const fn binding_version(&self) -> BindingVersion {
self.binding_version
}
/// Time after which active binding state must be resolved again.
pub const fn valid_until(&self) -> u64 {
self.valid_until
}
}
impl fmt::Debug for BindingLeaseBound {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BindingLeaseBound")
.field("binding_id", &"[redacted]")
.field("binding_version", &"[redacted]")
.field("valid_until", &"[redacted]")
.finish()
}
}
/// Versioned authority for a bounded set of federated capabilities.
///
/// This type has no public constructor or deserialization path. Only the
/// crate-owned finalizer can issue it from a validated provider snapshot and
/// matching active binding evidence.
#[derive(PartialEq, Eq)]
pub struct AuthorizationLease {
context_version: AuthContextVersion,
lease_version: LeaseVersion,
authorization_domain: CommunityId,
transport: AuthTransport,
actor_pubkey: PublicKey,
owner_pubkey: Option<PublicKey>,
binding_id: Uuid,
binding_version: BindingVersion,
profile_id: AuthorizationProfileId,
policy_version: PolicyVersion,
capabilities: CapabilitySet,
issued_at: u64,
expires_at: u64,
correlation_id: Uuid,
}
impl AuthorizationLease {
#[allow(clippy::too_many_arguments)]
pub(crate) fn issue(
lease_version: LeaseVersion,
authorization_domain: CommunityId,
transport: AuthTransport,
actor_pubkey: PublicKey,
owner_pubkey: Option<PublicKey>,
binding: &VersionedBindingRef,
binding_bound: BindingLeaseBound,
profile_id: AuthorizationProfileId,
policy_version: PolicyVersion,
capabilities: CapabilitySet,
provider_effective_until: u64,
now: AuthorizationTime,
policy: AccessLeasePolicy,
correlation_id: Uuid,
) -> Result<Self, LeaseIssueError> {
if binding_bound.binding_id != binding.binding_id()
|| binding_bound.binding_version != binding.binding_version()
{
return Err(LeaseIssueError::BindingBoundMismatch);
}
let expires_at = conservative_expiry(
now,
provider_effective_until,
binding_bound.valid_until,
policy.application_limit,
policy.clock_skew,
)?;
Ok(Self {
context_version: AuthContextVersion::V1,
lease_version,
authorization_domain,
transport,
actor_pubkey,
owner_pubkey,
binding_id: binding.binding_id(),
binding_version: binding.binding_version(),
profile_id,
policy_version,
capabilities,
issued_at: now.unix_seconds(),
expires_at,
correlation_id,
})
}
/// Authorization-context contract version carried by the lease.
pub const fn context_version(&self) -> AuthContextVersion {
self.context_version
}
/// Monotonic lease version.
pub const fn lease_version(&self) -> LeaseVersion {
self.lease_version
}
/// Server-resolved authorization domain.
pub const fn authorization_domain(&self) -> CommunityId {
self.authorization_domain
}
/// Exact transport authorized by the lease.
pub const fn transport(&self) -> AuthTransport {
self.transport
}
/// Authenticated Nostr actor authorized by the lease.
pub const fn actor_pubkey(&self) -> PublicKey {
self.actor_pubkey
}
/// Verified delegated owner, when the actor is delegated.
pub const fn owner_pubkey(&self) -> Option<PublicKey> {
self.owner_pubkey
}
/// Stable active binding identifier.
pub const fn binding_id(&self) -> Uuid {
self.binding_id
}
/// Exact active binding version.
pub const fn binding_version(&self) -> BindingVersion {
self.binding_version
}
/// Server-resolved provider profile.
pub const fn profile_id(&self) -> &AuthorizationProfileId {
&self.profile_id
}
/// Opaque provider policy version.
pub const fn policy_version(&self) -> &PolicyVersion {
&self.policy_version
}
/// Exact capabilities granted by current provider policy.
pub const fn capabilities(&self) -> &CapabilitySet {
&self.capabilities
}
/// Central issue time in Unix seconds.
pub const fn issued_at(&self) -> u64 {
self.issued_at
}
/// Conservative earliest expiry in Unix seconds.
pub const fn expires_at(&self) -> u64 {
self.expires_at
}
/// Correlation identifier for the decision that issued the lease.
pub const fn correlation_id(&self) -> Uuid {
self.correlation_id
}
/// Compute deterministic renewal and hard-expiry deadlines.
pub fn renewal_schedule(&self, lead_time: LeaseRenewalLeadTime) -> LeaseRenewalSchedule {
LeaseRenewalSchedule {
renew_at: self
.expires_at
.saturating_sub(lead_time.seconds())
.max(self.issued_at),
expires_at: self.expires_at,
}
}
}
impl fmt::Debug for AuthorizationLease {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AuthorizationLease")
.field("context_version", &self.context_version)
.field("lease_version", &"[redacted]")
.field("authorization_domain", &"[redacted]")
.field("transport", &self.transport)
.field("actor_pubkey", &"[redacted]")
.field("owner_pubkey", &"[redacted]")
.field("binding_id", &"[redacted]")
.field("binding_version", &"[redacted]")
.field("profile_id", &"[redacted]")
.field("policy_version", &"[redacted]")
.field("capabilities", &"[redacted]")
.field("issued_at", &"[redacted]")
.field("expires_at", &"[redacted]")
.field("correlation_id", &"[redacted]")
.finish()
}
}
/// Current typed state required to consume an authorization lease.
#[derive(Clone)]
pub struct LeaseUseRequirement {
/// Required authorization-context contract version.
pub context_version: AuthContextVersion,
/// Current lease version for the session.
pub lease_version: LeaseVersion,
/// Server-resolved authorization domain of the operation.
pub authorization_domain: CommunityId,
/// Transport carrying the protected operation.
pub transport: AuthTransport,
/// Authenticated actor performing the operation.
pub actor_pubkey: PublicKey,
/// Stable identifier of the current active binding.
pub binding_id: Uuid,
/// Current active binding version.
pub binding_version: BindingVersion,
/// Exact server-resolved provider profile currently selected for the operation.
pub profile_id: AuthorizationProfileId,
/// Current provider policy version.
pub policy_version: PolicyVersion,
/// Exact capability required by the operation.
pub capability: AuthorizationCapability,
}
impl fmt::Debug for LeaseUseRequirement {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LeaseUseRequirement")
.field("context_version", &self.context_version)
.field("lease_version", &"[redacted]")
.field("authorization_domain", &"[redacted]")
.field("transport", &self.transport)
.field("actor_pubkey", &"[redacted]")
.field("binding_id", &"[redacted]")
.field("binding_version", &"[redacted]")
.field("profile_id", &"[redacted]")
.field("policy_version", &"[redacted]")
.field("capability", &"[redacted]")
.finish()
}
}
/// Central validator for protected lease use.
#[derive(Clone)]
pub struct AuthorizationLeaseValidator {
clock: SharedAuthorizationClock,
}
impl AuthorizationLeaseValidator {
/// Create a validator that always reads from the supplied central clock.
pub fn new(clock: SharedAuthorizationClock) -> Self {
Self { clock }
}
/// Validate every typed version, subject, transport, capability, and time bound.
pub fn authorize(
&self,
lease: &AuthorizationLease,
requirement: &LeaseUseRequirement,
) -> Result<(), LeaseValidationError> {
if lease.context_version != requirement.context_version {
return Err(LeaseValidationError::ContextVersionMismatch);
}
if lease.lease_version != requirement.lease_version {
return Err(LeaseValidationError::LeaseVersionMismatch);
}
if lease.authorization_domain != requirement.authorization_domain {
return Err(LeaseValidationError::AuthorizationDomainMismatch);
}
if lease.transport != requirement.transport {
return Err(LeaseValidationError::TransportMismatch);
}
if lease.actor_pubkey != requirement.actor_pubkey {
return Err(LeaseValidationError::ActorMismatch);
}
if lease.binding_id != requirement.binding_id {
return Err(LeaseValidationError::BindingIdMismatch);
}
if lease.binding_version != requirement.binding_version {
return Err(LeaseValidationError::BindingVersionMismatch);
}
if lease.profile_id != requirement.profile_id {
return Err(LeaseValidationError::AuthorizationProfileMismatch);
}
if lease.policy_version != requirement.policy_version {
return Err(LeaseValidationError::PolicyVersionMismatch);
}
if !lease.capabilities.contains(requirement.capability) {
return Err(LeaseValidationError::MissingCapability);
}
let now = self.clock.now()?;
if now.unix_seconds() < lease.issued_at {
return Err(LeaseValidationError::ClockMovedBeforeIssue);
}
if now.unix_seconds() >= lease.expires_at {
return Err(LeaseValidationError::Expired);
}
Ok(())
}
/// Validate and retain a per-capability operation guard.
///
/// Long-running streams and mutations retain this guard and call
/// [`AuthorizationOperationGuard::revalidate`] immediately before each
/// protected emission or commit. The guard borrows an access lease and
/// cannot be constructed from verification-only status.
pub fn operation_guard<'a>(
&self,
lease: &'a AuthorizationLease,
requirement: LeaseUseRequirement,
) -> Result<AuthorizationOperationGuard<'a>, LeaseValidationError> {
self.authorize(lease, &requirement)?;
Ok(AuthorizationOperationGuard {
lease,
validator: self.clone(),
requirement,
})
}
/// Return the deterministic renewal action for WebSocket or audio authority.
pub fn renewal_action(
&self,
schedule: LeaseRenewalSchedule,
) -> Result<LeaseRenewalAction, LeaseValidationError> {
let now = self.clock.now()?;
if now.unix_seconds() >= schedule.expires_at {
return Ok(LeaseRenewalAction::Expired);
}
if now.unix_seconds() >= schedule.renew_at {
return Ok(LeaseRenewalAction::RenewNow);
}
Ok(LeaseRenewalAction::Current)
}
/// Remaining whole seconds until a trusted authorization deadline.
///
/// Long-lived transports use the same injected clock as lease validation;
/// they must not schedule expiry from an independently read wall clock.
pub fn seconds_until(&self, expires_at: u64) -> Result<u64, LeaseValidationError> {
let now = self.clock.now()?;
Ok(expires_at.saturating_sub(now.unix_seconds()))
}
}
impl fmt::Debug for AuthorizationLeaseValidator {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AuthorizationLeaseValidator")
.field("clock", &"[injected]")
.finish()
}
}
/// Retained per-capability authorization for one in-flight operation.
///
/// Initial validation is not a substitute for the mandatory pre-commit or
/// pre-emission revalidation hook. Expiry and current typed versions are
/// checked again every time [`Self::revalidate`] is called.
pub struct AuthorizationOperationGuard<'a> {
lease: &'a AuthorizationLease,
validator: AuthorizationLeaseValidator,
requirement: LeaseUseRequirement,
}
impl AuthorizationOperationGuard<'_> {
/// Revalidate the retained authority immediately before a protected effect.
pub fn revalidate(&self) -> Result<(), LeaseValidationError> {
self.validator.authorize(self.lease, &self.requirement)
}
/// Exact capability retained by this guard.
pub const fn capability(&self) -> AuthorizationCapability {
self.requirement.capability
}
/// Hard conservative expiry after which revalidation always denies.
pub const fn expires_at(&self) -> u64 {
self.lease.expires_at
}
}
impl fmt::Debug for AuthorizationOperationGuard<'_> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AuthorizationOperationGuard")
.field("lease", &"[borrowed]")
.field("validator", &self.validator)
.field("requirement", &self.requirement)
.finish()
}
}
/// Bounded lead time for renewing long-lived authority.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LeaseRenewalLeadTime(u64);
impl LeaseRenewalLeadTime {
/// Build a non-zero renewal lead no greater than one hour.
pub const fn from_seconds(seconds: u64) -> Result<Self, LeasePolicyError> {
if seconds == 0 {
return Err(LeasePolicyError::ZeroRenewalLead);
}
if seconds > MAX_LEASE_RENEWAL_LEAD_SECONDS {
return Err(LeasePolicyError::RenewalLeadTooLarge);
}
Ok(Self(seconds))
}
/// Renewal lead in seconds.
pub const fn seconds(self) -> u64 {
self.0
}
}
/// Deterministic renewal and fail-closed expiry deadlines.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LeaseRenewalSchedule {
renew_at: u64,
expires_at: u64,
}
impl LeaseRenewalSchedule {
/// Time at which a long-lived transport should begin renewal.
pub const fn renew_at(self) -> u64 {
self.renew_at
}
/// Hard deadline at which the transport must stop using authority.
pub const fn expires_at(self) -> u64 {
self.expires_at
}
}
/// Current hook result for a long-lived transport's renewal loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LeaseRenewalAction {
/// Existing authority remains current before its renewal window.
Current,
/// Renewal is due; failure to renew before expiry must close fail-closed.
RenewNow,
/// The hard deadline passed; authority must no longer be used.
Expired,
}
/// Failure to issue an authorization lease or bounded display status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum LeaseIssueError {
/// A lease version was zero.
#[error("authorization lease version must be greater than zero")]
InvalidLeaseVersion,
/// A binding freshness bound was zero.
#[error("binding freshness bound must be greater than zero")]
InvalidBindingBound,
/// Binding freshness evidence named another binding or version.
#[error("binding freshness bound does not match the active binding")]
BindingBoundMismatch,
/// Computing the configured application bound overflowed Unix seconds.
#[error("application authorization bound overflowed")]
ApplicationBoundOverflow,
/// At least one required evidence bound was already expired or inside skew.
#[error("authorization evidence is no longer current")]
EvidenceExpired,
}
impl LeaseIssueError {
/// Stable audit and metric code.
pub const fn code(self) -> &'static str {
match self {
Self::InvalidLeaseVersion => "authorization_lease_issue_001",
Self::InvalidBindingBound => "authorization_lease_issue_002",
Self::BindingBoundMismatch => "authorization_lease_issue_003",
Self::ApplicationBoundOverflow => "authorization_lease_issue_004",
Self::EvidenceExpired => "authorization_lease_issue_005",
}
}
}
/// Fail-closed protected-operation lease validation error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum LeaseValidationError {
/// The central authorization clock failed.
#[error(transparent)]
Clock(#[from] AuthorizationClockError),
/// The context did not carry a federated access lease.
#[error("authorization context does not carry an access lease")]
MissingLease,
/// The authorization-context contract version was not exact.
#[error("authorization context version does not match")]
ContextVersionMismatch,
/// The current session lease version was not exact.
#[error("authorization lease version does not match")]
LeaseVersionMismatch,
/// The operation belonged to another authorization domain.
#[error("authorization lease domain does not match")]
AuthorizationDomainMismatch,
/// The operation used another transport.
#[error("authorization lease transport does not match")]
TransportMismatch,
/// The operation was performed by another actor.
#[error("authorization lease actor does not match")]
ActorMismatch,
/// Current binding state identifies another stable binding.
#[error("authorization lease binding identifier does not match")]
BindingIdMismatch,
/// Current binding state has another version.
#[error("authorization lease binding version does not match")]
BindingVersionMismatch,
/// Current server policy selected another provider profile.
#[error("authorization lease provider profile does not match")]
AuthorizationProfileMismatch,
/// Current provider policy has another version.
#[error("authorization lease policy version does not match")]
PolicyVersionMismatch,
/// The lease did not grant the operation's exact capability.
#[error("authorization lease does not include the required capability")]
MissingCapability,
/// Central time moved before the lease issue time.
#[error("authorization clock moved before lease issue time")]
ClockMovedBeforeIssue,
/// The lease reached its conservative earliest expiry.
#[error("authorization lease has expired")]
Expired,
}
impl LeaseValidationError {
/// Stable audit and metric code.
pub const fn code(&self) -> &'static str {
match self {
Self::Clock(_) => "authorization_lease_use_001",
Self::MissingLease => "authorization_lease_use_002",
Self::ContextVersionMismatch => "authorization_lease_use_003",
Self::LeaseVersionMismatch => "authorization_lease_use_004",
Self::AuthorizationDomainMismatch => "authorization_lease_use_005",
Self::TransportMismatch => "authorization_lease_use_006",
Self::ActorMismatch => "authorization_lease_use_007",
Self::BindingIdMismatch => "authorization_lease_use_013",
Self::BindingVersionMismatch => "authorization_lease_use_008",
Self::AuthorizationProfileMismatch => "authorization_lease_use_014",
Self::PolicyVersionMismatch => "authorization_lease_use_009",
Self::MissingCapability => "authorization_lease_use_010",
Self::ClockMovedBeforeIssue => "authorization_lease_use_011",
Self::Expired => "authorization_lease_use_012",
}
}
}
pub(crate) fn conservative_expiry(
now: AuthorizationTime,
provider_effective_until: u64,
binding_valid_until: u64,
application_limit: ApplicationLeaseLimit,
clock_skew: AuthorizationClockSkew,
) -> Result<u64, LeaseIssueError> {
let application_until = now
.unix_seconds()
.checked_add(application_limit.seconds())
.ok_or(LeaseIssueError::ApplicationBoundOverflow)?;
let earliest = provider_effective_until
.min(binding_valid_until)
.min(application_until);
let conservative = earliest.saturating_sub(clock_skew.seconds());
if conservative <= now.unix_seconds() {
return Err(LeaseIssueError::EvidenceExpired);
}
Ok(conservative)
}

View File

@@ -21,6 +21,10 @@ pub mod access;
pub mod context;
/// Authentication error types.
pub mod error;
/// Trusted-workspace adapter for sealed verifier and binding evidence.
pub mod evidence_adapter;
/// Bounded, versioned authorization leases.
pub mod lease;
/// NIP-42 challenge–response authentication.
pub mod nip42;
/// NIP-98 HTTP Auth verification (kind:27235).
@@ -43,11 +47,25 @@ pub use context::{
BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink,
DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode,
ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization,
FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy,
VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission,
FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority,
ProviderEvidenceValidationError, ResolvedFederatedPolicy, VerifiedFederatedAssertion,
VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedProviderEvidence,
VerifiedTransportDelegation, VersionedBindingRef,
};
pub use error::AuthError;
pub use evidence_adapter::{
ActiveBindingResolution, EvidenceAdapterError, VerifiedDelegationOutput,
VerifiedEvidenceAdapter,
};
pub use lease::{
AccessLeasePolicy, ApplicationLeaseLimit, AuthorizationClock, AuthorizationClockError,
AuthorizationClockSkew, AuthorizationLease, AuthorizationLeaseValidator,
AuthorizationOperationGuard, AuthorizationTime, BindingLeaseBound, LeaseIssueError,
LeasePolicyError, LeaseRenewalAction, LeaseRenewalLeadTime, LeaseRenewalSchedule,
LeaseUseRequirement, LeaseValidationError, LeaseVersion, SharedAuthorizationClock,
SystemAuthorizationClock, VerificationStatusPolicy, MAX_APPLICATION_LEASE_SECONDS,
MAX_AUTHORIZATION_CLOCK_SKEW_SECONDS, MAX_LEASE_RENEWAL_LEAD_SECONDS,
};
pub use nip42::{generate_challenge, verify_nip42_event};
pub use nip98::verify_nip98_event;
pub use nip98_replay::{
@@ -55,7 +73,8 @@ pub use nip98_replay::{
MAX_REPLAY_TTL_SECS,
};
pub use provider::{
AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial,
AuthorizationAuthority, AuthorizationCapability,
AuthorizationClock as ProviderAuthorizationClock, AuthorizationDenial,
AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider,
AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet,
CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason,

View File

@@ -91,6 +91,11 @@ impl CapabilitySet {
.iter()
.all(|capability| self.0.binary_search(capability).is_ok())
}
/// Return whether the set includes one exact portable capability.
pub fn contains(&self, capability: AuthorizationCapability) -> bool {
self.0.binary_search(&capability).is_ok()
}
}
impl fmt::Debug for CapabilitySet {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,19 @@ impl Default for CorporateIdentityConfig {
}
}
impl CorporateIdentityConfig {
/// Whether a complete verifier is configured independently of the legacy
/// process-wide enforcement switch.
pub fn verifier_configured(&self) -> bool {
!self.jwt_header.is_empty()
&& !self.jwks_uri.is_empty()
&& !self.issuer.is_empty()
&& !self.audience.is_empty()
&& !self.uid_claim.is_empty()
&& !self.display_claim.is_empty()
}
}
/// Relay runtime configuration, loaded from environment variables.
#[derive(Debug, Clone)]
pub struct Config {
@@ -558,7 +571,11 @@ fn load_corporate_identity_config() -> Result<CorporateIdentityConfig, ConfigErr
corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM")?;
config.npub_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM")?;
if config.require {
let verifier_requested = config.require
|| !config.jwks_uri.is_empty()
|| !config.issuer.is_empty()
|| !config.audience.is_empty();
if verifier_requested {
let mut missing = Vec::new();
if config.jwt_header.is_empty() {
missing.push("BUZZ_CORPORATE_IDENTITY_JWT_HEADER");
@@ -580,7 +597,7 @@ fn load_corporate_identity_config() -> Result<CorporateIdentityConfig, ConfigErr
}
if !missing.is_empty() {
return Err(ConfigError::InvalidValue(format!(
"BUZZ_REQUIRE_CORPORATE_IDENTITY=true but required corporate identity config is missing: {}",
"corporate identity verifier config is incomplete; missing: {}",
missing.join(", ")
)));
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,19 @@ use axum::{
};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
use metrics_util::MetricKindMask;
use sha2::{Digest, Sha256};
/// Stable provider-neutral label that does not publish a tenant host.
///
/// This label is for ephemeral runtime metrics only. Durable audit identity
/// and managed pseudonymization remain outside the metrics plane.
pub(crate) fn community_label(community_id: buzz_core::CommunityId) -> String {
let mut digest = Sha256::new();
digest.update(b"buzz-runtime-community-metric-v1");
digest.update(community_id.as_uuid().as_bytes());
let encoded = hex::encode(digest.finalize());
encoded[..16].to_owned()
}
/// HTTP latency buckets (milliseconds) — only for `http_request_latency_ms`.
const LATENCY_BUCKETS_MS: [f64; 11] = [
@@ -205,3 +218,18 @@ pub async fn track_metrics(req: Request, next: Next) -> Response {
response
}
#[cfg(test)]
mod privacy_tests {
use super::*;
#[test]
fn community_metric_label_is_stable_bounded_and_opaque() {
let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::from_u128(0xfeed));
let label = community_label(community);
assert_eq!(label, community_label(community));
assert_eq!(label.len(), 16);
assert!(!label.contains(&community.to_string()));
assert!(label.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
}