mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
676
crates/buzz-auth/src/evidence_adapter.rs
Normal file
676
crates/buzz-auth/src/evidence_adapter.rs
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
834
crates/buzz-auth/src/lease.rs
Normal file
834
crates/buzz-auth/src/lease.rs
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
1420
crates/buzz-relay/src/authorization_runtime/transport.rs
Normal file
1420
crates/buzz-relay/src/authorization_runtime/transport.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user