feat(auth): add versioned authorization context

Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
(cherry picked from commit 49af92663ec3aefd9552b1bd6decbdec876e4761)
Signed-off-by: Cea Stapleton Cordasco <261786559+cea-block@users.noreply.github.com>
This commit is contained in:
Cea Stapleton Cordasco
2026-08-03 14:46:42 -05:00
parent bde90293c9
commit f5115171a3
8 changed files with 3074 additions and 47 deletions
+279
View File
@@ -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<Self, AuthContextError> {
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<Self, AuthContextError> {
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<Self, AuthContextError> {
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
}
}
+705
View File
@@ -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<Self, AuthContextError> {
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<Self, AuthContextError> {
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<Self, AuthContextError> {
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<VerifiedTransportDelegation>,
}
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<PublicKey> {
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<String>,
subject: impl Into<String>,
) -> Result<Self, AuthContextError> {
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<VerifiedKeyAttestation>,
transport: AssertionTransport,
not_before: Option<AssertionNotBefore>,
expires_at: AssertionExpiry,
}
impl VerifiedFederatedAssertion {
#[cfg(test)]
pub(crate) const fn new(
authorization_domain: CommunityId,
authorized_transport: AuthTransport,
principal: FederatedPrincipal,
key_attestation: Option<VerifiedKeyAttestation>,
transport: AssertionTransport,
not_before: Option<AssertionNotBefore>,
expires_at: AssertionExpiry,
) -> Self {
Self {
authorization_domain,
authorized_transport,
principal,
key_attestation,
transport,
not_before,
expires_at,
}
}
/// 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<AssertionNotBefore> {
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<DelegationExpiry>,
}
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<DelegationExpiry>,
) -> Result<Self, AuthContextError> {
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<DelegationExpiry> {
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<VerifiedTransportDelegation>,
}
impl VerifiedNostrProof {
#[cfg(test)]
pub(crate) fn new(
authorization_domain: CommunityId,
authorized_transport: AuthTransport,
actor_pubkey: PublicKey,
proof_method: AuthMethod,
verified_delegation: Option<VerifiedTransportDelegation>,
) -> Result<Self, AuthContextError> {
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<Scope>,
channel_ids: Option<Vec<Uuid>>,
}
impl AuthorizedCommunityAccess {
#[cfg(test)]
pub(crate) const fn new(
authorization_domain: CommunityId,
scopes: Vec<Scope>,
channel_ids: Option<Vec<Uuid>>,
) -> 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<Scope>, Option<Vec<Uuid>>) {
(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()
}
}
+460
View File
@@ -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<Scope>,
channel_ids: Option<Vec<Uuid>>,
}
/// 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<Self, AuthContextError> {
let authorization_domain = input.tenant.community();
let transport = input.nostr_proof.authorized_transport();
if input.nostr_proof.authorization_domain() != authorization_domain {
return Err(AuthContextError::NostrProofDomainMismatch);
}
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<PublicKey> {
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(())
}
+185
View File
@@ -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",
}
}
}
File diff suppressed because it is too large Load Diff
+81 -44
View File
@@ -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<Scope>,
/// Channel restriction (`None` means unrestricted).
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// How the connection was authenticated.
pub auth_method: AuthMethod,
/// NIP-OA verified owner pubkey, when present.
pub agent_owner_pubkey: Option<nostr::PublicKey>,
}
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<Scope>,
/// Channel restriction (reserved for future per-channel access control).
///
/// `None` means unrestricted.
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// 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<nostr::PublicKey>,
}
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<AuthContext, AuthError> {
) -> Result<ConnectionAuthContext, AuthError> {
// 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]
+2 -2
View File
@@ -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,
}
+1 -1
View File
@@ -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,