mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user