diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs new file mode 100644 index 000000000..352eb65b5 --- /dev/null +++ b/crates/buzz-auth/src/context/binding.rs @@ -0,0 +1,279 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use super::{AuthContextError, AuthorizationReason, FederatedPrincipal}; + +/// Policy used when no active binding exists for either principal or key. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentMode { + /// First use requires an assertion that attests the proven Nostr key. + AttestedKey, + /// Bindings must be created by an out-of-band administrative process. + Provisioned, + /// First use may bind the proven key without an asserted key claim. + Tofu, +} + +impl fmt::Debug for EnrollmentMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("EnrollmentMode") + .field(&"[redacted]") + .finish() + } +} + +/// Federated-identity requirement resolved for one authorization domain. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FederatedIdentityRequirement { + /// Federated identity is not required for this domain. + NotRequired, + /// Federated identity is required under the supplied enrollment policy. + Required(EnrollmentMode), +} + +impl fmt::Debug for FederatedIdentityRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedIdentityRequirement") + .field(&"[redacted]") + .finish() + } +} + +/// Server-resolved federated-identity policy for an authorization decision. +/// +/// Raw request data cannot construct this value. A policy adapter must resolve +/// the authorization domain's configuration before producing it. The evidence +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct ResolvedFederatedPolicy { + authorization_domain: CommunityId, + requirement: FederatedIdentityRequirement, +} + +impl fmt::Debug for ResolvedFederatedPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedFederatedPolicy") + .field("authorization_domain", &"[redacted]") + .field("requirement", &"[redacted]") + .finish() + } +} + +impl ResolvedFederatedPolicy { + #[cfg(test)] + pub(crate) const fn not_required(authorization_domain: CommunityId) -> Self { + Self { + authorization_domain, + requirement: FederatedIdentityRequirement::NotRequired, + } + } + + #[cfg(test)] + pub(crate) const fn required( + authorization_domain: CommunityId, + enrollment_mode: EnrollmentMode, + ) -> Self { + Self { + authorization_domain, + requirement: FederatedIdentityRequirement::Required(enrollment_mode), + } + } + + /// Authorization domain whose configuration was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Resolved federated-identity requirement. + pub const fn requirement(&self) -> FederatedIdentityRequirement { + self.requirement + } +} + +/// Provenance recorded when a binding is created. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + /// The identity provider attested the proven Nostr key. + AttestedKey, + /// An operator provisioned the binding out of band. + Provisioned, + /// The binding was established by trust on first use. + Tofu, +} + +impl fmt::Debug for BindingSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingSource") + .field(&"[redacted]") + .finish() + } +} + +/// Monotonically increasing version of an identity-to-key binding. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BindingVersion(u64); + +impl fmt::Debug for BindingVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingVersion") + .field(&"[redacted]") + .finish() + } +} + +impl BindingVersion { + /// Initial version assigned to a newly created binding. + pub const INITIAL: Self = Self(1); + + /// Build a non-zero binding version. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AuthContextError::InvalidBindingVersion); + } + Ok(Self(value)) + } + + /// Numeric binding version. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Stable reference to one active identity-to-key binding. +/// +/// This reference is identity evidence. It is not an authorization lease and +/// does not by itself provide expiry or live-revocation enforcement. An +/// authoritative binding adapter constructs this move-only value after checking +/// active lifecycle state; it has no default or deserialization path. +/// Production construction is intentionally unavailable in this phase. A +/// future crate-owned, sealed finalizer must require a durable non-nil ID, a +/// positive version, authoritative active-state evidence, and a typed database +/// result distinguishing a binding that already existed from one atomically +/// enrolled during this decision. Transport callers must never select that +/// lifecycle result. Pending, revoked, newly proposed, and synthetic records +/// must not cross that gate. +#[derive(PartialEq, Eq)] +pub struct VersionedBindingRef { + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + source: BindingSource, + resolution_reason: AuthorizationReason, +} + +impl fmt::Debug for VersionedBindingRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VersionedBindingRef") + .field("authorization_domain", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("principal", &self.principal) + .field("bound_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("source", &"[redacted]") + .field("resolution_reason", &"[redacted]") + .finish() + } +} + +impl VersionedBindingRef { + /// Build a reference to a binding authoritatively resolved as already active. + #[cfg(test)] + pub(crate) fn new_existing_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + source: BindingSource, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + source, + resolution_reason: AuthorizationReason::ExistingBinding, + }) + } + + /// Build a reference to a binding atomically enrolled in this decision. + #[cfg(test)] + pub(crate) fn new_enrolled_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + source: BindingSource, + reason: AuthorizationReason, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + if !matches!( + reason, + AuthorizationReason::EnrolledAttestedKey | AuthorizationReason::EnrolledTofu + ) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + source, + resolution_reason: reason, + }) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Current binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.source + } + + /// Stable reason proven by the authoritative binding lifecycle result. + pub(super) const fn authorization_reason(&self) -> AuthorizationReason { + self.resolution_reason + } +} diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs new file mode 100644 index 000000000..17960120e --- /dev/null +++ b/crates/buzz-auth/src/context/evidence.rs @@ -0,0 +1,705 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use crate::Scope; + +#[cfg(test)] +use super::transport_accepts_proof; +use super::AuthContextError; + +/// Cryptographic proof used to authenticate the Nostr actor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMethod { + /// NIP-42 challenge/response over WebSocket. + Nip42, + /// NIP-98 signed HTTP request. + Nip98, + /// Blossom upload authorization. + Blossom, +} + +/// Entry point that produced the authorization context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthTransport { + /// Relay WebSocket protocol. + RelayWebSocket, + /// HTTP relay bridge. + HttpBridge, + /// Git-over-HTTP endpoint. + Git, + /// Media upload endpoint. + MediaUpload, + /// Authenticated media download endpoint, including `GET` and `HEAD`. + MediaDownload, + /// Huddle audio WebSocket. + Audio, +} + +/// Transport profile used to deliver a federated assertion. +/// +/// This records how the assertion reached its verifier. It is intentionally +/// independent of [`AuthTransport`]; each authentication adapter must verify +/// the delivery profile before constructing authorization evidence. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AssertionTransport { + /// A trusted proxy stripped inbound copies and injected the assertion. + TrustedProxy, + /// The client attached the assertion to the authorized request. + ClientAttached, +} + +impl fmt::Debug for AssertionTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionTransport") + .field(&"[redacted]") + .finish() + } +} + +/// Expiry of a validated federated assertion, expressed as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionExpiry(u64); + +impl fmt::Debug for AssertionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionExpiry { + /// Build a non-zero assertion expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAssertionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the assertion is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Earliest valid time from a validated federated assertion, as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionNotBefore(u64); + +impl fmt::Debug for AssertionNotBefore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionNotBefore") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionNotBefore { + /// Preserve a validated `nbf` timestamp for finalization checks. + pub const fn new(unix_seconds: u64) -> Self { + Self(unix_seconds) + } + + /// Earliest valid time as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` while the assertion is not yet valid at `now`. + pub const fn is_not_yet_valid_at(self, now_unix_seconds: u64) -> bool { + self.0 > now_unix_seconds + } +} + +/// Expiry imposed by a separately verified delegation proof. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DelegationExpiry(u64); + +impl fmt::Debug for DelegationExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl DelegationExpiry { + /// Build a non-zero delegation expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidDelegationExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the delegation is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Freshness bound imposed by current community or enterprise admission. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AdmissionExpiry(u64); + +impl fmt::Debug for AdmissionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AdmissionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AdmissionExpiry { + /// Build a non-zero admission freshness bound. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAdmissionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Freshness bound as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when admission is no longer current at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Server-verified Nostr authority for a request or connection. +#[derive(PartialEq, Eq)] +pub struct NostrAuthority { + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl fmt::Debug for NostrAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NostrAuthority") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.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, + } + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Proof method used to authenticate the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Cryptographically verified owner for a delegated Nostr actor. + pub const fn verified_owner_pubkey(&self) -> Option { + match &self.verified_delegation { + Some(delegation) => Some(delegation.owner_pubkey()), + None => None, + } + } + + /// Cryptographically verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +/// Stable identity-provider principal. +/// +/// Equality uses the exact validated issuer and subject bytes. Construction +/// does not trim, case-fold, parse, or otherwise normalize either value; the +/// assertion verifier owns canonical validation before crossing this boundary. +/// Neither value is suitable for public events or general-purpose logs. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct FederatedPrincipal { + issuer: String, + subject: String, +} + +impl FederatedPrincipal { + /// Build an issuer-qualified principal from validated assertion claims. + pub fn new( + issuer: impl Into, + subject: impl Into, + ) -> Result { + let issuer = issuer.into(); + let subject = subject.into(); + if issuer.is_empty() { + return Err(AuthContextError::EmptyIssuer); + } + if subject.is_empty() { + return Err(AuthContextError::EmptySubject); + } + Ok(Self { issuer, subject }) + } + + /// Validated identity-provider issuer. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Stable, non-reassignable subject within the issuer namespace. + pub fn subject(&self) -> &str { + &self.subject + } +} + +impl fmt::Debug for FederatedPrincipal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FederatedPrincipal") + .field("issuer", &"[redacted]") + .field("subject", &"[redacted]") + .finish() + } +} + +/// Identity-provider attestation of the Nostr key proven by this request. +/// +/// The assertion verifier may construct this evidence only after requiring the +/// configured key claim, parsing it successfully, and proving that it names +/// the exact Nostr key. Absence or mismatch must fail rather than silently +/// falling back to another enrollment mode. The evidence is intentionally +/// move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedKeyAttestation { + pubkey: PublicKey, +} + +impl VerifiedKeyAttestation { + #[cfg(test)] + pub(crate) const fn new(pubkey: PublicKey) -> Self { + Self { pubkey } + } + + /// Nostr key named by the verified assertion claim. + pub const fn pubkey(&self) -> PublicKey { + self.pubkey + } +} + +impl fmt::Debug for VerifiedKeyAttestation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedKeyAttestation") + .field("pubkey", &"[redacted]") + .finish() + } +} + +/// Federated assertion accepted by the configured assertion verifier. +/// +/// The verifier must enforce an allowed algorithm and key, require correctly +/// typed `exp`, `iss`, and `aud` claims, validate the issuer and audience, and +/// reject a malformed `nbf` before constructing this evidence. Finalization +/// independently enforces the preserved `nbf` against server time. Raw +/// assertion claims cannot construct it from outside `buzz-auth`. Private +/// identity attributes and public display labels are deliberately excluded. +/// The evidence is intentionally move-only and has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedFederatedAssertion { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, +} + +impl VerifiedFederatedAssertion { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, + ) -> Self { + Self { + authorization_domain, + authorized_transport, + principal, + key_attestation, + transport, + not_before, + expires_at, + } + } + + /// Authorization domain for which the assertion was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the verifier authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Issuer-qualified principal from the verified assertion. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key attested by the verified assertion claim, when present. + pub const fn key_attestation(&self) -> Option<&VerifiedKeyAttestation> { + self.key_attestation.as_ref() + } + + /// Verified assertion delivery profile. + pub const fn transport(&self) -> AssertionTransport { + self.transport + } + + /// Earliest valid time preserved from the verified assertion, when present. + pub const fn not_before(&self) -> Option { + self.not_before + } + + /// Upper time bound carried by the verified assertion. + pub const fn expires_at(&self) -> AssertionExpiry { + self.expires_at + } +} + +impl fmt::Debug for VerifiedFederatedAssertion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedFederatedAssertion") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("principal", &self.principal) + .field("key_attestation", &"[redacted]") + .field("transport", &"[redacted]") + .field("not_before", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +/// Current admission for the owner of a delegated Nostr actor. +/// +/// This evidence is independent of a federated assertion: a delegated request +/// need not possess the owner's token. A provider adapter will construct it +/// only after confirming that the bound owner is currently admitted in the +/// same authorization domain. Until that adapter exists, only crate tests can +/// construct this move-only value, so delegated enterprise finalization cannot +/// be activated by production handlers. +#[derive(PartialEq, Eq)] +pub struct VerifiedOwnerAdmission { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, +} + +impl VerifiedOwnerAdmission { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, + ) -> Self { + Self { + authorization_domain, + principal, + fresh_until, + } + } + + /// Authorization domain for which owner admission was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Issuer-qualified owner admitted by the provider. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Upper bound after which provider admission must be resolved again. + pub const fn fresh_until(&self) -> AdmissionExpiry { + self.fresh_until + } +} + +impl fmt::Debug for VerifiedOwnerAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedOwnerAdmission") + .field("authorization_domain", &"[redacted]") + .field("principal", &self.principal) + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Capability represented by a verified owner-to-delegate proof. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DelegationCapability { + /// Authorizes the complete request or connection represented by the context. + TransportWide, +} + +impl fmt::Debug for DelegationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Transport-wide delegation from a bound owner to the authenticated key. +/// +/// A verifier may construct this only after proving the capability authorizes +/// the complete target request or connection. Time-only constraints may be +/// reduced to [`DelegationExpiry`], but operation-, event-kind-, or +/// request-specific constraints must not be discarded or promoted into this +/// transport-wide evidence. This move-only evidence has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedTransportDelegation { + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + capability: DelegationCapability, + expires_at: Option, +} + +impl fmt::Debug for VerifiedTransportDelegation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedTransportDelegation") + .field("owner_pubkey", &"[redacted]") + .field("delegate_pubkey", &"[redacted]") + .field("capability", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +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, + expires_at: Option, + ) -> Result { + if owner_pubkey == delegate_pubkey { + return Err(AuthContextError::SelfDelegation); + } + Ok(Self { + owner_pubkey, + delegate_pubkey, + capability: DelegationCapability::TransportWide, + expires_at, + }) + } + + /// Bound owner that authorized the delegate. + pub const fn owner_pubkey(&self) -> PublicKey { + self.owner_pubkey + } + + /// Authenticated delegate key. + pub const fn delegate_pubkey(&self) -> PublicKey { + self.delegate_pubkey + } + + /// Verified capability scope. + pub const fn capability(&self) -> DelegationCapability { + self.capability + } + + /// Optional upper bound imposed by the delegation proof. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +/// Cryptographically verified Nostr proof for one request or connection. +/// +/// Transport verifiers inside `buzz-auth` produce this evidence after checking +/// the signature and transport-specific binding. Raw request keys and claimed +/// proof methods cannot construct it in relay call sites. Conditional +/// delegation may be attached only when it has been fully evaluated for the +/// target operation or safely reduced to transport-wide evidence. The evidence +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedNostrProof { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl VerifiedNostrProof { + #[cfg(test)] + pub(crate) fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, + ) -> Result { + if !transport_accepts_proof(authorized_transport, proof_method) { + return Err(AuthContextError::TransportProofMismatch); + } + 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, + verified_delegation, + }) + } + + /// Authorization domain for which the Nostr proof was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the proof authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method accepted by the verifier. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +impl fmt::Debug for VerifiedNostrProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedNostrProof") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.proof_method) + .field("verified_delegation", &"[redacted]") + .finish() + } +} + +/// Successful community admission and permissions for one decision. +/// +/// An authorization adapter may construct this value only after membership, +/// invite, moderation, or equivalent community policy has allowed the actor. +/// Durable identity enrollment and public assertion publication must not occur +/// before this evidence exists; future binding adapters should require a borrow +/// of it before committing either side effect. Raw request scopes and channel +/// identifiers cannot construct this value in relay call sites. The resolution +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct AuthorizedCommunityAccess { + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, +} + +impl AuthorizedCommunityAccess { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Self { + Self { + authorization_domain, + scopes, + channel_ids, + } + } + + /// Authorization domain for which the permissions were resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Permission scopes resolved for the decision. + pub fn scopes(&self) -> &[Scope] { + &self.scopes + } + + /// Optional channel restriction resolved for the decision. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + self.channel_ids.as_deref() + } + + /// Consume verified admission into the final immutable permissions. + pub(super) fn into_permissions(self) -> (Vec, Option>) { + (self.scopes, self.channel_ids) + } +} + +impl fmt::Debug for AuthorizedCommunityAccess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizedCommunityAccess") + .field("authorization_domain", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs new file mode 100644 index 000000000..2649b61e4 --- /dev/null +++ b/crates/buzz-auth/src/context/mod.rs @@ -0,0 +1,460 @@ +//! Versioned, transport-neutral authorization context. +//! +//! Authentication adapters produce this context after verifying Nostr proof. +//! Federated identity is optional, but when present it remains distinct from +//! the Nostr authority that signed the request. Raw assertions and mutable +//! display claims never enter this type. + +use std::fmt; + +use buzz_core::{tenant::TenantContext, CommunityId}; +use nostr::PublicKey; +use uuid::Uuid; + +use crate::Scope; + +mod binding; +mod evidence; +mod reason; + +pub use binding::{ + BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, + ResolvedFederatedPolicy, VersionedBindingRef, +}; +pub use evidence::{ + AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, + AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, + FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, +}; +pub use reason::{AuthContextError, AuthorizationReason}; + +/// Version of the authorization-context contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthContextVersion { + /// Initial shared authorization-context contract. + V1, +} + +/// Federated authorization attached to a Nostr-authenticated actor. +#[derive(PartialEq, Eq)] +pub enum FederatedAuthorization { + /// This deployment does not require federated identity. + /// + /// An independently verified Nostr owner may still be present in the + /// [`NostrAuthority`] without acquiring a federated binding. + NotRequired, + /// The actor directly owns the active federated binding. + Direct { + /// Active identity-to-key binding. + /// + /// The binding carries its authoritative lifecycle result, so a caller + /// cannot relabel a binding enrolled in this decision as pre-existing. + binding: VersionedBindingRef, + /// Current assertion accepted by the configured verifier. + assertion: VerifiedFederatedAssertion, + }, + /// The actor is delegated by the owner of an active federated binding. + Delegated { + /// Owner's active binding. + owner: VersionedBindingRef, + /// Current admission resolved for the bound owner. + admission: VerifiedOwnerAdmission, + }, +} + +impl fmt::Debug for FederatedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedAuthorization") + .field(&"[redacted]") + .finish() + } +} + +/// Initial shared authorization-context contract. +#[derive(PartialEq, Eq)] +pub struct AuthContextV1 { + tenant: TenantContext, + correlation_id: Uuid, + transport: AuthTransport, + nostr: NostrAuthority, + federated_policy: ResolvedFederatedPolicy, + federated: FederatedAuthorization, + scopes: Vec, + channel_ids: Option>, +} + +/// Server-verified inputs consumed by the V1 authorization finalizer. +#[derive(PartialEq, Eq)] +pub struct AuthContextInput { + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + community_access: AuthorizedCommunityAccess, +} + +impl AuthContextInput { + /// Collect evidence after cryptographic authentication and community + /// admission have both succeeded. + pub fn new( + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + community_access: AuthorizedCommunityAccess, + ) -> Self { + Self { + tenant, + correlation_id, + nostr_proof, + community_access, + } + } +} + +impl fmt::Debug for AuthContextV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthContextV1") + .field("authorization_domain", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("transport", &self.transport) + .field("nostr", &self.nostr) + .field("federated_policy", &self.federated_policy) + .field("federated", &self.federated) + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} + +/// Versioned result of successful request or connection authorization. +/// +/// This security-boundary type intentionally has no default or deserialization +/// path. Persisted or transported data must be re-verified and finalized rather +/// than decoded directly into an authorized context. +#[derive(PartialEq, Eq)] +pub enum AuthContext { + /// Initial shared authorization-context contract. + V1(AuthContextV1), +} + +impl fmt::Debug for AuthContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::V1(context) => formatter.debug_tuple("V1").field(context).finish(), + } + } +} + +impl AuthContext { + /// Validate all authorization evidence and finalize an immutable V1 context. + /// + /// Adapters must preserve this phase order: cryptographic proof and + /// read-only assertion validation; community admission and capability + /// resolution; atomic binding/enrollment; then finalization. A denial before + /// admission must not create or refresh a binding, claim membership, or + /// publish a public identity assertion. + /// + /// `now_unix_seconds` must come from the server clock for the authorization + /// decision being finalized. + pub fn finalize_v1( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + now_unix_seconds: u64, + ) -> Result { + 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, + )?; + let nostr = NostrAuthority::new(input.nostr_proof); + let (scopes, channel_ids) = input.community_access.into_permissions(); + Ok(Self::V1(AuthContextV1 { + tenant: input.tenant, + correlation_id: input.correlation_id, + transport, + nostr, + federated_policy, + federated: authorization, + scopes, + channel_ids, + })) + } + + /// Contract version represented by this context. + pub const fn version(&self) -> AuthContextVersion { + match self { + Self::V1(_) => AuthContextVersion::V1, + } + } + + /// Server-resolved tenant for the request or connection. + pub const fn tenant(&self) -> &TenantContext { + match self { + Self::V1(context) => &context.tenant, + } + } + + /// Request or connection correlation identifier. + pub const fn correlation_id(&self) -> Uuid { + match self { + Self::V1(context) => context.correlation_id, + } + } + + /// Transport that established this authorization context. + pub const fn transport(&self) -> AuthTransport { + match self { + Self::V1(context) => context.transport, + } + } + + /// Verified Nostr authority. + pub const fn nostr(&self) -> &NostrAuthority { + match self { + Self::V1(context) => &context.nostr, + } + } + + /// Authenticated Nostr actor. + pub const fn pubkey(&self) -> PublicKey { + self.nostr().actor_pubkey() + } + + /// Proof method used to authenticate the Nostr actor. + pub const 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 { + self.nostr().verified_owner_pubkey() + } + + /// Federated authorization associated with the Nostr actor. + pub const fn federated_authorization(&self) -> &FederatedAuthorization { + match self { + Self::V1(context) => &context.federated, + } + } + + /// Federated-identity policy resolved for this authorization decision. + pub const fn federated_policy(&self) -> &ResolvedFederatedPolicy { + match self { + Self::V1(context) => &context.federated_policy, + } + } + + /// Stable reason for the successful authorization decision. + pub const fn authorization_reason(&self) -> AuthorizationReason { + match self.federated_authorization() { + FederatedAuthorization::NotRequired => AuthorizationReason::NostrOnly, + FederatedAuthorization::Direct { binding, .. } => binding.authorization_reason(), + FederatedAuthorization::Delegated { .. } => AuthorizationReason::DelegatedOwnerBinding, + } + } + + /// Permission scopes granted to the context. + pub fn scopes(&self) -> &[Scope] { + match self { + Self::V1(context) => &context.scopes, + } + } + + /// Optional channel restriction. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + match self { + Self::V1(context) => context.channel_ids.as_deref(), + } + } + + /// Returns `true` if this context includes the given scope. + pub fn has_scope(&self, scope: &Scope) -> bool { + self.scopes().contains(scope) + } +} + +pub(super) const fn transport_accepts_proof( + transport: AuthTransport, + proof_method: AuthMethod, +) -> bool { + match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => { + matches!(proof_method, AuthMethod::Nip42) + } + AuthTransport::HttpBridge | AuthTransport::Git => matches!(proof_method, AuthMethod::Nip98), + AuthTransport::MediaUpload => { + matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom) + } + AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98), + } +} + +#[cfg(test)] +mod tests; + +fn validate_federated_authorization( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + nostr_proof: &VerifiedNostrProof, + federated_policy: &ResolvedFederatedPolicy, + authorization: &FederatedAuthorization, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + match (federated_policy.requirement(), authorization) { + (FederatedIdentityRequirement::Required(_), FederatedAuthorization::NotRequired) => { + return Err(AuthContextError::FederatedIdentityRequired); + } + (FederatedIdentityRequirement::NotRequired, FederatedAuthorization::NotRequired) => {} + (FederatedIdentityRequirement::NotRequired, _) => { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + } + (FederatedIdentityRequirement::Required(_), _) => {} + } + + let actor_pubkey = nostr_proof.actor_pubkey(); + let verified_delegation = nostr_proof.verified_delegation(); + match authorization { + FederatedAuthorization::NotRequired => {} + FederatedAuthorization::Direct { binding, assertion } => { + if binding.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if assertion.authorization_domain() != authorization_domain { + return Err(AuthContextError::AssertionDomainMismatch); + } + if assertion.authorized_transport() != authorized_transport { + return Err(AuthContextError::AssertionTransportMismatch); + } + if assertion.principal() != binding.principal() { + return Err(AuthContextError::AssertionPrincipalMismatch); + } + if verified_delegation.is_some() { + return Err(AuthContextError::DirectAuthorizationHasOwner); + } + if binding.bound_pubkey() != actor_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch); + } + validate_assertion_time(assertion, now_unix_seconds)?; + let FederatedIdentityRequirement::Required(enrollment_mode) = + federated_policy.requirement() + else { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + }; + let reason = binding.authorization_reason(); + if !direct_reason_is_valid(reason, enrollment_mode, binding.source()) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + validate_enrollment_key_attestation(reason, binding.source(), assertion, actor_pubkey)?; + } + FederatedAuthorization::Delegated { owner, admission } => { + if owner.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if admission.authorization_domain() != authorization_domain { + return Err(AuthContextError::OwnerAdmissionDomainMismatch); + } + if admission.principal() != owner.principal() { + return Err(AuthContextError::OwnerAdmissionPrincipalMismatch); + } + let Some(delegation) = verified_delegation else { + return Err(AuthContextError::DelegationRequired); + }; + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(AuthContextError::DelegatedOwnerMismatch); + } + if admission.fresh_until().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::OwnerAdmissionExpired); + } + if delegation + .expires_at() + .is_some_and(|expiry| expiry.is_expired_at(now_unix_seconds)) + { + return Err(AuthContextError::DelegationExpired); + } + } + } + Ok(()) +} + +fn validate_assertion_time( + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if assertion + .not_before() + .is_some_and(|not_before| not_before.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(AuthContextError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::AssertionExpired); + } + Ok(()) +} + +const fn direct_reason_is_valid( + reason: AuthorizationReason, + enrollment_mode: EnrollmentMode, + binding_source: BindingSource, +) -> bool { + match reason { + AuthorizationReason::ExistingBinding => true, + AuthorizationReason::EnrolledAttestedKey => { + matches!(enrollment_mode, EnrollmentMode::AttestedKey) + && matches!(binding_source, BindingSource::AttestedKey) + } + AuthorizationReason::EnrolledTofu => { + matches!(enrollment_mode, EnrollmentMode::Tofu) + && matches!( + binding_source, + // An attested-key binding is stronger provenance than + // TOFU. The decision reason records the enrollment policy + // while the stored source remains truthful and is never + // downgraded to TOFU. + BindingSource::Tofu | BindingSource::AttestedKey + ) + } + AuthorizationReason::NostrOnly | AuthorizationReason::DelegatedOwnerBinding => false, + } +} + +fn validate_enrollment_key_attestation( + reason: AuthorizationReason, + binding_source: BindingSource, + assertion: &VerifiedFederatedAssertion, + actor_pubkey: PublicKey, +) -> Result<(), AuthContextError> { + let requires_attestation = matches!(reason, AuthorizationReason::EnrolledAttestedKey) + || (matches!(reason, AuthorizationReason::EnrolledTofu) + && matches!(binding_source, BindingSource::AttestedKey)); + if let Some(attestation) = assertion.key_attestation() { + if attestation.pubkey() != actor_pubkey { + return Err(AuthContextError::KeyAttestationMismatch); + } + return Ok(()); + } + if requires_attestation { + return Err(AuthContextError::KeyAttestationRequired); + } + Ok(()) +} diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs new file mode 100644 index 000000000..c071dc73d --- /dev/null +++ b/crates/buzz-auth/src/context/reason.rs @@ -0,0 +1,185 @@ +use std::fmt; + +use thiserror::Error; + +/// Stable reason for an allowed authorization decision. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationReason { + /// Only the configured Nostr proof was required. + NostrOnly, + /// An existing direct federated binding matched. + /// + /// Enrollment policy governs creation of new bindings. Resolution of an + /// existing active binding, including future lease checks, is a separate + /// lifecycle decision. + ExistingBinding, + /// A direct binding was created under attested-key enrollment. + EnrolledAttestedKey, + /// A direct binding was created under trust-on-first-use enrollment. + EnrolledTofu, + /// A verified delegate derived authority from a bound owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for AuthorizationReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationReason") + .field(&"[redacted]") + .finish() + } +} + +impl AuthorizationReason { + /// Stable audit and metric code for this decision. + pub const fn code(self) -> &'static str { + match self { + Self::NostrOnly => "authorization_allow_001", + Self::ExistingBinding => "authorization_allow_002", + Self::EnrolledAttestedKey => "authorization_allow_003", + Self::EnrolledTofu => "authorization_allow_004", + Self::DelegatedOwnerBinding => "authorization_allow_005", + } + } +} + +/// Invalid authorization-context construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AuthContextError { + /// Issuer was empty. + #[error("federated principal issuer must not be empty")] + EmptyIssuer, + /// Subject was empty. + #[error("federated principal subject must not be empty")] + EmptySubject, + /// Binding version was zero. + #[error("identity binding version must be greater than zero")] + InvalidBindingVersion, + /// Binding identifier was the nil UUID. + #[error("identity binding identifier must not be nil")] + InvalidBindingId, + /// Assertion expiry was not a valid Unix timestamp. + #[error("federated assertion expiry must be greater than zero")] + InvalidAssertionExpiry, + /// Delegation expiry was not a valid Unix timestamp. + #[error("delegation expiry must be greater than zero")] + InvalidDelegationExpiry, + /// Admission expiry was not a valid Unix timestamp. + #[error("admission expiry must be greater than zero")] + InvalidAdmissionExpiry, + /// Assertion had expired when authorization was evaluated. + #[error("federated assertion has expired")] + AssertionExpired, + /// Assertion was used before its validated not-before bound. + #[error("federated assertion is not yet valid")] + AssertionNotYetValid, + /// Required key-attestation evidence was absent. + #[error("verified key attestation is required for this enrollment result")] + KeyAttestationRequired, + /// Key-attestation evidence named a different Nostr actor. + #[error("verified key attestation does not match the authenticated Nostr key")] + KeyAttestationMismatch, + /// Owner admission was no longer current when authorization was evaluated. + #[error("owner admission is no longer current")] + OwnerAdmissionExpired, + /// Resolved policy required federated identity, but none was supplied. + #[error("federated identity is required by the resolved authorization policy")] + FederatedIdentityRequired, + /// Federated authorization was supplied for a domain that does not use it. + #[error("federated authorization does not match the resolved authorization policy")] + UnexpectedFederatedAuthorization, + /// Delegation had expired when authorization was evaluated. + #[error("verified delegation has expired")] + DelegationExpired, + /// Owner and delegate were the same key. + #[error("delegation owner and delegate must be different keys")] + SelfDelegation, + /// Direct authorization reason did not match its enrollment policy or source. + #[error("federated authorization reason does not match binding provenance")] + InvalidAuthorizationReason, + /// Binding belonged to a different server-resolved authorization domain. + #[error("federated binding does not belong to the authorization domain")] + BindingDomainMismatch, + /// Nostr proof was verified for a different authorization domain. + #[error("Nostr proof does not belong to the authorization domain")] + NostrProofDomainMismatch, + /// Federated policy was resolved for a different authorization domain. + #[error("federated policy does not belong to the authorization domain")] + PolicyDomainMismatch, + /// Community admission was resolved for a different authorization domain. + #[error("community admission does not belong to the authorization domain")] + CommunityAccessDomainMismatch, + /// Assertion was verified for a different authorization domain. + #[error("federated assertion does not belong to the authorization domain")] + AssertionDomainMismatch, + /// Assertion was verified for a different transport. + #[error("federated assertion does not match the authorization transport")] + AssertionTransportMismatch, + /// Owner admission was resolved for a different authorization domain. + #[error("owner admission does not belong to the authorization domain")] + OwnerAdmissionDomainMismatch, + /// Owner admission represented a different bound principal. + #[error("owner admission principal does not match the active binding")] + OwnerAdmissionPrincipalMismatch, + /// Validated assertion principal did not match the active binding. + #[error("federated assertion principal does not match the active binding")] + AssertionPrincipalMismatch, + /// Proof method was not valid for the transport being authorized. + #[error("Nostr proof method does not match authorization transport")] + TransportProofMismatch, + /// Direct federated authorization was attached to a delegated Nostr actor. + #[error("direct federated authorization cannot include a delegated Nostr owner")] + DirectAuthorizationHasOwner, + /// Direct binding key did not match the authenticated actor. + #[error("direct federated binding does not match the authenticated Nostr key")] + DirectBindingKeyMismatch, + /// Delegated authorization named a different actor. + #[error("delegated federated authorization does not match the authenticated Nostr key")] + DelegateKeyMismatch, + /// Delegated federated authorization lacked verified Nostr delegation. + #[error("delegated federated authorization requires verified Nostr delegation")] + DelegationRequired, + /// Delegated authorization did not match the verified Nostr owner. + #[error("delegated federated authorization does not match the verified Nostr owner")] + DelegatedOwnerMismatch, +} + +impl AuthContextError { + /// Stable audit and metric code for this rejected finalization. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyIssuer => "federated_principal_empty_issuer", + Self::EmptySubject => "federated_principal_empty_subject", + Self::InvalidBindingVersion => "federated_binding_invalid_version", + Self::InvalidBindingId => "federated_binding_invalid_id", + Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry", + Self::InvalidDelegationExpiry => "delegation_invalid_expiry", + Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry", + Self::AssertionExpired => "federated_assertion_expired", + Self::AssertionNotYetValid => "federated_assertion_not_yet_valid", + Self::KeyAttestationRequired => "federated_key_attestation_required", + Self::KeyAttestationMismatch => "federated_key_attestation_mismatch", + Self::OwnerAdmissionExpired => "owner_admission_expired", + Self::FederatedIdentityRequired => "federated_identity_required", + Self::UnexpectedFederatedAuthorization => "federated_authorization_unexpected", + Self::DelegationExpired => "delegation_expired", + Self::SelfDelegation => "delegation_self_reference", + Self::InvalidAuthorizationReason => "federated_binding_invalid_reason", + Self::BindingDomainMismatch => "federated_binding_domain_mismatch", + Self::NostrProofDomainMismatch => "nostr_proof_domain_mismatch", + Self::PolicyDomainMismatch => "federated_policy_domain_mismatch", + Self::CommunityAccessDomainMismatch => "community_access_domain_mismatch", + Self::AssertionDomainMismatch => "federated_assertion_domain_mismatch", + Self::AssertionTransportMismatch => "federated_assertion_transport_mismatch", + Self::OwnerAdmissionDomainMismatch => "owner_admission_domain_mismatch", + Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch", + Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch", + Self::TransportProofMismatch => "nostr_transport_proof_mismatch", + Self::DirectAuthorizationHasOwner => "federated_direct_has_owner", + Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch", + Self::DelegateKeyMismatch => "federated_delegate_key_mismatch", + Self::DelegationRequired => "federated_delegation_required", + Self::DelegatedOwnerMismatch => "federated_delegated_owner_mismatch", + } + } +} diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs new file mode 100644 index 000000000..e204336f3 --- /dev/null +++ b/crates/buzz-auth/src/context/tests.rs @@ -0,0 +1,1361 @@ +use super::*; +use buzz_core::CommunityId; +use nostr::Keys; + +fn tenant(value: u128) -> TenantContext { + TenantContext::resolved(authorization_domain(value), "relay.example") +} + +fn authorization_domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn assertion( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + assertion_in(1, authorized_transport, principal, transport, expiry) +} + +fn assertion_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + assertion_with_bounds_in( + domain, + authorized_transport, + principal, + transport, + None, + expiry, + ) +} + +fn assertion_with_bounds_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + not_before: Option, + expiry: u64, +) -> VerifiedFederatedAssertion { + VerifiedFederatedAssertion::new( + authorization_domain(domain), + authorized_transport, + principal, + None, + transport, + not_before.map(AssertionNotBefore::new), + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn assertion_with_attested_key( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, + attested_pubkey: PublicKey, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + VerifiedFederatedAssertion::new( + authorization_domain(1), + authorized_transport, + principal, + Some(VerifiedKeyAttestation::new(attested_pubkey)), + transport, + None, + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn policy_not_required() -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::not_required(authorization_domain(1)) +} + +fn policy_required(enrollment_mode: EnrollmentMode) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::required(authorization_domain(1), enrollment_mode) +} + +fn binding(pubkey: PublicKey) -> VersionedBindingRef { + binding_in(1, pubkey) +} + +fn binding_in(domain: u128, pubkey: PublicKey) -> VersionedBindingRef { + binding_with_source_in(domain, pubkey, BindingSource::AttestedKey) +} + +fn binding_with_source_in( + domain: u128, + pubkey: PublicKey, + source: BindingSource, +) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + authorization_domain(domain), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + source, + ) + .expect("synthetic binding identifier is valid") +} + +fn enrolled_binding( + pubkey: PublicKey, + source: BindingSource, + reason: AuthorizationReason, +) -> VersionedBindingRef { + VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + source, + reason, + ) + .expect("synthetic enrolled binding is valid") +} + +fn input( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, +) -> AuthContextInput { + input_with_delegation_expiry(actor_pubkey, transport, verified_owner_pubkey, 200) +} + +fn input_with_delegation_expiry( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, + delegation_expiry: u64, +) -> AuthContextInput { + let verified_delegation = verified_owner_pubkey.map(|owner_pubkey| { + VerifiedTransportDelegation::new_unrestricted( + owner_pubkey, + actor_pubkey, + Some( + DelegationExpiry::new(delegation_expiry) + .expect("synthetic delegation expiry is valid"), + ), + ) + .expect("synthetic owner and delegate are distinct") + }); + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + transport, + actor_pubkey, + proof_method, + verified_delegation, + ) + .expect("synthetic Nostr proof is internally consistent"), + AuthorizedCommunityAccess::new(authorization_domain(1), Scope::all_known(), None), + ) +} + +fn proof_in(domain: u128, transport: AuthTransport, actor_pubkey: PublicKey) -> VerifiedNostrProof { + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + VerifiedNostrProof::new( + authorization_domain(domain), + transport, + actor_pubkey, + proof_method, + None, + ) + .expect("synthetic Nostr proof is valid") +} + +fn community_access_in(domain: u128) -> AuthorizedCommunityAccess { + AuthorizedCommunityAccess::new(authorization_domain(domain), Scope::all_known(), None) +} + +fn delegated_authorization( + domain: u128, + owner_pubkey: PublicKey, + admission_principal: FederatedPrincipal, + admission_expiry: u64, +) -> FederatedAuthorization { + FederatedAuthorization::Delegated { + owner: binding_in(domain, owner_pubkey), + admission: VerifiedOwnerAdmission::new( + authorization_domain(domain), + admission_principal, + AdmissionExpiry::new(admission_expiry).expect("synthetic admission expiry is valid"), + ), + } +} + +#[test] +fn context_preserves_server_resolved_authority() { + let keys = Keys::generate(); + let correlation_id = Uuid::from_u128(2); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + correlation_id, + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + keys.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + None, + ), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr-only policy is final authorization"); + + assert_eq!(context.version(), AuthContextVersion::V1); + assert_eq!(context.tenant().community().as_uuid(), &Uuid::from_u128(1)); + assert_eq!(context.correlation_id(), correlation_id); + assert_eq!(context.transport(), AuthTransport::RelayWebSocket); + assert_eq!(context.pubkey(), keys.public_key()); + assert_eq!(context.auth_method(), AuthMethod::Nip42); + assert_eq!( + context.federated_policy().requirement(), + FederatedIdentityRequirement::NotRequired + ); + assert!(context.has_scope(&Scope::MessagesRead)); + assert_eq!( + context.federated_authorization(), + &FederatedAuthorization::NotRequired + ); +} + +#[test] +fn direct_authorization_requires_the_authenticated_key() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(other.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a direct binding for another key must be rejected"); + assert_eq!(error, AuthContextError::DirectBindingKeyMismatch); +} + +#[test] +fn delegated_authorization_requires_the_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect("verified owner and delegate match"); + + assert!(matches!( + context.federated_authorization(), + FederatedAuthorization::Delegated { .. } + )); +} + +#[test] +fn delegated_authorization_requires_verified_delegation() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization requires verifier-issued proof"); + + assert_eq!(error, AuthContextError::DelegationRequired); + assert_eq!(error.code(), "federated_delegation_required"); +} + +#[test] +fn principal_debug_output_redacts_claim_values() { + let principal = FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid"); + let output = format!("{principal:?}"); + + assert_eq!( + output, + "FederatedPrincipal { issuer: \"[redacted]\", subject: \"[redacted]\" }" + ); +} + +#[test] +fn principal_preserves_exact_validated_values() { + let principal = FederatedPrincipal::new(" HTTPS://IDP.EXAMPLE/ ", " Subject-123 ") + .expect("non-empty validated values are accepted exactly"); + + assert_eq!(principal.issuer(), " HTTPS://IDP.EXAMPLE/ "); + assert_eq!(principal.subject(), " Subject-123 "); + assert_eq!( + FederatedPrincipal::new("", "subject-123"), + Err(AuthContextError::EmptyIssuer) + ); + assert_eq!( + FederatedPrincipal::new("https://idp.example", ""), + Err(AuthContextError::EmptySubject) + ); +} + +#[test] +fn context_debug_output_omits_tenant_host() { + let actor = Keys::generate(); + let channel_id = Uuid::from_u128(20); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + Some(vec![channel_id]), + ), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("matching direct authorization is valid"); + + assert_eq!( + format!("{context:?}"), + concat!( + "V1(AuthContextV1 { authorization_domain: \"[redacted]\", ", + "correlation_id: \"[redacted]\", transport: RelayWebSocket, ", + "nostr: NostrAuthority { actor_pubkey: \"[redacted]\", ", + "proof_method: Nip42, verified_delegation: \"[redacted]\" }, ", + "federated_policy: ResolvedFederatedPolicy { ", + "authorization_domain: \"[redacted]\", requirement: \"[redacted]\" }, ", + "federated: FederatedAuthorization(\"[redacted]\"), ", + "scopes: \"[redacted]\", channel_ids: \"[redacted]\" })" + ) + ); +} + +#[test] +fn direct_authorization_rejects_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("direct authorization cannot derive authority from an owner"); + + assert_eq!(error, AuthContextError::DirectAuthorizationHasOwner); +} + +#[test] +fn delegated_authorization_requires_current_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 100), + 100, + ) + .expect_err("delegated authorization must not survive owner admission expiry"); + + assert_eq!(error, AuthContextError::OwnerAdmissionExpired); +} + +#[test] +fn delegated_authorization_rejects_cross_domain_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("delegated owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} + +#[test] +fn delegated_authorization_requires_the_owner_admission_principal() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), admission_principal, 200), + 100, + ) + .expect_err("current admission must identify the bound owner"); + + assert_eq!(error, AuthContextError::OwnerAdmissionPrincipalMismatch); +} + +#[test] +fn delegated_authorization_rejects_an_expired_proof() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input_with_delegation_expiry( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + 100, + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must not survive delegation expiry"); + + assert_eq!(error, AuthContextError::DelegationExpired); +} + +#[test] +fn verified_nostr_proof_requires_the_authenticated_delegate() { + let actor = Keys::generate(); + let other_delegate = Keys::generate(); + let owner = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + other_delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect_err("verified proof must name the authenticated actor"); + + assert_eq!(error, AuthContextError::DelegateKeyMismatch); +} + +#[test] +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( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(other_owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must match the verified owner"); + + assert_eq!(error, AuthContextError::DelegatedOwnerMismatch); +} + +#[test] +fn delegated_binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(2, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("a delegated binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); +} + +#[test] +fn zero_binding_version_is_rejected() { + assert_eq!( + BindingVersion::new(0), + Err(AuthContextError::InvalidBindingVersion) + ); +} + +#[test] +fn nil_binding_identifier_is_rejected() { + let actor = Keys::generate(); + let error = VersionedBindingRef::new_existing_active_for_test( + CommunityId::from_uuid(Uuid::from_u128(1)), + Uuid::nil(), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + BindingSource::AttestedKey, + ) + .expect_err("nil is not a stable binding identifier"); + + assert_eq!(error, AuthContextError::InvalidBindingId); + assert_eq!(error.code(), "federated_binding_invalid_id"); +} + +#[test] +fn evidence_value_debug_output_redacts_numeric_values() { + let assertion_expiry = AssertionExpiry::new(200).expect("synthetic expiry is valid"); + let assertion_not_before = AssertionNotBefore::new(100); + let delegation_expiry = DelegationExpiry::new(300).expect("synthetic expiry is valid"); + let admission_expiry = AdmissionExpiry::new(350).expect("synthetic expiry is valid"); + let binding_version = BindingVersion::new(400).expect("synthetic version is valid"); + + assert_eq!( + format!("{assertion_expiry:?}"), + "AssertionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{assertion_not_before:?}"), + "AssertionNotBefore(\"[redacted]\")" + ); + assert_eq!( + format!("{delegation_expiry:?}"), + "DelegationExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{admission_expiry:?}"), + "AdmissionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{binding_version:?}"), + "BindingVersion(\"[redacted]\")" + ); +} + +#[test] +fn zero_owner_admission_expiry_is_rejected() { + assert_eq!( + AdmissionExpiry::new(0), + Err(AuthContextError::InvalidAdmissionExpiry) + ); +} + +#[test] +fn owner_admission_debug_output_is_fully_redacted() { + let admission = VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ); + + assert_eq!( + format!("{admission:?}"), + concat!( + "VerifiedOwnerAdmission { authorization_domain: \"[redacted]\", ", + "principal: FederatedPrincipal { issuer: \"[redacted]\", ", + "subject: \"[redacted]\" }, fresh_until: \"[redacted]\" }" + ) + ); +} + +#[test] +fn verified_assertion_debug_output_is_fully_redacted() { + let actor = Keys::generate(); + let assertion = VerifiedFederatedAssertion::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(100)), + AssertionExpiry::new(200).expect("synthetic assertion expiry is valid"), + ); + + assert_eq!( + format!("{assertion:?}"), + concat!( + "VerifiedFederatedAssertion { authorization_domain: \"[redacted]\", ", + "authorized_transport: RelayWebSocket, principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "key_attestation: \"[redacted]\", transport: \"[redacted]\", ", + "not_before: \"[redacted]\", expires_at: \"[redacted]\" }" + ) + ); +} + +#[test] +fn direct_authorization_rejects_expired_assertions() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::ClientAttached, 100), + }, + 100, + ) + .expect_err("authorization must not survive assertion expiry"); + + assert_eq!(error, AuthContextError::AssertionExpired); + assert_eq!(error.code(), "federated_assertion_expired"); +} + +#[test] +fn direct_authorization_rejects_a_future_assertion() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_with_bounds_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::ClientAttached, + Some(101), + 200, + ), + }, + 100, + ) + .expect_err("authorization must enforce the assertion's not-before bound"); + + assert_eq!(error, AuthContextError::AssertionNotYetValid); + assert_eq!(error.code(), "federated_assertion_not_yet_valid"); +} + +#[test] +fn direct_authorization_requires_the_assertion_principal() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion( + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("the current assertion must identify the bound principal"); + + assert_eq!(error, AuthContextError::AssertionPrincipalMismatch); + assert_eq!(error.code(), "federated_assertion_principal_mismatch"); +} + +#[test] +fn enrolled_reason_must_match_policy_and_binding_source() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Provisioned), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("provisioned mode cannot enroll during authorization"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn existing_active_bindings_are_independent_of_enrollment_mode() { + let actor = Keys::generate(); + let enrollment_modes = [ + EnrollmentMode::AttestedKey, + EnrollmentMode::Provisioned, + EnrollmentMode::Tofu, + ]; + let binding_sources = [ + BindingSource::AttestedKey, + BindingSource::Provisioned, + BindingSource::Tofu, + ]; + + for enrollment_mode in enrollment_modes { + for binding_source in binding_sources { + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(enrollment_mode), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), binding_source), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("enrollment mode governs new bindings, not existing active bindings"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + } + } +} + +#[test] +fn binding_lifecycle_result_owns_the_authorization_reason() { + let actor = Keys::generate(); + let existing = binding(actor.public_key()); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + + let enrolled = enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ); + assert_eq!( + enrolled.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); + + let error = VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + BindingSource::AttestedKey, + AuthorizationReason::ExistingBinding, + ) + .expect_err("fresh enrollment cannot be relabeled as an existing binding"); + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn attested_enrollment_requires_matching_verified_key_evidence() { + let actor = Keys::generate(); + let other = Keys::generate(); + + let missing = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("attested enrollment cannot silently accept a missing key claim"); + assert_eq!(missing, AuthContextError::KeyAttestationRequired); + + let mismatch = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("attested enrollment cannot accept another Nostr key"); + assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch); + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }, + 100, + ) + .expect("matching verified key evidence permits attested enrollment"); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); +} + +#[test] +fn present_key_attestation_never_ignores_an_actor_mismatch() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), BindingSource::Tofu), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("a present but mismatched key claim must never be ignored"); + + assert_eq!(error, AuthContextError::KeyAttestationMismatch); +} + +#[test] +fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() { + let actor = Keys::generate(); + let authorization = FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledTofu, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }; + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + authorization, + 100, + ) + .expect("TOFU policy may retain stronger attested-key provenance"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledTofu + ); +} + +#[test] +fn authorization_reason_codes_are_unique_and_redaction_safe() { + let reasons = [ + AuthorizationReason::NostrOnly, + AuthorizationReason::ExistingBinding, + AuthorizationReason::EnrolledAttestedKey, + AuthorizationReason::EnrolledTofu, + AuthorizationReason::DelegatedOwnerBinding, + ]; + let mut codes = reasons + .iter() + .copied() + .map(AuthorizationReason::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), reasons.len()); + for code in codes { + assert!(code.starts_with("authorization_allow_")); + assert!(!code.contains("tofu")); + assert!(!code.contains("attest")); + assert!(!code.contains("provision")); + assert!(!code.contains("provider")); + } +} + +#[test] +fn authorization_error_codes_are_unique_and_provider_neutral() { + let errors = [ + AuthContextError::EmptyIssuer, + AuthContextError::EmptySubject, + AuthContextError::InvalidBindingVersion, + AuthContextError::InvalidBindingId, + AuthContextError::InvalidAssertionExpiry, + AuthContextError::InvalidDelegationExpiry, + AuthContextError::InvalidAdmissionExpiry, + AuthContextError::AssertionExpired, + AuthContextError::AssertionNotYetValid, + AuthContextError::KeyAttestationRequired, + AuthContextError::KeyAttestationMismatch, + AuthContextError::OwnerAdmissionExpired, + AuthContextError::FederatedIdentityRequired, + AuthContextError::UnexpectedFederatedAuthorization, + AuthContextError::DelegationExpired, + AuthContextError::SelfDelegation, + AuthContextError::InvalidAuthorizationReason, + AuthContextError::BindingDomainMismatch, + AuthContextError::NostrProofDomainMismatch, + AuthContextError::PolicyDomainMismatch, + AuthContextError::CommunityAccessDomainMismatch, + AuthContextError::AssertionDomainMismatch, + AuthContextError::AssertionTransportMismatch, + AuthContextError::OwnerAdmissionDomainMismatch, + AuthContextError::OwnerAdmissionPrincipalMismatch, + AuthContextError::AssertionPrincipalMismatch, + AuthContextError::TransportProofMismatch, + AuthContextError::DirectAuthorizationHasOwner, + AuthContextError::DirectBindingKeyMismatch, + AuthContextError::DelegateKeyMismatch, + AuthContextError::DelegationRequired, + AuthContextError::DelegatedOwnerMismatch, + ]; + let mut codes = errors + .iter() + .copied() + .map(AuthContextError::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), errors.len()); + for code in codes { + assert!(!code.contains("registry")); + assert!(!code.contains("proxy")); + assert!(!code.contains("role")); + assert!(!code.contains("group")); + } +} + +#[test] +fn security_posture_debug_output_is_fully_redacted() { + assert_eq!( + format!("{:?}", EnrollmentMode::AttestedKey), + "EnrollmentMode(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + FederatedIdentityRequirement::Required(EnrollmentMode::Tofu) + ), + "FederatedIdentityRequirement(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", BindingSource::Provisioned), + "BindingSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AssertionTransport::TrustedProxy), + "AssertionTransport(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AuthorizationReason::EnrolledTofu), + "AuthorizationReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", DelegationCapability::TransportWide), + "DelegationCapability(\"[redacted]\")" + ); + let key = Keys::generate(); + assert_eq!( + format!("{:?}", VerifiedKeyAttestation::new(key.public_key())), + "VerifiedKeyAttestation { pubkey: \"[redacted]\" }" + ); + assert_eq!( + format!("{:?}", binding(key.public_key())), + concat!( + "VersionedBindingRef { authorization_domain: \"[redacted]\", ", + "binding_id: \"[redacted]\", principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "bound_pubkey: \"[redacted]\", binding_version: \"[redacted]\", ", + "source: \"[redacted]\", resolution_reason: \"[redacted]\" }" + ) + ); + assert_eq!( + format!("{:?}", FederatedAuthorization::NotRequired), + "FederatedAuthorization(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", policy_required(EnrollmentMode::AttestedKey)), + concat!( + "ResolvedFederatedPolicy { authorization_domain: \"[redacted]\", ", + "requirement: \"[redacted]\" }" + ) + ); +} + +#[test] +fn tofu_enrollment_cannot_use_attested_key_policy_reason() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("TOFU policy must emit the TOFU enrollment reason"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn transport_and_proof_method_must_agree() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip98, + None, + ) + .expect_err("HTTP proof must not authorize a relay WebSocket"); + assert_eq!(error, AuthContextError::TransportProofMismatch); +} + +#[test] +fn blossom_upload_proof_cannot_authorize_media_downloads() { + let actor = Keys::generate(); + let error = 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"); + assert_eq!(error, AuthContextError::TransportProofMismatch); + + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaUpload, + actor.public_key(), + AuthMethod::Blossom, + None, + ) + .expect("Blossom proof may authorize the verified upload operation"); +} + +#[test] +fn binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding_in(2, actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); + assert_eq!(error.code(), "federated_binding_domain_mismatch"); +} + +#[test] +fn nostr_only_authorization_may_preserve_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr delegation remains independent of federated policy"); + + assert_eq!(context.agent_owner_pubkey(), Some(owner.public_key())); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::NostrOnly + ); +} + +#[test] +fn transport_delegation_rejects_self_reference() { + let actor = Keys::generate(); + let error = + VerifiedTransportDelegation::new_unrestricted(actor.public_key(), actor.public_key(), None) + .expect_err("an actor cannot be its own verified owner"); + + assert_eq!(error, AuthContextError::SelfDelegation); +} + +#[test] +fn transport_delegation_is_explicitly_transport_wide() { + let owner = Keys::generate(); + let delegate = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + + assert_eq!(delegation.capability(), DelegationCapability::TransportWide); +} + +#[test] +fn required_policy_rejects_nostr_only_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("required federated identity cannot be bypassed by the caller"); + + assert_eq!(error, AuthContextError::FederatedIdentityRequired); + assert_eq!(error.code(), "federated_identity_required"); +} + +#[test] +fn not_required_policy_rejects_federated_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_not_required(), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("federated evidence cannot override the resolved domain policy"); + + assert_eq!(error, AuthContextError::UnexpectedFederatedAuthorization); + assert_eq!(error.code(), "federated_authorization_unexpected"); +} + +#[test] +fn nostr_proof_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(2, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(1), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("a Nostr proof from another domain must be rejected"); + + assert_eq!(error, AuthContextError::NostrProofDomainMismatch); + assert_eq!(error.code(), "nostr_proof_domain_mismatch"); +} + +#[test] +fn federated_policy_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + ResolvedFederatedPolicy::not_required(authorization_domain(2)), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("policy from another domain must be rejected"); + + assert_eq!(error, AuthContextError::PolicyDomainMismatch); + assert_eq!(error.code(), "federated_policy_domain_mismatch"); +} + +#[test] +fn community_admission_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(1, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(2), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("community admission from another domain must be rejected"); + + assert_eq!(error, AuthContextError::CommunityAccessDomainMismatch); + assert_eq!(error.code(), "community_access_domain_mismatch"); +} + +#[test] +fn assertion_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 2, + AuthTransport::RelayWebSocket, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion from another domain must be rejected"); + + assert_eq!(error, AuthContextError::AssertionDomainMismatch); + assert_eq!(error.code(), "federated_assertion_domain_mismatch"); +} + +#[test] +fn assertion_must_match_the_authorized_transport() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion verified for another transport must be rejected"); + + assert_eq!(error, AuthContextError::AssertionTransportMismatch); + assert_eq!(error.code(), "federated_assertion_transport_mismatch"); +} + +#[test] +fn delegated_owner_admission_must_match_the_authorization_domain() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index aed9624d9..441459251 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,6 +17,8 @@ /// Channel access checking trait and helpers. pub mod access; +/// Versioned, transport-neutral authorization context. +pub mod context; /// Authentication error types. pub mod error; /// NIP-42 challenge–response authentication. @@ -31,6 +33,15 @@ pub mod rate_limit; pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; +pub use context::{ + AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthContext, + AuthContextError, AuthContextInput, AuthContextV1, AuthContextVersion, AuthMethod, + AuthTransport, AuthorizationReason, AuthorizedCommunityAccess, BindingSource, BindingVersion, + DelegationCapability, DelegationExpiry, EnrollmentMode, FederatedAuthorization, + FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, + VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, + VerifiedTransportDelegation, VersionedBindingRef, +}; pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; @@ -43,6 +54,44 @@ pub use rate_limit::{ }; pub use scope::{parse_scopes, Scope}; +/// Existing NIP authentication result stored on a relay connection. +/// +/// This remains separate from [`AuthContext`], which is finalized only after +/// transport authentication and every configured authorization policy pass. +#[derive(Clone)] +pub struct ConnectionAuthContext { + /// The authenticated Nostr public key. + pub pubkey: nostr::PublicKey, + /// Permission scopes granted to this connection. + pub scopes: Vec, + /// Channel restriction (`None` means unrestricted). + pub channel_ids: Option>, + /// How the connection was authenticated. + pub auth_method: AuthMethod, + /// NIP-OA verified owner pubkey, when present. + pub agent_owner_pubkey: Option, +} + +impl std::fmt::Debug for ConnectionAuthContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConnectionAuthContext") + .field("pubkey", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .field("auth_method", &self.auth_method) + .field("agent_owner_pubkey", &"[redacted]") + .finish() + } +} + +impl ConnectionAuthContext { + /// Returns `true` if this context includes the given [`Scope`]. + pub fn has_scope(&self, scope: &Scope) -> bool { + self.scopes.contains(scope) + } +} + #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] @@ -50,42 +99,6 @@ pub use nip98_replay::AlwaysFreshReplayGuard; #[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; -/// How the connection was authenticated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthMethod { - /// NIP-42 challenge/response — Schnorr signature over kind:22242. - Nip42, - /// NIP-98 HTTP Auth — Schnorr signature over kind:27235. - Nip98, -} - -/// The result of a successful authentication, bound to a connection. -#[derive(Debug, Clone)] -pub struct AuthContext { - /// The authenticated Nostr public key. - pub pubkey: nostr::PublicKey, - /// Permission scopes granted to this connection. - pub scopes: Vec, - /// Channel restriction (reserved for future per-channel access control). - /// - /// `None` means unrestricted. - pub channel_ids: Option>, - /// How the connection was authenticated. - pub auth_method: AuthMethod, - /// NIP-OA verified owner pubkey (if authenticated via owner attestation). - /// - /// `None` for direct relay members or non-NIP-OA auth paths. - /// Set by the relay membership gate when NIP-OA fallback succeeds. - pub agent_owner_pubkey: Option, -} - -impl AuthContext { - /// Returns `true` if this context includes the given [`Scope`]. - pub fn has_scope(&self, scope: &Scope) -> bool { - self.scopes.contains(scope) - } -} - /// Top-level authentication configuration, typically loaded from the relay's TOML config file. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct AuthConfig { @@ -112,7 +125,7 @@ impl AuthService { &self.config } - /// Verify a NIP-42 AUTH event and return an [`AuthContext`]. + /// Verify a NIP-42 AUTH event and return a [`ConnectionAuthContext`]. /// /// Pure cryptographic verification — no network calls, no JWT, no tokens. pub async fn verify_auth_event( @@ -120,7 +133,7 @@ impl AuthService { auth_event: nostr::Event, expected_challenge: &str, relay_url: &str, - ) -> Result { + ) -> Result { // Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify) let event_clone = auth_event.clone(); let challenge_owned = expected_challenge.to_string(); @@ -133,12 +146,12 @@ impl AuthService { // In pure Nostr mode, all authenticated connections get full scopes. // Per-channel access is enforced by the relay's membership checks (NIP-29). - Ok(AuthContext { + Ok(ConnectionAuthContext { pubkey: auth_event.pubkey, scopes: Scope::all_known(), channel_ids: None, auth_method: AuthMethod::Nip42, - agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA + agent_owner_pubkey: None, }) } } @@ -183,17 +196,41 @@ mod tests { } #[test] - fn auth_context_scope_check() { + fn connection_auth_context_scope_check() { let keys = Keys::generate(); - let ctx = AuthContext { + let context = ConnectionAuthContext { pubkey: keys.public_key(), scopes: vec![Scope::MessagesRead, Scope::ChannelsRead], channel_ids: None, auth_method: AuthMethod::Nip42, agent_owner_pubkey: None, }; - assert!(ctx.has_scope(&Scope::MessagesRead)); - assert!(!ctx.has_scope(&Scope::MessagesWrite)); + + assert!(context.has_scope(&Scope::MessagesRead)); + assert!(!context.has_scope(&Scope::MessagesWrite)); + } + + #[test] + fn connection_auth_context_debug_redacts_authorization_data() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let channel_id = uuid::Uuid::new_v4(); + let context = ConnectionAuthContext { + pubkey: actor.public_key(), + scopes: vec![Scope::MessagesRead], + channel_ids: Some(vec![channel_id]), + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: Some(owner.public_key()), + }; + + assert_eq!( + format!("{context:?}"), + concat!( + "ConnectionAuthContext { pubkey: \"[redacted]\", scopes: \"[redacted]\", ", + "channel_ids: \"[redacted]\", auth_method: Nip42, ", + "agent_owner_pubkey: \"[redacted]\" }" + ) + ); } #[tokio::test] diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 1b536271a..55de214e7 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,7 +14,7 @@ use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{generate_challenge, ConnectionAuthContext, LimitType}; use buzz_core::tenant::TenantContext; use nostr::Filter; @@ -41,7 +41,7 @@ pub enum AuthState { challenge: String, }, /// Client has successfully authenticated. - Authenticated(AuthContext), + Authenticated(ConnectionAuthContext), /// Authentication attempt was rejected. Failed, } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 2c2f5b02f..288129fd6 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1388,7 +1388,7 @@ mod tests { remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), corporate_identity_jwt: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( - buzz_auth::AuthContext { + buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), scopes: vec![], channel_ids: None,