mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Stack: this PR → #8377 → #8399 → #8401 bb-public creates communities all the time, so a hand-edited `BUZZ_NIP_FI_COMMUNITIES` map can't list them. This PR stores an explicit NIP-FI issuer allowlist for each community in the database and writes it when the community is created. Login checks don't read the table yet; that comes in #8377 together with the database-backed resolver. Operator steps are in [`docs/operator-nip-fi-bindings.md`](docs/operator-nip-fi-bindings.md). **Storage.** Migration `0066_community_nip_fi_issuers` adds `community_nip_fi_issuers (community_id, issuer, created_at)`, with primary key `(community_id, issuer)` and the community write fence attached. The assertion audience is `https://<communities.host>`, so the table has no `aud` column. Zero rows means no binding. The table is included in whole-community deletion and purge. **One bindable rule.** `BUZZ_NIP_FI_PROVISION_BINDING` (`{"host_suffix": ..., "authorized_issuers": [...]}`) is parsed in `buzz-auth` with `BUZZ_NIP_FI_ISSUERS`, whatever `BUZZ_NIP_FI_MODE` is set to, and both the relay and `buzz-admin` use the same parser. Each authorized issuer must be a configured issuer (1 to 2048 bytes), and the suffix must not cover any host declared in `BUZZ_NIP_FI_COMMUNITIES`; an unreadable declaration refuses startup even in mode `off`. `ProvisionBindingPolicy::is_bindable(host)` is true for a host exactly one label under the suffix that is not reserved. It decides creation-time binding, explicit `bind`, and what the lookup route reports. **Binding on creation.** Rows are written in the same transaction as the community insert on all three creation paths (`POST /operator/communities` `create_only`, its legacy convergence branch, and startup bootstrap), and only when the call actually created the community, so existing rows are never rewritten. **Reserved host.** A host whose `https://<host>` equals a configured `command_audiences` value never holds a binding. Operator provisioning of it is rejected with a 400 and the `reserved_host` error code, bootstrap creates it unbound, and the availability route reports it unavailable with an error code. `main.rs` calls `verify_reserved_hosts_unbound` at startup; if a reserved host has rows the relay refuses to start and names the fix (`buzz-admin nip-fi unbind --host <h> --issuer <i>`, or revert the command audience). A database failure during that check is a separate `Check` error. **Operator commands.** - `buzz-admin nip-fi backfill` pages unbound communities by host (1000 per page) and binds each page with one `INSERT ... SELECT` limited to active communities. It skips communities being deleted, always prints `{rows_bound, unbound, unbound_hosts, truncated}`, and exits non-zero while any bindable community is still unbound. Archived communities are bound on purpose. Both bind paths take the shared deletion lock and then lock the community row (`FOR NO KEY UPDATE`, in id order for a batch), the same order deletion uses, and the backfill insert also requires the community to still have no binding, so an operator `bind` that lands after the backfill listed a community wins and the default issuer is not added beside it. - `buzz-admin nip-fi bind --host --issuer` refuses a reserved host, a host outside the suffix, or an unconfigured issuer. - `buzz-admin nip-fi unbind --host --issuer` refuses to remove a community's last issuer (archive or delete the community instead), with one exception: when a valid provisioning policy shows that backfill can't bind the host (it's reserved, or outside the suffix), the last issuer can be removed. That is the reserved-host recovery path in the runbook. A missing or malformed policy doesn't block `unbind`; it just means the exception never applies. **Lookup route.** `GET /operator/communities/nip-fi-binding?host=` (NIP-98, `RELAY_OPERATOR_PUBKEYS`) reads the database and returns `{host, active, issuers[]}`. `host` is the stored host. It returns 404 for an unknown host, 200 with `active:false` for an archived or quiescing community, and `issuers:[]` when no provisioning policy is set. It lists only issuers that are configured and on a bindable host. kgoose depends on this contract; it is also documented in the runbook. **NIP-FA.** The `relay_url` bullet now also says: "An adapter MAY obtain its exact relay list from a configured, authenticated source; it MUST still compare `relay_url` exactly." **Deployment dependency.** The kgoose config side is already handled by [cash-server#137671](https://github.com/squareup/cash-server/pull/137671). In public-community mode it requires the command audience not to be a community audience. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
269 lines
10 KiB
Rust
269 lines
10 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::{
|
||
command_replay_key, validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, BindRefusal,
|
||
CanonicalCapabilities, ClientSubjectPosture, CommandError, CommandIssuerPolicy,
|
||
CommandPolicyError, CommandReplayGuard, CommandResult, CommandVerifier, CommunityBinding,
|
||
CommunityBindingError, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull,
|
||
FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass,
|
||
HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy,
|
||
IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract,
|
||
NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, ProvisionBindingError,
|
||
ProvisionBindingPolicy, RevalidationDependencies, SubjectClass, SubjectClassContract,
|
||
TokenClass, TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion,
|
||
CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, 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 nip_fi::jwks::ScriptedJwksFetcher;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::InMemoryCommandReplayGuard;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::StaticIssuerKeySource;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::ToggleJwksFetcher;
|
||
#[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)));
|
||
}
|
||
}
|