Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/buzz-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tracing = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
hmac = { workspace = true }
rand = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
Expand Down
198 changes: 198 additions & 0 deletions crates/buzz-auth/src/evidence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! Origin-sealed assertion transport evidence for NIP-FI authorization.
//!
//! This module deliberately stops at the transport boundary. It does not
//! validate JWT claims, prepare admission, consume replay identities, or make
//! lifecycle decisions. Those operations remain the responsibility of the
//! canonical verifier and final-admission authority.

use std::fmt;

use buzz_core::CommunityId;
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};

use crate::ProofTransport;

/// The closed assertion transport profile that produced sealed evidence.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionTransportProfile {
/// A request-bound `trusted-proxy-hmac-v1` provenance field.
TrustedProxyHmacV1,
}

/// Opaque identity for one trusted-proxy nonce that final admission must claim.
///
/// The key is a domain-separated one-way digest of the decoded nonce. It is
/// safe to persist, unlike the assertion and raw nonce, and remains stable if
/// one proxy serves multiple authorities. Final admission adds the frozen
/// server-owned authorization domain and claim kind. The exclusive retain
/// bound is the proxy timestamp plus the configured maximum provenance age.
#[derive(Clone, PartialEq, Eq)]
pub struct TrustedProxyNonceClaim {
claim_key: [u8; 32],
retain_until: DateTime<Utc>,
}

impl TrustedProxyNonceClaim {
pub(crate) const fn new(claim_key: [u8; 32], retain_until: DateTime<Utc>) -> Self {
Self {
claim_key,
retain_until,
}
}

/// Privacy-safe key that must be inserted atomically at final admission.
pub const fn claim_key(&self) -> &[u8; 32] {
&self.claim_key
}

/// Exclusive finite retention bound consumed by canonical admission.
pub const fn retain_until(&self) -> DateTime<Utc> {
self.retain_until
}
}

impl fmt::Debug for TrustedProxyNonceClaim {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("TrustedProxyNonceClaim([REDACTED])")
}
}

/// Move-only assertion bytes sealed to exact authenticated ingress facts.
///
/// This type intentionally does not implement [`Clone`]. Its debug output
/// never exposes the assertion, nonce, authority, path, or their digests. A
/// caller may borrow the confidential assertion only to pass it directly to
/// the frozen canonical assertion verifier.
pub struct SealedTransportEvidence {
authorization_domain: CommunityId,
assertion: Box<str>,
assertion_digest: [u8; 32],
request_fingerprint: [u8; 32],
transport_context_fingerprint: [u8; 32],
transport: ProofTransport,
proxy_expires_at: DateTime<Utc>,
nonce_claim: TrustedProxyNonceClaim,
profile: AssertionTransportProfile,
}

impl SealedTransportEvidence {
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_trusted_proxy(
authorization_domain: CommunityId,
assertion: Box<str>,
assertion_digest: [u8; 32],
method: &[u8],
authority: &[u8],
path_and_query: &[u8],
body_digest: [u8; 32],
transport: ProofTransport,
proxy_expires_at: DateTime<Utc>,
nonce_claim: TrustedProxyNonceClaim,
) -> Self {
let request_fingerprint = framed_fingerprint(
b"buzz:nip-fi:trusted-proxy-request:v1",
&[
authorization_domain.as_uuid().as_bytes(),
method,
authority,
path_and_query,
&body_digest,
&[proof_transport_code(transport)],
],
);
let transport_context_fingerprint = framed_fingerprint(
b"buzz:nip-fi:assertion-transport:v1",
&[
b"trusted-proxy-hmac-v1",
authorization_domain.as_uuid().as_bytes(),
authority,
&[proof_transport_code(transport)],
],
);
Self {
authorization_domain,
assertion,
assertion_digest,
request_fingerprint,
transport_context_fingerprint,
transport,
proxy_expires_at,
nonce_claim,
profile: AssertionTransportProfile::TrustedProxyHmacV1,
}
}

/// Server-resolved authorization domain sealed before replay lookup.
pub const fn authorization_domain(&self) -> CommunityId {
self.authorization_domain
}

/// Borrow the exact compact-JWS bytes after transport verification.
///
/// The returned value is confidential and must not be logged, serialized,
/// or placed in public errors. It still requires canonical JWT validation.
pub fn confidential_assertion(&self) -> &str {
&self.assertion
}

/// SHA-256 of the exact compact-JWS bytes after the Bearer scheme.
pub const fn assertion_digest(&self) -> &[u8; 32] {
&self.assertion_digest
}

/// Stable digest of exact method, authority, path/query, and body digest.
pub const fn request_fingerprint(&self) -> &[u8; 32] {
&self.request_fingerprint
}

/// Stable digest of the authenticated transport profile and authority.
pub const fn transport_context_fingerprint(&self) -> &[u8; 32] {
&self.transport_context_fingerprint
}

/// Server-selected proof transport sealed into the HMAC request binding.
pub const fn transport(&self) -> ProofTransport {
self.transport
}

/// Exclusive finite provenance expiry.
pub const fn proxy_expires_at(&self) -> DateTime<Utc> {
self.proxy_expires_at
}

/// Read-only nonce identity that final admission must claim atomically.
pub const fn nonce_claim(&self) -> &TrustedProxyNonceClaim {
&self.nonce_claim
}

/// Assertion transport profile selected by trusted server configuration.
pub const fn profile(&self) -> AssertionTransportProfile {
self.profile
}
}

pub(crate) const fn proof_transport_code(transport: ProofTransport) -> u8 {
match transport {
ProofTransport::Nip42 => 1,
ProofTransport::Nip98 => 2,
ProofTransport::GitSmartHttpSession => 3,
ProofTransport::Blossom => 4,
}
}

impl fmt::Debug for SealedTransportEvidence {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("SealedTransportEvidence([REDACTED])")
}
}

fn framed_fingerprint(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update(domain);
for field in fields {
digest.update((field.len() as u64).to_be_bytes());
digest.update(field);
}
digest.finalize().into()
}
12 changes: 12 additions & 0 deletions crates/buzz-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
pub mod access;
/// Authentication error types.
pub mod error;
/// Sealed transport provenance shared by protected authorization paths.
pub mod evidence;
/// Provider-free authorization composition and sealed verifier evidence.
pub mod foundation;
/// NIP-42 challenge–response authentication.
Expand All @@ -27,13 +29,18 @@ pub mod nip42;
pub mod nip98;
/// NIP-98 replay protection — shared, community-scoped, atomic seen-set.
pub mod nip98_replay;
/// Origin-sealed deployment-operator identity lifecycle authority.
pub mod operator_lifecycle;
/// Per-connection rate limiting.
pub mod rate_limit;
/// OAuth scope parsing and enforcement.
pub mod scope;
/// Request-bound HMAC provenance verification for trusted proxies.
pub mod trusted_proxy;

pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
pub use error::AuthError;
pub use evidence::{AssertionTransportProfile, SealedTransportEvidence, TrustedProxyNonceClaim};
pub use foundation::{
ActiveLocalBinding, AuthContext as FinalizedAuthContext, AuthoritativeAuthorizationRecheck,
AuthorizationAuditConfig, AuthorizationAuditConfigError, AuthorizationError,
Expand Down Expand Up @@ -62,6 +69,11 @@ pub use rate_limit::{
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
};
pub use scope::{parse_scopes, Scope};
pub use trusted_proxy::{
HttpHeaderField, TrustedProxyError, TrustedProxyNonceReplayReader,
TrustedProxyProvenanceVerifier, TrustedProxyReplayReadError, TrustedProxyRequest,
ASSERTION_HEADER_NAME, PROVENANCE_HEADER_NAME,
};

#[cfg(any(test, feature = "test-utils"))]
pub use access::MockAccessChecker;
Expand Down
Loading
Loading