mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
The closed, provider-neutral contract layer at the root of the NIP-FI federated-identity dependency graph — Phase A, PR 1 of the plan. It has no dependencies on any other PR and defines no database schema, migration, runtime JWKS fetching, binding resolution, enrollment, or request/proof binding; those belong to later PRs. ## What this adds - **Multi-issuer assertion-policy config** (`IssuerRegistry`, `IssuerPolicy`) keyed by exact `iss`. Identity is issuer-qualified `(iss, sub)` throughout — equal `sub` under different `iss` are distinct identities. The subject coordinate is fixed to the JWT `sub` claim (`SUBJECT_CLAIM`), never configurable, so no deployment can seal a mutable attribute like `email` as identity. Issuer URL and audience remain deployment configuration. - **The two deterministic semantic contract identities** — `AssertionPolicyId = H(canonical assertion-policy contract)` and `TransportContractId = H(canonical transport contract)` — derived by length-prefixed, domain-separated SHA-256 so a semantic change moves exactly its owning ID while benign JWKS rotation never changes policy lineage. Set-valued policy inputs (audiences, algorithms, subject-class values, scope capture) are canonicalized before derivation, so the ID is invariant under permutation and duplication. Config fields that a freshness class never reads are rejected at construction (an `offline-jwt` policy cannot carry `maximum_status_age`), so the canonical encoding stays total over valid configs and semantically identical policies always derive one ID. - **The single canonical verifier** (`FederatedAssertionVerifier`, `FI-INV-16`) producing the origin-sealed, provider-neutral `VerifiedAssertion` normalized result. Its constructor is crate-private, so unverified claims cannot be promoted into authority. The issuer→JWKS authority is entirely crate-owned: `AssertionKeySet` has no public constructor and `IssuerKeySource` is sealed, so no downstream crate can relabel one issuer's keys as another's. The authenticated key set is bounded (`MAX_JWKS_KEYS`) and the bound is folded into `AssertionPolicyId`, so an unbounded attacker-controlled `kid` scan cannot be driven; every snapshot requires a finite positive hard deadline. - **Sealed revalidation dependencies** — `RevalidationDependencies` carries the key-snapshot hard deadline and a `ConfidentialAssertion` handle to the exact compact JWS (no `Debug`/`Display`/`serde` leak, sole read path `compact_jws()`), so a changed snapshot can revalidate the same evidence: a retained key revalidates, a removed key denies. - **The privacy-preserving four-class denial contract** (`DenialClass`, `FI-INV-13`) with the byte-exact Nostr text, HTTP status, body, `Content-Type`, and `WWW-Authenticate` values fixed by the spec's rejection table. An unreadable required current dependency maps to `authorization_unavailable`/503, never to rejected evidence, and rejected evidence never masquerades as a 503 at either end of the pipeline: all bounded, dependency-independent checks (compact structure, header, signature shape, policy, algorithm, token class) precede key-source lookup, and all offline validation (token-class, key, signature, audience, claims, time) completes before any status-witness deferral. A wrong-`typ` or structurally malformed token, or malformed/invalidly-signed input naming a current-status issuer, therefore denies with `evidence_rejected`/403 rather than being reported as a 503 availability signal. ## Corrections applied to the mined source Mined from the `buzz-auth` verifier core in [#1476](https://github.com/block/buzz/pull/1476) and corrected to the settled spec (the merged `docs/nips/NIP-FI*.md`, [#5946](https://github.com/block/buzz/pull/5946)), which settled after #1476 was written: - Token-class selection with exact `typ` enforcement. Two classes are offered: `at+jwt` and `nip-fi+jwt`. There is deliberately no generic/absent-`typ` "named compatibility" class: it cannot be proven disjoint from an OIDC ID token by claim presence alone (an issuer can mint an ID token carrying `client_id`), and the only authenticated discriminator is `typ`, which such a mode declines to constrain. - OIDC ID-token denial — denies even when `iss`, `aud`, and `sub` match, via exact `typ` mismatch against every accepted class. - The fixed `nostr_pubkey` claim accepted only as lowercase hex of exactly one 32-byte key; bech32 and other aliases deny. - Resource-owner / client-subject ambiguity denial and required `client_id` for `at+jwt`. - JWK admissibility: a key's `use` and, when present, `key_ops` must authorize signature verification (a key restricted to other operations such as `encrypt` is rejected), and the selected JOSE algorithm is bound to the key's required family and curve (ES256↔EC/P-256, ES384↔EC/P-384, EdDSA↔OKP/Ed25519, RS/PS↔RSA). The JWK `alg` is advisory; the actual key material is what signs, so a JWK declaring a matching `alg` over mismatched material (a different family or curve) is rejected before signature verification. - Spec-exact time arithmetic (`now < exp`, `iat <= now + skew`, `now < iat + maximum_assertion_age`, equality at expiry is expired). `exp`/`iat`/`nbf` accept finite integer or fractional RFC 7519 `NumericDate` values with checked, overflow-safe conversion; non-finite and out-of-range values deny. ## Verification In-crate tests in `crates/buzz-auth/src/nip_fi/verifier/tests.rs` sign real ES256 assertions against a fixed test key and cover: the happy path, exact-wire-text for all four denial classes, deterministic and semantic contract IDs, canonicalization invariance, token-class enforcement including ID-token denial, JWK `key_ops` rejection, `nostr_pubkey` hex handling, time bounds, and multi-issuer selection (same subject across distinct issuers yields distinct identities and policy IDs). They also cover the round-3 contracts: the key-set bound (oversized rejected, at-bound accepted, empty rejected), fixed-`sub` identity (identity is `sub` not a configured `email`; a token without `sub` denies), offline-before-deferral (invalid-signature, wrong-audience, malformed-claim, and expired under a current-status issuer each deny 403/`evidence_rejected`), and the revalidation contracts (dependencies carry the deadline and exact JWS; a retained key revalidates, a removed key denies). And the round-4 contracts: algorithm↔key-material binding, covered exactly per accepted algorithm (a table-driven matrix asserts every policy-acceptable algorithm — ES256, ES384, EdDSA, RS256/384/512, PS256/384/512 — matches only its required family/curve and rejects every other, so each mapping mutation fails individually; and an ES256 token against P-384, RSA, and Ed25519 material each deny `InvalidKey`); malformed evidence classified before key lookup (wrong-`typ`, two-segment, four-segment, empty-signature, and non-base64url-signature tokens against an empty source deny 403 not 503); `maximum_status_age` inapplicable under offline-jwt rejected at construction while current-status still requires it; and fractional `NumericDate` (finite fractional `iat`/`exp`/`nbf` within bounds verify, non-finite and out-of-range deny). Compile-fail doctests guard the sealed authority seam (`AssertionKeySet::new`, `IssuerKeySource`) and the absence of the named-compatibility token class. ## Notes - `Co-authored-by` attribution is preserved for the mined author. The #1476 `buzz-auth` commits are authored by Cea Stapleton Cordasco (the salvage map records #1476 under Franco; the git history on that PR is Cea's — surfacing for the attribution/closure record). - Amends `docs/nips/NIP-FI.md` to remove the "named compatibility access token" class and its `FI-TRACE-TOKEN-CLASS` oracle reference, so the normative spec matches the two-class root implementation (a generic/absent `typ` cannot be proven disjoint from an OIDC ID token). Removal-only, no neighbor redesign; a `docs/nips/` grep confirms no dangling cross-reference to the class remains. - Adds `jsonwebtoken 10.4.0` (`aws_lc_rs`) as a workspace dependency. Dependencies: none. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
255 lines
9.1 KiB
Rust
255 lines
9.1 KiB
Rust
#![deny(unsafe_code)]
|
||
#![warn(missing_docs)]
|
||
//! `buzz-auth` — Authentication and authorization for the Buzz relay.
|
||
//!
|
||
//! ## Auth paths
|
||
//!
|
||
//! | Path | Transport | Description |
|
||
//! |------|-----------|-------------|
|
||
//! | NIP-42 | WebSocket | Challenge/response; client signs kind:22242 event |
|
||
//! | NIP-98 | HTTP | Signed kind:27235 event in `Authorization: Nostr` header |
|
||
//!
|
||
//! ## Security invariants
|
||
//!
|
||
//! - **AUTH events (kind:22242) are NEVER stored or logged.**
|
||
//! - All paths produce an [`AuthContext`] bound to the connection.
|
||
//! - No JWT validation, no token management, no IdP runtime dependency.
|
||
|
||
/// Channel access checking trait and helpers.
|
||
pub mod access;
|
||
/// Authentication error types.
|
||
pub mod error;
|
||
/// NIP-42 challenge–response authentication.
|
||
pub mod nip42;
|
||
/// NIP-98 HTTP Auth verification (kind:27235).
|
||
pub mod nip98;
|
||
/// NIP-98 replay protection — shared, community-scoped, atomic seen-set.
|
||
pub mod nip98_replay;
|
||
/// NIP-FI federated-identity assertion verifier and contracts.
|
||
pub mod nip_fi;
|
||
/// Per-connection rate limiting.
|
||
pub mod rate_limit;
|
||
/// OAuth scope parsing and enforcement.
|
||
pub mod scope;
|
||
|
||
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
|
||
pub use error::AuthError;
|
||
pub use nip42::{generate_challenge, verify_nip42_event};
|
||
pub use nip98::verify_nip98_event;
|
||
pub use nip98_replay::{
|
||
nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS,
|
||
MAX_REPLAY_TTL_SECS,
|
||
};
|
||
pub use rate_limit::{
|
||
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
|
||
};
|
||
pub use scope::{parse_scopes, Scope};
|
||
|
||
pub use nip_fi::{
|
||
AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, ClientSubjectPosture,
|
||
ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, FederatedIdentity,
|
||
FreshnessClass, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
|
||
RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId,
|
||
VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM,
|
||
OAUTH_CLIENT_ID_CLAIM,
|
||
};
|
||
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use access::MockAccessChecker;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
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 {
|
||
/// Per-user and per-IP rate limit thresholds.
|
||
#[serde(default)]
|
||
pub rate_limits: RateLimitConfig,
|
||
}
|
||
|
||
/// Simplified auth service — NIP-42 and NIP-98 only.
|
||
/// No JWT validation, no token management, no IdP runtime dependency.
|
||
#[derive(Debug, Clone)]
|
||
pub struct AuthService {
|
||
config: AuthConfig,
|
||
}
|
||
|
||
impl AuthService {
|
||
/// Create a new `AuthService` with the given configuration.
|
||
pub fn new(config: AuthConfig) -> Self {
|
||
Self { config }
|
||
}
|
||
|
||
/// Return a reference to the auth configuration.
|
||
pub fn config(&self) -> &AuthConfig {
|
||
&self.config
|
||
}
|
||
|
||
/// Verify a NIP-42 AUTH event and return an [`AuthContext`].
|
||
///
|
||
/// Pure cryptographic verification — no network calls, no JWT, no tokens.
|
||
pub async fn verify_auth_event(
|
||
&self,
|
||
auth_event: nostr::Event,
|
||
expected_challenge: &str,
|
||
relay_url: &str,
|
||
) -> Result<AuthContext, 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();
|
||
let relay_owned = relay_url.to_string();
|
||
tokio::task::spawn_blocking(move || {
|
||
verify_nip42_event(&event_clone, &challenge_owned, &relay_owned)
|
||
})
|
||
.await
|
||
.map_err(|_| AuthError::Internal("spawn_blocking panicked".into()))??;
|
||
|
||
// 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 {
|
||
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
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Derive a deterministic Nostr pubkey from a username string.
|
||
///
|
||
/// Uses `SHA-256("buzz-test-key:{username}")` as the secret key material.
|
||
/// This matches the derivation used by the desktop's `set_test_identity` function,
|
||
/// allowing the relay to resolve usernames to Nostr pubkeys in dev mode.
|
||
///
|
||
/// # ⚠️ SECURITY — Dev/test only
|
||
///
|
||
/// This function is gated behind `#[cfg(any(test, feature = "dev"))]`
|
||
/// and **must never be compiled into a production release build**.
|
||
///
|
||
/// - The derived keys are deterministic and predictable from the username alone.
|
||
/// - Any attacker who knows a username can compute the corresponding private key.
|
||
#[cfg(any(test, feature = "dev"))]
|
||
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
|
||
use sha2::{Digest, Sha256};
|
||
let seed = format!("buzz-test-key:{username}");
|
||
let hash: [u8; 32] = Sha256::digest(seed.as_bytes()).into();
|
||
let secret_key = nostr::SecretKey::from_slice(&hash)
|
||
.map_err(|e| AuthError::Internal(format!("key derivation failed: {e}")))?;
|
||
Ok(nostr::Keys::new(secret_key).public_key())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use nostr::{EventBuilder, Keys, Kind, RelayUrl};
|
||
|
||
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> nostr::Event {
|
||
let url = RelayUrl::parse(relay_url).expect("valid url");
|
||
EventBuilder::auth(challenge, url)
|
||
.sign_with_keys(keys)
|
||
.expect("signing failed")
|
||
}
|
||
|
||
fn test_service() -> AuthService {
|
||
AuthService::new(AuthConfig::default())
|
||
}
|
||
|
||
#[test]
|
||
fn auth_context_scope_check() {
|
||
let keys = Keys::generate();
|
||
let ctx = AuthContext {
|
||
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));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn nip42_auth_succeeds() {
|
||
let keys = Keys::generate();
|
||
let challenge = generate_challenge();
|
||
let relay = "wss://relay.example.com";
|
||
let event = make_auth_event(&keys, &challenge, relay);
|
||
|
||
let ctx = test_service()
|
||
.verify_auth_event(event, &challenge, relay)
|
||
.await
|
||
.expect("NIP-42 auth should succeed");
|
||
|
||
assert_eq!(ctx.pubkey, keys.public_key());
|
||
assert_eq!(ctx.auth_method, AuthMethod::Nip42);
|
||
assert!(ctx.has_scope(&Scope::MessagesRead));
|
||
assert!(ctx.has_scope(&Scope::MessagesWrite));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn wrong_challenge_rejected() {
|
||
let keys = Keys::generate();
|
||
let challenge = generate_challenge();
|
||
let relay = "wss://relay.example.com";
|
||
let event = make_auth_event(&keys, &challenge, relay);
|
||
|
||
let result = test_service()
|
||
.verify_auth_event(event, "wrong-challenge", relay)
|
||
.await;
|
||
assert!(matches!(result, Err(AuthError::ChallengeMismatch)));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn wrong_kind_rejected() {
|
||
let keys = Keys::generate();
|
||
let event = EventBuilder::new(Kind::TextNote, "not auth")
|
||
.tags([])
|
||
.sign_with_keys(&keys)
|
||
.expect("sign");
|
||
|
||
let result = test_service()
|
||
.verify_auth_event(event, &generate_challenge(), "wss://relay.example.com")
|
||
.await;
|
||
assert!(matches!(result, Err(AuthError::InvalidSignature)));
|
||
}
|
||
}
|