diff --git a/Justfile b/Justfile index 4bc865726e..d6df05fcaa 100644 --- a/Justfile +++ b/Justfile @@ -600,6 +600,9 @@ test-unit: + test(=handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected) + test(=handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path) + test(=handlers::auth::tests::shadow_root_auth_records_pairing_denial) + + test(=handlers::auth::tests::shadow_root_auth_records_unavailable_owner_read) + + test(=handlers::ingest::postgres_tests::relay_admin_ban_maps_to_blocked_auth_failure) + + test(=handlers::ingest::postgres_tests::relay_admin_internal_maps_to_error_not_client_fault) + test(=handlers::auth::tests::shadow_root_invalid_nip42_retires_without_record) + test(=handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence) + test(=handlers::event::tests::fanout_access::owner_only_kinds_keep_only_the_owner) @@ -612,6 +615,7 @@ test-unit: + test(=state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped) + test(=state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason) + test(=state::tests::nip_fi_disconnect_audio_is_issuer_scoped) + + test(=state::tests::nip_fi_owner_disconnect_closes_owned_agent_sockets_on_root_and_audio) + test(=state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason) + test(=state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer) + test(=state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket) diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index c8432f093a..520d712287 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -56,8 +56,8 @@ pub use nip_fi::{ 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, + CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, NOSTR_OWNER_PUBKEY_CLAIM, + NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/assertion.rs b/crates/buzz-auth/src/nip_fi/assertion.rs index ae5d57e7c4..879734d22a 100644 --- a/crates/buzz-auth/src/nip_fi/assertion.rs +++ b/crates/buzz-auth/src/nip_fi/assertion.rs @@ -118,6 +118,7 @@ impl fmt::Debug for RevalidationDependencies { pub struct VerifiedAssertion { identity: FederatedIdentity, asserted_key: Option, + owner: Option, capabilities: CanonicalCapabilities, authority_deadlines: Vec>, assertion_policy_id: AssertionPolicyId, @@ -133,6 +134,7 @@ impl VerifiedAssertion { issuer: String, subject: String, asserted_key: Option, + owner: Option, capabilities: CanonicalCapabilities, authority_deadlines: Vec>, assertion_policy_id: AssertionPolicyId, @@ -146,6 +148,7 @@ impl VerifiedAssertion { Self { identity: FederatedIdentity { issuer, subject }, asserted_key, + owner, capabilities, authority_deadlines, assertion_policy_id, @@ -165,6 +168,12 @@ impl VerifiedAssertion { self.asserted_key } + /// The verified `nostr_owner_pubkey` of an agent assertion; `None` for a + /// key acting for itself. + pub const fn owner(&self) -> Option { + self.owner + } + /// The canonical closed claims/capabilities carried by the assertion. pub const fn capabilities(&self) -> &CanonicalCapabilities { &self.capabilities @@ -212,6 +221,7 @@ impl VerifiedAssertion { "https://test.issuer.example".to_owned(), "test-subject".to_owned(), Some(asserted_key), + None, CanonicalCapabilities::from_pairs(vec![]), vec![Utc::now() + Duration::seconds(3600)], AssertionPolicyId::zero(), @@ -258,6 +268,7 @@ impl VerifiedAssertion { subject: "test-subject".to_string(), }, asserted_key, + owner: None, capabilities: CanonicalCapabilities::from_pairs(vec![]), authority_deadlines, assertion_policy_id: AssertionPolicyId::zero(), @@ -272,6 +283,13 @@ impl VerifiedAssertion { }, } } + + /// Test-only: the same assertion as an agent assertion owned by `owner`. + #[must_use] + pub fn with_owner_for_test(mut self, owner: PublicKey) -> Self { + self.owner = Some(owner); + self + } } impl RevalidationDependencies { diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 99231d82d5..cfe9859670 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -79,7 +79,12 @@ pub(crate) const MAX_JTI_BYTES: usize = 512; /// v2 (PR #7221): `nostr_pubkey` absence now unconditionally rejects — the /// per-issuer `require_attested_key` knob is removed and the NIP-FI v2 spec /// requirement is always enforced. -pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 2; +/// +/// v3 (PR #8401): dedicated policies also accept the `nip-fi-agent+jwt` agent +/// type, which requires a `nostr_owner_pubkey` claim (a valid lowercase key +/// distinct from the asserted key); a `nip-fi+jwt` or `at+jwt` token carrying +/// `nostr_owner_pubkey` with any value, `null` included, is rejected. +pub(crate) const VERIFIER_CONTRACT_VERSION: u32 = 3; /// The transport-contract fingerprint folded into [`TransportContractId`]. /// **Bump on any change** to the client-attached parsing, attachment, @@ -90,6 +95,10 @@ pub(crate) const TRANSPORT_CONTRACT_VERSION: u32 = 1; /// "Assertion validation"). Not configurable: other encodings and aliases deny. pub const NOSTR_PUBKEY_CLAIM: &str = "nostr_pubkey"; +/// The fixed name of the owner-key claim on an agent assertion (NIP-FI.md, +/// "Agent keys"). Same encoding as [`NOSTR_PUBKEY_CLAIM`]. +pub const NOSTR_OWNER_PUBKEY_CLAIM: &str = "nostr_owner_pubkey"; + /// The fixed identity-subject claim. Identity is the exact tuple `(iss, sub)` /// (NIP-FI.md:35-41), so the subject coordinate is always the JWT `sub` claim /// and is never deployment-configurable: an operator cannot seal a mutable @@ -326,7 +335,9 @@ pub enum TokenClass { /// The mutually exclusive resource-owner/client-subject contract. subject_class: SubjectClassContract, }, - /// A dedicated Buzz assertion: protected `typ` is exactly `nip-fi+jwt`. + /// A dedicated Buzz assertion: protected `typ` is exactly `nip-fi+jwt` + /// (human; `nostr_owner_pubkey` forbidden) or `nip-fi-agent+jwt` (agent; + /// `nostr_owner_pubkey` required). DedicatedNipFi, } @@ -792,7 +803,7 @@ mod jwks_transport_identity_tests { assert_eq!( hex::encode(policy(contract).id().as_bytes()), - "fae0dd479d79306c5eef135594f678a5cdf3a2ee45770e20a7e83395fc76857f", + "29ecb98c34dc7574616d72caf1565cda35585a15f682f0d30f6c011342a555d8", "public_https mode omits the mesh transport tag" ); } @@ -808,7 +819,7 @@ mod jwks_transport_identity_tests { assert_eq!( hex::encode(policy(contract).id().as_bytes()), - "dca3e998388ae75bdea6bb2070c8f28b8d389a8b874de96e7852dd1f71b14bcd", + "da846eec66b2e084ac3557d8e5c2a3a2faf12946e965c82d2ef46f91b6fa014e", "mesh transport is part of policy identity" ); } diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index aeb9349b4b..f580ad089d 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -39,7 +39,7 @@ pub use community::{CommunityBinding, CommunityBindingError}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, - NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, + NOSTR_OWNER_PUBKEY_CLAIM, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; pub use deny_map::{CrossPodMergeResult, DenySetFull, IssuerCapacity, NipFiDenyMap}; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index 750c617324..daa91f2940 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -15,8 +15,8 @@ //! Corrections applied to the mined #1476 verifier, per the settled spec: //! //! - **Token class + `typ` enforcement**: a policy selects exactly one class -//! before parsing claims; `at+jwt` and `nip-fi+jwt` `typ` values are enforced -//! exactly, and the long-form `application/at+jwt` is rejected. +//! before parsing claims; `at+jwt`, `nip-fi+jwt` and `nip-fi-agent+jwt` +//! `typ` values are enforced exactly, and the long-form `application/at+jwt` is rejected. //! - **ID-token denial**: OIDC ID tokens deny even when `iss`, `aud`, `sub` //! match, via exact `typ` mismatch against every accepted class. //! - **Fixed `nostr_pubkey`**: accepted only as lowercase hex of exactly one @@ -30,8 +30,8 @@ use super::community::CommunityBinding; use super::config::{ is_asymmetric_algorithm, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerRegistry, SubjectClass, TokenClass, TransportContractId, MAX_CLIENT_ID_BYTES, MAX_JWKS_KEYS, - MAX_KID_BYTES, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, - SUBJECT_CLAIM, + MAX_KID_BYTES, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, NOSTR_OWNER_PUBKEY_CLAIM, + NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, SUBJECT_CLAIM, }; use super::denial::DenialClass; use chrono::{DateTime, TimeZone, Utc}; @@ -392,7 +392,7 @@ impl FederatedAssertionVerifier { if !policy.algorithms().contains(&header.algorithm) { return Err(VerifierError::UnsupportedAlgorithm); } - enforce_token_type(policy.token_class(), header.typ.as_deref())?; + let owner_rule = enforce_token_type(policy.token_class(), header.typ.as_deref())?; // Resolve the key snapshot internally from the trusted source, keyed by // the policy's exact `iss`. The snapshot is never a caller argument, so @@ -440,6 +440,7 @@ impl FederatedAssertionVerifier { let subject = claim_string(&claims, SUBJECT_CLAIM, MAX_SUBJECT_BYTES)?; let asserted_key = parse_nostr_pubkey_claim(&claims)?; + let owner = parse_owner_claim(owner_rule, &claims, asserted_key)?; let now = Utc::now(); let deadlines = self.check_time_and_deadlines(policy, &key_set, &claims, now)?; @@ -461,6 +462,7 @@ impl FederatedAssertionVerifier { policy.issuer().to_owned(), subject, asserted_key, + owner, capabilities, deadlines, policy.id_for_community(community.expected_aud()), @@ -746,17 +748,48 @@ fn parse_algorithm(alg: &str) -> Result { } } -/// Enforce the policy's single token class against the header `typ`. -fn enforce_token_type(class: &TokenClass, typ: Option<&str>) -> Result<(), VerifierError> { - match class { - TokenClass::AccessTokenAtJwt { .. } => match typ { - Some("at+jwt") => Ok(()), - _ => Err(VerifierError::TokenTypeRejected), - }, - TokenClass::DedicatedNipFi => match typ { - Some("nip-fi+jwt") => Ok(()), - _ => Err(VerifierError::TokenTypeRejected), - }, +/// Whether an accepted token type must carry `nostr_owner_pubkey`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnerRule { + /// `nip-fi-agent+jwt`: the owner claim is mandatory. + Required, + /// Every other accepted type: the owner claim must be absent. + Forbidden, +} + +/// Enforce the policy's single token class against the header `typ`. The +/// dedicated class has a human (`nip-fi+jwt`) and an agent +/// (`nip-fi-agent+jwt`) type; `at+jwt` policies accept only `at+jwt`. +fn enforce_token_type(class: &TokenClass, typ: Option<&str>) -> Result { + match (class, typ) { + (TokenClass::AccessTokenAtJwt { .. }, Some("at+jwt")) + | (TokenClass::DedicatedNipFi, Some("nip-fi+jwt")) => Ok(OwnerRule::Forbidden), + (TokenClass::DedicatedNipFi, Some("nip-fi-agent+jwt")) => Ok(OwnerRule::Required), + _ => Err(VerifierError::TokenTypeRejected), + } +} + +/// The `nostr_owner_pubkey` claim, bound to the token type (NIP-FI.md, "Agent +/// keys"): an agent token must carry a canonical owner key distinct from the +/// asserted key; any other type carrying the claim, with any value, denies. +fn parse_owner_claim( + rule: OwnerRule, + claims: &Map, + asserted_key: Option, +) -> Result, VerifierError> { + let value = claims.get(NOSTR_OWNER_PUBKEY_CLAIM); + match (rule, value) { + (OwnerRule::Forbidden, None) => Ok(None), + (OwnerRule::Required, None) | (OwnerRule::Forbidden, Some(_)) => { + Err(VerifierError::ClaimRejected) + } + (OwnerRule::Required, Some(value)) => { + let owner = parse_lowercase_hex_key(value)?; + if Some(owner) == asserted_key { + return Err(VerifierError::ClaimRejected); + } + Ok(Some(owner)) + } } } @@ -802,21 +835,23 @@ fn enforce_claim_semantics( fn parse_nostr_pubkey_claim( claims: &Map, ) -> Result, VerifierError> { - match claims.get(NOSTR_PUBKEY_CLAIM) { - None => Err(VerifierError::ClaimRejected), - Some(value) => { - let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; - if raw.len() != 64 - || !raw - .bytes() - .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) - { - return Err(VerifierError::ClaimRejected); - } - let key = PublicKey::from_hex(raw).map_err(|_| VerifierError::ClaimRejected)?; - Ok(Some(key)) - } + let value = claims + .get(NOSTR_PUBKEY_CLAIM) + .ok_or(VerifierError::ClaimRejected)?; + parse_lowercase_hex_key(value).map(Some) +} + +/// Lowercase hex of exactly one 32-byte key; any other encoding denies. +fn parse_lowercase_hex_key(value: &Value) -> Result { + let raw = value.as_str().ok_or(VerifierError::ClaimRejected)?; + if raw.len() != 64 + || !raw + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(VerifierError::ClaimRejected); } + PublicKey::from_hex(raw).map_err(|_| VerifierError::ClaimRejected) } /// Capture only the claim names the policy reads into a canonical set. The diff --git a/crates/buzz-auth/src/nip_fi/verifier/tests.rs b/crates/buzz-auth/src/nip_fi/verifier/tests.rs index 77ce87a154..8912e653c1 100644 --- a/crates/buzz-auth/src/nip_fi/verifier/tests.rs +++ b/crates/buzz-auth/src/nip_fi/verifier/tests.rs @@ -745,6 +745,131 @@ fn absent_nostr_pubkey_claim_denies() { ); } +// ---- Agent assertions (`nip-fi-agent+jwt`, `nostr_owner_pubkey`) ----------- + +const AGENT_TYP: &str = "nip-fi-agent+jwt"; + +fn dedicated_token(typ: &str, owner: Option) -> String { + let mut claims = json!({ "sub": "u" }); + if let Some(owner) = owner { + claims[NOSTR_OWNER_PUBKEY_CLAIM] = owner; + } + mint(Some(typ), TEST_KID, claims) +} + +fn assert_evidence_rejected(err: VerifierError, case: &str) { + assert_eq!(err, VerifierError::ClaimRejected, "{case}"); + assert_eq!(err.denial_class(), DenialClass::EvidenceRejected, "{case}"); +} + +#[test] +fn agent_type_with_valid_owner_seals_the_owner() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let owner = nostr::Keys::generate().public_key(); + let assertion = verifier + .verify_for_aud( + &dedicated_token(AGENT_TYP, Some(json!(owner.to_hex()))), + AUDIENCE, + ) + .expect("verifies"); + assert_eq!(assertion.owner(), Some(owner)); +} + +#[test] +fn agent_type_without_owner_denies() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let err = verifier + .verify_for_aud(&dedicated_token(AGENT_TYP, None), AUDIENCE) + .unwrap_err(); + assert_evidence_rejected(err, "absent owner"); +} + +#[test] +fn agent_type_with_malformed_or_self_equal_owner_denies() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let upper = nostr::Keys::generate().public_key().to_hex().to_uppercase(); + for owner in [ + json!(upper), + json!(""), + json!("not-hex"), + json!(null), + json!(TEST_NOSTR_PUBKEY), + ] { + let err = verifier + .verify_for_aud(&dedicated_token(AGENT_TYP, Some(owner.clone())), AUDIENCE) + .unwrap_err(); + assert_evidence_rejected(err, &format!("owner {owner}")); + } +} + +#[test] +fn human_type_has_no_owner_and_denies_any_owner_claim() { + let verifier = verifier_with(dedicated_policy(ISSUER)); + let assertion = verifier + .verify_for_aud(&dedicated_token("nip-fi+jwt", None), AUDIENCE) + .expect("verifies"); + assert_eq!(assertion.owner(), None); + let valid = nostr::Keys::generate().public_key().to_hex(); + for owner in [json!(valid), json!(null), json!("not-hex")] { + let err = verifier + .verify_for_aud( + &dedicated_token("nip-fi+jwt", Some(owner.clone())), + AUDIENCE, + ) + .unwrap_err(); + assert_evidence_rejected(err, &format!("owner {owner}")); + } +} + +#[test] +fn access_token_denies_any_owner_claim() { + let verifier = verifier_with(access_token_policy()); + let valid = nostr::Keys::generate().public_key().to_hex(); + for owner in [json!(valid), json!(null)] { + let mut claims = resource_owner_claims(); + claims[NOSTR_OWNER_PUBKEY_CLAIM] = owner.clone(); + let err = verifier + .verify_for_aud(&mint(Some("at+jwt"), TEST_KID, claims), AUDIENCE) + .unwrap_err(); + assert_evidence_rejected(err, &format!("owner {owner}")); + } +} + +#[test] +fn agent_type_under_access_token_policy_denies() { + let verifier = verifier_with(access_token_policy()); + let mut claims = resource_owner_claims(); + claims[NOSTR_OWNER_PUBKEY_CLAIM] = json!(nostr::Keys::generate().public_key().to_hex()); + assert_eq!( + verifier + .verify_for_aud(&mint(Some(AGENT_TYP), TEST_KID, claims), AUDIENCE) + .unwrap_err(), + VerifierError::TokenTypeRejected + ); +} + +#[test] +fn unknown_type_denies_under_both_classes() { + let owner = nostr::Keys::generate().public_key().to_hex(); + for policy in [dedicated_policy(ISSUER), access_token_policy()] { + let verifier = verifier_with(policy); + for typ in [ + "nip-fi-agent", + "nip-fi-agent+JWT", + "application/nip-fi-agent+jwt", + "agent+jwt", + ] { + assert_eq!( + verifier + .verify_for_aud(&dedicated_token(typ, Some(json!(owner))), AUDIENCE) + .unwrap_err(), + VerifierError::TokenTypeRejected, + "{typ}" + ); + } + } +} + // ---- Time bounds ---------------------------------------------------------- #[test] diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a110f90e3d..d5420096ef 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -22,6 +22,7 @@ use crate::handlers::ingest::{IngestAuth, IngestError}; use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; +use super::relay_members::MembershipRefusal; use super::{api_error, db_read_error, internal_error, not_found, parse_query_or_400}; mod thread_roots; @@ -66,6 +67,7 @@ pub(crate) async fn authenticate_member_post( ) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); enforce_http_admission(state, &tenant, &pubkey) @@ -76,10 +78,11 @@ pub(crate) async fn authenticate_member_post( .map_err(|e| e.into_response())?; super::relay_members::enforce_relay_membership( state, - tenant.community(), + &tenant, &pubkey.to_bytes(), super::relay_members::extract_auth_tag_header(headers), signed_created_at, + token_owner, ) .await .map_err(|e| e.into_response())?; @@ -1021,6 +1024,7 @@ pub async fn submit_event( }) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -1036,6 +1040,7 @@ pub async fn submit_event( pubkey, event_id_bytes, signed_created_at, + token_owner, ) .await; @@ -1127,16 +1132,16 @@ enum SubmitOutcome { /// only the HTTP status is logged; the response body is returned as-is. Err { status: StatusCode, - response: (StatusCode, Json), + response: MembershipRefusal, }, } impl SubmitOutcome { - fn into_response(self) -> Result, (StatusCode, Json)> { + fn into_response(self) -> Result, MembershipRefusal> { match self { SubmitOutcome::Ok { response, .. } => Ok(response), - SubmitOutcome::ParseFail { response, .. } => Err(response), - SubmitOutcome::Rejected { response, .. } => Err(response), + SubmitOutcome::ParseFail { response, .. } => Err(response.into()), + SubmitOutcome::Rejected { response, .. } => Err(response.into()), SubmitOutcome::Err { response, .. } => Err(response), } } @@ -1146,6 +1151,7 @@ impl SubmitOutcome { /// parse, and ingest. Returns a [`SubmitOutcome`] that carries both the log /// fields and the HTTP response so the thin wrapper can emit exactly one /// terminal attribution line covering every outcome. +#[allow(clippy::too_many_arguments)] async fn submit_event_authed( state: &Arc, tenant: &TenantContext, @@ -1154,19 +1160,20 @@ async fn submit_event_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], signed_auth_created_at: Option, + token_owner: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. if let Err(e) = enforce_http_admission(state, tenant, &pubkey).await { return SubmitOutcome::Err { status: e.0, - response: e, + response: e.into(), }; } if let Err(e) = check_nip98_replay(state, tenant, event_id_bytes).await { return SubmitOutcome::Err { status: e.0, - response: e, + response: e.into(), }; } let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -1205,19 +1212,21 @@ async fn submit_event_authed( let membership = if is_typing { super::relay_members::enforce_relay_membership_ephemeral( state, - tenant.community(), + tenant, &pubkey_bytes, auth_tag, signed_auth_created_at, + token_owner, ) .await } else { super::relay_members::enforce_relay_membership( state, - tenant.community(), + tenant, &pubkey_bytes, auth_tag, signed_auth_created_at, + token_owner, ) .await }; @@ -1235,7 +1244,7 @@ async fn submit_event_authed( }), Err(e) => { return SubmitOutcome::Err { - status: e.0, + status: e.status(), response: e, }; } @@ -1264,6 +1273,28 @@ async fn submit_event_authed( crate::handlers::ingest::ingest_event(state, tenant, event, auth).await }; + // A ban caught after admission is still an admission refusal: a verified + // agent token gets only NIP-FI's fixed denial. Everything else, and every + // token-less request, keeps its existing response. + let result = match result { + Err(IngestError::Ban { refusal, .. }) if token_owner.is_some() => { + crate::handlers::ingest::reject_with_transport("http", "auth"); + let refusal = MembershipRefusal::NipFi(match refusal { + crate::handlers::ingest::BanRefusal::Banned => { + buzz_auth::DenialClass::AuthorizationDenied + } + crate::handlers::ingest::BanRefusal::Unavailable => { + buzz_auth::DenialClass::AuthorizationUnavailable + } + }); + return SubmitOutcome::Err { + status: refusal.status(), + response: refusal, + }; + } + other => other.map_err(IngestError::into_legacy), + }; + match result { Ok(result) => { let response = Json(serde_json::json!({ @@ -1308,7 +1339,16 @@ async fn submit_event_authed( let e = api_error(StatusCode::FORBIDDEN, &msg); SubmitOutcome::Err { status: e.0, - response: e, + response: e.into(), + } + } + // `into_legacy` never returns `Ban`; it would fail closed here. + Err(IngestError::Ban { .. }) => { + crate::handlers::ingest::reject_with_transport("http", "error"); + let e = internal_error("ban refusal reached the legacy mapping"); + SubmitOutcome::Err { + status: e.0, + response: e.into(), } } Err(IngestError::Internal(msg)) => { @@ -1316,7 +1356,7 @@ async fn submit_event_authed( let e = internal_error(&msg); SubmitOutcome::Err { status: e.0, - response: e, + response: e.into(), } } } @@ -1375,6 +1415,7 @@ pub async fn query_events( }) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -1390,6 +1431,7 @@ pub async fn query_events( pubkey, event_id_bytes, signed_created_at, + token_owner, ) .await; match &result { @@ -1405,11 +1447,11 @@ pub async fn query_events( Ok(_) => { tracing::info!(pubkey = %pubkey_hex, route = "/query", status = 200u16, "HTTP bridge request"); } - Err((status, _)) => { + Err(refusal) => { tracing::warn!( pubkey = %pubkey_hex, route = "/query", - status = status.as_u16(), + status = refusal.status().as_u16(), "HTTP bridge request" ); } @@ -1420,6 +1462,7 @@ pub async fn query_events( /// Filter execution for [`query_events`], run once NIP-98 auth succeeds. /// Handles admission, replay, membership, and all filter paths so the thin /// wrapper above can emit exactly one terminal attribution line from the Result. +#[allow(clippy::too_many_arguments)] async fn query_events_authed( state: &Arc, tenant: &TenantContext, @@ -1428,7 +1471,8 @@ async fn query_events_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], signed_auth_created_at: Option, -) -> Result, (StatusCode, Json)> { + token_owner: Option, +) -> Result, MembershipRefusal> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -1436,13 +1480,25 @@ async fn query_events_authed( let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, - tenant.community(), + tenant, &pubkey_bytes, auth_tag, signed_auth_created_at, + token_owner, ) .await?; + Ok(query_member_events(state, tenant, body, pubkey).await?) +} + +/// The rest of [`query_events_authed`], once the caller is admitted as a relay member. +async fn query_member_events( + state: &Arc, + tenant: &TenantContext, + body: &[u8], + pubkey: nostr::PublicKey, +) -> Result, (StatusCode, Json)> { + let pubkey_bytes = pubkey.to_bytes().to_vec(); // Two-pass parse: preserve raw JSON for custom extension fields (before_id, // depth_limit, feed_types) that nostr::Filter silently drops. let raw_filters: Vec = serde_json::from_slice(body) @@ -2053,6 +2109,7 @@ pub async fn count_events( }) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); @@ -2068,6 +2125,7 @@ pub async fn count_events( pubkey, event_id_bytes, signed_created_at, + token_owner, ) .await; match &result { @@ -2081,11 +2139,11 @@ pub async fn count_events( "HTTP bridge request" ); } - Err((status, _)) => { + Err(refusal) => { tracing::warn!( pubkey = %pubkey_hex, route = "/count", - status = status.as_u16(), + status = refusal.status().as_u16(), "HTTP bridge request" ); } @@ -2096,6 +2154,7 @@ pub async fn count_events( /// Filter execution for [`count_events`], run once NIP-98 auth succeeds. /// Handles admission, replay, membership, and count execution so the thin /// wrapper above can emit exactly one terminal attribution line from the Result. +#[allow(clippy::too_many_arguments)] async fn count_events_authed( state: &Arc, tenant: &TenantContext, @@ -2104,7 +2163,8 @@ async fn count_events_authed( pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], signed_auth_created_at: Option, -) -> Result, (StatusCode, Json)> { + token_owner: Option, +) -> Result, MembershipRefusal> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2112,13 +2172,25 @@ async fn count_events_authed( let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, - tenant.community(), + tenant, &pubkey_bytes, auth_tag, signed_auth_created_at, + token_owner, ) .await?; + Ok(count_member_events(state, tenant, body, pubkey).await?) +} + +/// The rest of [`count_events_authed`], once the caller is admitted as a relay member. +async fn count_member_events( + state: &Arc, + tenant: &TenantContext, + body: &[u8], + pubkey: nostr::PublicKey, +) -> Result, (StatusCode, Json)> { + let pubkey_bytes = pubkey.to_bytes().to_vec(); let raw: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; if let Some(result) = super::artifact::query(state, tenant, &pubkey, &raw, true).await { @@ -2897,6 +2969,7 @@ async fn authorize_moderation_read( }) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let event_id_bytes = admission.into_extra(); check_nip98_replay(state, &tenant, event_id_bytes) @@ -2907,10 +2980,11 @@ async fn authorize_moderation_read( // Membership and community ban, same step as the other NIP-98 routes. super::relay_members::enforce_relay_membership( state, - tenant.community(), + &tenant, &pubkey_bytes, super::relay_members::extract_auth_tag_header(headers), None, + token_owner, ) .await .map_err(|e| e.into_response())?; @@ -5349,39 +5423,10 @@ pub(crate) mod postgres_tests { Some(Arc::new(state)) } - const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; - const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; - const HANDLER_TEST_KID: &str = "test-key-1"; - - /// Ephemeral signing material shared by the test signer and verifier. - /// Never persist a private key fixture in source or on disk. - fn handler_test_key() -> &'static (jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet) { - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; - use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; - static KEY: std::sync::OnceLock<(jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet)> = - std::sync::OnceLock::new(); - KEY.get_or_init(|| { - let rng = ring::rand::SystemRandom::new(); - let algorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; - let der = EcdsaKeyPair::generate_pkcs8(algorithm, &rng).expect("generate test key"); - let pair = EcdsaKeyPair::from_pkcs8(algorithm, der.as_ref(), &rng) - .expect("parse generated test key"); - // P-256 public keys use uncompressed SEC1: 0x04 || x || y. - let public = pair.public_key().as_ref(); - assert_eq!(public.len(), 65); - assert_eq!(public[0], 4); - let jwks = serde_json::from_value(serde_json::json!({ - "keys": [{ - "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", - "kid": HANDLER_TEST_KID, - "x": URL_SAFE_NO_PAD.encode(&public[1..33]), - "y": URL_SAFE_NO_PAD.encode(&public[33..65]) - }] - })) - .expect("valid generated JWKS"); - (jsonwebtoken::EncodingKey::from_ec_der(der.as_ref()), jwks) - }) - } + use crate::nip_fi_core::test_support::{ + assertion_signing_key as handler_test_key, assertion_verifier as handler_test_verifier_for, + sign_assertion as sign_typed_assertion, TEST_ISSUER as HANDLER_TEST_ISSUER, + }; /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. /// @@ -5389,65 +5434,38 @@ pub(crate) mod postgres_tests { /// was reached, not just deny-all). Same key material as used in the /// cardinality test and the `signed_assertion_for_pubkey` helper below. async fn nip_fi_enforce_test_state_with_verifier() -> Option> { - use buzz_auth::{ - AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, - IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, - }; - use jsonwebtoken::Algorithm; - let mut state = (*nip_fi_enforce_test_state().await?).clone(); - - let jwks = handler_test_key().1.clone(); - let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); - let key_set = - AssertionKeySet::new_for_test(HANDLER_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) - .expect("valid test key set"); - let jwks_contract = buzz_auth::JwksSourceContract::new( - format!("{HANDLER_TEST_ISSUER}/.well-known/jwks.json"), - 300, - 3600, - ) - .expect("valid jwks contract"); - let policy = IssuerPolicy::new( - HANDLER_TEST_ISSUER.to_owned(), - vec![HANDLER_TEST_AUDIENCE.to_owned()], - TokenClass::DedicatedNipFi, - FreshnessClass::OfflineJwt, - vec![Algorithm::ES256], - 60, - 3600, - None, - jwks_contract, - ) - .expect("valid issuer policy"); - let mut registry = IssuerRegistry::new(); - registry.insert(policy); - let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( - registry, - StaticIssuerKeySource::new([key_set]), - )); - state.nip_fi_verifier = Some(verifier); + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier())); Some(Arc::new(state)) } + /// The real verifier trusting the shared HANDLER_TEST_* key material. + fn handler_test_verifier( + ) -> buzz_auth::FederatedAssertionVerifier { + handler_test_verifier_for(buzz_auth::TokenClass::DedicatedNipFi) + } + /// Mint a signed NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`, /// using the shared HANDLER_TEST_* key material. fn signed_assertion_for_pubkey(pubkey_hex: &str) -> String { - use jsonwebtoken::{Algorithm, Header}; - let now = chrono::Utc::now().timestamp(); - let claims = serde_json::json!({ - "iss": HANDLER_TEST_ISSUER, - "aud": HANDLER_TEST_AUDIENCE, - "iat": now, - "exp": now + 600, - "sub": "test-subject", - "nostr_pubkey": pubkey_hex, - }); - let mut header = Header::new(Algorithm::ES256); - header.kid = Some(HANDLER_TEST_KID.to_owned()); - header.typ = Some("nip-fi+jwt".to_owned()); - let key = &handler_test_key().0; - jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") + sign_test_assertion(pubkey_hex, None) + } + + /// An agent assertion for `agent` naming `owner` as its owner. + fn signed_agent_assertion(agent: &Keys, owner: &Keys) -> String { + sign_test_assertion( + &agent.public_key().to_hex(), + Some(&owner.public_key().to_hex()), + ) + } + + fn sign_test_assertion(pubkey_hex: &str, owner_hex: Option<&str>) -> String { + let typ = if owner_hex.is_some() { + "nip-fi-agent+jwt" + } else { + "nip-fi+jwt" + }; + sign_typed_assertion(typ, pubkey_hex, owner_hex.map(serde_json::Value::from)) } /// Build a HeaderMap containing a valid NIP-98 Authorization header + @@ -6434,6 +6452,793 @@ pub(crate) mod postgres_tests { buzz_v1_wire_contract("POST", "/buzz/v1/me/read-state", &body).await; } + /// HTTP admission binds `nostr_owner_pubkey` to the token type: an agent + /// token with a valid owner is admitted, while an agent token without + /// one, a human token carrying one (valid or null), an agent token under + /// an `at+jwt` policy and an unknown type are refused before any route. + /// Every token carries valid `at+jwt` claims, so the `at+jwt` case is + /// refused only for its type. Mutations: accept `nip-fi-agent+jwt` under + /// `at+jwt`, skip the owner rule, or accept any `nip-fi*` type in + /// `enforce_token_type` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn agent_token_type_binds_the_owner_claim_on_http() { + use axum::http::StatusCode; + + let fixture = nip_fi_enforce_test_state().await.expect("local Postgres"); + let (agent, owner) = (Keys::generate(), Keys::generate()); + let (agent_hex, owner_hex) = (agent.public_key().to_hex(), owner.public_key().to_hex()); + let dedicated = buzz_auth::TokenClass::DedicatedNipFi; + let access_token = crate::nip_fi_core::test_support::at_jwt_class; + let cases = [ + ( + "agent with owner", + dedicated.clone(), + "nip-fi-agent+jwt", + Some(owner_hex.clone().into()), + StatusCode::OK, + ), + ( + "agent without owner", + dedicated.clone(), + "nip-fi-agent+jwt", + None, + StatusCode::FORBIDDEN, + ), + ( + "human with owner", + dedicated.clone(), + "nip-fi+jwt", + Some(owner_hex.clone().into()), + StatusCode::FORBIDDEN, + ), + ( + "human with null owner", + dedicated.clone(), + "nip-fi+jwt", + Some(serde_json::Value::Null), + StatusCode::FORBIDDEN, + ), + ( + "agent under at+jwt", + access_token(), + "nip-fi-agent+jwt", + Some(owner_hex.clone().into()), + StatusCode::FORBIDDEN, + ), + ( + "unknown type", + dedicated, + "nip-fi-agent", + Some(owner_hex.into()), + StatusCode::FORBIDDEN, + ), + ]; + for (case, token_class, typ, owner_claim, expected) in cases { + let mut state = (*fixture).clone(); + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier_for(token_class))); + let state = Arc::new(state); + let host = format!("agent-typ-{}.local", uuid::Uuid::new_v4().simple()); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let body = br#"[{"kinds":[1],"limit":1}]"#.as_slice(); + let mut headers = + make_nip98_headers(&agent, &format!("https://{host}/query"), "POST", body); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + sign_typed_assertion(typ, &agent_hex, owner_claim) + ) + .parse() + .expect("assertion header"), + ); + let (status, _, body) = + oneshot_request_full(state, "POST", "/query", &host, headers, body).await; + assert_eq!(status, expected, "{case}: {body:?}"); + } + } + + /// The owner of an agent's verified token decides HTTP admission, and + /// every refusal is NIP-FI's fixed denial on `/query` (bridge), + /// `/buzz/v1/me/sidebar` and git `info/refs`: an owner other than the stored one, a banned + /// owner, and a failed owner-link write. The body never shows which step + /// refused or that a stored owner exists. + /// Mutations: return the relay's own body for token-owner refusals in + /// `enforce_membership`, or git's mismatch body for `OwnerLink::Conflict` + /// → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_refusals_are_the_fixed_nip_fi_denial_on_http() { + use axum::http::{header, StatusCode}; + + let fixture = nip_fi_enforce_test_state().await.expect("local Postgres"); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier())); + let state = Arc::new(state); + + for case in ["admitted", "conflict", "banned", "link_failure"] { + let host = format!("token-owner-{case}-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let (agent, owner) = (Keys::generate(), Keys::generate()); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + let suffix = uuid::Uuid::new_v4().simple().to_string(); + match case { + "conflict" => { + let prior = Keys::generate(); + for key in [&agent, &prior] { + state + .db + .ensure_user(community, key.public_key().as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner( + community, + agent.public_key().as_bytes(), + prior.public_key().as_bytes(), + ) + .await + .expect("seed prior owner")); + } + "banned" => { + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ban_community_member(community, &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + } + "link_failure" => { + // Owner-link writes fail for this community only. + let community = *community.as_uuid(); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "CREATE FUNCTION fail_owner_link_{suffix}() RETURNS trigger AS $$ BEGIN \ + IF NEW.community_id = '{community}' THEN \ + RAISE EXCEPTION 'owner link unavailable'; END IF; \ + RETURN NEW; END $$ LANGUAGE plpgsql; \ + CREATE TRIGGER fail_owner_link_{suffix} \ + BEFORE UPDATE OF agent_owner_pubkey ON users \ + FOR EACH ROW EXECUTE FUNCTION fail_owner_link_{suffix}();" + ))) + .execute(state.db.pool()) + .await + .expect("install owner-link failure trigger"); + } + _ => {} + } + + let repo_root = format!("/git/{}/repo", owner.public_key().to_hex()); + let git_path = format!("{repo_root}/info/refs?service=git-upload-pack"); + let mut outcomes = Vec::new(); + for (method, path, signed_path, body) in [ + ( + "POST", + "/query", + "/query", + br#"[{"kinds":[1],"limit":1}]"#.as_slice(), + ), + ( + "GET", + "/buzz/v1/me/sidebar", + "/buzz/v1/me/sidebar", + b"".as_slice(), + ), + // Git signs the repository root, not the request path. + ("GET", git_path.as_str(), repo_root.as_str(), b"".as_slice()), + ] { + let url = format!("https://{host}{signed_path}"); + let mut headers = make_nip98_headers(&agent, &url, method, body); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {}", signed_agent_assertion(&agent, &owner)) + .parse() + .expect("assertion header"), + ); + let (status, headers, body) = + oneshot_request_full(state.clone(), method, path, &host, headers, body).await; + outcomes.push((path, status, headers, body)); + } + + if case == "link_failure" { + let _ = sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "DROP TRIGGER fail_owner_link_{suffix} ON users; \ + DROP FUNCTION fail_owner_link_{suffix}();" + ))) + .execute(state.db.pool()) + .await; + } + + for (path, status, headers, body) in outcomes { + let (expected_status, expected_body) = match case { + "admitted" => { + // Admitted past auth: git finds no such repository. + let expected = if path.starts_with("/git/") { + StatusCode::NOT_FOUND + } else { + StatusCode::OK + }; + assert_eq!(status, expected, "{case} {path}: {body:?}"); + continue; + } + "link_failure" => ( + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + ), + _ => (StatusCode::FORBIDDEN, "authorization denied\n"), + }; + assert_eq!( + ( + status, + headers[header::CONTENT_TYPE].to_str().unwrap_or_default(), + body.as_ref() + ), + ( + expected_status, + "text/plain; charset=utf-8", + expected_body.as_bytes() + ), + "{case} {path}: {body:?}" + ); + } + } + } + + /// Every HTTP route that admits a NIP-FI agent token passes the token's + /// owner to the membership step: an agent that is itself a relay member + /// but whose token owner is banned gets NIP-FI's fixed denial on each one. + /// With the owner clear, the same requests get past admission. Mutation: + /// pass `None` instead of the token owner on any route (for example + /// media upload) → that row admits → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_reaches_membership_on_every_nip_fi_http_route() { + use axum::http::{header, StatusCode}; + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + use sha2::{Digest, Sha256}; + + let fixture = nip_fi_enforce_test_state().await.expect("local Postgres"); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier())); + let state = Arc::new(state); + + let blossom = |keys: &Keys, verb: &str, host: &str, sha: &str| { + let exp = (nostr::Timestamp::now().as_secs() + 55).to_string(); + let event = EventBuilder::new(Kind::from(24242), "Blossom auth") + .tags([ + Tag::parse(["t", verb]).unwrap(), + Tag::parse(["expiration", exp.as_str()]).unwrap(), + Tag::parse(["server", host]).unwrap(), + Tag::parse(["x", sha]).unwrap(), + ]) + .sign_with_keys(keys) + .expect("sign blossom auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + }; + + for owner_banned in [true, false] { + let host = format!("owner-routes-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let (agent, owner) = (Keys::generate(), Keys::generate()); + for key in [&agent, &owner] { + state + .db + .add_relay_member( + community, + &key.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add member"); + } + if owner_banned { + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ban_community_member(community, &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + } + + let event = EventBuilder::new(Kind::TextNote, "hi") + .sign_with_keys(&agent) + .expect("sign event") + .as_json(); + let filter = br#"[{"kinds":[1],"limit":1}]"#.to_vec(); + let blob = b"blob".to_vec(); + let sha = hex::encode(Sha256::digest(&blob)); + let workflow = uuid::Uuid::new_v4(); + let repo = format!("/git/{}/repo", agent.public_key().to_hex()); + // (method, path, NIP-98 signed path or None for Blossom, body) + let routes: Vec<(&str, String, Option, Vec)> = vec![ + ( + "POST", + "/events".into(), + Some("/events".into()), + event.into_bytes(), + ), + ( + "POST", + "/query".into(), + Some("/query".into()), + filter.clone(), + ), + ("POST", "/count".into(), Some("/count".into()), filter), + ( + "POST", + crate::api::gifs::SEARCH_PATH.into(), + Some(crate::api::gifs::SEARCH_PATH.into()), + br#"{"query":"cat"}"#.to_vec(), + ), + ( + "POST", + crate::api::gifs::SHARE_PATH.into(), + Some(crate::api::gifs::SHARE_PATH.into()), + br#"{"id":"x"}"#.to_vec(), + ), + ( + "POST", + crate::api::profiles::SEARCH_PATH.into(), + Some(crate::api::profiles::SEARCH_PATH.into()), + br#"{"query":"a"}"#.to_vec(), + ), + ( + "GET", + format!("/workflows/{workflow}/runs"), + Some(format!("/workflows/{workflow}/runs")), + vec![], + ), + ( + "GET", + format!("/workflows/{workflow}/runs/{workflow}/approvals"), + Some(format!("/workflows/{workflow}/runs/{workflow}/approvals")), + vec![], + ), + ( + "POST", + "/api/invites".into(), + Some("/api/invites".into()), + b"{}".to_vec(), + ), + ( + "GET", + "/moderation/reports".into(), + Some("/moderation/reports".into()), + vec![], + ), + ( + "GET", + "/moderation/audit".into(), + Some("/moderation/audit".into()), + vec![], + ), + ( + "GET", + "/moderation/restricted".into(), + Some("/moderation/restricted".into()), + vec![], + ), + ("PUT", "/upload".into(), None, blob.clone()), + ("PUT", "/media/upload".into(), None, blob), + ("GET", format!("/media/{sha}.png"), None, vec![]), + ("HEAD", format!("/media/{sha}.png"), None, vec![]), + ( + "GET", + "/buzz/v1/me/sidebar".into(), + Some("/buzz/v1/me/sidebar".into()), + vec![], + ), + ( + "POST", + "/buzz/v1/me/read-state".into(), + Some("/buzz/v1/me/read-state".into()), + br#"{"intents":[]}"#.to_vec(), + ), + // Git signs the repository root, not the request path. + ( + "GET", + format!("{repo}/info/refs?service=git-upload-pack"), + Some(repo.clone()), + vec![], + ), + ( + "POST", + format!("{repo}/git-upload-pack"), + Some(repo.clone()), + vec![], + ), + ( + "POST", + format!("{repo}/git-receive-pack"), + Some(repo.clone()), + vec![], + ), + ( + "GET", + format!("{repo}/default-branch"), + Some(format!("{repo}/default-branch")), + vec![], + ), + ( + "POST", + format!("{repo}/default-branch"), + Some(format!("{repo}/default-branch")), + br#"{"branch":"main"}"#.to_vec(), + ), + ]; + for (method, path, signed, body) in routes { + let mut headers = match signed { + Some(signed) => make_nip98_headers( + &agent, + &format!("https://{host}{signed}"), + method, + &body, + ), + None => { + let verb = if method == "PUT" { "upload" } else { "get" }; + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + blossom(&agent, verb, &host, &sha).parse().unwrap(), + ); + if method == "PUT" { + headers.insert("x-sha-256", sha.parse().unwrap()); + headers.insert(header::CONTENT_TYPE, "image/png".parse().unwrap()); + } + headers + } + }; + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {}", signed_agent_assertion(&agent, &owner)) + .parse() + .expect("assertion header"), + ); + let (status, headers, body) = + oneshot_request_full(state.clone(), method, &path, &host, headers, &body).await; + let denied = status == StatusCode::FORBIDDEN + && headers + .get(header::CONTENT_TYPE) + .is_some_and(|v| v == "text/plain; charset=utf-8") + && (method == "HEAD" || body.as_ref() == b"authorization denied\n"); + assert_eq!( + denied, owner_banned, + "{method} {path} owner_banned={owner_banned}: {status} {body:?}" + ); + } + } + } + + /// The fresh ban reread after the shared membership check also answers a + /// verified agent token with NIP-FI's fixed denial, on `/buzz/v1/me/sidebar` + /// and git `default-branch` settings. The shared check passes; then, at + /// the reread, the agent is banned or the pool is closed. + /// Mutations: return the legacy response for token-bearing requests from + /// `buzz_v1::auth::recheck` or Git settings' `deny_banned_git_principal` + /// → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_ban_reread_is_the_fixed_nip_fi_denial_on_http() { + use crate::nip_fi_test_hooks::http_ban_recheck_hook::arm; + use axum::http::{header, StatusCode}; + + for case in ["banned", "unavailable"] { + for route in ["buzz_v1", "git_settings"] { + let fixture = nip_fi_enforce_test_state().await.expect("local Postgres"); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_relay_membership = true; + config.buzz_v1_enabled = true; + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier())); + let state = Arc::new(state); + let host = format!("ban-reread-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let (agent, owner) = (Keys::generate(), Keys::generate()); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + + let (arrived, release) = arm(community); + let db = state.db.clone(); + let agent_bytes = agent.public_key().to_bytes(); + let owner_bytes = owner.public_key().to_bytes(); + let refuse = tokio::spawn(async move { + arrived.await.expect("ban reread reached"); + if case == "banned" { + db.ban_community_member(community, &agent_bytes, &owner_bytes, None, None) + .await + .expect("ban agent"); + } else { + db.pool().close().await; + } + release.notify_one(); + }); + + let path = match route { + "buzz_v1" => "/buzz/v1/me/sidebar".to_owned(), + _ => format!("/git/{}/repo/default-branch", owner.public_key().to_hex()), + }; + let url = format!("https://{host}{path}"); + let mut headers = make_nip98_headers(&agent, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {}", signed_agent_assertion(&agent, &owner)) + .parse() + .expect("assertion header"), + ); + let (status, headers, body) = + oneshot_request_full(state.clone(), "GET", &path, &host, headers, b"").await; + refuse.await.expect("refusal task"); + + let (expected_status, expected_body) = match case { + "banned" => (StatusCode::FORBIDDEN, "authorization denied\n"), + _ => ( + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + ), + }; + assert_eq!( + ( + status, + headers[header::CONTENT_TYPE].to_str().unwrap_or_default(), + body.as_ref() + ), + ( + expected_status, + "text/plain; charset=utf-8", + expected_body.as_bytes() + ), + "{case} {route}: {body:?}" + ); + } + } + } + + /// A ban caught while publishing over `POST /events`, after the shared + /// membership check passed, answers a verified agent token with NIP-FI's + /// fixed denial at all four checks: ingest's write gate, the HTTP typing + /// indicator's cached gate, and the moderation and relay-admin command + /// handlers' own rereads. At each check the agent is banned or the pool + /// is closed. Controls: the same ban on a token-less request keeps its + /// legacy response (NIP-FI off, since enforce refuses it at admission), + /// and a timed-out agent keeps its timeout response. + /// Mutation: return the legacy error for token requests in + /// `submit_event_authed`, or drop `IngestError::ban` at any check → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_publish_ban_is_the_fixed_nip_fi_denial_on_http() { + use crate::nip_fi_test_hooks::{command_ban_check_hook, write_ban_check_hook}; + use axum::http::{header, StatusCode}; + + let json = "application/json"; + let text = "text/plain; charset=utf-8"; + let banned_json = r#"{"error":"blocked: you are banned from this community"}"#; + // (check, kind, refusal, token): refusal is "banned", "unavailable" + // or "timeout"; token-less rows publish as the member owner. + let mut cases = Vec::new(); + for check in ["write", "typing", "moderation", "relay_admin"] { + for refusal in ["banned", "unavailable"] { + cases.push((check, refusal, true)); + } + cases.push((check, "banned", false)); + } + cases.push(("write", "timeout", true)); + + for (check, refusal, token) in cases { + let fixture = nip_fi_enforce_test_state().await.expect("local Postgres"); + let mut state = (*fixture).clone(); + let config = Arc::make_mut(&mut state.config); + config.require_relay_membership = true; + if !token { + // Enforce refuses a request without a token at admission. + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + } + state.nip_fi_verifier = Some(Arc::new(handler_test_verifier())); + let state = Arc::new(state); + let host = format!("publish-ban-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let (agent, owner) = (Keys::generate(), Keys::generate()); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + let author = if token { &agent } else { &owner }; + + if refusal == "timeout" { + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ensure_user(community, agent.public_key().as_bytes()) + .await + .expect("ensure agent"); + state + .db + .timeout_community_member( + community, + agent.public_key().as_bytes(), + &owner_bytes, + chrono::Utc::now() + chrono::Duration::hours(1), + None, + ) + .await + .expect("time out agent"); + } + + let refuse = (refusal != "timeout").then(|| { + let (arrived, release) = match check { + "moderation" | "relay_admin" => command_ban_check_hook::arm(community), + _ => write_ban_check_hook::arm(community), + }; + let state = state.clone(); + let target = author.public_key().to_bytes(); + let actor = owner.public_key().to_bytes(); + tokio::spawn(async move { + arrived.await.expect("ban check reached"); + if refusal == "banned" { + state + .db + .ban_community_member(community, &target, &actor, None, None) + .await + .expect("ban"); + state.invalidate_restriction_cache(community, &target); + } else { + state.invalidate_restriction_cache(community, &target); + state.db.pool().close().await; + } + release.notify_one(); + }) + }); + + let channel = uuid::Uuid::new_v4().to_string(); + let target = Keys::generate().public_key().to_hex(); + let (kind, tags) = match check { + "typing" => ( + buzz_core::kind::KIND_TYPING_INDICATOR, + vec![["h", &channel]], + ), + "moderation" => (buzz_core::kind::KIND_MODERATION_BAN, vec![["p", &target]]), + "relay_admin" => ( + buzz_core::kind::RELAY_ADMIN_ADD_MEMBER, + vec![["p", &target]], + ), + _ => (1, vec![["h", &channel]]), + }; + let event = EventBuilder::new(Kind::Custom(kind as u16), "") + .tags(tags.into_iter().map(|t| Tag::parse(t).expect("tag"))) + .sign_with_keys(author) + .expect("sign event"); + let body = nostr::JsonUtil::as_json(&event).into_bytes(); + let url = format!("https://{host}/events"); + let mut headers = make_nip98_headers(author, &url, "POST", &body); + if token { + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {}", signed_agent_assertion(&agent, &owner)) + .parse() + .expect("assertion header"), + ); + } + let (status, headers, body) = + oneshot_request_full(state.clone(), "POST", "/events", &host, headers, &body).await; + if let Some(refuse) = refuse { + tokio::time::timeout(std::time::Duration::from_secs(10), refuse) + .await + .unwrap_or_else(|_| { + panic!("{check} {refusal}: ban check never reached: {status} {body:?}") + }) + .expect("refusal task"); + } + + let content_type = headers[header::CONTENT_TYPE].to_str().unwrap_or_default(); + let body = std::str::from_utf8(&body).expect("utf-8 body"); + let case = format!("{check} {refusal} token={token}"); + match (refusal, token) { + ("banned", true) => assert_eq!( + (status, content_type, body), + (StatusCode::FORBIDDEN, text, "authorization denied\n"), + "{case}" + ), + ("unavailable", _) => assert_eq!( + (status, content_type, body), + ( + StatusCode::SERVICE_UNAVAILABLE, + text, + "authorization unavailable\n" + ), + "{case}" + ), + // Legacy: moderation commands answer a ban as a 400 rejection. + ("banned", false) if check == "moderation" => { + assert_eq!( + (status, content_type), + (StatusCode::BAD_REQUEST, json), + "{case}" + ); + assert!(body.contains("banned"), "{case}: {body}"); + } + ("banned", false) => assert_eq!( + (status, content_type, body), + (StatusCode::FORBIDDEN, json, banned_json), + "{case}" + ), + _ => { + assert_eq!( + (status, content_type), + (StatusCode::FORBIDDEN, json), + "{case}" + ); + assert!( + body.starts_with(r#"{"error":"restricted: you are timed out until "#), + "{case}: {body}" + ); + } + } + } + } + async fn buzz_v1_wire_contract(method: &str, path: &str, request_body: &[u8]) { use axum::http::{header, StatusCode}; use buzz_auth::{CrossPodMergeResult, IssuerCapacity, NipFiDenyMap, NipFiMode}; @@ -8809,6 +9614,7 @@ pub(crate) mod postgres_tests { agent.public_key(), fresh_nip98_event_id_bytes(), Some(nostr::Timestamp::now().as_secs()), + None, ) .await; assert!( diff --git a/crates/buzz-relay/src/api/buzz_v1/auth.rs b/crates/buzz-relay/src/api/buzz_v1/auth.rs index 6a3b731f03..920221d856 100644 --- a/crates/buzz-relay/src/api/buzz_v1/auth.rs +++ b/crates/buzz-relay/src/api/buzz_v1/auth.rs @@ -7,6 +7,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; +use buzz_auth::DenialClass; use buzz_core::TenantContext; use serde_json::{json, Value}; use std::sync::Arc; @@ -79,6 +80,7 @@ pub(super) struct Principal { pub(super) tenant: TenantContext, pub(super) actor: nostr::PublicKey, signed_at: Option, + token_owner: Option, } pub(super) async fn authorize( @@ -120,6 +122,7 @@ pub(super) async fn authorize( } })?; let actor = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id, signed_at) = admission.into_extra(); bridge::enforce_http_admission(state, &tenant, &actor) .await @@ -131,6 +134,7 @@ pub(super) async fn authorize( tenant, actor, signed_at, + token_owner, }; recheck(state, headers, &principal).await?; Ok(principal) @@ -143,20 +147,36 @@ pub(super) async fn recheck( ) -> Result<(), Error> { relay_members::enforce_relay_membership( state, - principal.tenant.community(), + &principal.tenant, &principal.actor.to_bytes(), relay_members::extract_auth_tag_header(headers), principal.signed_at, + principal.token_owner, ) .await - .map_err(bridge_error)?; + .map_err(|refusal| match refusal { + relay_members::MembershipRefusal::Api(refusal) => bridge_error(refusal), + // Preserve the shared NIP-FI wire contract, as admission does. + refusal => Error::Admission(Box::new(refusal.into_response())), + })?; + // A verified agent token gets only NIP-FI's fixed denial here too, so a + // ban or outage between the two reads never shows which step refused it. + let refuse = |class, legacy: Error| match principal.token_owner { + Some(_) => Error::Admission(Box::new(crate::nip_fi_core::http_denial(class))), + None => legacy, + }; + #[cfg(test)] + crate::nip_fi_test_hooks::before_http_ban_recheck(principal.tenant.community()).await; let restrictions = state .db .moderation_restriction_state(principal.tenant.community(), &principal.actor.to_bytes()) .await - .map_err(|_| Error::unavailable())?; + .map_err(|_| refuse(DenialClass::AuthorizationUnavailable, Error::unavailable()))?; if restrictions.banned { - return Err(Error::new(StatusCode::FORBIDDEN, "forbidden")); + return Err(refuse( + DenialClass::AuthorizationDenied, + Error::new(StatusCode::FORBIDDEN, "forbidden"), + )); } // Timeouts block posting conversation content, not personal reading. Ok(()) diff --git a/crates/buzz-relay/src/api/git/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index 3602c8a560..00e70998a1 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -196,6 +196,7 @@ async fn authenticate( ) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); bridge::enforce_http_admission(state, &tenant, &pubkey) @@ -207,21 +208,35 @@ async fn authenticate( let tag = relay_members::extract_auth_tag_header(headers); relay_members::enforce_relay_membership( state, - tenant.community(), + &tenant, pubkey.as_bytes(), tag, signed_created_at, + token_owner, ) .await .map_err(IntoResponse::into_response)?; + #[cfg(test)] + crate::nip_fi_test_hooks::before_http_ban_recheck(tenant.community()).await; deny_banned_git_principal( &state.db, tenant.community(), &pubkey, tag, signed_created_at, + token_owner, ) - .await?; + .await + .map_err(|legacy| match token_owner { + // A verified agent token gets only NIP-FI's fixed denial, as smart + // Git transport does, so the response never shows which step refused. + Some(_) => crate::nip_fi_core::http_denial(if legacy.status().is_server_error() { + buzz_auth::DenialClass::AuthorizationUnavailable + } else { + buzz_auth::DenialClass::AuthorizationDenied + }), + None => legacy, + })?; // Admission ignores kind= restrictions by design (NIP-AA). Repository // management must not turn a message-only credential into write authority. // This HTTP operation has no event kind: only kind-unrestricted credentials diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d1ace7b62d..72b34bbe77 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -199,6 +199,7 @@ impl axum::extract::FromRequestParts> for GitAuth { .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event, signed_auth_created_at) = admission.into_extra(); // Relay membership gate (NIP-43). Git cannot carry a standalone @@ -217,6 +218,15 @@ impl axum::extract::FromRequestParts> for GitAuth { legacy } }; + // A verified agent token gets only NIP-FI's fixed denial, so the + // response never shows which step refused it. + let denied = |legacy: Response| { + if token_owner.is_some() { + crate::nip_fi_core::http_denial(buzz_auth::DenialClass::AuthorizationDenied) + } else { + legacy + } + }; #[cfg(test)] crate::nip_fi_test_hooks::before_git_membership(tenant.community()).await; match crate::api::relay_members::check_relay_membership( @@ -225,14 +235,15 @@ impl axum::extract::FromRequestParts> for GitAuth { pubkey.as_bytes(), auth_tag, Some(signed_auth_created_at), + token_owner, ) .await { Ok(crate::api::relay_members::MembershipDecision::Denied) => { warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); - return Err( - (StatusCode::FORBIDDEN, "restricted: not a relay member").into_response() - ); + return Err(denied( + (StatusCode::FORBIDDEN, "restricted: not a relay member").into_response(), + )); } Ok(_) => {} Err(e) => { @@ -253,16 +264,40 @@ impl axum::extract::FromRequestParts> for GitAuth { &pubkey, auth_tag, Some(signed_auth_created_at), + token_owner, ) .await .map_err(|denial| { if denial.status() == StatusCode::SERVICE_UNAVAILABLE { unavailable(denial) } else { - denial + denied(denial) } })?; + // A token owner other than the agent's stored owner is refused; an + // unset stored owner is recorded. + if let Some(owner) = token_owner { + use crate::api::relay_members::{link_agent_owner, OwnerLink}; + match link_agent_owner(state, &tenant, &pubkey, &owner).await { + OwnerLink::Linked => {} + OwnerLink::Conflict => { + return Err(crate::nip_fi_core::http_denial( + buzz_auth::DenialClass::AuthorizationDenied, + )) + } + OwnerLink::Unavailable => { + return Err(unavailable( + ( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + ) + .into_response(), + )) + } + } + } + Ok(GitAuth { pubkey, tenant }) } } @@ -284,6 +319,7 @@ pub(super) async fn deny_banned_git_principal( pubkey: &nostr::PublicKey, auth_tag: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, ) -> Result<(), Response> { let agent = git_restriction_state(db, community, pubkey).await?; @@ -292,11 +328,13 @@ pub(super) async fn deny_banned_git_principal( let owner = if agent.banned { None } else { - crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag, - signed_auth_created_at, - ) + token_owner.or_else(|| { + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag, + signed_auth_created_at, + ) + }) }; let owner_state = match owner { Some(owner) => Some(git_restriction_state(db, community, &owner).await?), @@ -3789,7 +3827,7 @@ mod sec005_postgres_tests { db.ensure_user(community, &member_pk).await.expect("member"); assert!( - deny_banned_git_principal(&db, community, &member.public_key(), None, None) + deny_banned_git_principal(&db, community, &member.public_key(), None, None, None) .await .is_ok(), "precondition: an unbanned member passes the git ban gate" @@ -3800,7 +3838,7 @@ mod sec005_postgres_tests { .expect("ban"); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &member.public_key(), None, None).await, + deny_banned_git_principal(&db, community, &member.public_key(), None, None, None).await, ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -3829,6 +3867,7 @@ mod sec005_postgres_tests { &agent.public_key(), Some(&auth_tag), Some(200), + None, ) .await .is_ok(), @@ -3847,6 +3886,7 @@ mod sec005_postgres_tests { &agent.public_key(), Some(&auth_tag), Some(200), + None, ) .await, ) @@ -3860,7 +3900,7 @@ mod sec005_postgres_tests { // An unattested request from the same agent key is unaffected: the // cascade must follow a verified owner, not punish every agent. assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), None, None) + deny_banned_git_principal(&db, community, &agent.public_key(), None, None, None) .await .is_ok(), "without an attestation there is no owner to inherit from" @@ -3882,8 +3922,15 @@ mod sec005_postgres_tests { let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None, None) - .await, + deny_banned_git_principal( + &db, + community, + &Keys::generate().public_key(), + None, + None, + None, + ) + .await, ) .await; assert_eq!( diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 058293e3ad..d229b83eb1 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -324,6 +324,7 @@ async fn mint_invite_checked( Err(resp) => return resp, }; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); // Replay detection runs after NIP-98+assertion admission (both proofs verified). @@ -334,10 +335,11 @@ async fn mint_invite_checked( // Membership and community ban, same step as the other NIP-98 routes. if let Err(e) = super::relay_members::enforce_relay_membership( &state, - tenant.community(), + &tenant, pubkey.as_bytes(), super::relay_members::extract_auth_tag_header(&headers), signed_created_at, + token_owner, ) .await { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 9c5f3dd93b..2592574b9b 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -132,10 +132,10 @@ fn media_denial(error: MediaError, strictness: BlossomStrictness) -> MediaDenial /// Map a shared membership-step refusal to media's response shape: a failed /// lookup stays 503, a real refusal (non-member or banned) is a policy denial. fn membership_denial( - (status, _): (StatusCode, axum::Json), + refusal: crate::api::relay_members::MembershipRefusal, strictness: BlossomStrictness, ) -> MediaDenial { - let error = if status.is_server_error() { + let error = if refusal.status().is_server_error() { MediaError::AuthorizationUnavailable } else { MediaError::RelayMembershipRequired @@ -355,6 +355,7 @@ pub(crate) async fn upload_blob( Ok(a) => a, Err(resp) => return resp, }; + let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); // Post-admission: validate X-SHA-256 header and hash binding. @@ -385,10 +386,11 @@ pub(crate) async fn upload_blob( let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); if let Err(e) = crate::api::relay_members::enforce_relay_membership( &state, - ctx.tenant.community(), + &ctx.tenant, auth_event.pubkey.as_bytes(), auth_tag, Some(auth_event.created_at.as_secs()), + token_owner, ) .await .map(|_| ()) @@ -691,16 +693,18 @@ async fn enforce_blossom_read_membership( state: &AppState, tenant: &TenantContext, auth_event: &nostr::Event, + token_owner: Option, headers: &HeaderMap, strictness: BlossomStrictness, ) -> Result<(), MediaDenial> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, - tenant.community(), + tenant, auth_event.pubkey.as_bytes(), auth_tag, Some(auth_event.created_at.as_secs()), + token_owner, ) .await .map(|_| ()) @@ -832,9 +836,18 @@ pub(crate) async fn get_blob( Ok(a) => a, Err(resp) => return Ok(resp), }; + let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); // Post-admission: membership gate. - enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers, strictness).await?; + enforce_blossom_read_membership( + &state, + &tenant, + &auth_event, + token_owner, + &req_headers, + strictness, + ) + .await?; serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers) .await .map_err(MediaDenial::from) @@ -1129,8 +1142,17 @@ pub(crate) async fn head_blob( Ok(a) => a, Err(resp) => return Ok(resp), }; + let token_owner = admission.token_owner(); let auth_event = admission.into_extra(); - enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers, strictness).await?; + enforce_blossom_read_membership( + &state, + &tenant, + &auth_event, + token_owner, + &headers, + strictness, + ) + .await?; let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 89ff79f7e4..50946748bb 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -82,8 +82,9 @@ pub(crate) fn parse_query_or_400( pub mod relay_members { use axum::{ http::{HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; + use buzz_auth::DenialClass; use buzz_core::{tenant::CommunityId, TenantContext}; use tracing::{debug, info}; @@ -102,6 +103,43 @@ pub mod relay_members { Denied, } + /// An HTTP relay-membership refusal. + #[derive(Debug)] + pub enum MembershipRefusal { + /// The relay's own JSON refusal. + Api((StatusCode, Json)), + /// NIP-FI's fixed denial. A request carrying a verified agent token + /// gets only this, so the response never shows which step refused it + /// or whether the agent already has a stored owner. + NipFi(DenialClass), + } + + impl MembershipRefusal { + /// The response status. + pub fn status(&self) -> StatusCode { + match self { + Self::Api((status, _)) => *status, + Self::NipFi(class) => StatusCode::from_u16(class.http_status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + } + } + } + + impl From<(StatusCode, Json)> for MembershipRefusal { + fn from(refusal: (StatusCode, Json)) -> Self { + Self::Api(refusal) + } + } + + impl IntoResponse for MembershipRefusal { + fn into_response(self) -> Response { + match self { + Self::Api(refusal) => refusal.into_response(), + Self::NipFi(class) => crate::nip_fi_core::http_denial(class), + } + } + } + /// Return the sole NIP-OA credential header, if one was supplied. /// /// Repeated security-sensitive headers are ambiguous across HTTP stacks, @@ -120,12 +158,15 @@ pub mod relay_members { /// scoped to it so admitting a pubkey to community A never admits it to B. /// A NIP-OA credential is usable only when `signed_auth_created_at` came /// from the already-verified authentication event carrying that request. + /// `token_owner` is the owner named by the request's verified NIP-FI agent + /// assertion; when present it is the only owner considered. pub async fn check_relay_membership( state: &AppState, community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, ) -> Result { check_membership( state, @@ -133,6 +174,7 @@ pub mod relay_members { pubkey_bytes, auth_tag_header, signed_auth_created_at, + token_owner, false, ) .await @@ -147,6 +189,7 @@ pub mod relay_members { pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, ) -> Result { check_membership( state, @@ -154,6 +197,7 @@ pub mod relay_members { pubkey_bytes, auth_tag_header, signed_auth_created_at, + token_owner, true, ) .await @@ -178,6 +222,7 @@ pub mod relay_members { pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, writer: bool, ) -> Result { if !state.config.require_relay_membership { @@ -192,6 +237,20 @@ pub mod relay_members { return Ok(MembershipDecision::Member); } + // A verified agent assertion's issuer already passed this community's + // NIP-FI binding check, so its owner's membership admits the agent + // without NIP-OA. + if let Some(owner) = token_owner { + let owner_is_member = read_membership(state, community, &owner.to_hex(), writer) + .await + .map_err(|e| format!("relay membership check (token owner) failed: {e}"))?; + return Ok(if owner_is_member { + MembershipDecision::ViaOwner(owner) + } else { + MembershipDecision::Denied + }); + } + if state.config.allow_nip_oa_auth { if let Some(tag_json) = auth_tag_header { let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) @@ -243,17 +302,19 @@ pub mod relay_members { /// no NIP-OA tag is present/applicable (open relay without auth tag). pub async fn enforce_relay_membership( state: &AppState, - community: CommunityId, + tenant: &TenantContext, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, - ) -> Result, (StatusCode, Json)> { + token_owner: Option, + ) -> Result, MembershipRefusal> { enforce_membership( state, - community, + tenant, pubkey_bytes, auth_tag_header, signed_auth_created_at, + token_owner, false, ) .await @@ -265,75 +326,117 @@ pub mod relay_members { /// Every other HTTP request reads ban state fresh. pub async fn enforce_relay_membership_ephemeral( state: &AppState, - community: CommunityId, + tenant: &TenantContext, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, - ) -> Result, (StatusCode, Json)> { + token_owner: Option, + ) -> Result, MembershipRefusal> { enforce_membership( state, - community, + tenant, pubkey_bytes, auth_tag_header, signed_auth_created_at, + token_owner, true, ) .await } + /// With a verified agent token, every refusal is NIP-FI's fixed denial. async fn enforce_membership( state: &AppState, - community: CommunityId, + tenant: &TenantContext, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, + cached_ban: bool, + ) -> Result, MembershipRefusal> { + admit_member( + state, + tenant, + pubkey_bytes, + auth_tag_header, + signed_auth_created_at, + token_owner, + cached_ban, + ) + .await + .map_err(|refusal| match token_owner { + None => MembershipRefusal::Api(refusal), + Some(_) if refusal.0.is_server_error() => { + MembershipRefusal::NipFi(DenialClass::AuthorizationUnavailable) + } + Some(_) => MembershipRefusal::NipFi(DenialClass::AuthorizationDenied), + }) + } + + async fn admit_member( + state: &AppState, + tenant: &TenantContext, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + signed_auth_created_at: Option, + token_owner: Option, cached_ban: bool, ) -> Result, (StatusCode, Json)> { - match check_relay_membership( + let community = tenant.community(); + let owner = match check_relay_membership( state, community, pubkey_bytes, auth_tag_header, signed_auth_created_at, + token_owner, ) .await { - Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => { - deny_banned( - state, - community, - pubkey_bytes, - auth_tag_header, - signed_auth_created_at, - cached_ban, - ) - .await?; - Ok(None) + Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => None, + Ok(MembershipDecision::ViaOwner(owner)) => Some(owner), + Ok(MembershipDecision::Denied) => { + return Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + )) } - Ok(MembershipDecision::ViaOwner(owner)) => { - deny_banned( - state, - community, - pubkey_bytes, - auth_tag_header, - signed_auth_created_at, - cached_ban, - ) - .await?; - Ok(Some(owner)) - } - Ok(MembershipDecision::Denied) => Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "relay_membership_required", - "message": "You must be a relay member to access this relay" - })), - )), Err(e) => { tracing::error!("relay membership check errored: {e}"); - Err(super::internal_error(&e)) + return Err(super::internal_error(&e)); + } + }; + deny_banned( + state, + community, + pubkey_bytes, + auth_tag_header, + signed_auth_created_at, + token_owner, + cached_ban, + ) + .await?; + if let Some(token_owner) = token_owner { + let Ok(agent) = nostr::PublicKey::from_slice(pubkey_bytes) else { + return Err(super::internal_error("invalid pubkey for owner link")); + }; + match link_agent_owner(state, tenant, &agent, &token_owner).await { + OwnerLink::Linked => {} + OwnerLink::Conflict => { + return Err(super::api_error(StatusCode::FORBIDDEN, OWNER_MISMATCH)) + } + OwnerLink::Unavailable => { + return Err(super::api_error( + StatusCode::SERVICE_UNAVAILABLE, + crate::handlers::auth::OWNER_LINK_ERROR, + )) + } } } + Ok(owner) } /// Refuse a community-banned principal (own ban or its agent owner's) on @@ -345,6 +448,7 @@ pub mod relay_members { pubkey_bytes: &[u8], auth_tag_header: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, cached: bool, ) -> Result<(), (StatusCode, Json)> { let Ok(pubkey) = nostr::PublicKey::from_slice(pubkey_bytes) else { @@ -356,6 +460,7 @@ pub mod relay_members { pubkey, auth_tag_header, signed_auth_created_at, + token_owner, cached, ) .await @@ -415,14 +520,89 @@ pub mod relay_members { agent: &nostr::PublicKey, owner: &nostr::PublicKey, ) -> bool { + link_agent_owner(state, tenant, agent, owner).await == OwnerLink::Linked + } + + /// Refusal when a verified agent assertion names an owner other than the + /// one already stored for the agent (NIP-FI agent keys). + pub(crate) const OWNER_MISMATCH: &str = "restricted: agent owner mismatch"; + + /// Result of recording an agent's owner. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum OwnerLink { + /// The stored owner is now `owner`. + Linked, + /// The agent already has a different stored owner. + Conflict, + /// The link could not be read or written. + Unavailable, + } + + /// The agent's stored owner compared with `owner`, or `None` when the + /// agent has no stored owner yet. Shadow and enforce both apply the + /// owner-conflict rule through this read; [`link_agent_owner`] writes only + /// when it returns `None`. + pub(crate) async fn stored_owner_link( + state: &AppState, + community: CommunityId, + agent: &nostr::PublicKey, + owner: &nostr::PublicKey, + ) -> Option { + match state + .db + .get_agent_channel_policy(community, agent.as_bytes()) + .await + { + Ok(Some((_, Some(stored)))) if stored.as_slice() == owner.as_bytes() => { + Some(OwnerLink::Linked) + } + Ok(Some((_, Some(_)))) => Some(OwnerLink::Conflict), + Ok(_) => None, + Err(e) => { + tracing::warn!(error = %e, "failed to read agent_owner_pubkey"); + Some(OwnerLink::Unavailable) + } + } + } + + /// [`materialize_nip_oa_owner`], distinguishing a different stored owner + /// from a failed read or write. + pub async fn link_agent_owner( + state: &AppState, + tenant: &TenantContext, + agent: &nostr::PublicKey, + owner: &nostr::PublicKey, + ) -> OwnerLink { let owner_key = ( tenant.community(), agent.to_bytes().to_vec(), owner.to_bytes().to_vec(), ); if state.observer_owner_cache.get(&owner_key) == Some(true) { - return true; + return OwnerLink::Linked; } + let link = match stored_owner_link(state, tenant.community(), agent, owner).await { + Some(link) => link, + None => write_agent_owner(state, tenant, agent, owner).await, + }; + if link == OwnerLink::Linked { + state + .author_type_cache + .insert((tenant.community(), agent.to_bytes().to_vec()), true); + state.observer_owner_cache.insert(owner_key, true); + } + link + } + + /// Record `owner` for an agent that had none when read. The write is + /// first-write-wins, so a concurrent writer that got there first is + /// judged by the same read. + async fn write_agent_owner( + state: &AppState, + tenant: &TenantContext, + agent: &nostr::PublicKey, + owner: &nostr::PublicKey, + ) -> OwnerLink { for (role, pubkey) in [("agent", agent), ("owner", owner)] { match state .db @@ -439,12 +619,11 @@ pub mod relay_members { Ok(false) => {} Err(e) => { tracing::warn!(%role, error = %e, "ensure_user failed during NIP-OA backfill"); - return false; + return OwnerLink::Unavailable; } } } - - let materialized = match state + match state .db .set_agent_owner_for_authorization( tenant.community(), @@ -458,26 +637,17 @@ pub mod relay_members { // without it would only be found by an owner-to-agent lookup // at revoke time; make them reconnect with the owner attached. state.disconnect_unowned_agent_clusterwide(tenant, &agent.to_bytes()); - true + OwnerLink::Linked } - Ok(false) => state - .db - .is_agent_owner(tenant.community(), agent.as_bytes(), owner.as_bytes()) + // Another writer stored an owner first. + Ok(false) => stored_owner_link(state, tenant.community(), agent, owner) .await - .unwrap_or(false), + .unwrap_or(OwnerLink::Unavailable), Err(e) => { tracing::warn!(error = %e, "failed to backfill agent_owner_pubkey"); - false + OwnerLink::Unavailable } - }; - - if materialized { - state - .author_type_cache - .insert((tenant.community(), agent.to_bytes().to_vec()), true); - state.observer_owner_cache.insert(owner_key, true); } - materialized } #[cfg(test)] diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 39a24bbdc3..6c577b42b6 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -1300,12 +1300,17 @@ mod route_integration_tests { let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); let ctrl_b = CommunityConnectionControl::new(cancel_b.clone()); - ctrl_b.set_proven_identity(k_b.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + ctrl_b.set_proven_identity( + k_b.to_bytes().to_vec(), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), + ); let _guard_b = registry.register(uuid::Uuid::new_v4(), community, ctrl_b); let ctrl_unrelated = CommunityConnectionControl::new(cancel_unrelated.clone()); - ctrl_unrelated - .set_proven_identity(k_unrelated.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + ctrl_unrelated.set_proven_identity( + k_unrelated.to_bytes().to_vec(), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), + ); let _guard_unrelated = registry.register(uuid::Uuid::new_v4(), community, ctrl_unrelated); // Deliver k_b — capacity exhausted, no map entry added. @@ -1420,7 +1425,7 @@ mod route_integration_tests { state.conn_manager.set_authenticated_identity( conn_id, pubkey_bytes, - Some(TEST_ISS.to_owned()), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), ); (conn_id, cancel) }; @@ -1485,7 +1490,11 @@ mod route_integration_tests { let cancel_audio_target = CancellationToken::new(); let _audio_target_guard = { let ctrl = CommunityConnectionControl::new(cancel_audio_target.clone()); - audio_post_auth_register(&ctrl, target.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + audio_post_auth_register( + &ctrl, + target.to_bytes().to_vec(), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), + ); audio_registry.register(Uuid::new_v4(), community, ctrl) }; @@ -1498,7 +1507,7 @@ mod route_integration_tests { audio_post_auth_register( &ctrl, unrelated.to_bytes().to_vec(), - Some(TEST_ISS.to_owned()), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), ); audio_registry.register(Uuid::new_v4(), community, ctrl) }; @@ -1551,7 +1560,7 @@ mod route_integration_tests { audio_post_auth_register( &ctrl, target2.to_bytes().to_vec(), - Some(TEST_ISS.to_owned()), + Some(crate::nip_fi_core::NipFiSession::new(TEST_ISS, None)), ); audio_registry.register(Uuid::new_v4(), community, ctrl) }; @@ -2111,14 +2120,14 @@ mod route_integration_tests { state.conn_manager.set_authenticated_identity( conn_id, key.to_bytes().to_vec(), - Some(issuer.to_owned()), + Some(crate::nip_fi_core::NipFiSession::new(issuer, None)), ); let audio = CancellationToken::new(); let ctrl = CommunityConnectionControl::new(audio.clone()); crate::audio::handler::audio_post_auth_register( &ctrl, key.to_bytes().to_vec(), - Some(issuer.to_owned()), + Some(crate::nip_fi_core::NipFiSession::new(issuer, None)), ); let _audio_guard = state diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index 5fb6494bfd..1bc43dfa11 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -82,6 +82,7 @@ async fn authorize_workflow_read( ) .await?; let pubkey = *admission.proven_pubkey(); + let token_owner = admission.token_owner(); let (event_id_bytes, signed_created_at) = admission.into_extra(); bridge::enforce_http_admission(state, &tenant, &pubkey) @@ -95,10 +96,11 @@ async fn authorize_workflow_read( let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, - tenant.community(), + &tenant, &pubkey_bytes, auth_tag, signed_created_at, + token_owner, ) .await .map_err(|e| e.into_response())?; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 7a09f97505..eb204b8499 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -416,16 +416,16 @@ where } } -/// Records the NIP-42-proven pubkey and its admitting NIP-FI issuer on an audio +/// Records the NIP-42-proven pubkey and its admitting NIP-FI session on an audio /// control after successful auth so the issuer-scoped NIP-FI disconnect scan /// can reach audio sockets alongside relay peers. Shared by the handler and /// tests that register audio sockets through the production seam. pub(crate) fn audio_post_auth_register( control: &CommunityConnectionControl, pubkey_bytes: Vec, - nip_fi_issuer: Option, + nip_fi: Option, ) { - control.set_proven_identity(pubkey_bytes, nip_fi_issuer); + control.set_proven_identity(pubkey_bytes, nip_fi); } /// Runs the audio session, then holds the read half for the client's Close @@ -751,6 +751,9 @@ async fn run_active_audio_connection( if let Some(shadow) = control.nip_fi_shadow() { shadow.observe_pairing(pubkey); + shadow + .observe_owner(&state, tenant.community(), pubkey) + .await; } // NIP-FI key pairing [FI-INV-05]: unconditional, using the shared production // seam. When an assertion was presented at upgrade, the proven NIP-42 key @@ -798,19 +801,13 @@ async fn run_active_audio_connection( pubkey_bytes.clone(), nip_fi_assertion .as_ref() - .map(|a| a.identity().issuer().to_owned()), + .map(crate::nip_fi_core::NipFiSession::from_assertion), ); #[cfg(test)] crate::nip_fi_test_hooks::before_deny_set_check(tenant.community()).await; if let Some(assertion) = &nip_fi_assertion { - if let (Some(asserted_key), Some(deny_map)) = - (assertion.asserted_key(), state.nip_fi_deny_map.as_deref()) - { - if deny_map.is_denied( - assertion.identity().issuer(), - &asserted_key, - chrono::Utc::now(), - ) { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if crate::nip_fi_core::assertion_denied(deny_map, assertion, chrono::Utc::now()) { warn!( channel_id = %channel_id, pubkey = %pubkey_hex, @@ -827,8 +824,44 @@ async fn run_active_audio_connection( #[cfg(test)] crate::nip_fi_test_hooks::after_deny_set_check_passed(tenant.community()).await; - // NIP-OA owner of a delegated agent: from relay membership on a closed - // relay, or straight from the self-proving auth tag on an open one. + // Owner of a delegated agent: the verified NIP-FI token owner, else the + // NIP-OA owner from relay membership on a closed relay, or straight from + // the self-proving auth tag on an open one. + let token_owner = nip_fi_assertion + .as_ref() + .and_then(|assertion| assertion.owner()); + // A token owner is linked during membership, so refuse a banned principal + // or token owner before that write, as root AUTH does. The final check + // below rereads bans. + let early_ban = match token_owner { + Some(_) => { + crate::handlers::auth::community_ban_outcome( + &state, + tenant.community(), + pubkey, + auth_tag_json.as_deref(), + Some(signed_auth_created_at), + token_owner, + false, + ) + .await + } + None => crate::handlers::auth::BanOutcome::Clear, + }; + if let Some(denial) = crate::handlers::auth::ban_admission_denial(early_ban) { + shadow_attempt.refused(); + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, reason = denial.reason, "audio: denied at ban check"); + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, + nip_fi_assertion.is_some(), + denial.class, + serde_json::json!({"type": "error", "message": denial.reason}), + ) + .await; + return; + } let mut nip_oa_owner = None; let relay_refusal = match crate::api::relay_members::check_relay_membership( &state, @@ -836,6 +869,7 @@ async fn run_active_audio_connection( pubkey.as_bytes(), auth_tag_json.as_deref(), Some(signed_auth_created_at), + token_owner, ) .await { @@ -846,11 +880,13 @@ async fn run_active_audio_connection( Ok(decision) => { nip_oa_owner = match decision { crate::api::relay_members::MembershipDecision::ViaOwner(owner) => Some(owner), - _ => crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag_json.as_deref(), - Some(signed_auth_created_at), - ), + _ => token_owner.or_else(|| { + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag_json.as_deref(), + Some(signed_auth_created_at), + ) + }), }; None } @@ -921,7 +957,7 @@ async fn run_active_audio_connection( if let Some(owner) = nip_oa_owner { #[cfg(test)] crate::nip_fi_test_hooks::before_owner_permit(tenant.community()).await; - let linked = { + let link = { let _owner_permit = match audio_gate.acquire_effect().await { Ok(permit) => permit, Err(crate::nip_fi_gate::SessionExpired) => { @@ -937,17 +973,17 @@ async fn run_active_audio_connection( return; } }; - crate::api::relay_members::materialize_nip_oa_owner(&state, &tenant, &pubkey, &owner) - .await + crate::api::relay_members::link_agent_owner(&state, &tenant, &pubkey, &owner).await }; - if !linked { + if let Some(denial) = crate::handlers::auth::owner_link_denial(link, token_owner.is_some()) + { shadow_attempt.refused(); - warn!(channel_id = %channel_id, pubkey = %pubkey_hex, nip_oa_owner = %owner.to_hex(), - "audio: NIP-OA owner could not be materialized, denying"); + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, owner = %owner.to_hex(), ?link, + "audio: agent owner could not be linked, denying"); let deny_frame = authorization_exit_frame( nip_fi_assertion.is_some(), - buzz_auth::DenialClass::AuthorizationUnavailable, - serde_json::json!({"type": "error", "message": crate::handlers::auth::OWNER_LINK_ERROR}), + denial.class, + serde_json::json!({"type": "error", "message": denial.reason}), ); crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; return; @@ -980,6 +1016,7 @@ async fn run_active_audio_connection( pubkey, auth_tag_json.as_deref(), Some(signed_auth_created_at), + token_owner, ) .await } @@ -4626,7 +4663,11 @@ mod tests { let control = CommunityConnectionControl::new(CancellationToken::new()); let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); control.set_terminal_frame_sender(terminal_tx); - audio_post_auth_register(&control, key.clone(), Some("test-issuer".to_owned())); + audio_post_auth_register( + &control, + key.clone(), + Some(crate::nip_fi_core::NipFiSession::new("test-issuer", None)), + ); let _guard = registry.register(uuid::Uuid::new_v4(), community, control.clone()); let mut wire = Vec::new(); @@ -7153,16 +7194,34 @@ mod tests { #[tokio::test] async fn w_audio_deny_active_key_refused_at_post_registration_check() { + w_audio_deny_active_refused_body(None).await; + } + + // An owner-only deny entry refuses the agent's audio AUTH at the same + // check. Mutation: dropping the owner arm of `assertion_denied` admits it. + #[tokio::test] + async fn w_audio_owner_deny_refuses_agent_at_post_registration_check() { + w_audio_deny_active_refused_body(Some(nostr::Keys::generate())).await; + } + + async fn w_audio_deny_active_refused_body(owner: Option) { use buzz_auth::VerifiedAssertion; use chrono::{Duration, Utc}; use std::sync::Arc; let key = nostr::Keys::generate(); let deadline = Utc::now() + Duration::hours(1); - // Assertion with "test-issuer"; the key IS in the deny map. - let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + // Assertion with "test-issuer"; the key, or the agent's owner, IS in + // the deny map. + let mut assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + let target = owner + .as_ref() + .map_or(key.public_key(), nostr::Keys::public_key); + if owner.is_some() { + assertion = assertion.with_owner_for_test(target); + } - let state = audio_deny_state(Some(&key.public_key())).await; + let state = audio_deny_state(Some(&target)).await; let tenant = buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), @@ -7306,6 +7365,20 @@ mod tests { #[tokio::test] async fn w_audio_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { + w_audio_deny_straddle_body(None).await; + } + + // The owner's disconnect in the same window: the close scan finds the + // agent's audio socket by its recorded owner, and the check refuses it on + // the owner's entry. Mutation: dropping the owner in + // `NipFiSession::from_assertion`, or the owner arm of the audio + // registry's match, leaves the scan at 0. + #[tokio::test] + async fn w_audio_owner_straddle_closes_agent_and_refuses_admission() { + w_audio_deny_straddle_body(Some(nostr::Keys::generate())).await; + } + + async fn w_audio_deny_straddle_body(owner: Option) { // Arms `before_deny_set_check` — fires AFTER audio_post_auth_register and // BEFORE the is_denied call. Entry starts absent; inserted during the window. // The deny check finds it and closes the connection. @@ -7315,7 +7388,14 @@ mod tests { let key = nostr::Keys::generate(); let deadline = Utc::now() + Duration::hours(1); - let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + let mut assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + // The deny and the close scan target the owner when there is one. + let target = owner + .as_ref() + .map_or(key.public_key(), nostr::Keys::public_key); + if owner.is_some() { + assertion = assertion.with_owner_for_test(target); + } // Build state with empty deny map (key not denied yet). let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( @@ -7451,12 +7531,8 @@ mod tests { // Insert the deny entry — handler is between registration and check. let until = Utc::now() + Duration::seconds(3600); - let merge = deny_map_for_insert.merge_cross_pod_deny( - "test-issuer", - &key.public_key(), - until, - Utc::now(), - ); + let merge = + deny_map_for_insert.merge_cross_pod_deny("test-issuer", &target, until, Utc::now()); assert!( matches!(merge, buzz_auth::CrossPodMergeResult::Merged), "W_audio_deny_straddle: deny entry must be inserted during hook window" @@ -7472,7 +7548,7 @@ mod tests { // Mutation evidence (Mut-C: move hook before audio_post_auth_register): // disconnect_nip_fi returns 0 (not yet registered) → assertion panics. // Causally falsifies the registration-before-check invariant. - let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let pubkey_bytes = target.to_bytes().to_vec(); let closed = state .community_connections .disconnect_nip_fi("test-issuer", &pubkey_bytes); @@ -7842,6 +7918,97 @@ mod tests { } } + // Audio judges an agent assertion by its token owner where enforce + // does: a banned token owner or a stored owner other than the token + // owner records `owner/denied`; a clean owner records nothing before + // the channel refusal that ends this join. Mutation: deleting audio's + // `observe_owner` call records nothing for every case. + #[test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + fn shadow_audio_auth_records_token_owner_refusals() { + use crate::nip_fi_shadow_session::tests::{ + owned_assertion, shadow_records, shadow_state, + }; + #[derive(Clone, Copy, Debug)] + enum Case { + Clean, + Banned, + OtherStored, + } + let cases: [(Case, &[&str]); 3] = [ + (Case::Clean, &[]), + (Case::Banned, &["owner/denied"]), + (Case::OtherStored, &["owner/denied"]), + ]; + for (case, expected) in cases { + let records = shadow_records(async move { + let mut state = (*audio_test_state_real_db() + .await + .expect("postgres-ci provides a database")) + .clone(); + state.nip_fi_deny_map = shadow_state(None).await.nip_fi_deny_map; + state.nip_fi_shadow_sessions = std::sync::Arc::default(); + let state = std::sync::Arc::new(state); + let id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("audio-owner-{}.example", id.simple())) + .execute(state.db.pool()) + .await + .expect("insert community"); + let community = buzz_core::tenant::CommunityId::from_uuid(id); + let (agent, owner) = (nostr::Keys::generate(), nostr::Keys::generate()); + let owner_bytes = owner.public_key().to_bytes(); + match case { + Case::Banned => { + state + .db + .ban_community_member( + community, + &owner_bytes, + &owner_bytes, + None, + None, + ) + .await + .expect("ban owner"); + } + Case::OtherStored => { + let prior = nostr::Keys::generate().public_key(); + for key in [agent.public_key(), prior] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner( + community, + agent.public_key().as_bytes(), + prior.as_bytes() + ) + .await + .expect("seed stored owner")); + } + Case::Clean => {} + } + let session = crate::nip_fi_shadow_session::ShadowSession::start( + &state, + "audio", + crate::nip_fi_shadow::BOUND.into(), + owned_assertion(agent.public_key(), owner.public_key()), + chrono::Utc::now(), + ); + shadow_audio_auth_in(state, &agent, std::sync::Arc::clone(&session), community) + .await; + session.expire(); + }); + assert_eq!(records, expected, "{case:?}"); + } + } + // A relay-membership refusal retires the session where it is decided: // the deadline firing while its refusal frame is held records nothing. // Mutation: deleting that site's `refused()` records @@ -14836,6 +15003,155 @@ mod tests { bystander_server.abort(); } + /// Audio AUTH of a NIP-FI agent on a closed relay, with no NIP-OA: a + /// member token owner admits a non-member agent and records the + /// stored owner; a non-member owner, a banned owner (even for an agent + /// that is itself a member) and a different stored owner are each + /// refused with `authorization denied`. A banned owner is refused + /// before its owner link is written. + /// Mutations: drop the token-owner arm of `check_membership`, treat + /// `OwnerLink::Conflict` as linked, or drop audio's early ban check + /// → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn audio_token_owner_decides_agent_admission() { + #[derive(Clone, Copy, Debug)] + enum Case { + MemberOwner, + NonMemberOwner, + BannedOwnerOfMemberAgent, + OtherStoredOwner, + } + let mut state = (*audio_test_state_real_db() + .await + .expect("postgres-ci provides a database")) + .clone(); + let mut config = (*state.config).clone(); + config.require_relay_membership = true; + state.config = std::sync::Arc::new(config); + let state = std::sync::Arc::new(state); + for case in [ + Case::MemberOwner, + Case::NonMemberOwner, + Case::BannedOwnerOfMemberAgent, + Case::OtherStoredOwner, + ] { + let (tenant, channel_id, _member) = seed_audio_fixture(state.db.pool()).await; + let community = tenant.community(); + let (agent, owner) = (nostr::Keys::generate(), nostr::Keys::generate()); + add_channel_member(state.db.pool(), &tenant, channel_id, &agent).await; + if !matches!(case, Case::NonMemberOwner) { + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + } + match case { + Case::BannedOwnerOfMemberAgent => { + state + .db + .add_relay_member( + community, + &agent.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add agent"); + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ban_community_member(community, &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + } + Case::OtherStoredOwner => { + let prior = nostr::Keys::generate(); + for key in [&agent, &prior] { + state + .db + .ensure_user_for_authorization( + community, + key.public_key().as_bytes(), + ) + .await + .expect("seed user"); + } + assert!(state + .db + .set_agent_owner_for_authorization( + community, + agent.public_key().as_bytes(), + prior.public_key().as_bytes(), + ) + .await + .expect("seed prior owner")); + } + Case::MemberOwner | Case::NonMemberOwner => {} + } + let assertion = buzz_auth::VerifiedAssertion::for_test( + Some(agent.public_key()), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ) + .with_owner_for_test(owner.public_key()); + + let (mut client, server) = + start_audio_auth(&state, tenant, channel_id, &agent, None, Some(assertion)) + .await; + let first = next_audio_text(&mut client).await; + server.abort(); + if matches!(case, Case::MemberOwner) { + assert!(!first.contains("\"error\""), "{case:?}: {first}"); + assert!(state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("read stored owner")); + } else { + // Every refusal of a NIP-FI session is the one + // `authorization denied` frame. + assert_eq!( + first, + serde_json::json!({ + "message": "restricted: authorization denied", + "type": "restricted" + }) + .to_string(), + "{case:?}" + ); + } + if matches!(case, Case::BannedOwnerOfMemberAgent) { + // The ban is refused before the owner link is written. + assert!( + !state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("read stored owner"), + "a banned token owner must not be linked" + ); + } + } + } + /// Audio refuses an agent whose owner link cannot be recorded (the /// agent is already linked to a different owner): revoking its NIP-OA /// owner could not find it. diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 0ceb8b67b6..0abeb15218 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -436,6 +436,8 @@ pub fn sanitized_reason_for(err: &crate::handlers::ingest::IngestError) -> Sanit E::CanvasConflict(_) => SanitizedReason::Invalid, E::AuthFailed(_) => SanitizedReason::Restricted, E::Internal(_) => SanitizedReason::ServerError, + // A ban refusal is sanitized as the error every transport sees. + E::Ban { legacy, .. } => sanitized_reason_for(legacy), } } diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index ad58fd032f..b9826168a9 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1479,6 +1479,19 @@ pub(crate) mod tests { pub(crate) fn test_conn( auth: AuthState, nip_fi_assertion: Option, + ) -> TestConn { + test_conn_in( + auth, + nip_fi_assertion, + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + ) + } + + /// [`test_conn`] whose tenant is `community`. + pub(crate) fn test_conn_in( + auth: AuthState, + nip_fi_assertion: Option, + community: buzz_core::tenant::CommunityId, ) -> TestConn { let (send_tx, send_rx) = mpsc::channel(4); let (ctrl_tx, ctrl_rx) = mpsc::channel(8); @@ -1486,10 +1499,7 @@ pub(crate) mod tests { let cancel = CancellationToken::new(); let conn = ConnectionState { conn_id: Uuid::new_v4(), - tenant: TenantContext::resolved( - buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), - "test.local".to_string(), - ), + tenant: TenantContext::resolved(community, "test.local".to_string()), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), auth_state: StdMutex::new(auth), subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index b035d09479..474edf227a 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -72,47 +72,76 @@ pub(crate) async fn community_ban_outcome( pubkey: nostr::PublicKey, auth_tag_json: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, cached: bool, ) -> BanOutcome { - async fn lookup( - state: &AppState, - community: buzz_core::CommunityId, - key: &nostr::PublicKey, - cached: bool, - ) -> BanOutcome { - let restriction = if cached { - state - .restriction_state_cached(community, key.as_bytes()) - .await - } else { - state - .db - .moderation_restriction_state(community, key.as_bytes()) - .await - }; - match restriction { - Ok(restriction) if restriction.banned => BanOutcome::Banned, - Ok(_) => BanOutcome::Clear, - Err(e) => { - warn!(pubkey = %key.to_hex(), error = %e, "ban-state DB lookup failed, denying (fail-closed)"); - BanOutcome::DbError - } - } - } - let outcome = lookup(state, community, &pubkey, cached).await; + let outcome = principal_ban_outcome(state, community, &pubkey, cached).await; if outcome != BanOutcome::Clear { return outcome; } - match crate::api::relay_members::extract_nip_oa_owner( - pubkey.as_bytes(), - auth_tag_json, - signed_auth_created_at, - ) { - Some(owner) => lookup(state, community, &owner, cached).await, + // A NIP-FI agent assertion's verified owner cascades unconditionally, + // whether the agent is a member itself or admitted through that owner. + let owner = token_owner.or_else(|| { + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag_json, + signed_auth_created_at, + ) + }); + match owner { + Some(owner) => principal_ban_outcome(state, community, &owner, cached).await, None => BanOutcome::Clear, } } +/// Ban verdict for one key, without any owner cascade. +async fn principal_ban_outcome( + state: &AppState, + community: buzz_core::CommunityId, + key: &nostr::PublicKey, + cached: bool, +) -> BanOutcome { + let restriction = if cached { + state + .restriction_state_cached(community, key.as_bytes()) + .await + } else { + state + .db + .moderation_restriction_state(community, key.as_bytes()) + .await + }; + match restriction { + Ok(restriction) if restriction.banned => BanOutcome::Banned, + Ok(_) => BanOutcome::Clear, + Err(e) => { + warn!(pubkey = %key.to_hex(), error = %e, "ban-state DB lookup failed, denying (fail-closed)"); + BanOutcome::DbError + } + } +} + +/// Shadow only: the refusal enforce's token-owner rules would give `agent`, +/// whose verified agent assertion names `owner`. Enforce bans the agent with +/// its token owner and refuses a token owner other than the stored one. +/// Reads only: nothing is linked or recorded on the socket. +pub(crate) async fn token_owner_denial( + state: &AppState, + community: buzz_core::CommunityId, + agent: nostr::PublicKey, + owner: nostr::PublicKey, +) -> Option { + let ban = principal_ban_outcome(state, community, &owner, false).await; + if let Some(denial) = ban_admission_denial(ban) { + return Some(denial.class); + } + // No stored owner yet: enforce would link this one. + let link = crate::api::relay_members::stored_owner_link(state, community, &agent, &owner) + .await + .unwrap_or(crate::api::relay_members::OwnerLink::Linked); + owner_link_denial(link, true).map(|denial| denial.class) +} + fn ban_denial(outcome: BanOutcome) -> Option<(&'static str, &'static str, AuthOutcome)> { match outcome { BanOutcome::Clear => None, @@ -137,6 +166,21 @@ pub(crate) struct AdmissionDenial { pub(crate) class: buzz_auth::DenialClass, } +/// The refusal for a ban outcome other than [`BanOutcome::Clear`]. +pub(crate) fn ban_admission_denial(ban: BanOutcome) -> Option { + let (metric, reason, outcome) = ban_denial(ban)?; + let class = match ban { + BanOutcome::DbError => buzz_auth::DenialClass::AuthorizationUnavailable, + _ => buzz_auth::DenialClass::AuthorizationDenied, + }; + Some(AdmissionDenial { + metric, + reason, + outcome, + class, + }) +} + /// Final ban and relay-membership verdict for an authenticating socket. /// /// Callers bind the socket to `pubkey` first (the registry a ban's or @@ -151,6 +195,7 @@ pub(crate) async fn final_admission_denial( pubkey: nostr::PublicKey, auth_tag_json: Option<&str>, signed_auth_created_at: Option, + token_owner: Option, ) -> Option { let ban = community_ban_outcome( state, @@ -158,20 +203,12 @@ pub(crate) async fn final_admission_denial( pubkey, auth_tag_json, signed_auth_created_at, + token_owner, false, ) .await; - if let Some((metric, reason, outcome)) = ban_denial(ban) { - let class = match ban { - BanOutcome::DbError => buzz_auth::DenialClass::AuthorizationUnavailable, - _ => buzz_auth::DenialClass::AuthorizationDenied, - }; - return Some(AdmissionDenial { - metric, - reason, - outcome, - class, - }); + if let Some(denial) = ban_admission_denial(ban) { + return Some(denial); } match crate::api::relay_members::check_relay_membership_authoritative( state, @@ -179,6 +216,7 @@ pub(crate) async fn final_admission_denial( pubkey.as_bytes(), auth_tag_json, signed_auth_created_at, + token_owner, ) .await { @@ -242,6 +280,31 @@ pub(crate) async fn admitted_owner( /// link, revoking the owner cannot find the agent's live sockets. pub(crate) const OWNER_LINK_ERROR: &str = "error: internal error recording agent owner"; +/// Socket refusal for an owner link that did not land. A different stored +/// owner is `authorization denied` only for a NIP-FI token owner; for NIP-OA +/// it stays the link error it has always been. +pub(crate) fn owner_link_denial( + link: crate::api::relay_members::OwnerLink, + token_owner: bool, +) -> Option { + use crate::api::relay_members::OwnerLink; + match link { + OwnerLink::Linked => None, + OwnerLink::Conflict if token_owner => Some(AdmissionDenial { + metric: "agent_owner_mismatch", + reason: crate::api::relay_members::OWNER_MISMATCH, + outcome: AuthOutcome::NotRelayMember, + class: buzz_auth::DenialClass::AuthorizationDenied, + }), + OwnerLink::Conflict | OwnerLink::Unavailable => Some(AdmissionDenial { + metric: "agent_owner_link_error", + reason: OWNER_LINK_ERROR, + outcome: AuthOutcome::RelayMembershipCheckError, + class: buzz_auth::DenialClass::AuthorizationUnavailable, + }), + } +} + /// NIP-FI class for a failed NIP-42 proof: a bad proof is client evidence /// (`evidence rejected`); only a relay-internal verifier failure is /// `authorization unavailable`. @@ -373,6 +436,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let shadow = conn.community_control.nip_fi_shadow(); if let Some(shadow) = shadow { shadow.observe_pairing(pubkey); + shadow + .observe_owner(&state, conn.tenant.community(), pubkey) + .await; } // NIP-FI key pairing [FI-INV-05]: immediately after successful // verify_auth_event, before community-ban/allowlist/membership gates. @@ -402,6 +468,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // a ban on its cryptographically-proven owner cascades to the agent // (owner ban ⇒ agents banned; agent ban is agent-only). The owner is // extracted from the self-proving auth tag with no DB round-trip. + // The owner named by a verified NIP-FI agent assertion. It takes + // the place of a NIP-OA owner at the ban, membership and owner-link + // steps below. + let token_owner = conn + .nip_fi_assertion + .as_ref() + .and_then(|assertion| assertion.owner()); { // Fail closed on a DB error, but distinguish it from a real ban: // a transient blip must deny (never let a banned principal @@ -415,6 +488,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: pubkey, auth_tag_json.as_deref(), Some(signed_auth_created_at), + token_owner, false, ) .await; @@ -518,6 +592,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: pubkey.as_bytes(), auth_tag_json.as_deref(), Some(signed_auth_created_at), + token_owner, ) .await; if let Err(e) = &membership { @@ -569,7 +644,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // (needed for observer frame auth). Only runs on open relays — on closed // relays, enforce_relay_membership already handles NIP-OA delegation. // No feature flag needed: NIP-OA is cryptographically self-proving. - let nip_oa_owner = nip_oa_owner.or_else(|| { + let nip_oa_owner = nip_oa_owner.or(token_owner).or_else(|| { if !state.config.require_relay_membership && auth_tag_json.is_some() { crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), @@ -617,31 +692,25 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // owner finds the agent's sockets through it, so an agent whose // link cannot be recorded is refused rather than admitted // unrevocable. + // A token owner that differs from the stored owner is refused + // (NIP-FI agent keys); an unset stored owner is recorded. if let Some(owner) = nip_oa_owner { - if !crate::api::relay_members::materialize_nip_oa_owner( + let link = crate::api::relay_members::link_agent_owner( &state, &conn.tenant, &pubkey, &owner, ) - .await - { + .await; + if let Some(denial) = owner_link_denial(link, token_owner.is_some()) { warn!( conn_id = %conn_id, agent = %pubkey.to_hex(), - nip_oa_owner = %owner.to_hex(), - "NIP-OA owner could not be materialized, denying" - ); - deny_admission( - &conn, - &event_id_hex, - AdmissionDenial { - metric: "agent_owner_link_error", - reason: OWNER_LINK_ERROR, - outcome: AuthOutcome::RelayMembershipCheckError, - class: buzz_auth::DenialClass::AuthorizationUnavailable, - }, + owner = %owner.to_hex(), + ?link, + "agent owner could not be linked, denying" ); + deny_admission(&conn, &event_id_hex, denial); return; } auth_ctx.agent_owner_pubkey = Some(owner); @@ -661,15 +730,16 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: .conn_manager .set_admitted_owner(conn_id, owner.to_bytes()); } - // The bind carries the admitting NIP-FI issuer, so a concurrent - // `disconnect_nip_fi` scan that sees the pubkey also sees its - // issuer; the deny-set check after `authenticate` closes the rest. + // The bind carries the admitting NIP-FI issuer and agent owner, so + // a concurrent `disconnect_nip_fi` scan that sees the pubkey also + // sees its session; the deny-set check after `authenticate` closes + // the rest. state.conn_manager.set_authenticated_identity( conn_id, pubkey.to_bytes().to_vec(), conn.nip_fi_assertion .as_ref() - .map(|a| a.identity().issuer().to_owned()), + .map(crate::nip_fi_core::NipFiSession::from_assertion), ); match admitted_owner(&state, conn.tenant.community(), pubkey, nip_oa_owner).await { Ok(Some(owner)) => state.conn_manager.set_admitted_owner(conn_id, owner), @@ -685,6 +755,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: pubkey, auth_tag_json.as_deref(), Some(signed_auth_created_at), + token_owner, ) .await { @@ -716,14 +787,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: crate::nip_fi_test_hooks::before_deny_set_check(conn.tenant.community()).await; // [FI-TRACE-DENY-SET] if let Some(assertion) = &conn.nip_fi_assertion { - if let (Some(asserted_key), Some(deny_map)) = - (assertion.asserted_key(), state.nip_fi_deny_map.as_deref()) - { - if deny_map.is_denied( - assertion.identity().issuer(), - &asserted_key, - chrono::Utc::now(), - ) { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if crate::nip_fi_core::assertion_denied(deny_map, assertion, chrono::Utc::now()) + { warn!( conn_id = %conn_id, pubkey = %pubkey.to_hex(), @@ -1148,6 +1214,15 @@ mod tests { /// A pending connection whose FI assertion (if any) names `key`, so /// pairing always passes and a later gate is the one that denies. fn new(with_fi_assertion: bool) -> Self { + Self::build(with_fi_assertion, None) + } + + /// A pending connection whose FI agent assertion names `owner`. + fn new_agent(owner: nostr::PublicKey) -> Self { + Self::build(true, Some(owner)) + } + + fn build(with_fi_assertion: bool, owner: Option) -> Self { use tokio::sync::mpsc; let key = Keys::generate(); let challenge = format!("fi-invariant-{}", uuid::Uuid::new_v4()); @@ -1156,10 +1231,14 @@ mod tests { let (terminal_ctrl_tx, terminal_rx) = mpsc::channel(1); let cancel = tokio_util::sync::CancellationToken::new(); let assertion = with_fi_assertion.then(|| { - buzz_auth::VerifiedAssertion::for_test( + let assertion = buzz_auth::VerifiedAssertion::for_test( Some(key.public_key()), vec![chrono::Utc::now() + chrono::Duration::hours(1)], - ) + ); + match owner { + Some(owner) => assertion.with_owner_for_test(owner), + None => assertion, + } }); let conn = std::sync::Arc::new(crate::connection::ConnectionState { conn_id: uuid::Uuid::new_v4(), @@ -1406,7 +1485,7 @@ mod tests { state.conn_manager.set_authenticated_identity( conn.conn_id, pubkey.clone(), - Some("test-issuer".to_owned()), + Some(crate::nip_fi_core::NipFiSession::new("test-issuer", None)), ); assert_eq!( state.conn_manager.disconnect_nip_fi("test-issuer", &pubkey), @@ -1455,6 +1534,23 @@ mod tests { asserted: nostr::PublicKey, signer: &Keys, lifetime: chrono::Duration, + ) -> (crate::connection::tests::TestConn, nostr::Event) { + let assertion = buzz_auth::VerifiedAssertion::for_test( + Some(asserted), + vec![chrono::Utc::now() + lifetime], + ); + let nil = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()); + shadow_root_conn_with(state, nil, assertion, signer, None) + } + + /// [`shadow_root_conn`] in `community` observing `assertion`, its AUTH + /// event carrying `auth_tag` when given. + pub(super) fn shadow_root_conn_with( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + assertion: buzz_auth::VerifiedAssertion, + signer: &Keys, + auth_tag: Option<&str>, ) -> (crate::connection::tests::TestConn, nostr::Event) { use chrono::Utc; let challenge = "shadow-root-challenge".to_owned(); @@ -1462,9 +1558,7 @@ mod tests { challenge: challenge.clone(), started_at: Instant::now(), }; - let t = crate::connection::tests::test_conn(pending, None); - let assertion = - buzz_auth::VerifiedAssertion::for_test(Some(asserted), vec![Utc::now() + lifetime]); + let t = crate::connection::tests::test_conn_in(pending, None, community); let session = crate::nip_fi_shadow_session::ShadowSession::start( state, "ws", @@ -1473,11 +1567,14 @@ mod tests { Utc::now(), ); t.conn.community_control.attach_nip_fi_shadow(Some(session)); - let event = EventBuilder::new(Kind::Authentication, "") + let mut builder = EventBuilder::new(Kind::Authentication, "") .tag(Tag::parse(["relay", "ws://test.local"]).unwrap()) - .tag(Tag::parse(["challenge", &challenge]).unwrap()) - .sign_with_keys(signer) - .unwrap(); + .tag(Tag::parse(["challenge", &challenge]).unwrap()); + if let Some(tag) = auth_tag { + let fields: Vec = serde_json::from_str(tag).unwrap(); + builder = builder.tag(Tag::parse(fields).unwrap()); + } + let event = builder.sign_with_keys(signer).unwrap(); (t, event) } @@ -1499,6 +1596,28 @@ mod tests { assert_eq!(records, ["pairing/denied"]); } + // A failed owner read records `owner` as unavailable, as enforce fails + // closed, and changes nothing else. The state's database is unreachable. + // Mutation: deleting root's `observe_owner` call loses the record. + #[test] + fn shadow_root_auth_records_unavailable_owner_read() { + use crate::nip_fi_shadow_session::tests::{owned_assertion, shadow_records, shadow_state}; + let records = shadow_records(async { + let state = std::sync::Arc::new(shadow_state(None).await); + let agent = Keys::generate(); + let nil = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()); + let (t, event) = shadow_root_conn_with( + &state, + nil, + owned_assertion(agent.public_key(), Keys::generate().public_key()), + &agent, + None, + ); + handle_auth(event, std::sync::Arc::clone(&t.conn), state).await; + }); + assert_eq!(records, ["owner/unavailable"]); + } + const HOUR: chrono::Duration = chrono::Duration::hours(1); /// Fires the connection's shadow deadline once its AUTH has finished, @@ -1602,6 +1721,98 @@ mod tests { assert!(records.is_empty(), "{records:?}"); } + /// Shadow judges an agent assertion by the owner enforce uses: the + /// token owner, even when the AUTH also carries a NIP-OA tag naming + /// another owner. A banned token owner or a stored owner other than + /// the token owner records `owner/denied`, while shadow still admits + /// as Off; a clean, matching owner records `admit/admit`. + /// Mutation: deleting root's `observe_owner` call records + /// `admit/admit` for every case. + #[test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + fn shadow_root_auth_records_token_owner_refusals() { + use crate::nip_fi_shadow_session::tests::{ + owned_assertion, shadow_records, shadow_state, + }; + #[derive(Clone, Copy, Debug)] + enum Case { + CleanOwner, + BannedOwner, + OtherStoredOwner, + BannedOwnerWithCleanNipOa, + NipOaOwnerStored, + } + for (case, expected) in [ + (Case::CleanOwner, "admit/admit"), + (Case::BannedOwner, "owner/denied"), + (Case::OtherStoredOwner, "owner/denied"), + (Case::BannedOwnerWithCleanNipOa, "owner/denied"), + (Case::NipOaOwnerStored, "owner/denied"), + ] { + let mut ok_true = false; + let records = shadow_records(async { + let mut state = (*auth_test_state_real_db_expect().await).clone(); + state.nip_fi_deny_map = shadow_state(None).await.nip_fi_deny_map; + let state = std::sync::Arc::new(state); + let (agent, owner, nip_oa) = + (Keys::generate(), Keys::generate(), Keys::generate()); + let community = seeded_community(&state).await; + let (mut t, event) = super::shadow_root_conn_with( + &state, + community, + owned_assertion(agent.public_key(), owner.public_key()), + &agent, + matches!( + case, + Case::BannedOwnerWithCleanNipOa | Case::NipOaOwnerStored + ) + .then(|| { + buzz_sdk::nip_oa::compute_auth_tag(&nip_oa, &agent.public_key(), "") + .unwrap() + }) + .as_deref(), + ); + let owner_bytes = owner.public_key().to_bytes(); + let stored = match case { + Case::OtherStoredOwner => Some(Keys::generate().public_key()), + Case::NipOaOwnerStored => Some(nip_oa.public_key()), + _ => None, + }; + if matches!(case, Case::BannedOwner | Case::BannedOwnerWithCleanNipOa) { + state + .db + .ban_community_member(community, &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + } + if let Some(stored) = stored { + for key in [agent.public_key(), stored] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner( + community, + agent.public_key().as_bytes(), + stored.as_bytes() + ) + .await + .expect("seed stored owner")); + } + handle_auth(event, std::sync::Arc::clone(&t.conn), state).await; + while let Ok(WsMessage::Text(text)) = t.send_rx.try_recv() { + ok_true |= is_ok_true(text.as_str()); + } + }); + assert!(ok_true, "{case:?}: shadow admits as Off"); + assert_eq!(records, [expected], "{case:?}"); + } + } + /// A community whose row exists, so bans and users can reference it. async fn seeded_community( state: &crate::state::AppState, @@ -2179,6 +2390,18 @@ mod tests { ); } + async fn insert_test_community( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + ) { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community.as_uuid()) + .bind(format!("w-deny-{}.example", community.as_uuid().simple())) + .execute(state.db.pool()) + .await + .expect("insert community"); + } + async fn auth_test_state_real_db_expect() -> std::sync::Arc { use std::sync::Arc; let db_url = crate::test_support::database_url(); @@ -2227,7 +2450,22 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { - w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body().await; + w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body(None) + .await; + } + + // The owner's disconnect in the same window: the close scan finds the + // agent's socket by its recorded owner, and the check refuses it on + // the owner's entry. Mutation: dropping the owner in + // `NipFiSession::from_assertion`, or the owner arm of the root + // registry's match, leaves the scan at 0. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_owner_straddle_closes_agent_and_refuses_admission() { + w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body(Some( + Keys::generate(), + )) + .await; } // ── W_deny_straddle: deny entry inserted in window between registration and check @@ -2255,7 +2493,9 @@ mod tests { // Causally falsifies the registration-before-check invariant. // // Runs in PG lane on wrapper DB (same constraint as W1: ban-check is fail-closed). - async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body() { + async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body( + owner: Option, + ) { use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; use chrono::{Duration, Utc}; use std::collections::HashMap; @@ -2268,7 +2508,12 @@ mod tests { let key = Keys::generate(); let deadline = Utc::now() + Duration::hours(1); // `for_test` produces issuer = "test-issuer". - let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + let mut assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + // The deny and the close scan target the owner when there is one. + let target = owner.as_ref().map_or(key.public_key(), Keys::public_key); + if owner.is_some() { + assertion = assertion.with_owner_for_test(target); + } let challenge = "w-deny-straddle-challenge".to_string(); let (send_tx, mut send_rx) = mpsc::channel::(8); @@ -2325,6 +2570,9 @@ mod tests { let deny_map_for_insert = Arc::clone(&deny_map); state.nip_fi_deny_map = Some(deny_map); let state = Arc::new(state); + // An agent assertion's owner is linked before the deny-set check, + // and that link needs the community row. + insert_test_community(&state, community).await; // Register the connection with conn_manager so set_authenticated_pubkey // (called by handle_auth after NIP-42 succeeds) stores the pubkey — the @@ -2370,12 +2618,8 @@ mod tests { // Handler is now AFTER set_authenticated_pubkey (registered) and BEFORE // the deny check. Insert the deny entry into the live map. let until = Utc::now() + Duration::seconds(3600); - let merge = deny_map_for_insert.merge_cross_pod_deny( - "test-issuer", - &key.public_key(), - until, - Utc::now(), - ); + let merge = + deny_map_for_insert.merge_cross_pod_deny("test-issuer", &target, until, Utc::now()); assert!( matches!(merge, buzz_auth::CrossPodMergeResult::Merged), "W_deny_straddle: deny entry must be inserted during the hook window" @@ -2386,7 +2630,7 @@ mod tests { // to the concurrent close scan — the normative invariant [FI-TRACE-DENY-SET]. // With the deny entry live, the scan finds exactly one session matching this // pubkey and closes it. - let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let pubkey_bytes = target.to_bytes().to_vec(); let closed = state .conn_manager .disconnect_nip_fi("test-issuer", &pubkey_bytes); @@ -2626,13 +2870,22 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn w_deny_pre_registration_denied_by_handler_check() { - w_deny_pre_registration_body(true).await; + w_deny_pre_registration_body(true, None).await; + } + + // An owner-only deny entry refuses the agent at the handler's check. + // Mutation: dropping the owner arm of `assertion_denied` admits the + // agent with OK(true). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_pre_registration_owner_denied_by_handler_check() { + w_deny_pre_registration_body(true, Some(Keys::generate())).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn w_deny_pre_registration_clean_key_admitted() { - w_deny_pre_registration_body(false).await; + w_deny_pre_registration_body(false, None).await; } // ── W_deny_pre_registration: the deny entry exists BEFORE the connection @@ -2644,7 +2897,7 @@ mod tests { // // Mutation: bypass the `[FI-TRACE-DENY-SET]` check in `handle_auth` → // the denied case sends OK(true), stays uncancelled, enqueues no frame. - async fn w_deny_pre_registration_body(deny_self: bool) { + async fn w_deny_pre_registration_body(deny_self: bool, owner: Option) { use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; use chrono::{Duration, Utc}; use std::collections::HashMap; @@ -2656,7 +2909,12 @@ mod tests { let key = Keys::generate(); let deadline = Utc::now() + Duration::hours(1); // `for_test` produces issuer = "test-issuer". - let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + let mut assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + // An agent assertion's deny entry targets its owner. + let target = owner.as_ref().map_or(key.public_key(), Keys::public_key); + if owner.is_some() { + assertion = assertion.with_owner_for_test(target); + } let challenge = "w-deny-pre-registration-challenge".to_string(); let (send_tx, mut send_rx) = mpsc::channel::(8); @@ -2700,7 +2958,7 @@ mod tests { }], )); let denied_key = if deny_self { - key.public_key() + target } else { Keys::generate().public_key() }; @@ -2713,6 +2971,9 @@ mod tests { assert!(matches!(merge, buzz_auth::CrossPodMergeResult::Merged)); state.nip_fi_deny_map = Some(deny_map); let state = Arc::new(state); + // An agent assertion's owner is linked before the deny-set check, + // and that link needs the community row. + insert_test_community(&state, community).await; state.conn_manager.register( conn.conn_id, @@ -3254,6 +3515,127 @@ mod tests { } } + /// Root WS AUTH of a NIP-FI agent on a closed relay, with no NIP-OA: + /// a member token owner admits a non-member agent and records the + /// stored owner; a non-member owner, a banned owner (even for an agent + /// that is itself a member) and a different stored owner are each + /// refused with `authorization denied`. + /// The banned-owner case is also caught by the stored owner link + /// admission records, so the ban helper's own token-owner arm is + /// isolated on HTTP (`token_owner_ban_refuses_direct_member_agent_on_http`). + /// Mutations: drop the token-owner arm of `check_membership`, or treat + /// `OwnerLink::Conflict` as linked → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn fi_token_owner_decides_agent_admission_on_root_ws() { + #[derive(Clone, Copy, Debug)] + enum Case { + MemberOwner, + NonMemberOwner, + BannedOwnerOfMemberAgent, + OtherStoredOwner, + } + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("PostgreSQL must be available"); + let state = state_with_pool(pool, |c| c.require_relay_membership = true).await; + for case in [ + Case::MemberOwner, + Case::NonMemberOwner, + Case::BannedOwnerOfMemberAgent, + Case::OtherStoredOwner, + ] { + let owner = Keys::generate(); + let mut harness = AuthHarness::new_agent(owner.public_key()); + let tenant = harness.conn.tenant.clone(); + let community = tenant.community(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community.as_uuid()) + .bind(format!( + "fi-token-owner-{}.example", + community.as_uuid().simple() + )) + .execute(state.db.pool()) + .await + .expect("insert community"); + let agent = harness.key.public_key(); + if !matches!(case, Case::NonMemberOwner) { + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + } + match case { + Case::BannedOwnerOfMemberAgent => { + state + .db + .add_relay_member( + community, + &agent.to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add agent"); + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ban_community_member(community, &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + } + Case::OtherStoredOwner => { + let prior = Keys::generate().public_key(); + for key in [&agent, &prior] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner(community, agent.as_bytes(), prior.as_bytes()) + .await + .expect("seed prior owner")); + } + Case::MemberOwner | Case::NonMemberOwner => {} + } + + harness.run(harness.auth_event(), state.clone()).await; + + if matches!(case, Case::MemberOwner) { + let mut ok_true = false; + while let Ok(WsMessage::Text(t)) = harness.send_rx.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&t).unwrap(); + ok_true |= frame[0] == "OK" && frame[2] == true; + } + assert!(ok_true, "{case:?}: agent must receive OK(true)"); + assert!(matches!( + harness.conn.auth_state_snapshot(), + AuthState::Authenticated(_) + )); + assert!(state + .db + .is_agent_owner(community, agent.as_bytes(), owner.public_key().as_bytes()) + .await + .expect("read stored owner")); + } else { + harness.assert_fi_terminal(buzz_auth::DenialClass::AuthorizationDenied); + } + } + } + /// Carl's coverage gap: a matching-key FI connection whose pubkey is /// banned gets the terminal `authorization denied` NOTICE and the /// socket closes; off mode keeps `OK false` on ctrl, then closes. diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 51fa1c24fb..2c5a8ff5b5 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -1442,6 +1442,7 @@ mod postgres_tests { } Err(IngestError::AuthFailed(message)) => panic!("unexpected auth failure: {message}"), Err(IngestError::Internal(message)) => panic!("unexpected internal failure: {message}"), + Err(error @ IngestError::Ban { .. }) => panic!("unexpected ban refusal: {error:?}"), Ok(_) => panic!("expected revision parsing to fail"), } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ed68cf9b22..6ee506f9e5 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -685,8 +685,9 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc ( + let (message, reason) = match e.into_legacy() { + // `into_legacy` never returns `Ban`; it would fail closed here. + IngestError::Internal(_) | IngestError::Ban { .. } => ( "error: internal error checking restriction state".to_string(), "error", ), @@ -821,11 +822,15 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc (message, "invalid"), IngestError::CanvasConflict(message) => (message, "invalid"), IngestError::AuthFailed(message) => (message, "auth"), IngestError::Internal(message) => (message, "error"), + // `into_legacy` never returns `Ban`; it would fail closed here. + IngestError::Ban { .. } => { + ("error: internal server error".to_string(), "error") + } }; reject(reason); conn.send(RelayMessage::ok(&event_id_hex, false, &message)); @@ -885,12 +890,16 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc { + let e = e.into_legacy(); // Sanitize internal errors — don't leak DB/system details over WS. let (msg, reason) = match &e { IngestError::Rejected(m) => (m.clone(), "invalid"), IngestError::CanvasConflict(m) => (m.clone(), "invalid"), IngestError::AuthFailed(m) => (m.clone(), "auth"), - IngestError::Internal(_) => ("error: internal server error".to_string(), "error"), + // `into_legacy` never returns `Ban`; it would fail closed here. + IngestError::Internal(_) | IngestError::Ban { .. } => { + ("error: internal server error".to_string(), "error") + } }; reject(reason); conn.send(RelayMessage::ok(&event_id_hex, false, &msg)); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index b44b44c025..4985951019 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -451,6 +451,45 @@ pub enum IngestError { AuthFailed(String), /// Server error — WS: OK false, HTTP: 500. Internal(String), + /// A durable community-ban refusal, or the failed lookup that decides + /// one. This is an admission refusal, so HTTP answers a verified agent + /// token with NIP-FI's fixed denial. WS and token-less HTTP get `legacy` + /// exactly as before. + Ban { + /// Whether the principal is banned or the lookup failed. + refusal: BanRefusal, + /// The error every non-NIP-FI response is built from. + legacy: Box, + }, +} + +/// Which admission outcome an [`IngestError::Ban`] carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BanRefusal { + /// The principal or its owner is banned. + Banned, + /// The restriction lookup failed, so no ban decision could be made. + Unavailable, +} + +impl IngestError { + /// A ban refusal answered with `legacy` outside NIP-FI agent HTTP. + pub(crate) fn ban(refusal: BanRefusal, legacy: IngestError) -> Self { + Self::Ban { + refusal, + legacy: Box::new(legacy.into_legacy()), + } + } + + /// The error every pre-existing transport mapping sees: a ban refusal + /// becomes its legacy error, everything else is unchanged. Never returns + /// [`Self::Ban`], because [`Self::ban`] never nests one. + pub(crate) fn into_legacy(self) -> Self { + match self { + Self::Ban { legacy, .. } => *legacy, + other => other, + } + } } /// Map the durable community write-fence lookup onto the ingest error taxonomy. @@ -478,11 +517,16 @@ fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestEr match error { // Same wire prefix and HTTP status (403) as every other durable // restriction refusal — see the write-path gate below and `auth.rs`. - RelayAdminError::Banned => { - IngestError::AuthFailed("blocked: you are banned from this community".to_string()) - } + RelayAdminError::Banned => IngestError::ban( + BanRefusal::Banned, + IngestError::AuthFailed("blocked: you are banned from this community".to_string()), + ), RelayAdminError::Rejected(reason) => IngestError::Rejected(format!("invalid: {reason}")), - RelayAdminError::Internal(reason) => IngestError::Internal(format!("error: {reason}")), + // Only the restriction lookup produces this arm. + RelayAdminError::Internal(reason) => IngestError::ban( + BanRefusal::Unavailable, + IngestError::Internal(format!("error: {reason}")), + ), } } @@ -2305,8 +2349,9 @@ fn write_restriction_denial( now: chrono::DateTime, ) -> Option { if restriction.banned { - return Some(IngestError::AuthFailed( - "blocked: you are banned from this community".to_string(), + return Some(IngestError::ban( + BanRefusal::Banned, + IngestError::AuthFailed("blocked: you are banned from this community".to_string()), )); } match restriction.muted_until { @@ -2335,14 +2380,19 @@ pub(crate) async fn enforce_write_restriction( kind: u32, pubkey: &nostr::PublicKey, ) -> Result<(), IngestError> { + #[cfg(test)] + crate::nip_fi_test_hooks::before_write_ban_check(tenant.community()).await; let restriction = state .db .moderation_restriction_state(tenant.community(), pubkey.as_bytes()) .await .map_err(|e| { - IngestError::Internal(format!( - "error: internal error checking restriction state: {e}" - )) + IngestError::ban( + BanRefusal::Unavailable, + IngestError::Internal(format!( + "error: internal error checking restriction state: {e}" + )), + ) })?; write_restriction_denial(kind, &restriction, Utc::now()).map_or(Ok(()), Err) } @@ -2357,13 +2407,18 @@ pub(crate) async fn enforce_cached_write_restriction( kind: u32, pubkey: &nostr::PublicKey, ) -> Result<(), IngestError> { + #[cfg(test)] + crate::nip_fi_test_hooks::before_write_ban_check(tenant.community()).await; let restriction = state .restriction_state_cached(tenant.community(), pubkey.as_bytes()) .await .map_err(|e| { - IngestError::Internal(format!( - "error: internal error checking restriction state: {e}" - )) + IngestError::ban( + BanRefusal::Unavailable, + IngestError::Internal(format!( + "error: internal error checking restriction state: {e}" + )), + ) })?; write_restriction_denial(kind, &restriction, Utc::now()).map_or(Ok(()), Err) } @@ -2683,9 +2738,7 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event) - .await - .map_err(IngestError::Rejected)?; + super::moderation_commands::handle_moderation_command(tenant, state, &event).await?; return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -3909,7 +3962,14 @@ mod postgres_tests { #[test] fn relay_admin_ban_maps_to_blocked_auth_failure() { let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Banned); - match mapped { + assert!(matches!( + mapped, + IngestError::Ban { + refusal: BanRefusal::Banned, + .. + } + )); + match mapped.into_legacy() { IngestError::AuthFailed(msg) => { assert_eq!(msg, "blocked: you are banned from this community"); } @@ -3944,7 +4004,14 @@ mod postgres_tests { let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Internal( "internal error checking restriction state: pool timed out".to_string(), )); - match mapped { + assert!(matches!( + mapped, + IngestError::Ban { + refusal: BanRefusal::Unavailable, + .. + } + )); + match mapped.into_legacy() { IngestError::Internal(msg) => { assert!( msg.starts_with("error: "), @@ -7333,9 +7400,17 @@ mod postgres_tests { .expect("ban owner"); match enforce_write_restriction(&state, &tenant, KIND_REPORT, &agent.public_key()).await { - Err(IngestError::AuthFailed(msg)) => { - assert_eq!(msg, "blocked: you are banned from this community") - } + Err( + err @ IngestError::Ban { + refusal: BanRefusal::Banned, + .. + }, + ) => match err.into_legacy() { + IngestError::AuthFailed(msg) => { + assert_eq!(msg, "blocked: you are banned from this community") + } + other => panic!("owner ban must keep its legacy AuthFailed, got {other:?}"), + }, other => panic!("banned owner's agent must not report, got {other:?}"), } @@ -7365,15 +7440,20 @@ mod postgres_tests { .expect("ban owner"); for keys in [&owner, &agent] { - let (status, body) = crate::api::relay_members::enforce_relay_membership( - &state, - tenant.community(), - keys.public_key().as_bytes(), - None, - None, - ) - .await - .expect_err("banned principal (or its agent) must be refused"); + let crate::api::relay_members::MembershipRefusal::Api((status, body)) = + crate::api::relay_members::enforce_relay_membership( + &state, + &tenant, + keys.public_key().as_bytes(), + None, + None, + None, + ) + .await + .expect_err("banned principal (or its agent) must be refused") + else { + panic!("without a token the relay's own refusal is returned"); + }; assert_eq!(status, axum::http::StatusCode::FORBIDDEN); assert_eq!( body.0["error"], @@ -7388,15 +7468,208 @@ mod postgres_tests { .expect("unban owner"); crate::api::relay_members::enforce_relay_membership( &state, - tenant.community(), + &tenant, owner.public_key().as_bytes(), None, None, + None, ) .await .expect("unbanned member is admitted"); } + /// A closed-relay copy of the ingest state. + fn closed_relay(state: &Arc) -> Arc { + let mut state = (**state).clone(); + let mut config = (*state.config).clone(); + config.require_relay_membership = true; + state.config = Arc::new(config); + Arc::new(state) + } + + /// A community with a member owner and an agent key with no stored owner. + async fn token_owner_fixture( + state: &crate::state::AppState, + label: &str, + ) -> (TenantContext, nostr::Keys, nostr::Keys) { + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + state + .db + .add_relay_member( + community, + &owner.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add owner"); + (TenantContext::resolved(community, &host), owner, agent) + } + + async fn http_membership( + state: &crate::state::AppState, + tenant: &TenantContext, + agent: &nostr::Keys, + token_owner: Option, + ) -> Result, (axum::http::StatusCode, String)> { + crate::api::relay_members::enforce_relay_membership( + state, + tenant, + agent.public_key().as_bytes(), + None, + None, + token_owner, + ) + .await + .map_err(|refusal| { + let status = refusal.status(); + let body = match refusal { + crate::api::relay_members::MembershipRefusal::Api((_, body)) => { + body.0["error"].as_str().unwrap_or("").to_owned() + } + crate::api::relay_members::MembershipRefusal::NipFi(class) => { + class.http_body().to_owned() + } + }; + (status, body) + }) + } + + /// A non-member agent is admitted on HTTP through its NIP-FI token owner's + /// membership, with no NIP-OA, and its stored owner is recorded. Removing + /// the owner from the community refuses it. + /// Mutation: drop the token-owner arm of `check_membership` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_membership_admits_non_member_agent_on_http() { + let state = closed_relay(&ingest_state().await); + let (tenant, owner, agent) = token_owner_fixture(&state, "token-owner").await; + assert_eq!( + http_membership(&state, &tenant, &agent, None).await, + Err(( + axum::http::StatusCode::FORBIDDEN, + "relay_membership_required".to_owned() + )) + ); + assert_eq!( + http_membership(&state, &tenant, &agent, Some(owner.public_key())).await, + Ok(Some(owner.public_key())) + ); + assert!(state + .db + .is_agent_owner( + tenant.community(), + agent.public_key().as_bytes(), + owner.public_key().as_bytes() + ) + .await + .expect("read stored owner")); + + state + .db + .remove_relay_member( + tenant.community(), + &owner.public_key().to_hex(), + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("remove owner"); + assert_eq!( + http_membership(&state, &tenant, &agent, Some(owner.public_key())).await, + Err(( + axum::http::StatusCode::FORBIDDEN, + "authorization denied\n".to_owned() + )) + ); + } + + /// The token owner's ban reaches an agent that is itself a member, on a + /// closed and an open relay, with no stored owner link. + /// Mutation: ignore `token_owner` in `community_ban_outcome` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_ban_refuses_direct_member_agent_on_http() { + let open = ingest_state().await; + for state in [closed_relay(&open), open] { + let (tenant, owner, agent) = token_owner_fixture(&state, "token-owner-ban").await; + state + .db + .add_relay_member( + tenant.community(), + &agent.public_key().to_hex(), + "member", + None, + &nostr::Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("add agent"); + let owner_bytes = owner.public_key().to_bytes(); + state + .db + .ban_community_member(tenant.community(), &owner_bytes, &owner_bytes, None, None) + .await + .expect("ban owner"); + + assert_eq!( + http_membership(&state, &tenant, &agent, None).await, + Ok(None), + "control: without the token owner the agent is admitted" + ); + assert_eq!( + http_membership(&state, &tenant, &agent, Some(owner.public_key())).await, + Err(( + axum::http::StatusCode::FORBIDDEN, + "authorization denied\n".to_owned() + )) + ); + } + } + + /// A token owner other than the agent's stored owner is refused on HTTP. + /// Mutation: treat `OwnerLink::Conflict` as linked → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn token_owner_other_than_stored_owner_is_refused_on_http() { + let state = closed_relay(&ingest_state().await); + let (tenant, owner, agent) = token_owner_fixture(&state, "token-owner-conflict").await; + let prior = nostr::Keys::generate(); + for keys in [&agent, &prior] { + state + .db + .ensure_user(tenant.community(), keys.public_key().as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner( + tenant.community(), + agent.public_key().as_bytes(), + prior.public_key().as_bytes(), + ) + .await + .expect("seed prior owner")); + + assert_eq!( + http_membership(&state, &tenant, &agent, Some(owner.public_key())).await, + Err(( + axum::http::StatusCode::FORBIDDEN, + "authorization denied\n".to_owned() + )) + ); + } + /// Revoking a member's live access also closes their agents' sockets, and /// no one else's. /// Mutation: disconnect only the target pubkey → RED. diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index e10bbfed0c..a5a3587b1a 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -93,9 +93,11 @@ pub async fn handle_moderation_command( tenant: &TenantContext, state: &Arc, event: &Event, -) -> Result<(), String> { - let kind = event.kind.as_u16() as u32; +) -> Result<(), super::ingest::IngestError> { + use super::ingest::{BanRefusal, IngestError}; let actor = event.pubkey.to_bytes().to_vec(); + #[cfg(test)] + crate::nip_fi_test_hooks::before_command_ban_check(tenant.community()).await; // A ban is an admission boundary, not only a WebSocket-auth check. HTTP // NIP-98 requests and already-authenticated sockets can reach this handler @@ -105,8 +107,29 @@ pub async fn handle_moderation_command( .db .moderation_restriction_state(tenant.community(), &actor) .await - .map_err(|e| error(format!("database error checking restriction state: {e}")))?; - ensure_actor_not_banned(&restriction)?; + .map_err(|e| { + IngestError::ban( + BanRefusal::Unavailable, + IngestError::Rejected(error(format!( + "database error checking restriction state: {e}" + ))), + ) + })?; + ensure_actor_not_banned(&restriction) + .map_err(|e| IngestError::ban(BanRefusal::Banned, IngestError::Rejected(e)))?; + execute_moderation_command(tenant, state, event, &actor) + .await + .map_err(IngestError::Rejected) +} + +/// Run an admitted moderation command. +async fn execute_moderation_command( + tenant: &TenantContext, + state: &Arc, + event: &Event, + actor: &[u8], +) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; // Freshness: reject stale/replayed commands (they are never stored). let event_ts = event.created_at.as_secs() as i64; @@ -122,11 +145,11 @@ pub async fn handle_moderation_command( } match kind { - KIND_MODERATION_BAN => handle_ban(tenant, state, event, &actor).await, - KIND_MODERATION_UNBAN => handle_unban(tenant, state, event, &actor).await, - KIND_MODERATION_TIMEOUT => handle_timeout(tenant, state, event, &actor).await, - KIND_MODERATION_UNTIMEOUT => handle_untimeout(tenant, state, event, &actor).await, - KIND_MODERATION_RESOLVE_REPORT => handle_resolve(tenant, state, event, &actor).await, + KIND_MODERATION_BAN => handle_ban(tenant, state, event, actor).await, + KIND_MODERATION_UNBAN => handle_unban(tenant, state, event, actor).await, + KIND_MODERATION_TIMEOUT => handle_timeout(tenant, state, event, actor).await, + KIND_MODERATION_UNTIMEOUT => handle_untimeout(tenant, state, event, actor).await, + KIND_MODERATION_RESOLVE_REPORT => handle_resolve(tenant, state, event, actor).await, other => Err(invalid(format!( "unexpected moderation command kind: {other}" ))), diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 5830c52750..16648595f8 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -192,6 +192,8 @@ pub(super) async fn handle_relay_admin_event( // // This gate wraps execution rather than opening it so no future early // return inside the command body can precede it. + #[cfg(test)] + crate::nip_fi_test_hooks::before_command_ban_check(tenant.community()).await; let restriction = state .db .moderation_restriction_state(tenant.community(), &event.pubkey.to_bytes()) @@ -1246,8 +1248,8 @@ mod postgres_tests { let error = result.expect_err("the audit failure is reported"); assert!( - error.contains("audit"), - "reports the audit failure: {error}" + matches!(&error, crate::handlers::ingest::IngestError::Rejected(m) if m.contains("audit")), + "reports the audit failure: {error:?}" ); assert!( state @@ -1316,7 +1318,7 @@ mod postgres_tests { &tenant, &state, &event, ) .await - .unwrap_or_else(|e| panic!("kind {} must apply: {e}", event.kind.as_u16())); + .unwrap_or_else(|e| panic!("kind {} must apply: {e:?}", event.kind.as_u16())); assert_eq!( state.restriction_cache.get(&key), None, diff --git a/crates/buzz-relay/src/nip_fi_core.rs b/crates/buzz-relay/src/nip_fi_core.rs index 1b1d887df7..9e6b8b5278 100644 --- a/crates/buzz-relay/src/nip_fi_core.rs +++ b/crates/buzz-relay/src/nip_fi_core.rs @@ -182,6 +182,51 @@ pub(crate) fn asserted_key_matches( assertion.asserted_key() == Some(proven_pubkey) } +/// Whether the deny set holds `(iss, k)` or, for an agent assertion, +/// `(iss, owner)` (NIP-FI.md, admission step 7). +pub(crate) fn assertion_denied( + deny_map: &impl crate::nip_fi_http::HttpDenyMap, + assertion: &VerifiedAssertion, + now: chrono::DateTime, +) -> bool { + let issuer = assertion.identity().issuer(); + [assertion.asserted_key(), assertion.owner()] + .into_iter() + .flatten() + .any(|key| deny_map.is_denied(issuer, &key, now)) +} + +/// The NIP-FI facts a live session is registered under: its admitting issuer +/// and, for an agent assertion, the verified owner. +#[derive(Clone)] +pub(crate) struct NipFiSession { + issuer: String, + owner: Option<[u8; 32]>, +} + +impl NipFiSession { + pub(crate) fn new(issuer: impl Into, owner: Option<[u8; 32]>) -> Self { + Self { + issuer: issuer.into(), + owner, + } + } + + pub(crate) fn from_assertion(assertion: &VerifiedAssertion) -> Self { + Self::new( + assertion.identity().issuer(), + assertion.owner().map(|owner| owner.to_bytes()), + ) + } + + /// An admin disconnect of `(issuer, target)` reaches this session when + /// its proven key or its recorded owner is `target`. + pub(crate) fn matches(&self, issuer: &str, proven: &[u8], target: &[u8]) -> bool { + self.issuer == issuer + && (proven == target || self.owner.is_some_and(|owner| owner[..] == *target)) + } +} + // ── Transport extraction ────────────────────────────────────────────────────── /// Extract the single `Bearer ` from the `Nostr-Federated-Identity` @@ -260,6 +305,124 @@ pub(crate) mod test_support { pub(crate) const TEST_HOST: &str = "relay.example"; pub(crate) const TEST_COMMUNITY_URI: &str = "https://relay.example"; + /// The issuer and key ID of [`assertion_signing_key`]. + pub(crate) const TEST_ISSUER: &str = "https://issuer.example"; + pub(crate) const TEST_KID: &str = "test-key-1"; + + /// Ephemeral signing material shared by the test signer and verifier. + /// Never persist a private key fixture in source or on disk. + pub(crate) fn assertion_signing_key( + ) -> &'static (jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet) { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use ring::signature::{EcdsaKeyPair, KeyPair, ECDSA_P256_SHA256_FIXED_SIGNING}; + static KEY: std::sync::OnceLock<(jsonwebtoken::EncodingKey, jsonwebtoken::jwk::JwkSet)> = + std::sync::OnceLock::new(); + KEY.get_or_init(|| { + let rng = ring::rand::SystemRandom::new(); + let algorithm = &ECDSA_P256_SHA256_FIXED_SIGNING; + let der = EcdsaKeyPair::generate_pkcs8(algorithm, &rng).expect("generate test key"); + let pair = EcdsaKeyPair::from_pkcs8(algorithm, der.as_ref(), &rng) + .expect("parse generated test key"); + // P-256 public keys use uncompressed SEC1: 0x04 || x || y. + let public = pair.public_key().as_ref(); + assert_eq!(public.len(), 65); + assert_eq!(public[0], 4); + let jwks = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": TEST_KID, + "x": URL_SAFE_NO_PAD.encode(&public[1..33]), + "y": URL_SAFE_NO_PAD.encode(&public[33..65]) + }] + })) + .expect("valid generated JWKS"); + (jsonwebtoken::EncodingKey::from_ec_der(der.as_ref()), jwks) + }) + } + + /// The real verifier trusting [`assertion_signing_key`] for [`TEST_ISSUER`] + /// at [`TEST_COMMUNITY_URI`], under `token_class`. + pub(crate) fn assertion_verifier( + token_class: buzz_auth::TokenClass, + ) -> buzz_auth::FederatedAssertionVerifier { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, + }; + use jsonwebtoken::Algorithm; + + let jwks = assertion_signing_key().1.clone(); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_COMMUNITY_URI.to_owned()], + token_class, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + None, + jwks_contract, + ) + .expect("valid issuer policy"); + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + FederatedAssertionVerifier::new(registry, StaticIssuerKeySource::new([key_set])) + } + + /// An `at+jwt` token class whose resource-owner subjects carry + /// `sub_type: "user"`. + pub(crate) fn at_jwt_class() -> buzz_auth::TokenClass { + buzz_auth::TokenClass::AccessTokenAtJwt { + subject_class: buzz_auth::SubjectClassContract::new( + "sub_type".to_owned(), + vec!["user".to_owned()], + vec!["client".to_owned()], + buzz_auth::ClientSubjectPosture::Reject, + ) + .expect("valid subject class"), + } + } + + /// Sign an assertion with an explicit `typ` and raw `nostr_owner_pubkey`. + /// It also carries claims satisfying [`at_jwt_class`] (`client_id`, + /// resource-owner `sub_type`), so under an `at+jwt` policy only `typ` + /// decides; dedicated policies ignore them. + pub(crate) fn sign_assertion( + typ: &str, + pubkey_hex: &str, + owner: Option, + ) -> String { + use jsonwebtoken::{Algorithm, Header}; + let now = chrono::Utc::now().timestamp(); + let mut claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_COMMUNITY_URI, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": pubkey_hex, + "client_id": "test-client", + "sub_type": "user", + }); + if let Some(owner) = owner { + claims[buzz_auth::NOSTR_OWNER_PUBKEY_CLAIM] = owner; + } + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some(typ.to_owned()); + let key = &assertion_signing_key().0; + jsonwebtoken::encode(&header, &claims, key).expect("sign assertion") + } + /// One community at [`TEST_COMMUNITY_URI`] authorizing `issuers`. pub(crate) fn communities_for(issuers: &[&str]) -> NipFiCommunities { NipFiCommunities::for_test(TEST_COMMUNITY_URI, issuers) @@ -319,6 +482,71 @@ pub(crate) mod tests { ) } + /// An agent assertion for `agent` owned by `owner`, from `test-issuer`. + fn agent_assertion(agent: nostr::PublicKey, owner: nostr::PublicKey) -> VerifiedAssertion { + VerifiedAssertion::for_test( + Some(agent), + vec![chrono::Utc::now() + chrono::Duration::seconds(300)], + ) + .with_owner_for_test(owner) + } + + fn deny_map_denying(key: &nostr::PublicKey) -> buzz_auth::NipFiDenyMap { + let map = buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + ); + let now = chrono::Utc::now(); + map.merge_cross_pod_deny("test-issuer", key, now + chrono::Duration::seconds(60), now); + map + } + + #[test] + fn deny_entry_for_the_owner_denies_its_agent_assertion() { + let agent = nostr::Keys::generate().public_key(); + let owner = nostr::Keys::generate().public_key(); + let unrelated = nostr::Keys::generate().public_key(); + let assertion = agent_assertion(agent, owner); + let now = chrono::Utc::now(); + assert!(assertion_denied(&deny_map_denying(&owner), &assertion, now)); + assert!(assertion_denied(&deny_map_denying(&agent), &assertion, now)); + assert!(!assertion_denied( + &deny_map_denying(&unrelated), + &assertion, + now + )); + } + + #[test] + fn owner_deny_entry_does_not_reach_an_ownerless_assertion() { + let key = nostr::Keys::generate().public_key(); + let other = nostr::Keys::generate().public_key(); + let assertion = VerifiedAssertion::for_test( + Some(key), + vec![chrono::Utc::now() + chrono::Duration::seconds(300)], + ); + assert!(!assertion_denied( + &deny_map_denying(&other), + &assertion, + chrono::Utc::now() + )); + } + + #[test] + fn session_matches_disconnect_of_its_key_or_owner_under_its_issuer() { + let (agent, owner, other) = ([1u8; 32], [2u8; 32], [3u8; 32]); + let session = NipFiSession::new("test-issuer", Some(owner)); + assert!(session.matches("test-issuer", &agent, &agent)); + assert!(session.matches("test-issuer", &agent, &owner)); + assert!(!session.matches("test-issuer", &agent, &other)); + assert!(!session.matches("other-issuer", &agent, &owner)); + let human = NipFiSession::new("test-issuer", None); + assert!(!human.matches("test-issuer", &agent, &owner)); + } + /// Verifier returning a fixed result and counting calls. /// /// Lives here, next to the shared evaluator, so the crate has one NIP-FI diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 42993bbea3..54151adfa2 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -63,7 +63,8 @@ use std::fmt; use crate::nip_fi_core::ResolvedCommunity; use crate::nip_fi_core::{ - asserted_key_matches, evaluate_attached_assertion, http_denial, AssertionRejection, Communities, + asserted_key_matches, assertion_denied, evaluate_attached_assertion, http_denial, + AssertionRejection, Communities, }; use buzz_auth::CommunityBinding; @@ -254,6 +255,12 @@ impl NipFiAdmission { self.assertion.as_ref() } + /// The owner named by a verified agent assertion (Enforce only). It + /// takes the place of a NIP-OA owner in the relay-membership step. + pub(crate) fn token_owner(&self) -> Option { + self.assertion.as_ref().and_then(VerifiedAssertion::owner) + } + /// Caller-supplied side-data from the NIP-98 extraction closure. #[allow(dead_code)] pub(crate) fn extra(&self) -> &X { @@ -279,7 +286,9 @@ impl NipFiAdmission { /// `(proven_pubkey, X)` on success, or a `Response` to emit on failure. /// Off and Shadow return `Ok(NipFiAdmission { proven_pubkey, assertion: /// None, extra: X })` here, identical to pre-NIP-FI (no assertion -/// requirement); Shadow first records the verdict steps 4-7 would reach. +/// requirement); Shadow first records the verdict steps 4-7 would reach, +/// then, given `owner_state`, the token-owner refusal every admitted route +/// applies after admission. /// [FI-INV-15] /// 4. Enforce mode: extract `Nostr-Federated-Identity: Bearer `. /// 5. Verify assertion (signature, issuer, expiry, claims). @@ -327,6 +336,7 @@ pub(crate) async fn admit_nip_fi_http<'a, D, X, F>( verifier: Option<&dyn VerifyAssertion>, mode: NipFiMode, deny_map: &D, + owner_state: Option<&crate::state::AppState>, ) -> Result, Response> where D: HttpDenyMap, @@ -346,7 +356,15 @@ where let proof = extract_nip98(); if mode.observes_only() { let signed = proof.as_ref().ok().filter(|p| p.signed).map(|p| p.pubkey); - observe_shadow_verdict(headers, communities, verifier, deny_map, signed).await; + observe_shadow_verdict( + headers, + communities, + verifier, + deny_map, + signed, + owner_state, + ) + .await; } let proof = proof?; return Ok(NipFiAdmission { @@ -390,27 +408,60 @@ where } /// Record the verdict enforce would reach for a request whose NIP-98 step -/// proved `signed` (`None`: missing, rejected or unsigned). +/// proved `signed` (`None`: missing, rejected or unsigned). With +/// `owner_state`, an agent assertion that passes admission is also judged by +/// the token-owner rules every admitted route applies after it. async fn observe_shadow_verdict( headers: &HeaderMap, communities: Communities<'_>, verifier: Option<&dyn VerifyAssertion>, deny_map: &D, signed: Option, + owner_state: Option<&crate::state::AppState>, ) { let community = resolve_for_admission(communities, headers).await; - let verdict = evaluate_enforce_steps(headers, community.as_deref(), verifier, deny_map, || { - signed.map(|key| Nip98Proof::new(key, ())).ok_or(()) - }) - .and_then(|_| match &community { - // Enforce's final re-check, after the handler's awaits. - Ok(community) => community - .deadline() - .check() - .map_err(|class| (Stage::Deadline, class)), - Err(_) => Ok(()), - }); - crate::nip_fi_shadow::record("http", &community, verdict); + let mut verdict = + evaluate_enforce_steps(headers, community.as_deref(), verifier, deny_map, || { + signed.map(|key| Nip98Proof::new(key, ())).ok_or(()) + }) + .and_then(|(proof, assertion)| match &community { + // Enforce's final re-check, after the handler's awaits. + Ok(community) => community + .deadline() + .check() + .map(|()| (proof.pubkey, assertion.owner())) + .map_err(|class| (Stage::Deadline, class)), + Err(_) => Ok((proof.pubkey, assertion.owner())), + }); + if let (Ok((agent, Some(owner))), Some(state)) = (verdict, owner_state) { + if let Some(class) = observe_owner(state, headers, agent, owner).await { + verdict = Err((Stage::Owner, class)); + } + } + crate::nip_fi_shadow::record("http", &community, verdict.map(|_| ())); +} + +/// The token-owner refusal for `agent` in the request's tenant. A Host that +/// binds no tenant is refused by the route itself, so it has none; a failed +/// tenant read leaves the owner unchecked, so it is unavailable. +async fn observe_owner( + state: &crate::state::AppState, + headers: &HeaderMap, + agent: PublicKey, + owner: PublicKey, +) -> Option { + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = match crate::tenant::bind_community(&state.db, host).await { + Ok(tenant) => tenant, + Err(crate::tenant::BindError::UnmappedHost) => return None, + Err(crate::tenant::BindError::Lookup(_)) => { + return Some(DenialClass::AuthorizationUnavailable) + } + }; + crate::handlers::auth::token_owner_denial(state, tenant.community(), agent, owner).await } /// Shadow only: record the enforce verdict for a request whose NIP-98 proof @@ -425,6 +476,7 @@ pub(crate) async fn observe_failed_proof(state: &crate::state::AppState, headers verifier, &NoDenyMapConfigured, None, + None, ) .await; } @@ -489,9 +541,10 @@ where return Err((Stage::Pairing, DenialClass::AuthorizationDenied)); } - // Step 7: deny-map check — (iss, pubkey) must not be in an active deny - // window. Private-state denial. [FI-INV-14] [NIP-FI.md:624-627] - if deny_map.is_denied(assertion.identity().issuer(), &proof.pubkey, Utc::now()) { + // Step 7: deny-map check — (iss, pubkey) and, for an agent assertion, + // (iss, owner) must not be in an active deny window. Private-state + // denial. [FI-INV-14] [NIP-FI.md:624-627] + if assertion_denied(deny_map, &assertion, Utc::now()) { return Err((Stage::DenySet, DenialClass::AuthorizationDenied)); } @@ -534,6 +587,7 @@ where verifier, mode, deny_map, + Some(state), ) .await } @@ -545,6 +599,7 @@ where verifier, mode, &NoDenyMapConfigured, + Some(state), ) .await } @@ -559,6 +614,31 @@ mod tests { // throughout this module — it IS the HTTP response returned from tests. #![allow(clippy::result_large_err)] use super::*; + + /// [`admit_nip_fi_http`] with no state, so shadow skips its owner step. + async fn admit_stateless<'a, D, X, F>( + headers: &HeaderMap, + extract_nip98: F, + communities: impl Into>, + verifier: Option<&dyn VerifyAssertion>, + mode: NipFiMode, + deny_map: &D, + ) -> Result, Response> + where + D: HttpDenyMap, + F: FnOnce() -> Result, Response>, + { + admit_nip_fi_http( + headers, + extract_nip98, + communities, + verifier, + mode, + deny_map, + None, + ) + .await + } use crate::nip_fi_core::extract_bearer_token; use crate::nip_fi_core::test_support::{communities, host_headers}; use crate::nip_fi_core::tests::ScriptedVerifier; @@ -782,7 +862,7 @@ mod tests { HeaderValue::from_static("Bearer any.valid.looking.token"), ); let admit = |proven: PublicKey, deny_map: &FixedDenyMap| { - admit_nip_fi_http( + admit_stateless( &headers, || Ok(Nip98Proof::new(proven, ())), &communities(), @@ -831,7 +911,7 @@ mod tests { let headers = host_headers(); // no assertion let expected_pubkey = any_pubkey(); let ep = expected_pubkey; - let outcome = admit_nip_fi_http( + let outcome = admit_stateless( &headers, || Ok(Nip98Proof::new(ep, ())), &communities(), @@ -856,7 +936,7 @@ mod tests { let headers = host_headers(); let deny_resp = http_denial(DenialClass::MissingEvidence); let deny_status = deny_resp.status(); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || Err(deny_resp), &communities(), @@ -890,7 +970,7 @@ mod tests { // Authorization header absent → NIP-98 closure fails → MissingEvidence (401). let headers = host_headers(); // no Authorization header let legacy_resp = http_denial(DenialClass::EvidenceRejected); // would be 403 if propagated - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || Err(legacy_resp), &communities(), @@ -918,7 +998,7 @@ mod tests { HeaderValue::from_static("Nostr invalid_base64!!!"), ); let legacy_resp = http_denial(DenialClass::MissingEvidence); // would be 401 if propagated - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || Err(legacy_resp), &communities(), @@ -944,7 +1024,7 @@ mod tests { let headers = host_headers(); // no Authorization header let legacy_status = StatusCode::UNAUTHORIZED; let legacy_resp = http_denial(DenialClass::MissingEvidence); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || Err(legacy_resp), &communities(), @@ -984,7 +1064,7 @@ mod tests { .body(axum::body::Body::from(legacy_body.as_ref())) .unwrap(); let headers = host_headers(); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || Err(legacy_resp), &communities(), @@ -1068,7 +1148,7 @@ mod tests { for (case, headers, closure_ok) in cases { let closure_calls = Cell::new(0u32); let verifier = CountingVerifier(AtomicUsize::new(0)); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( headers, || { closure_calls.set(closure_calls.get() + 1); @@ -1124,7 +1204,7 @@ mod tests { fn enforce_missing_assertion_is_401() { let headers = host_headers(); let pubkey = any_pubkey(); - let outcome = admit_nip_fi_http( + let outcome = admit_stateless( &headers, || Ok(Nip98Proof::new(pubkey, ())), &communities(), @@ -1158,7 +1238,7 @@ mod tests { HeaderValue::from_static("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), ); let pubkey = any_pubkey(); - let outcome = admit_nip_fi_http( + let outcome = admit_stateless( &headers, || Ok(Nip98Proof::new(pubkey, ())), &communities(), @@ -1220,7 +1300,7 @@ mod tests { HeaderValue::from_static("Bearer any.valid.looking.token"), ); - let outcome = admit_nip_fi_http( + let outcome = admit_stateless( &headers, || Ok(Nip98Proof::new(pubkey_b, ())), &communities(), @@ -1295,7 +1375,7 @@ mod tests { HeaderValue::from_static("Nostr second.token"), ); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || { closure_ran_clone.store(true, Ordering::SeqCst); @@ -1347,7 +1427,7 @@ mod tests { ); // Closure succeeds → Off mode should admit. - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || { closure_ran_clone.store(true, Ordering::SeqCst); @@ -1396,7 +1476,7 @@ mod tests { proven: PublicKey, verifier: Option<&dyn VerifyAssertion>, ) -> Result, Response> { - admit_nip_fi_http( + admit_stateless( headers, || Ok(Nip98Proof::new(proven, ())), &communities(), @@ -1502,6 +1582,170 @@ mod tests { ); } + /// Verifier answering every call with one fixed assertion. + struct AgentVerifier(buzz_auth::VerifiedAssertion); + + impl VerifyAssertion for AgentVerifier { + fn verify_assertion( + &self, + _token: &str, + _community: &buzz_auth::CommunityBinding, + ) -> Result { + Ok(self.0.clone()) + } + } + + /// One shadow HTTP admission of `agent`, acting for `owner`, judged + /// against `state`; asserts shadow still admits. + async fn admit_owned_in_shadow( + state: &crate::state::AppState, + agent: PublicKey, + owner: PublicKey, + ) { + let verifier = AgentVerifier(crate::nip_fi_shadow_session::tests::owned_assertion( + agent, owner, + )); + let admission = admit_nip_fi_http::<_, (), _>( + &bearer_headers("Bearer a.b.c"), + || Ok(Nip98Proof::new(agent, ())), + &communities(), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Shadow, + &AlwaysAdmitStubDenyMap, + Some(state), + ) + .await; + assert!(admission.is_ok(), "shadow admits"); + } + + mod postgres_tests { + use super::*; + + // Shadow judges an admitted agent assertion by its token owner, as + // `enforce_relay_membership` does after admission: a banned token owner + // or a stored owner other than the token owner records `owner/denied`, + // a clean owner `admit/admit`, and shadow admits each. A tenant read that + // fails before the owner is checked records `owner/unavailable`. + // Mutations: deleting the owner step in `observe_shadow_verdict` records + // `admit/admit` for every case; collapsing the read failure in + // `observe_owner` to `None` records `admit/admit` for `TenantReadFails`. + #[test] + #[ignore = "requires Postgres"] + fn shadow_records_token_owner_refusals() { + #[derive(Clone, Copy, Debug)] + enum Case { + CleanOwner, + BannedOwner, + OtherStoredOwner, + TenantReadFails, + } + for (case, expected) in [ + (Case::CleanOwner, "admit/admit"), + (Case::BannedOwner, "owner/denied"), + (Case::OtherStoredOwner, "owner/denied"), + (Case::TenantReadFails, "owner/unavailable"), + ] { + let records = crate::nip_fi_shadow_session::tests::shadow_records(async { + let state = crate::state::tests::test_state_with_database_pool( + sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect test database"), + ) + .await; + let community = state + .db + .ensure_configured_community(crate::nip_fi_core::test_support::TEST_HOST) + .await + .expect("ensure community") + .id; + let (agent, owner) = (any_pubkey(), any_pubkey()); + match case { + Case::BannedOwner => state + .db + .ban_community_member( + community, + &owner.to_bytes(), + &owner.to_bytes(), + None, + None, + ) + .await + .expect("ban owner"), + Case::OtherStoredOwner => { + let prior = any_pubkey(); + for key in [agent, prior] { + state + .db + .ensure_user(community, key.as_bytes()) + .await + .expect("ensure user"); + } + assert!(state + .db + .set_agent_owner(community, agent.as_bytes(), prior.as_bytes()) + .await + .expect("seed stored owner")); + } + Case::TenantReadFails => state.db.pool().close().await, + Case::CleanOwner => {} + } + admit_owned_in_shadow(&state, agent, owner).await; + }); + assert_eq!(records, [expected], "{case:?}"); + } + } + } + + // Pins: step 7 reads the owner of an agent assertion. With the owner's + // deny entry live, enforce denies the agent with the deny-map row and + // shadow records the would-deny; without it, enforce admits and the + // admission carries the owner. Mutation: dropping the owner arm of + // `assertion_denied` admits the agent and records an admit. + #[test] + fn owner_deny_entry_denies_agent_assertion() { + use buzz_auth::{IssuerCapacity, NipFiDenyMap}; + let (agent, owner) = (any_pubkey(), any_pubkey()); + let verifier = AgentVerifier(crate::nip_fi_shadow_session::tests::owned_assertion( + agent, owner, + )); + let map = NipFiDenyMap::new( + 8, + vec![IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 8, + }], + ); + let headers = bearer_headers("Bearer a.b.c"); + let admit = |mode, map: &NipFiDenyMap| { + admit_stateless( + &headers, + || Ok(Nip98Proof::new(agent, ())), + &communities(), + Some(&verifier as &dyn VerifyAssertion), + mode, + map, + ) + .now_or_never() + .expect("env resolution never waits") + }; + + let admission = match admit(NipFiMode::Enforce, &map) { + Ok(admission) => admission, + Err(resp) => panic!("a clean owner admits its agent, got {}", resp.status()), + }; + assert_eq!(admission.assertion().and_then(|a| a.owner()), Some(owner)); + + let now = Utc::now(); + map.merge_cross_pod_deny("test-issuer", &owner, now + chrono::Duration::hours(1), now); + let (status, body) = denial_parts(admit(NipFiMode::Enforce, &map)); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, b"authorization denied\n"); + let records = crate::nip_fi_shadow_session::tests::shadow_records(async { + assert!(admit(NipFiMode::Shadow, &map).is_ok(), "shadow admits"); + }); + assert_eq!(records, ["deny_set/denied"]); + } + // Pins: NIP-98 proof runs before assertion extraction. A failed NIP-98 // proof with a missing assertion header is 401 from the NIP-98 step and // the verifier never runs. This is the reverse of NIP-FI.md §Admission @@ -1512,7 +1756,7 @@ mod tests { #[test] fn characterize_http_nip98_precedes_assertion_evaluation() { let verifier = ScriptedVerifier::new(Ok(Some(any_pubkey()))); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &bearer_headers("Bearer a.b.c"), || Err(http_denial(DenialClass::MissingEvidence)), &communities(), @@ -1543,7 +1787,7 @@ mod tests { for headers in [unmapped, absent] { let verifier = ScriptedVerifier::new(Ok(Some(any_pubkey()))); let closure_ran = std::cell::Cell::new(false); - let outcome = admit_nip_fi_http::<_, (), _>( + let outcome = admit_stateless::<_, (), _>( &headers, || { closure_ran.set(true); @@ -1581,7 +1825,7 @@ mod tests { absent.remove(axum::http::header::HOST); let key = any_pubkey(); for headers in [unmapped, absent] { - let admission = admit_nip_fi_http::<_, (), _>( + let admission = admit_stateless::<_, (), _>( &headers, || Ok(Nip98Proof::new(key, ())), &communities(), @@ -1614,7 +1858,7 @@ mod tests { headers.insert("x-pubkey", HeaderValue::from_str(&key.to_hex()).unwrap()); let recorder = metrics_util::debugging::DebuggingRecorder::new(); let admission = metrics::with_local_recorder(&recorder, || { - admit_nip_fi_http( + admit_stateless( &headers, || { crate::api::bridge::verify_bridge_auth_with_options( @@ -1652,7 +1896,7 @@ mod tests { fn shadow_evaluates_assertion_without_changing_admission() { let key = any_pubkey(); let verifier = ScriptedVerifier::new(Ok(Some(key))); - let admission = admit_nip_fi_http::<_, (), _>( + let admission = admit_stateless::<_, (), _>( &bearer_headers("Bearer a.b.c"), || Ok(Nip98Proof::new(key, ())), &communities(), @@ -1666,7 +1910,7 @@ mod tests { assert!(admission.assertion.is_none()); assert_eq!(verifier.calls(), 1); - let legacy = admit_nip_fi_http::<_, (), _>( + let legacy = admit_stateless::<_, (), _>( &bearer_headers("Bearer a.b.c"), || Err(Response::new(Body::from("legacy"))), &communities(), diff --git a/crates/buzz-relay/src/nip_fi_shadow.rs b/crates/buzz-relay/src/nip_fi_shadow.rs index 7a924f1161..e985ed8994 100644 --- a/crates/buzz-relay/src/nip_fi_shadow.rs +++ b/crates/buzz-relay/src/nip_fi_shadow.rs @@ -12,7 +12,7 @@ //! //! | Series | Labels | One increment per | //! |---|---|---| -//! | `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`, `outcome`, `community` | NIP-FI decision enforce would make: one per HTTP request that reaches admission, one per WebSocket upgrade enforce would refuse, and one per observed session: at the AUTH where enforce would admit or deny it, or `deadline` when its deadline passes first. A request or upgrade whose community binding goes stale before enforce's final re-check also records `deadline` | +//! | `buzz_nip_fi_shadow_total` | `route` (`http`, `ws`, `audio`), `stage`, `outcome`, `community` | NIP-FI decision enforce would make: one per HTTP request that reaches admission, one per WebSocket upgrade enforce would refuse, and one per observed session: at the AUTH where enforce would admit or deny it, or `deadline` when its deadline passes first. A request or upgrade whose community binding goes stale before enforce's final re-check also records `deadline`. An agent assertion enforce would refuse for its token owner, banned or not the agent's stored owner, records `owner` | //! | `buzz_nip_fi_shadow_strict_proof_total` | `route` (`bridge`, `blossom`), `outcome` (`pass`, `rejected`), `community` | strict NIP-98 side check on a route whose proof is stricter in enforce (payload tag, Blossom proof); not every request runs one | //! | `buzz_nip_fi_shadow_session_end_total` | `route` (`ws`, `audio`), `reason` (`expired`, `revoked`), `community` | admitted shadow session enforce would have ended; a session never admitted records none | //! | `buzz_nip_fi_shadow_disconnect_total` | `route` (`admin`, `cross_pod`), `outcome` | disconnect-path event that moves a real disconnect counter in enforce: an accepted admin command, a capacity rejection, a subscriber lag, or a cross-pod capacity or poison failsafe. An admin command refused for its header, body, proof, replay, or a dependency error records nothing | @@ -44,6 +44,7 @@ pub(crate) enum Stage { Pairing, DenySet, Deadline, + Owner, } impl Stage { @@ -56,6 +57,7 @@ impl Stage { Self::Pairing => "pairing", Self::DenySet => "deny_set", Self::Deadline => "deadline", + Self::Owner => "owner", } } } diff --git a/crates/buzz-relay/src/nip_fi_shadow_session.rs b/crates/buzz-relay/src/nip_fi_shadow_session.rs index b6fb243882..9422bb014c 100644 --- a/crates/buzz-relay/src/nip_fi_shadow_session.rs +++ b/crates/buzz-relay/src/nip_fi_shadow_session.rs @@ -5,8 +5,8 @@ //! would-deny where enforce checks them, the admit once the connection is //! actually admitted, or `deadline` when enforce's deadline passes first. An //! admitted session then records at most one end: `expired` at the deadline, -//! or `revoked` when a deny for its key arrives once it registered. Every -//! transition runs under one session lock, and a session whose socket was +//! or `revoked` when a deny for its key or owner arrives once it registered. +//! Every transition runs under one session lock, and a session whose socket was //! cancelled, or whose AUTH ended unadmitted for a non-NIP-FI reason, retires //! without a record. @@ -18,12 +18,13 @@ use buzz_auth::{DenialClass, VerifiedAssertion}; use nostr::PublicKey; use tokio_util::sync::CancellationToken; +use crate::nip_fi_core::NipFiSession; use crate::nip_fi_shadow::{Stage, WouldDeny}; use crate::state::AppState; #[derive(Clone, Copy)] enum Phase { - /// `revoked`: a deny for the key arrived after registration, which + /// `revoked`: a deny for the key or owner arrived after registration, which /// enforce's registered-session scan acts on whatever the phase. Pending { revoked: bool, @@ -47,10 +48,11 @@ impl ShadowSessions { .unwrap_or_else(PoisonError::into_inner) } - /// Records one `revoked` end for each registered session of `pubkey` - /// under `issuer`, at once if admitted or at its admit if still pending; - /// a session already ended records nothing, so re-sent denies are - /// idempotent. + /// Records one `revoked` end for each registered session under `issuer` + /// whose asserted key or verified owner is `pubkey`, matched as enforce's + /// [`NipFiSession`] matches, at once if admitted or at its admit if still + /// pending; a session already ended records nothing, so re-sent denies + /// are idempotent. pub(crate) fn would_close(&self, issuer: &str, pubkey: &[u8]) { let live: Vec<_> = self .registered() @@ -58,10 +60,10 @@ impl ShadowSessions { .filter_map(Weak::upgrade) .collect(); for session in live { - let key = session.assertion.asserted_key().map(|k| k.to_bytes()); - if session.assertion.identity().issuer() == issuer - && key.as_ref().map(|k| &k[..]) == Some(pubkey) - { + let assertion = &session.assertion; + if assertion.asserted_key().is_some_and(|key| { + NipFiSession::from_assertion(assertion).matches(issuer, &key.to_bytes(), pubkey) + }) { session.revoke(); } } @@ -134,6 +136,24 @@ impl ShadowSession { } } + /// At enforce's token-owner checks: records the would-deny when an agent + /// assertion's owner is banned or differs from the agent's stored owner. + pub(crate) async fn observe_owner( + &self, + state: &AppState, + community: buzz_core::CommunityId, + pubkey: PublicKey, + ) { + let Some(owner) = self.assertion.owner() else { + return; + }; + if let Some(class) = + crate::handlers::auth::token_owner_denial(state, community, pubkey, owner).await + { + self.decide(Err((Stage::Owner, class))); + } + } + /// At enforce's post-registration deny-set check: registers the session, /// so a later deny marks it, then records the deny-set would-deny. pub(crate) fn observe_deny_set(self: &Arc, state: &AppState) { @@ -276,11 +296,10 @@ pub(crate) fn upgrade_denied( } fn deny_listed(state: &AppState, assertion: &VerifiedAssertion) -> bool { - let issuer = assertion.identity().issuer(); - assertion - .asserted_key() - .zip(state.nip_fi_deny_map.as_deref()) - .is_some_and(|(key, map)| map.is_denied(issuer, &key, chrono::Utc::now())) + state + .nip_fi_deny_map + .as_deref() + .is_some_and(|map| crate::nip_fi_core::assertion_denied(map, assertion, chrono::Utc::now())) } #[cfg(test)] @@ -478,6 +497,115 @@ pub(crate) mod tests { } } + /// An agent assertion for `agent` acting for `owner`, valid for an hour. + pub(crate) fn owned_assertion(agent: PublicKey, owner: PublicKey) -> VerifiedAssertion { + VerifiedAssertion::for_test(Some(agent), vec![Utc::now() + Duration::hours(1)]) + .with_owner_for_test(owner) + } + + // An owner's disconnect, delivered cross-pod after admission or between + // the deny-set check and the admit, ends each of its agents' ws and audio + // sessions `revoked` exactly once and closes no real socket, even one the + // real registry would match; the owner under another issuer and an + // ownerless session of the agent key take no end. Mutation: dropping the + // owner arm of `would_close` loses every `end/revoked`. + #[test] + fn owner_disconnect_ends_owned_agent_sessions_and_closes_nothing() { + let records = shadow_records_by_route(async { + let state = pod(8).await; + let (agent, owner) = (Keys::generate().public_key(), Keys::generate().public_key()); + let owned = |route| { + let assertion = owned_assertion(agent, owner); + ShadowSession::start( + &state, + route, + crate::nip_fi_shadow::BOUND.into(), + assertion, + Utc::now(), + ) + }; + let cancel = CancellationToken::new(); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + control.set_terminal_frame_sender(terminal_tx); + control.set_proven_identity( + agent.to_bytes().to_vec(), + Some(NipFiSession::from_assertion(&owned_assertion(agent, owner))), + ); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let _guard = + state + .community_connections + .register(uuid::Uuid::new_v4(), community, control); + + let admitted = [owned("ws"), owned("audio")]; + let pending = [owned("ws"), owned("audio")]; + let ownerless = session(&state, Some(agent), Duration::hours(1)); + for s in admitted.iter().chain([&ownerless]) { + auth(s, &state, agent); + } + for s in &pending { + s.observe_pairing(agent); + s.observe_deny_set(&state); + } + state + .nip_fi_shadow_sessions + .would_close("other-issuer", &owner.to_bytes()); + for s in &admitted { + assert!(matches!(*s.phase.lock().unwrap(), Phase::Admitted)); + } + for s in &pending { + let phase = s.phase.lock().unwrap(); + assert!(matches!(*phase, Phase::Pending { revoked: false })); + } + deliver(&state, &owner); + revoke(&state, &owner); + for s in &pending { + s.admit(); + } + assert!(!cancel.is_cancelled(), "shadow never cancels a socket"); + assert!(terminal_rx.try_recv().is_err(), "shadow sends no frame"); + }); + let expected = [ + "audio admit/admit", + "audio admit/admit", + "audio end/revoked", + "audio end/revoked", + "ws admit/admit", + "ws admit/admit", + "ws admit/admit", + "ws end/revoked", + "ws end/revoked", + ]; + assert_eq!(records, expected); + } + + // The shadow deny observer reads the owner's deny entry at the upgrade + // and after registration. Mutation: dropping the owner arm of + // `assertion_denied` records two admits instead. + #[test] + fn owner_deny_entry_records_deny_set_at_upgrade_and_after_registration() { + let (agent, owner) = (Keys::generate().public_key(), Keys::generate().public_key()); + let records = shadow_records(async move { + let state = shadow_state(Some(owner)).await; + let assertion = owned_assertion(agent, owner); + assert!(upgrade_denied( + &state, + &crate::nip_fi_shadow::BOUND.into(), + &assertion + )); + let s = ShadowSession::start( + &state, + "ws", + crate::nip_fi_shadow::BOUND.into(), + assertion, + Utc::now(), + ); + auth(&s, &state, agent); + }); + assert_eq!(records, ["deny_set/denied", "deny_set/denied"]); + } + /// A shadow pod whose deny map holds `capacity` entries for the /// `for_test` issuer. pub(crate) async fn pod(capacity: usize) -> AppState { diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index 04fd241e5d..cfcce6155b 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -198,6 +198,16 @@ make_hook!(audio_membership_check_hook, before_membership_check); // immediately before the relay membership lookup. Closing the pool here makes // that lookup fail with the tenant already bound. make_hook!(git_membership_hook, before_git_membership); +// `before_http_ban_recheck`: fires in `buzz_v1` and Git settings after the +// shared membership check passes, immediately before the fresh ban reread. +// A ban or closed pool here makes only the reread refuse. +make_hook!(http_ban_recheck_hook, before_http_ban_recheck); +// `before_write_ban_check`: fires in ingest's write-restriction gates (plain +// and cached) immediately before the restriction read. +// `before_command_ban_check`: fires in the moderation and relay-admin command +// handlers immediately before their own ban reread, after the write gate. +make_hook!(write_ban_check_hook, before_write_ban_check); +make_hook!(command_ban_check_hook, before_command_ban_check); make_hook!(audio_membership_lock_hook, before_membership_lock); make_hook!(audio_participant_commit_hook, before_participant_commit); // `before_audio_refusal_frames`: fires in `audio/handler.rs` once a NIP-42 diff --git a/crates/buzz-relay/src/nip_fi_upgrade.rs b/crates/buzz-relay/src/nip_fi_upgrade.rs index 5c5e864469..d5710f7eef 100644 --- a/crates/buzz-relay/src/nip_fi_upgrade.rs +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -545,6 +545,99 @@ mod tests { } } + // The WebSocket upgrade binds `nostr_owner_pubkey` to the token type with + // the real verifier: an agent token with a valid owner is admitted and + // carries that owner into the session (where AUTH applies owner bans); + // an agent token without one, a human token carrying one (valid or null), + // an agent token under an `at+jwt` policy and an unknown type are + // refused `evidence rejected`. Every token carries valid `at+jwt` claims, + // so the `at+jwt` case is refused only for its type. Mutations: accept + // `nip-fi-agent+jwt` under `at+jwt`, skip the owner rule, or accept any + // `nip-fi*` type in `enforce_token_type` → RED. + #[test] + fn upgrade_binds_the_owner_claim_to_the_agent_token_type() { + use crate::nip_fi_core::test_support::{ + assertion_verifier, at_jwt_class, communities_for, sign_assertion, TEST_ISSUER, + }; + use buzz_auth::TokenClass::DedicatedNipFi; + + let (agent, owner) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (agent_hex, owner_hex) = (agent.public_key().to_hex(), owner.public_key().to_hex()); + let owner_claim = || Some(serde_json::Value::from(owner_hex.clone())); + let cases = [ + ( + "agent with owner", + DedicatedNipFi, + "nip-fi-agent+jwt", + owner_claim(), + true, + ), + ( + "agent without owner", + DedicatedNipFi, + "nip-fi-agent+jwt", + None, + false, + ), + ( + "human with owner", + DedicatedNipFi, + "nip-fi+jwt", + owner_claim(), + false, + ), + ( + "human with null owner", + DedicatedNipFi, + "nip-fi+jwt", + Some(serde_json::Value::Null), + false, + ), + ( + "agent under at+jwt", + at_jwt_class(), + "nip-fi-agent+jwt", + owner_claim(), + false, + ), + ( + "unknown type", + DedicatedNipFi, + "nip-fi-agent", + owner_claim(), + false, + ), + ]; + for (case, token_class, typ, claim, admitted) in cases { + let verifier = assertion_verifier(token_class); + let headers = headers_with(&format!( + "Bearer {}", + sign_assertion(typ, &agent_hex, claim) + )); + let outcome = check_nip_fi_at_upgrade( + ROOT, + &headers, + &communities_for(&[TEST_ISSUER]), + Some(&verifier), + NipFiMode::Enforce, + ) + .now_or_never() + .expect("env resolution never waits"); + if admitted { + let NipFiUpgradeOutcome::Admitted(assertion, _) = outcome else { + panic!("{case}: expected admission"); + }; + assert_eq!(assertion.owner(), Some(owner.public_key()), "{case}"); + } else { + assert_eq!( + denied_parts(outcome), + (StatusCode::FORBIDDEN, b"evidence rejected\n".to_vec()), + "{case}" + ); + } + } + } + // Pins: upgrade maps verifier errors through `VerifierError::denial_class` // (503 for unavailable dependencies, 403 evidence rejected otherwise). // Mutation: mapping every verifier error to EvidenceRejected fails the diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 49ed25223d..1127770b52 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -710,12 +710,9 @@ async fn nip11_or_ws_handler( // `asserted_key` is `None` → no key to check → pass through. // [FI-TRACE-DENY-SET] if let Some(assertion) = &nip_fi_assertion { - if let Some(key) = assertion.asserted_key() { - if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { - if deny_map.is_denied(assertion.identity().issuer(), &key, chrono::Utc::now()) { - return http_denial(buzz_auth::DenialClass::AuthorizationDenied) - .into_response(); - } + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if crate::nip_fi_core::assertion_denied(deny_map, assertion, chrono::Utc::now()) { + return http_denial(buzz_auth::DenialClass::AuthorizationDenied).into_response(); } } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index b272fb40f0..b4bdd62108 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -33,6 +33,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; use crate::connection::{ConnectionSubscriptions, RestartClose}; +use crate::nip_fi_core::NipFiSession; use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); @@ -74,16 +75,18 @@ impl CommunityDisconnectReason { } } -/// NIP-42-proven key and the NIP-FI issuer a socket was admitted under. +/// NIP-42-proven key and the NIP-FI session a socket was admitted under. #[derive(Clone)] struct ProvenIdentity { pubkey: Vec, - nip_fi_issuer: Option, + nip_fi: Option, } impl ProvenIdentity { fn matches(&self, issuer: &str, pubkey: &[u8]) -> bool { - self.pubkey == pubkey && self.nip_fi_issuer.as_deref() == Some(issuer) + self.nip_fi + .as_ref() + .is_some_and(|session| session.matches(issuer, &self.pubkey, pubkey)) } } @@ -92,7 +95,7 @@ impl ProvenIdentity { pub(crate) struct CommunityConnectionControl { cancel: CancellationToken, reason_tx: watch::Sender>, - /// NIP-42-proven pubkey plus the NIP-FI issuer the session was admitted + /// NIP-42-proven pubkey plus the NIP-FI session it was admitted /// under; matched by the registry's `disconnect_nip_fi` scan. [FI-TRACE-DENY-SET] proven_identity: Arc>>, /// Serializes NIP-FI denial writers across reason-win + terminal enqueue, @@ -167,14 +170,11 @@ impl CommunityConnectionControl { self.reason_tx.subscribe() } - /// Records the NIP-42-proven pubkey and admitting NIP-FI issuer for this + /// Records the NIP-42-proven pubkey and admitting NIP-FI session for this /// connection so the registry can close it via `disconnect_nip_fi`. - pub(crate) fn set_proven_identity(&self, pubkey: Vec, nip_fi_issuer: Option) { + pub(crate) fn set_proven_identity(&self, pubkey: Vec, nip_fi: Option) { if let Ok(mut slot) = self.proven_identity.write() { - *slot = Some(ProvenIdentity { - pubkey, - nip_fi_issuer, - }); + *slot = Some(ProvenIdentity { pubkey, nip_fi }); } } @@ -271,7 +271,7 @@ impl CommunityConnectionControl { let nip_fi = self .proven_identity .read() - .is_ok_and(|id| id.as_ref().is_some_and(|id| id.nip_fi_issuer.is_some())); + .is_ok_and(|id| id.as_ref().is_some_and(|id| id.nip_fi.is_some())); if nip_fi { self.publish_authorization_denied(crate::nip_fi_session::NipFiWsRoute::Audio, None); } else { @@ -311,10 +311,11 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, - /// NIP-FI issuer the session was admitted under. Written while holding - /// the `authenticated_pubkey` write lock and read under its read lock, so - /// `disconnect_nip_fi` never sees the pubkey without its issuer. - nip_fi_issuer: std::sync::RwLock>, + /// NIP-FI session (issuer and agent owner) the connection was admitted + /// under. Written while holding the `authenticated_pubkey` write lock and + /// read under its read lock, so `disconnect_nip_fi` never sees the pubkey + /// without its session. + nip_fi: std::sync::RwLock>, /// Owner of an admitted agent; revoking the owner closes this socket. admitted_owner: std::sync::OnceLock<[u8; 32]>, /// Set once AUTH succeeds. The pubkey is bound earlier so revocation can @@ -714,7 +715,7 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), - nip_fi_issuer: std::sync::RwLock::new(None), + nip_fi: std::sync::RwLock::new(None), admitted_owner: std::sync::OnceLock::new(), admitted: AtomicBool::new(false), grace_limit, @@ -745,20 +746,20 @@ impl ConnectionManager { self.set_authenticated_identity(conn_id, pubkey_bytes, None); } - /// Record the authenticated pubkey and the NIP-FI issuer it was admitted - /// under as one unit: the issuer is written while the pubkey write lock is - /// held, so a concurrent `disconnect_nip_fi` scan that sees the pubkey also - /// sees its issuer. [FI-TRACE-DENY-SET] + /// Record the authenticated pubkey and the NIP-FI session it was admitted + /// under as one unit: the session is written while the pubkey write lock + /// is held, so a concurrent `disconnect_nip_fi` scan that sees the pubkey + /// also sees its issuer and owner. [FI-TRACE-DENY-SET] pub(crate) fn set_authenticated_identity( &self, conn_id: Uuid, pubkey_bytes: Vec, - nip_fi_issuer: Option, + nip_fi: Option, ) { if let Some(entry) = self.connections.get(&conn_id) { if let Ok(mut slot) = entry.authenticated_pubkey.write() { - if let Ok(mut issuer_slot) = entry.nip_fi_issuer.write() { - *issuer_slot = nip_fi_issuer; + if let Ok(mut session_slot) = entry.nip_fi.write() { + *session_slot = nip_fi; } *slot = Some(pubkey_bytes); } @@ -894,7 +895,8 @@ impl ConnectionManager { } /// Close all live connections admitted under NIP-FI `issuer` whose proven - /// pubkey equals `pubkey`, **across all communities**. + /// pubkey or recorded agent owner equals `pubkey`, **across all + /// communities**. /// /// Used by the NIP-FI admin disconnect API: the deny entry is keyed by /// `(issuer, pubkey)` and spans every community this relay serves under @@ -916,11 +918,13 @@ impl ConnectionManager { .read() .ok() .map(|stored| { - stored.as_deref() == Some(pubkey) - && entry - .nip_fi_issuer - .read() - .is_ok_and(|iss| iss.as_deref() == Some(issuer)) + stored.as_deref().is_some_and(|proven| { + entry.nip_fi.read().is_ok_and(|session| { + session + .as_ref() + .is_some_and(|s| s.matches(issuer, proven, pubkey)) + }) + }) }) .unwrap_or(false); if matches { @@ -2572,7 +2576,11 @@ pub(crate) mod tests { 3, CommunityConnectionControl::new(cancel.clone()), ); - mgr.set_authenticated_identity(conn_id, key.clone(), issuer.map(str::to_owned)); + mgr.set_authenticated_identity( + conn_id, + key.clone(), + issuer.map(|issuer| NipFiSession::new(issuer, None)), + ); (cancel, terminal_ctrl_rx) }; let (cancel_a, mut frames_a) = register(Some("https://issuer-a.example")); @@ -2635,7 +2643,11 @@ pub(crate) mod tests { 3, control, ); - mgr.set_authenticated_identity(conn_id, pubkey.clone(), Some("test-issuer".to_owned())); + mgr.set_authenticated_identity( + conn_id, + pubkey.clone(), + Some(NipFiSession::new("test-issuer", None)), + ); let closed = mgr.disconnect_nip_fi("test-issuer", &pubkey); @@ -2668,7 +2680,10 @@ pub(crate) mod tests { let cancel = CancellationToken::new(); let (terminal_tx, terminal_rx) = mpsc::channel::(1); let control = CommunityConnectionControl::new(cancel.clone()); - control.set_proven_identity(key.clone(), issuer.map(str::to_owned)); + control.set_proven_identity( + key.clone(), + issuer.map(|issuer| NipFiSession::new(issuer, None)), + ); control.set_terminal_frame_sender(terminal_tx); let guard = registry.register(Uuid::new_v4(), community, control); (cancel, terminal_rx, guard) @@ -2714,7 +2729,10 @@ pub(crate) mod tests { let cancel = CancellationToken::new(); let control = CommunityConnectionControl::new(cancel.clone()); let reason_rx = control.disconnect_reason(); - control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + control.set_proven_identity( + target_pubkey.clone(), + Some(NipFiSession::new("test-issuer", None)), + ); let _guard = registry.register(Uuid::new_v4(), community, control); assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); @@ -2726,6 +2744,52 @@ pub(crate) mod tests { ); } + /// A disconnect of an owner reaches its agents' sockets on both the root + /// manager and the audio registry, and leaves the owner's other issuer and + /// ownerless sockets of the same key alone. + #[test] + fn nip_fi_owner_disconnect_closes_owned_agent_sockets_on_root_and_audio() { + let (agent, owner) = (vec![0x51u8; 32], [0x52u8; 32]); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcb)); + let owned = Some(NipFiSession::new("test-issuer", Some(owner))); + + let mgr = ConnectionManager::new(); + let root_conn = |session: Option| { + let conn_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + mpsc::channel(8).0, + mpsc::channel(8).0, + mpsc::channel(1).0, + None, + cancel.clone(), + community, + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + CommunityConnectionControl::new(cancel.clone()), + ); + mgr.set_authenticated_identity(conn_id, agent.clone(), session); + cancel + }; + let root_owned = root_conn(owned.clone()); + let root_other_issuer = root_conn(Some(NipFiSession::new("other-issuer", Some(owner)))); + let root_ownerless = root_conn(Some(NipFiSession::new("test-issuer", None))); + + let registry = CommunityConnectionRegistry::new(); + let audio_cancel = CancellationToken::new(); + let audio = CommunityConnectionControl::new(audio_cancel.clone()); + audio.set_proven_identity(agent.clone(), owned); + let _guard = registry.register(Uuid::new_v4(), community, audio); + + assert_eq!(mgr.disconnect_nip_fi("test-issuer", &owner), 1); + assert_eq!(registry.disconnect_nip_fi("test-issuer", &owner), 1); + assert!(root_owned.is_cancelled() && audio_cancel.is_cancelled()); + assert!(!root_other_issuer.is_cancelled()); + assert!(!root_ownerless.is_cancelled()); + } + #[test] fn nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer() { // Two audio sockets in the same community: only the target's is closed. @@ -2736,12 +2800,15 @@ pub(crate) mod tests { let target_cancel = CancellationToken::new(); let target_control = CommunityConnectionControl::new(target_cancel.clone()); - target_control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + target_control.set_proven_identity( + target_pubkey.clone(), + Some(NipFiSession::new("test-issuer", None)), + ); let _target_guard = registry.register(Uuid::new_v4(), community, target_control); let peer_cancel = CancellationToken::new(); let peer_control = CommunityConnectionControl::new(peer_cancel.clone()); - peer_control.set_proven_identity(peer_pubkey, Some("test-issuer".to_owned())); + peer_control.set_proven_identity(peer_pubkey, Some(NipFiSession::new("test-issuer", None))); let _peer_guard = registry.register(Uuid::new_v4(), community, peer_control); assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); @@ -2766,7 +2833,7 @@ pub(crate) mod tests { let cancel = CancellationToken::new(); let control = CommunityConnectionControl::new(cancel.clone()); - control.set_proven_identity(other_pubkey, Some("test-issuer".to_owned())); + control.set_proven_identity(other_pubkey, Some(NipFiSession::new("test-issuer", None))); let _guard = registry.register(Uuid::new_v4(), community, control); assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 0); diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index 3d71a7c436..5e7e97a287 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -70,8 +70,10 @@ The assertion is a compact JWS carrying the following claims. Policy selects exactly one token class before parsing claims: -- **`nip-fi+jwt`**: a dedicated assertion whose protected `typ` is exactly - `nip-fi+jwt`. +- **Dedicated NIP-FI assertion**: a dedicated assertion whose protected `typ` + is exactly `nip-fi+jwt` (a human assertion) or exactly `nip-fi-agent+jwt` + (an agent assertion; see "Agent keys"). The `typ` value, not any claim, + determines which of the two an assertion is. - **`at+jwt` access token**: a resource access token whose protected `typ` is exactly `at+jwt`. When this class is selected: - The assertion MUST contain a non-empty `client_id` claim. @@ -84,10 +86,13 @@ Policy selects exactly one token class before parsing claims: client-subject `(iss, sub)` coordinates are disjoint. - Absent, unknown, or ambiguous classification always denies; no fallback to the other class is attempted. + - The access token MUST NOT carry `nostr_owner_pubkey`; its presence, with + any value, denies. OIDC ID tokens always deny, even when `iss`, `aud`, and `sub` match. A -generic or absent `typ` has no accepted class. Failure under one class never -triggers validation under another. [FI-TRACE-TOKEN-CLASS] +generic or absent `typ` has no accepted class. `nip-fi-agent+jwt` is accepted +only under the dedicated NIP-FI class. Failure under one class never triggers +validation under another. [FI-TRACE-TOKEN-CLASS] ### Time bounds @@ -129,16 +134,19 @@ audience; issuer policy selection by `iss` MUST NOT broaden either binding. ### Agent keys -An issuer MAY issue an assertion whose `nostr_pubkey` names an agent key -acting for an owner key. Such an assertion MUST carry `nostr_owner_pubkey`: -the lowercase hexadecimal encoding of one 32-byte public key, distinct from -`nostr_pubkey`. An issuer MUST NOT issue an assertion without -`nostr_owner_pubkey` for a key it has issued an agent assertion for, and MUST -NOT issue an agent assertion while the owner is not authorized by the issuer. +An issuer MAY issue an agent assertion: an assertion whose `nostr_pubkey` +names an agent key acting for an owner key. An agent assertion MUST use the +protected `typ` `nip-fi-agent+jwt` and MUST carry `nostr_owner_pubkey`: the +lowercase hexadecimal encoding of one 32-byte public key, distinct from +`nostr_pubkey`. An issuer MUST NOT issue an assertion of any other type for a +key it has issued an agent assertion for, and MUST NOT issue an agent assertion +while the owner is not authorized by the issuer. -A relay advertising `agent_assertions: true` MUST verify `nostr_owner_pubkey` -as part of the assertion. A malformed value, or one equal to `nostr_pubkey`, -MUST deny `evidence_rejected`. The relay MUST record the value as the +A relay MUST verify `nostr_owner_pubkey` as part of every `nip-fi-agent+jwt` +assertion. An absent or malformed value, or one equal to `nostr_pubkey`, MUST +deny `evidence_rejected`. A `nip-fi+jwt` assertion carrying +`nostr_owner_pubkey`, with any value, MUST deny `evidence_rejected`. The relay +MUST record the value as the owner of the session or request the assertion admits. If the relay already associates a different owner with the asserted key in the resolved community, it MUST deny `authorization_denied`. @@ -147,9 +155,8 @@ authorized in the community, the relay MUST deny when the community bans the asserted key or the owner, or when the issuer has denied either key. Only then, when the asserted key is not itself authorized in the community, the relay MUST authorize it if and only if the issuer is trusted for the community -and the owner is authorized in the community when access is decided. A relay -not advertising `agent_assertions: true` MUST deny an assertion carrying -`nostr_owner_pubkey` `evidence_rejected`. +and the owner is authorized in the community when access is decided. +[FI-TRACE-AGENT-OWNER] ### Unverified claims @@ -295,10 +302,11 @@ VerifyAssertion(token, community, D, R_t): AssertTimeBounds(claims, policy) or DENY(evidence_rejected) // [FI-TRACE-ASSERTION-VALIDATION] k_claimed := ParseHexKey(claims.nostr_pubkey) or DENY(evidence_rejected) owner := absent - if claims.nostr_owner_pubkey is present: - AssertAgentAssertionsAdvertised(relay) or DENY(evidence_rejected) + if header.typ == "nip-fi-agent+jwt": owner := ParseHexKey(claims.nostr_owner_pubkey) or DENY(evidence_rejected) AssertNotEqual(owner, k_claimed) or DENY(evidence_rejected) + else: + AssertAbsent(claims.nostr_owner_pubkey) or DENY(evidence_rejected) return VerifiedAssertion(identity=(claims.iss, claims.sub), asserted_key=k_claimed, owner=owner, @@ -871,8 +879,7 @@ normative behavior for them: primitive is pubkey-scoped and community-wide, keyed `(iss, target_pubkey)` (see Admin disconnect API). If an issuer revokes an entire identity, it disconnects each key it has asserted for that identity. A disconnect of an - owner key also covers that owner's agent keys at relays advertising - `agent_assertions: true`. + owner key also covers that owner's agent keys. Individual session/device logout is handled by assertion expiry within the configured TTL window. - Revocation signaling to the issuer/IdP: issuer-side; the issuer stops @@ -911,11 +918,6 @@ issuer stops issuance after a successful deny, the residual ceiling is issuer-operational property, not a protocol invariant this field can advertise unconditionally. -`agent_assertions` (boolean, optional). `true` means the relay admits agent -assertions as defined in "Agent keys". Absent or `false` means it does not. -An issuer MUST NOT issue an agent assertion for a community whose relay does -not advertise `agent_assertions: true`. - Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] @@ -925,7 +927,8 @@ deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] |---|---| | `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, and fallback variants deny. | | `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and missing-configuration negative denies; a missing or unavailable community configuration denies `authorization_unavailable`; a known-but-community-unauthorized issuer denies `evidence_rejected` without touching its key source, indistinguishably from an unknown issuer, including when that issuer's snapshot is unavailable; an issuer authorized only for community A cannot authorize community B merely by signing an assertion with community B's audience; an assertion for community A presented on a `Host` resolved to community B denies on the community binding; an unknown, ambiguous, or unresolvable `Host` denies `authorization_unavailable`. | -| `FI-TRACE-TOKEN-CLASS` | `at+jwt` and `nip-fi+jwt` pass only their selected class; ID tokens, wrong or generic types, and cross-class fallback deny. | +| `FI-TRACE-TOKEN-CLASS` | `at+jwt`, `nip-fi+jwt`, and `nip-fi-agent+jwt` pass only their selected class; ID tokens, wrong or generic types, `nip-fi-agent+jwt` under an `at+jwt` policy, and cross-class fallback deny. | +| `FI-TRACE-AGENT-OWNER` | A `nip-fi-agent+jwt` with a valid `nostr_owner_pubkey` distinct from `nostr_pubkey` passes verification and records that owner; a `nip-fi-agent+jwt` whose `nostr_owner_pubkey` is absent, malformed, or equal to `nostr_pubkey` denies `evidence_rejected`; a `nip-fi+jwt` or `at+jwt` carrying `nostr_owner_pubkey` with any value denies `evidence_rejected`; an agent assertion denies when the community bans the asserted key or the owner, or the issuer has denied either key, even when the asserted key is itself authorized; an asserted key already associated with a different owner in the community denies `authorization_denied`; an agent whose key is not itself authorized is admitted only while its owner is authorized in the community. | | `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch between `nostr_pubkey` and the NIP-42 proven key denies with the private-state response. | | `FI-TRACE-JWKS-ADD` | A key added to the JWKS is accepted after the next snapshot refresh. | | `FI-TRACE-JWKS-REMOVE` | Connections verified under a removed key deny on next revalidation or reconnect. | diff --git a/docs/operator-nip-fi-bindings.md b/docs/operator-nip-fi-bindings.md index f63c73e755..afebc7de47 100644 --- a/docs/operator-nip-fi-bindings.md +++ b/docs/operator-nip-fi-bindings.md @@ -150,6 +150,8 @@ without refusing anything. Shadow labels database-source records - `buzz_nip_fi_resolver_miss_rejections_total` is flat. - The non-`admit` share of `buzz_nip_fi_shadow_total` is only what you expect to deny; `community="unmapped"` comes only from hosts you do not serve. + - `stage="owner"` counts as would-deny: each is an agent enforce will refuse + for its token owner. - No-go: any of these fails. Return to `off` and investigate. ### Enforce diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index c6a83b2a09..3de473cf62 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -370,6 +370,9 @@ run_unit_tests() { handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path handlers::auth::tests::shadow_root_auth_records_pairing_denial + handlers::auth::tests::shadow_root_auth_records_unavailable_owner_read + handlers::ingest::postgres_tests::relay_admin_ban_maps_to_blocked_auth_failure + handlers::ingest::postgres_tests::relay_admin_internal_maps_to_error_not_client_fault handlers::auth::tests::shadow_root_invalid_nip42_retires_without_record handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence handlers::event::tests::fanout_access::owner_only_kinds_keep_only_the_owner @@ -382,6 +385,7 @@ run_unit_tests() { state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason state::tests::nip_fi_disconnect_audio_is_issuer_scoped + state::tests::nip_fi_owner_disconnect_closes_owned_agent_sockets_on_root_and_audio state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket