diff --git a/.env.example b/.env.example index b9bfcada0e..d42e61fa8b 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,39 @@ RELAY_URL=ws://localhost:3000 # BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN=300 # BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN=600 +# Relay-verified identity (disabled by default). When enabled, the relay +# requires authenticated requests to present a valid corporate JWT, then binds +# the configured uid claim to the Nostr pubkey proven by NIP-42/NIP-98. The JWT +# may be injected by a trusted proxy or attached by a first-party client; the +# relay treats both as the same header. Clients must forward the configured +# token header on every authenticated HTTP request and session handshake. +# +# Operational notes for the initial implementation: +# - When a trusted proxy injects this header, it MUST overwrite any inbound +# client-supplied value before forwarding to the relay. +# - Revocation and rotation are explicit database lifecycle operations; +# ordinary authentication never silently replaces a key. +# - JWKS outages fail closed for human JWT authentication. Delegated agent +# admission can still work when the owner binding is already present. +# - DISPLAY_CLAIM is private binding metadata. It is never projected publicly +# unless PUBLIC_DISPLAY_CLAIM is separately configured. +# BUZZ_REQUIRE_CORPORATE_IDENTITY=false +# BUZZ_CORPORATE_IDENTITY_JWT_HEADER=x-forwarded-identity-token +# BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION=true +# When a request carries both a JWT and a verified NIP-OA owner declaration, +# choose whether the JWT identifies the signer or the owner binding delegates +# access. Defaults to direct; deployments that inject an owner's JWT into agent +# requests can explicitly select delegated. +# BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE=direct +# BUZZ_CORPORATE_IDENTITY_JWKS_URI=https://idp.example/.well-known/jwks.json +# BUZZ_CORPORATE_IDENTITY_ISSUER=https://idp.example +# BUZZ_CORPORATE_IDENTITY_AUDIENCE=buzz-relay +# BUZZ_CORPORATE_IDENTITY_UID_CLAIM=sub +# BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM=email +# Optional, public NIP-85 label. Unset by default to keep identity claims private. +# BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM=display_name +# BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM=buzz_npub + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e65157705a..76398a6502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,27 @@ jobs: --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Relay-verified identity lifecycle tests + run: | + docker exec -e PGPASSWORD="${BUZZ_TEST_POSTGRES_PASSWORD}" buzz-postgres \ + psql -U buzz -d postgres -v ON_ERROR_STOP=1 \ + -c "CREATE DATABASE buzz_identity_tests" + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E '(package(buzz-db) and test(/identity_binding::tests/)) or (package(buzz-relay) and test(/corporate_identity::tests/))' \ + --test-threads 1 \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests + - name: Corporate identity boundary regressions + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/^(corporate_identity::tests::jwt_validation_rejects_missing_and_malformed_audience_claims|api::bridge::tests::(corporate_identity_disables_x_pubkey_bridge_fallback|moderation_reads_require_corporate_identity_after_nip98_proof)|api::media::tests::protected_media_reads_require_corporate_identity_for_get_and_head)$/)' \ + --test-threads 1 \ + --run-ignored all + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz_identity_tests - name: Workspace profile (kind:9033) gate tests # Call-site integration for the 9033 authorization gate: open relay # rosterless/steward transitions and the closed-relay admin/owner rule, diff --git a/Cargo.lock b/Cargo.lock index 937ead564a..a23390675d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -1207,6 +1208,7 @@ dependencies = [ "hex", "hmac 0.13.0", "infer", + "jsonwebtoken", "mesh-llm-host-runtime", "mesh-llm-sdk", "metrics", @@ -2591,7 +2593,7 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki", "zeroize", ] @@ -2604,7 +2606,7 @@ checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -2618,7 +2620,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -4337,6 +4339,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature 2.2.0", + "zeroize", +] + [[package]] name = "k8s-openapi" version = "0.26.1" @@ -7896,7 +7914,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -8116,7 +8134,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -8676,6 +8694,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -10349,6 +10376,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index cc1dd0f9df..09e78a885c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ postcard = { version = "1", default-features = false, features = ["use-std"] iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] } serde_json = "1" serde_yaml = "0.9" +jsonwebtoken = { version = "10.3", default-features = false, features = ["aws_lc_rs"] } evalexpr = "11" cron = "0.16" # Observability diff --git a/crates/buzz-audit/src/action.rs b/crates/buzz-audit/src/action.rs index be7ccc3545..cafe043be9 100644 --- a/crates/buzz-audit/src/action.rs +++ b/crates/buzz-audit/src/action.rs @@ -28,6 +28,12 @@ pub enum AuditAction { RateLimitExceeded, /// A media file was uploaded via the Blossom endpoint. MediaUploaded, + /// A corporate identity binding was created. + CorporateIdentityBindingCreated, + /// A corporate identity binding attempt conflicted with an active binding. + CorporateIdentityBindingConflict, + /// A corporate identity binding attempt matched a revoked binding. + CorporateIdentityBindingRevokedAttempt, } impl AuditAction { @@ -45,6 +51,11 @@ impl AuditAction { Self::AuthFailure => "auth_failure", Self::RateLimitExceeded => "rate_limit_exceeded", Self::MediaUploaded => "media_uploaded", + Self::CorporateIdentityBindingCreated => "corporate_identity_binding_created", + Self::CorporateIdentityBindingConflict => "corporate_identity_binding_conflict", + Self::CorporateIdentityBindingRevokedAttempt => { + "corporate_identity_binding_revoked_attempt" + } } } @@ -60,6 +71,9 @@ impl AuditAction { Self::AuthFailure, Self::RateLimitExceeded, Self::MediaUploaded, + Self::CorporateIdentityBindingCreated, + Self::CorporateIdentityBindingConflict, + Self::CorporateIdentityBindingRevokedAttempt, ]; } diff --git a/crates/buzz-auth/src/context/authority.rs b/crates/buzz-auth/src/context/authority.rs new file mode 100644 index 0000000000..390273ecb3 --- /dev/null +++ b/crates/buzz-auth/src/context/authority.rs @@ -0,0 +1,636 @@ +use std::{fmt, future::Future, pin::Pin}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use super::{ + AuthContextError, AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, + BindingSource, BindingVersion, EnrollmentMode, FederatedIdentityRequirement, + FederatedPolicyStamp, FederatedPrincipal, ResolvedFederatedPolicy, +}; + +/// Boxed asynchronous result returned by a federated authority adapter. +pub type AuthorityAdapterFuture<'a, T> = Pin + Send + 'a>>; + +/// Failure while invoking or validating a federated authority adapter. +#[derive(PartialEq, Eq)] +pub enum AuthorityAdapterError { + /// The storage adapter failed before producing authoritative state. + Adapter(E), + /// Adapter output violated the authorization contract. + Contract(AuthContextError), + /// Current policy no longer matches the atomic binding precondition. + PolicyChanged, +} + +impl fmt::Debug for AuthorityAdapterError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let variant = match self { + Self::Adapter(_) => "Adapter", + Self::Contract(_) => "Contract", + Self::PolicyChanged => "PolicyChanged", + }; + formatter + .debug_struct("AuthorityAdapterError") + .field("variant", &variant) + .field("detail", &"[redacted]") + .finish() + } +} + +impl AuthorityAdapterError { + /// Wrap a storage-adapter failure. + pub const fn adapter(error: E) -> Self { + Self::Adapter(error) + } + + /// Report that the policy identifier or epoch changed before binding resolution. + pub const fn policy_changed() -> Self { + Self::PolicyChanged + } +} + +impl From for AuthorityAdapterError { + fn from(error: AuthContextError) -> Self { + Self::Contract(error) + } +} + +/// Read-only request for the current enrollment policy of one authorization domain. +#[derive(Clone, PartialEq, Eq)] +pub struct CurrentPolicyRequest { + authorization_domain: CommunityId, + correlation_id: Uuid, + observed_at: u64, +} + +impl CurrentPolicyRequest { + /// Server-resolved authorization domain to read. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Correlation identifier for the decision being assembled. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Trusted server time for the policy read. + pub const fn observed_at(&self) -> u64 { + self.observed_at + } +} + +impl fmt::Debug for CurrentPolicyRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CurrentPolicyRequest") + .field("authorization_domain", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("observed_at", &"[redacted]") + .finish() + } +} + +/// Crate-owned capability for sealing one current policy read. +/// +/// The adapter receives this value from [`resolve_current_federated_policy`]; +/// downstream callers cannot construct it. Calling [`Self::resolved`] validates +/// the raw storage fields and returns an opaque policy value. +pub struct CurrentPolicyResolutionSink { + request: CurrentPolicyRequest, +} + +impl CurrentPolicyResolutionSink { + /// Validate and seal current policy fields read by the adapter. + #[allow(clippy::too_many_arguments)] + pub fn resolved( + self, + authorization_domain: CommunityId, + policy_id: Uuid, + epoch: u64, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, + ) -> Result { + if authorization_domain != self.request.authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch); + } + let stamp = FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + policy_id, + epoch, + self.request.correlation_id, + requirement, + effective_from, + effective_until, + )?; + if stamp.is_not_yet_effective_at(self.request.observed_at) { + return Err(AuthContextError::FederatedPolicyNotYetEffective); + } + if stamp.is_expired_at(self.request.observed_at) { + return Err(AuthContextError::FederatedPolicyExpired); + } + Ok(ResolvedFederatedPolicy::from_authoritative_resolution( + stamp, + )) + } +} + +impl fmt::Debug for CurrentPolicyResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CurrentPolicyResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Atomic binding request tied to an exact current enrollment-policy epoch. +#[derive(Clone, PartialEq, Eq)] +pub struct BindingResolutionRequest { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + policy_id: Uuid, + policy_epoch: u64, + policy_requirement: FederatedIdentityRequirement, + correlation_id: Uuid, + key_attested: bool, + effective_from: u64, + effective_until: u64, + observed_at: u64, +} + +impl BindingResolutionRequest { + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact issuer-qualified principal being resolved. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Authenticated Nostr key being resolved. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Stable current enrollment-policy identifier. + pub const fn policy_id(&self) -> Uuid { + self.policy_id + } + + /// Exact policy epoch that must still be current inside the binding transaction. + pub const fn policy_epoch(&self) -> u64 { + self.policy_epoch + } + + /// Enrollment requirement at the expected policy epoch. + pub const fn policy_requirement(&self) -> FederatedIdentityRequirement { + self.policy_requirement + } + + /// Correlation identifier for this decision. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Whether verifier-owned assertion evidence attested the exact bound key. + pub const fn key_attested(&self) -> bool { + self.key_attested + } + + /// Inclusive joined assertion, capability, and policy validity bound. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive joined assertion, capability, and policy validity bound. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Trusted server time for binding eligibility. + pub const fn observed_at(&self) -> u64 { + self.observed_at + } +} + +impl fmt::Debug for BindingResolutionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BindingResolutionRequest") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("bound_pubkey", &"[redacted]") + .field("policy_id", &"[redacted]") + .field("policy_epoch", &"[redacted]") + .field("policy_requirement", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("effective_from", &"[redacted]") + .field("effective_until", &"[redacted]") + .field("observed_at", &"[redacted]") + .finish() + } +} + +#[derive(Clone)] +struct BindingExpectation { + request: BindingResolutionRequest, +} + +impl BindingExpectation { + #[allow(clippy::too_many_arguments)] + fn evidence( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if authorization_domain != self.request.authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if principal != self.request.principal { + return Err(AuthContextError::AssertionPrincipalMismatch); + } + if bound_pubkey != self.request.bound_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch); + } + if expires_at.is_some_and(|bound| bound.is_expired_at(self.request.observed_at)) { + return Err(AuthContextError::BindingExpired); + } + AuthoritativeBindingEvidence::new( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + } +} + +/// Crate-owned capability for sealing direct binding state. +/// +/// Implementations may call [`Self::existing_active`] after a current active +/// read, or [`Self::atomically_enrolled`] only after enrollment commits in the +/// same transaction that compared the request's policy identifier and epoch. +pub struct DirectBindingResolutionSink { + expected: BindingExpectation, +} + +impl DirectBindingResolutionSink { + /// Seal an already-active binding returned by authoritative storage. + #[allow(clippy::too_many_arguments)] + pub fn existing_active( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::existing_active) + } + + /// Seal a binding created under the request's atomic policy precondition. + #[allow(clippy::too_many_arguments)] + pub fn atomically_enrolled( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + match self.expected.request.policy_requirement { + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey) + if !self.expected.request.key_attested => + { + return Err(AuthContextError::KeyAttestationRequired); + } + FederatedIdentityRequirement::Required( + EnrollmentMode::AttestedKey | EnrollmentMode::Tofu, + ) => {} + FederatedIdentityRequirement::NotRequired + | FederatedIdentityRequirement::Required(EnrollmentMode::Provisioned) => { + return Err(AuthContextError::InvalidAuthorizationReason); + } + } + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::atomically_enrolled) + } +} + +impl fmt::Debug for DirectBindingResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DirectBindingResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Crate-owned capability for sealing a read-only existing owner binding. +/// +/// This sink intentionally has no enrollment method, so delegated-owner +/// resolution cannot create or relabel a binding. +pub struct ExistingBindingResolutionSink { + expected: BindingExpectation, +} + +impl ExistingBindingResolutionSink { + /// Seal an already-active owner binding returned by authoritative storage. + #[allow(clippy::too_many_arguments)] + pub fn existing_active( + self, + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + self.expected + .evidence( + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + ) + .map(AuthoritativeBindingResolution::existing_active) + } +} + +impl fmt::Debug for ExistingBindingResolutionSink { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ExistingBindingResolutionSink") + .field(&"[redacted]") + .finish() + } +} + +/// Trusted cross-crate adapter for current policy and binding state. +/// +/// The server must compose exactly one implementation backed by authoritative +/// storage; request and transport code must never select an implementation. +/// Binding methods must compare `policy_id` and `policy_epoch` and check +/// database time against `[effective_from, effective_until)` after lock +/// acquisition and immediately before commit, inside the same transaction as +/// the active read or enrollment. A mismatch or elapsed interval fails closed +/// without binding mutation; policy mismatch is +/// [`AuthorityAdapterError::PolicyChanged`]. +pub trait FederatedAuthorityAdapter: Send + Sync { + /// Storage-specific failure type. + type Error; + + /// Read the domain's current enrollment policy and seal it with `sink`. + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; + + /// Resolve or atomically enroll a direct binding under the exact policy precondition. + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; + + /// Resolve an already-active owner binding without enrollment or mutation. + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + >; +} + +/// Resolve and seal the current policy for one authorization decision. +pub async fn resolve_current_federated_policy( + adapter: &A, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result> { + let request = CurrentPolicyRequest { + authorization_domain, + correlation_id, + observed_at: now_unix_seconds, + }; + let sink = CurrentPolicyResolutionSink { + request: request.clone(), + }; + let policy = adapter.resolve_current_policy(request, sink).await?; + validate_returned_policy( + &policy, + authorization_domain, + correlation_id, + now_unix_seconds, + )?; + Ok(policy) +} + +/// Resolve or atomically enroll a direct binding under an exact current policy. +#[allow(dead_code)] +// Keep the verifier-derived attestation bit and joined interval explicit at +// this sealed boundary so storage adapters cannot infer or widen either fact. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn resolve_direct_binding( + adapter: &A, + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + key_attested: bool, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + let request = binding_request( + policy, + principal, + bound_pubkey, + key_attested, + effective_from, + effective_until, + now_unix_seconds, + )?; + let sink = DirectBindingResolutionSink { + expected: BindingExpectation { + request: request.clone(), + }, + }; + let resolution = adapter + .resolve_direct_binding(request.clone(), sink) + .await?; + validate_returned_binding(&resolution, &request, false)?; + Ok(resolution) +} + +/// Resolve an already-active owner binding under an exact current policy. +#[allow(dead_code)] +pub(crate) async fn resolve_existing_binding( + adapter: &A, + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + let request = binding_request( + policy, + principal, + bound_pubkey, + false, + effective_from, + effective_until, + now_unix_seconds, + )?; + let sink = ExistingBindingResolutionSink { + expected: BindingExpectation { + request: request.clone(), + }, + }; + let resolution = adapter + .resolve_existing_binding(request.clone(), sink) + .await?; + validate_returned_binding(&resolution, &request, true)?; + Ok(resolution) +} + +fn binding_request( + policy: &ResolvedFederatedPolicy, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + key_attested: bool, + effective_from: u64, + effective_until: u64, + now_unix_seconds: u64, +) -> Result> { + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + if effective_from < policy.stamp().effective_from() + || effective_until > policy.stamp().effective_until() + || effective_from >= effective_until + { + return Err(AuthContextError::InvalidFederatedPolicyInterval.into()); + } + if now_unix_seconds < effective_from { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if now_unix_seconds >= effective_until { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + Ok(BindingResolutionRequest { + authorization_domain: policy.authorization_domain(), + principal, + bound_pubkey, + policy_id: policy.stamp().policy_id(), + policy_epoch: policy.stamp().epoch(), + policy_requirement: policy.requirement(), + correlation_id: policy.stamp().correlation_id(), + key_attested, + effective_from, + effective_until, + observed_at: now_unix_seconds, + }) +} + +fn validate_returned_policy( + policy: &ResolvedFederatedPolicy, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result<(), AuthorityAdapterError> { + if policy.authorization_domain() != authorization_domain { + return Err(AuthContextError::PolicyDomainMismatch.into()); + } + if policy.stamp().correlation_id() != correlation_id { + return Err(AuthContextError::FederatedPolicyCorrelationMismatch.into()); + } + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyNotYetEffective.into()); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired.into()); + } + Ok(()) +} + +fn validate_returned_binding( + resolution: &AuthoritativeBindingResolution, + request: &BindingResolutionRequest, + require_existing: bool, +) -> Result<(), AuthorityAdapterError> { + if require_existing && !resolution.is_existing_active() { + return Err(AuthContextError::DelegatedBindingNotExistingActive.into()); + } + if resolution.authorization_domain() != request.authorization_domain { + return Err(AuthContextError::BindingDomainMismatch.into()); + } + if resolution.principal() != &request.principal { + return Err(AuthContextError::AssertionPrincipalMismatch.into()); + } + if resolution.bound_pubkey() != request.bound_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch.into()); + } + if resolution + .expires_at() + .is_some_and(|bound| bound.is_expired_at(request.observed_at)) + { + return Err(AuthContextError::BindingExpired.into()); + } + Ok(()) +} diff --git a/crates/buzz-auth/src/context/binding.rs b/crates/buzz-auth/src/context/binding.rs new file mode 100644 index 0000000000..e9ca0e9c9e --- /dev/null +++ b/crates/buzz-auth/src/context/binding.rs @@ -0,0 +1,703 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use super::{AuthContextError, AuthorizationReason, FederatedPrincipal}; + +/// Policy used when no active binding exists for either principal or key. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum EnrollmentMode { + /// First use requires an assertion that attests the proven Nostr key. + AttestedKey, + /// Bindings must be created by an out-of-band administrative process. + Provisioned, + /// First use may bind the proven key without an asserted key claim. + Tofu, +} + +impl fmt::Debug for EnrollmentMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("EnrollmentMode") + .field(&"[redacted]") + .finish() + } +} + +/// Federated-identity requirement resolved for one authorization domain. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FederatedIdentityRequirement { + /// Federated identity is not required for this domain. + NotRequired, + /// Federated identity is required under the supplied enrollment policy. + Required(EnrollmentMode), +} + +impl fmt::Debug for FederatedIdentityRequirement { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedIdentityRequirement") + .field(&"[redacted]") + .finish() + } +} + +/// Exact authoritative enrollment-policy lineage for one decision. +/// +/// This stamp is not provider capability-policy evidence. It names the +/// server-owned federated enrollment policy that supplied the requirement and +/// its half-open effective interval. The constructor validates shape, while a +/// crate-owned authority adapter remains responsible for sourcing current policy state. +#[derive(Clone, PartialEq, Eq)] +pub struct FederatedPolicyStamp { + authorization_domain: CommunityId, + policy_id: Uuid, + epoch: u64, + correlation_id: Uuid, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, +} + +impl FederatedPolicyStamp { + /// Validate lineage read from current authoritative policy state. + /// + /// This constructor enforces structural invariants only. Callers must not + /// source any field from transport input, and the authority adapter must + /// compare the epoch as an atomic precondition before enrollment. + pub(crate) fn from_authoritative_state( + authorization_domain: CommunityId, + policy_id: Uuid, + epoch: u64, + correlation_id: Uuid, + requirement: FederatedIdentityRequirement, + effective_from: u64, + effective_until: u64, + ) -> Result { + if policy_id.is_nil() { + return Err(AuthContextError::InvalidFederatedPolicyId); + } + if epoch == 0 { + return Err(AuthContextError::InvalidFederatedPolicyEpoch); + } + if correlation_id.is_nil() { + return Err(AuthContextError::InvalidFederatedPolicyCorrelation); + } + if effective_from >= effective_until { + return Err(AuthContextError::InvalidFederatedPolicyInterval); + } + Ok(Self { + authorization_domain, + policy_id, + epoch, + correlation_id, + requirement, + effective_from, + effective_until, + }) + } + + /// Authorization domain whose enrollment policy was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable, non-nil identifier of the enrollment-policy namespace. + pub const fn policy_id(&self) -> Uuid { + self.policy_id + } + + /// Positive monotonic epoch within the enrollment-policy namespace. + pub const fn epoch(&self) -> u64 { + self.epoch + } + + /// Correlation identifier of the decision that resolved this policy. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Federated-identity requirement resolved at this epoch. + pub const fn requirement(&self) -> FederatedIdentityRequirement { + self.requirement + } + + /// Inclusive start of the policy's effective interval. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive end of the policy's effective interval. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Whether the policy is not yet effective at trusted server time. + pub const fn is_not_yet_effective_at(&self, now_unix_seconds: u64) -> bool { + now_unix_seconds < self.effective_from + } + + /// Whether the policy is expired at trusted server time. + pub const fn is_expired_at(&self, now_unix_seconds: u64) -> bool { + now_unix_seconds >= self.effective_until + } +} + +impl fmt::Debug for FederatedPolicyStamp { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FederatedPolicyStamp") + .field("authorization_domain", &"[redacted]") + .field("policy_id", &"[redacted]") + .field("epoch", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("requirement", &"[redacted]") + .field("effective_from", &"[redacted]") + .field("effective_until", &"[redacted]") + .finish() + } +} + +/// Server-resolved federated-identity policy for an authorization decision. +/// +/// A policy adapter must resolve the authorization domain's current +/// configuration before producing it; transport values are never authoritative +/// input. The evidence is intentionally move-only and has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct ResolvedFederatedPolicy { + stamp: FederatedPolicyStamp, +} + +impl fmt::Debug for ResolvedFederatedPolicy { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResolvedFederatedPolicy") + .field("stamp", &self.stamp) + .finish() + } +} + +impl ResolvedFederatedPolicy { + /// Seal structurally validated current policy lineage for finalization. + pub(crate) const fn from_authoritative_resolution(stamp: FederatedPolicyStamp) -> Self { + Self { stamp } + } + + #[cfg(test)] + pub(crate) fn not_required(authorization_domain: CommunityId) -> Self { + Self::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + FederatedIdentityRequirement::NotRequired, + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + #[cfg(test)] + pub(crate) fn required( + authorization_domain: CommunityId, + enrollment_mode: EnrollmentMode, + ) -> Self { + Self::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain, + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + FederatedIdentityRequirement::Required(enrollment_mode), + 1, + u64::MAX, + ) + .expect("synthetic federated policy lineage is valid"), + ) + } + + /// Authorization domain whose configuration was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.stamp.authorization_domain() + } + + /// Resolved federated-identity requirement. + pub const fn requirement(&self) -> FederatedIdentityRequirement { + self.stamp.requirement() + } + + /// Exact authoritative enrollment-policy lineage for this decision. + pub const fn stamp(&self) -> &FederatedPolicyStamp { + &self.stamp + } + + #[allow(dead_code)] + pub(crate) fn into_stamp(self) -> FederatedPolicyStamp { + self.stamp + } +} + +/// Provenance recorded when a binding is created. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + /// The identity provider attested the proven Nostr key. + AttestedKey, + /// An operator provisioned the binding out of band. + Provisioned, + /// The binding was established by trust on first use. + Tofu, +} + +impl fmt::Debug for BindingSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingSource") + .field(&"[redacted]") + .finish() + } +} + +/// Monotonically increasing version of an identity-to-key binding. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BindingVersion(u64); + +impl fmt::Debug for BindingVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingVersion") + .field(&"[redacted]") + .finish() + } +} + +impl BindingVersion { + /// Initial version assigned to a newly created binding. + pub const INITIAL: Self = Self(1); + + /// Build a non-zero binding version. + pub const fn new(value: u64) -> Result { + if value == 0 { + return Err(AuthContextError::InvalidBindingVersion); + } + Ok(Self(value)) + } + + /// Numeric binding version. + pub const fn get(self) -> u64 { + self.0 + } +} + +/// Optional authoritative expiry of a lifecycle-active identity binding. +/// +/// Expiry makes the binding ineligible for authorization but does not remove it +/// from lifecycle state or turn it into retirement or revocation evidence. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct BindingExpiry(u64); + +impl fmt::Debug for BindingExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("BindingExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl BindingExpiry { + /// Build a non-zero binding expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidBindingExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the binding is no longer authorization-eligible. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Stable reference to one active identity-to-key binding. +/// +/// This reference is identity evidence. It is not an authorization lease and +/// does not by itself provide live-revocation +/// enforcement. Its optional authoritative expiry is a finalization and later +/// lease bound; expiry does not synthesize lifecycle state. An +/// authoritative binding adapter constructs this move-only value after checking +/// active lifecycle state; it has no default or deserialization path. +/// Production construction is available only through the crate-owned +/// authoritative-resolution finalizer. Pending, revoked, newly proposed, and +/// synthetic records must not cross that gate. +#[derive(PartialEq, Eq)] +pub struct VersionedBindingRef { + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + resolution_reason: AuthorizationReason, +} + +/// Structurally validated binding fields returned by authoritative state. +/// +/// This is not authorization by itself. The crate-owned finalizer additionally +/// requires a typed lifecycle outcome proving that the binding was already +/// active or was atomically enrolled during this decision. It has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub(crate) struct AuthoritativeBindingEvidence { + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, +} + +impl AuthoritativeBindingEvidence { + /// Validate typed fields read from authoritative binding state. + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + }) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Current local binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.expires_at + } + + /// Persisted provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.source + } +} + +impl fmt::Debug for AuthoritativeBindingEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthoritativeBindingEvidence") + .field("authorization_domain", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("principal", &"[redacted]") + .field("bound_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("source", &"[redacted]") + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum BindingResolutionOutcome { + ExistingActive, + AtomicallyEnrolled, +} + +/// Typed authoritative lifecycle result consumed by the crate-owned finalizer. +/// +/// It carries no caller-selected authorization reason; the finalizer derives that reason +/// from the lifecycle outcome, persisted provenance, and current enrollment +/// policy. +#[derive(PartialEq, Eq)] +pub struct AuthoritativeBindingResolution { + evidence: AuthoritativeBindingEvidence, + outcome: BindingResolutionOutcome, +} + +impl AuthoritativeBindingResolution { + /// Record the authoritative result that the binding already existed. + pub(crate) fn existing_active(evidence: AuthoritativeBindingEvidence) -> Self { + Self { + evidence, + outcome: BindingResolutionOutcome::ExistingActive, + } + } + + /// Record the authoritative result that enrollment committed atomically. + pub(crate) fn atomically_enrolled(evidence: AuthoritativeBindingEvidence) -> Self { + Self { + evidence, + outcome: BindingResolutionOutcome::AtomicallyEnrolled, + } + } + + /// Whether authoritative storage resolved an already-active binding. + pub const fn is_existing_active(&self) -> bool { + matches!(self.outcome, BindingResolutionOutcome::ExistingActive) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.evidence.authorization_domain() + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.evidence.binding_id() + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + self.evidence.principal() + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.evidence.bound_pubkey() + } + + /// Current local binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.evidence.binding_version() + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.evidence.expires_at() + } + + /// Persisted provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.evidence.source() + } +} + +impl fmt::Debug for AuthoritativeBindingResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthoritativeBindingResolution") + .field(&"[redacted]") + .finish() + } +} + +impl fmt::Debug for VersionedBindingRef { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VersionedBindingRef") + .field("authorization_domain", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("principal", &self.principal) + .field("bound_pubkey", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("expires_at", &"[redacted]") + .field("source", &"[redacted]") + .field("resolution_reason", &"[redacted]") + .finish() + } +} + +impl VersionedBindingRef { + pub(super) fn from_authoritative_resolution( + resolution: AuthoritativeBindingResolution, + requirement: FederatedIdentityRequirement, + ) -> Result { + let reason = match resolution.outcome { + BindingResolutionOutcome::ExistingActive => AuthorizationReason::ExistingBinding, + BindingResolutionOutcome::AtomicallyEnrolled => { + match (requirement, resolution.evidence.source) { + ( + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + BindingSource::AttestedKey, + ) => AuthorizationReason::EnrolledAttestedKey, + ( + FederatedIdentityRequirement::Required(EnrollmentMode::Tofu), + BindingSource::Tofu | BindingSource::AttestedKey, + ) => AuthorizationReason::EnrolledTofu, + _ => return Err(AuthContextError::InvalidAuthorizationReason), + } + } + }; + Ok(Self::from_authoritative_evidence( + resolution.evidence, + reason, + )) + } + + pub(super) fn from_existing_authoritative_resolution( + resolution: AuthoritativeBindingResolution, + ) -> Result { + if !resolution.is_existing_active() { + return Err(AuthContextError::DelegatedBindingNotExistingActive); + } + Ok(Self::from_authoritative_evidence( + resolution.evidence, + AuthorizationReason::ExistingBinding, + )) + } + + fn from_authoritative_evidence( + evidence: AuthoritativeBindingEvidence, + resolution_reason: AuthorizationReason, + ) -> Self { + Self { + authorization_domain: evidence.authorization_domain, + binding_id: evidence.binding_id, + principal: evidence.principal, + bound_pubkey: evidence.bound_pubkey, + binding_version: evidence.binding_version, + expires_at: evidence.expires_at, + source: evidence.source, + resolution_reason, + } + } + + /// Build a reference to a binding authoritatively resolved as already active. + #[cfg(test)] + pub(crate) fn new_existing_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason: AuthorizationReason::ExistingBinding, + }) + } + + /// Build a reference to a binding atomically enrolled in this decision. + #[cfg(test)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_enrolled_active_for_test( + authorization_domain: CommunityId, + binding_id: Uuid, + principal: FederatedPrincipal, + bound_pubkey: PublicKey, + binding_version: BindingVersion, + expires_at: Option, + source: BindingSource, + reason: AuthorizationReason, + ) -> Result { + if binding_id.is_nil() { + return Err(AuthContextError::InvalidBindingId); + } + if !matches!( + reason, + AuthorizationReason::EnrolledAttestedKey | AuthorizationReason::EnrolledTofu + ) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + Ok(Self { + authorization_domain, + binding_id, + principal, + bound_pubkey, + binding_version, + expires_at, + source, + resolution_reason: reason, + }) + } + + /// Server-resolved authorization domain that owns the binding. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Stable binding identifier. + pub const fn binding_id(&self) -> Uuid { + self.binding_id + } + + /// Issuer-qualified principal represented by the binding. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key owned by the binding. + pub const fn bound_pubkey(&self) -> PublicKey { + self.bound_pubkey + } + + /// Current binding version. + pub const fn binding_version(&self) -> BindingVersion { + self.binding_version + } + + /// Optional authoritative temporal bound for authorization eligibility. + pub const fn expires_at(&self) -> Option { + self.expires_at + } + + /// Provenance of the active binding. + pub const fn source(&self) -> BindingSource { + self.source + } + + /// Stable reason proven by the authoritative binding lifecycle result. + pub(super) const fn authorization_reason(&self) -> AuthorizationReason { + self.resolution_reason + } +} diff --git a/crates/buzz-auth/src/context/evidence.rs b/crates/buzz-auth/src/context/evidence.rs new file mode 100644 index 0000000000..c99018a4b0 --- /dev/null +++ b/crates/buzz-auth/src/context/evidence.rs @@ -0,0 +1,706 @@ +use std::fmt; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use uuid::Uuid; + +use crate::Scope; + +#[cfg(test)] +use super::transport_accepts_proof; +use super::AuthContextError; + +/// Cryptographic proof used to authenticate the Nostr actor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMethod { + /// NIP-42 challenge/response over WebSocket. + Nip42, + /// NIP-98 signed HTTP request. + Nip98, + /// Blossom upload authorization. + Blossom, +} + +/// Entry point that produced the authorization context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthTransport { + /// Relay WebSocket protocol. + RelayWebSocket, + /// HTTP relay bridge. + HttpBridge, + /// Git-over-HTTP endpoint. + Git, + /// Media upload endpoint. + MediaUpload, + /// Authenticated media download endpoint, including `GET` and `HEAD`. + MediaDownload, + /// Huddle audio WebSocket. + Audio, +} + +/// Transport profile used to deliver a federated assertion. +/// +/// This records how the assertion reached its verifier. It is intentionally +/// independent of [`AuthTransport`]; each authentication adapter must verify +/// the delivery profile before constructing authorization evidence. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AssertionTransport { + /// A trusted proxy stripped inbound copies and injected the assertion. + TrustedProxy, + /// The client attached the assertion to the authorized request. + ClientAttached, +} + +impl fmt::Debug for AssertionTransport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionTransport") + .field(&"[redacted]") + .finish() + } +} + +/// Expiry of a validated federated assertion, expressed as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionExpiry(u64); + +impl fmt::Debug for AssertionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionExpiry { + /// Build a non-zero assertion expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAssertionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the assertion is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Earliest valid time from a validated federated assertion, as Unix seconds. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AssertionNotBefore(u64); + +impl fmt::Debug for AssertionNotBefore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AssertionNotBefore") + .field(&"[redacted]") + .finish() + } +} + +impl AssertionNotBefore { + /// Preserve a validated `nbf` timestamp for finalization checks. + pub const fn new(unix_seconds: u64) -> Self { + Self(unix_seconds) + } + + /// Earliest valid time as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` while the assertion is not yet valid at `now`. + pub const fn is_not_yet_valid_at(self, now_unix_seconds: u64) -> bool { + self.0 > now_unix_seconds + } +} + +/// Expiry imposed by a separately verified delegation proof. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DelegationExpiry(u64); + +impl fmt::Debug for DelegationExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl DelegationExpiry { + /// Build a non-zero delegation expiry. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidDelegationExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Expiry as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when the delegation is no longer valid at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Freshness bound imposed by current community or enterprise admission. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AdmissionExpiry(u64); + +impl fmt::Debug for AdmissionExpiry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AdmissionExpiry") + .field(&"[redacted]") + .finish() + } +} + +impl AdmissionExpiry { + /// Build a non-zero admission freshness bound. + pub const fn new(unix_seconds: u64) -> Result { + if unix_seconds == 0 { + return Err(AuthContextError::InvalidAdmissionExpiry); + } + Ok(Self(unix_seconds)) + } + + /// Freshness bound as seconds since the Unix epoch. + pub const fn unix_seconds(self) -> u64 { + self.0 + } + + /// Returns `true` when admission is no longer current at `now`. + pub const fn is_expired_at(self, now_unix_seconds: u64) -> bool { + self.0 <= now_unix_seconds + } +} + +/// Server-verified Nostr authority for a request or connection. +#[derive(PartialEq, Eq)] +pub struct NostrAuthority { + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl fmt::Debug for NostrAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NostrAuthority") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.proof_method) + .field("verified_delegation", &"[redacted]") + .finish() + } +} + +impl NostrAuthority { + pub(super) fn new(proof: VerifiedNostrProof) -> Self { + Self { + actor_pubkey: proof.actor_pubkey, + proof_method: proof.proof_method, + verified_delegation: proof.verified_delegation, + } + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Proof method used to authenticate the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Cryptographically verified owner for a delegated Nostr actor. + pub const fn verified_owner_pubkey(&self) -> Option { + match &self.verified_delegation { + Some(delegation) => Some(delegation.owner_pubkey()), + None => None, + } + } + + /// Cryptographically verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +/// Stable identity-provider principal. +/// +/// Equality uses the exact validated issuer and subject bytes. Construction +/// does not trim, case-fold, parse, or otherwise normalize either value; the +/// assertion verifier owns canonical validation before crossing this boundary. +/// Neither value is suitable for public events or general-purpose logs. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct FederatedPrincipal { + issuer: String, + subject: String, +} + +impl FederatedPrincipal { + /// Build an issuer-qualified principal from validated assertion claims. + pub fn new( + issuer: impl Into, + subject: impl Into, + ) -> Result { + let issuer = issuer.into(); + let subject = subject.into(); + if issuer.is_empty() { + return Err(AuthContextError::EmptyIssuer); + } + if subject.is_empty() { + return Err(AuthContextError::EmptySubject); + } + Ok(Self { issuer, subject }) + } + + /// Validated identity-provider issuer. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// Stable, non-reassignable subject within the issuer namespace. + pub fn subject(&self) -> &str { + &self.subject + } +} + +impl fmt::Debug for FederatedPrincipal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FederatedPrincipal") + .field("issuer", &"[redacted]") + .field("subject", &"[redacted]") + .finish() + } +} + +/// Identity-provider attestation of the Nostr key proven by this request. +/// +/// The assertion verifier may construct this evidence only after requiring the +/// configured key claim, parsing it successfully, and proving that it names +/// the exact Nostr key. Absence or mismatch must fail rather than silently +/// falling back to another enrollment mode. The evidence is intentionally +/// move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedKeyAttestation { + pubkey: PublicKey, +} + +impl VerifiedKeyAttestation { + #[cfg(test)] + pub(crate) const fn new(pubkey: PublicKey) -> Self { + Self { pubkey } + } + + /// Nostr key named by the verified assertion claim. + pub const fn pubkey(&self) -> PublicKey { + self.pubkey + } +} + +impl fmt::Debug for VerifiedKeyAttestation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedKeyAttestation") + .field("pubkey", &"[redacted]") + .finish() + } +} + +/// Federated assertion accepted by the configured assertion verifier. +/// +/// The verifier must enforce an allowed algorithm and key, require correctly +/// typed `exp`, `iss`, and `aud` claims, validate the issuer and audience, and +/// reject a malformed `nbf` before constructing this evidence. Finalization +/// independently enforces the preserved `nbf` against server time. Raw +/// assertion claims cannot construct it from outside `buzz-auth`. Private +/// identity attributes and public display labels are deliberately excluded. +/// The evidence is intentionally move-only and has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedFederatedAssertion { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, +} + +impl VerifiedFederatedAssertion { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + key_attestation: Option, + transport: AssertionTransport, + not_before: Option, + expires_at: AssertionExpiry, + ) -> Self { + Self { + authorization_domain, + authorized_transport, + principal, + key_attestation, + transport, + not_before, + expires_at, + } + } + + /// Authorization domain for which the assertion was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the verifier authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Issuer-qualified principal from the verified assertion. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Nostr key attested by the verified assertion claim, when present. + pub const fn key_attestation(&self) -> Option<&VerifiedKeyAttestation> { + self.key_attestation.as_ref() + } + + /// Verified assertion delivery profile. + pub const fn transport(&self) -> AssertionTransport { + self.transport + } + + /// Earliest valid time preserved from the verified assertion, when present. + pub const fn not_before(&self) -> Option { + self.not_before + } + + /// Upper time bound carried by the verified assertion. + pub const fn expires_at(&self) -> AssertionExpiry { + self.expires_at + } +} + +impl fmt::Debug for VerifiedFederatedAssertion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedFederatedAssertion") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("principal", &self.principal) + .field("key_attestation", &"[redacted]") + .field("transport", &"[redacted]") + .field("not_before", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +/// Current admission for the owner of a delegated Nostr actor. +/// +/// This evidence is independent of a federated assertion: a delegated request +/// need not possess the owner's token. A provider adapter will construct it +/// only after confirming that the bound owner is currently admitted in the +/// same authorization domain. Construction remains crate-private so only the +/// validated provider finalizer can turn a current capability decision into +/// this move-only evidence. +#[derive(PartialEq, Eq)] +pub struct VerifiedOwnerAdmission { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, +} + +impl VerifiedOwnerAdmission { + // Consumed by the provider finalizer in the stacked capability contract. + #[allow(dead_code)] + pub(crate) const fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + fresh_until: AdmissionExpiry, + ) -> Self { + Self { + authorization_domain, + principal, + fresh_until, + } + } + + /// Authorization domain for which owner admission was resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Issuer-qualified owner admitted by the provider. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Upper bound after which provider admission must be resolved again. + pub const fn fresh_until(&self) -> AdmissionExpiry { + self.fresh_until + } +} + +impl fmt::Debug for VerifiedOwnerAdmission { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedOwnerAdmission") + .field("authorization_domain", &"[redacted]") + .field("principal", &self.principal) + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Capability represented by a verified owner-to-delegate proof. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DelegationCapability { + /// Authorizes the complete request or connection represented by the context. + TransportWide, +} + +impl fmt::Debug for DelegationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DelegationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Transport-wide delegation from a bound owner to the authenticated key. +/// +/// A verifier may construct this only after proving the capability authorizes +/// the complete target request or connection. Time-only constraints may be +/// reduced to [`DelegationExpiry`], but operation-, event-kind-, or +/// request-specific constraints must not be discarded or promoted into this +/// transport-wide evidence. This move-only evidence has no default or +/// deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedTransportDelegation { + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + capability: DelegationCapability, + expires_at: Option, +} + +impl fmt::Debug for VerifiedTransportDelegation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedTransportDelegation") + .field("owner_pubkey", &"[redacted]") + .field("delegate_pubkey", &"[redacted]") + .field("capability", &"[redacted]") + .field("expires_at", &"[redacted]") + .finish() + } +} + +impl VerifiedTransportDelegation { + /// Build transport-wide evidence after validating both keys and confirming + /// that no narrower capability constraint is being discarded. + #[cfg(test)] + pub(crate) fn new_unrestricted( + owner_pubkey: PublicKey, + delegate_pubkey: PublicKey, + expires_at: Option, + ) -> Result { + if owner_pubkey == delegate_pubkey { + return Err(AuthContextError::SelfDelegation); + } + Ok(Self { + owner_pubkey, + delegate_pubkey, + capability: DelegationCapability::TransportWide, + expires_at, + }) + } + + /// Bound owner that authorized the delegate. + pub const fn owner_pubkey(&self) -> PublicKey { + self.owner_pubkey + } + + /// Authenticated delegate key. + pub const fn delegate_pubkey(&self) -> PublicKey { + self.delegate_pubkey + } + + /// Verified capability scope. + pub const fn capability(&self) -> DelegationCapability { + self.capability + } + + /// Optional upper bound imposed by the delegation proof. + pub const fn expires_at(&self) -> Option { + self.expires_at + } +} + +/// Cryptographically verified Nostr proof for one request or connection. +/// +/// Transport verifiers inside `buzz-auth` produce this evidence after checking +/// the signature and transport-specific binding. Raw request keys and claimed +/// proof methods cannot construct it in relay call sites. Conditional +/// delegation may be attached only when it has been fully evaluated for the +/// target operation or safely reduced to transport-wide evidence. The evidence +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct VerifiedNostrProof { + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, +} + +impl VerifiedNostrProof { + #[cfg(test)] + pub(crate) fn new( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + verified_delegation: Option, + ) -> Result { + if !transport_accepts_proof(authorized_transport, proof_method) { + return Err(AuthContextError::TransportProofMismatch); + } + if verified_delegation + .as_ref() + .is_some_and(|delegation| delegation.delegate_pubkey() != actor_pubkey) + { + return Err(AuthContextError::DelegateKeyMismatch); + } + Ok(Self { + authorization_domain, + authorized_transport, + actor_pubkey, + proof_method, + verified_delegation, + }) + } + + /// Authorization domain for which the Nostr proof was verified. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Transport whose request or connection the proof authorized. + pub const fn authorized_transport(&self) -> AuthTransport { + self.authorized_transport + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method accepted by the verifier. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Verified owner-to-actor delegation, when present. + pub const fn verified_delegation(&self) -> Option<&VerifiedTransportDelegation> { + self.verified_delegation.as_ref() + } +} + +impl fmt::Debug for VerifiedNostrProof { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("VerifiedNostrProof") + .field("authorization_domain", &"[redacted]") + .field("authorized_transport", &self.authorized_transport) + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &self.proof_method) + .field("verified_delegation", &"[redacted]") + .finish() + } +} + +/// Successful community admission and permissions for one decision. +/// +/// An authorization adapter may construct this value only after membership, +/// invite, moderation, or equivalent community policy has allowed the actor. +/// Durable identity enrollment and public assertion publication must not occur +/// before this evidence exists; future binding adapters should require a borrow +/// of it before committing either side effect. Raw request scopes and channel +/// identifiers cannot construct this value in relay call sites. The resolution +/// is intentionally move-only and has no default or deserialization path. +#[derive(PartialEq, Eq)] +pub struct AuthorizedCommunityAccess { + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, +} + +impl AuthorizedCommunityAccess { + #[cfg(test)] + pub(crate) const fn new( + authorization_domain: CommunityId, + scopes: Vec, + channel_ids: Option>, + ) -> Self { + Self { + authorization_domain, + scopes, + channel_ids, + } + } + + /// Authorization domain for which the permissions were resolved. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Permission scopes resolved for the decision. + pub fn scopes(&self) -> &[Scope] { + &self.scopes + } + + /// Optional channel restriction resolved for the decision. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + self.channel_ids.as_deref() + } + + /// Consume verified admission into the final immutable permissions. + pub(super) fn into_permissions(self) -> (Vec, Option>) { + (self.scopes, self.channel_ids) + } +} + +impl fmt::Debug for AuthorizedCommunityAccess { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizedCommunityAccess") + .field("authorization_domain", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} diff --git a/crates/buzz-auth/src/context/mod.rs b/crates/buzz-auth/src/context/mod.rs new file mode 100644 index 0000000000..95883be426 --- /dev/null +++ b/crates/buzz-auth/src/context/mod.rs @@ -0,0 +1,656 @@ +//! Versioned, transport-neutral authorization context. +//! +//! Authentication adapters produce this context after verifying Nostr proof. +//! Federated identity is optional, but when present it remains distinct from +//! the Nostr authority that signed the request. Raw assertions and mutable +//! display claims never enter this type. + +use std::fmt; + +use buzz_core::{tenant::TenantContext, CommunityId}; +use nostr::PublicKey; +use uuid::Uuid; + +use crate::Scope; + +pub(crate) mod authority; +mod binding; +mod evidence; +mod reason; + +pub use authority::{ + resolve_current_federated_policy, AuthorityAdapterError, AuthorityAdapterFuture, + BindingResolutionRequest, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DirectBindingResolutionSink, ExistingBindingResolutionSink, FederatedAuthorityAdapter, +}; +pub(crate) use binding::AuthoritativeBindingEvidence; +pub use binding::{ + AuthoritativeBindingResolution, BindingExpiry, BindingSource, BindingVersion, EnrollmentMode, + FederatedIdentityRequirement, FederatedPolicyStamp, ResolvedFederatedPolicy, + VersionedBindingRef, +}; +pub use evidence::{ + AdmissionExpiry, AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthMethod, + AuthTransport, AuthorizedCommunityAccess, DelegationCapability, DelegationExpiry, + FederatedPrincipal, NostrAuthority, VerifiedFederatedAssertion, VerifiedKeyAttestation, + VerifiedNostrProof, VerifiedOwnerAdmission, VerifiedTransportDelegation, +}; +pub use reason::{AuthContextError, AuthorizationReason}; + +/// Version of the authorization-context contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthContextVersion { + /// Initial shared authorization-context contract. + V1, +} + +/// Federated authorization attached to a Nostr-authenticated actor. +#[derive(PartialEq, Eq)] +pub enum FederatedAuthorization { + /// This deployment does not require federated identity. + /// + /// An independently verified Nostr owner may still be present in the + /// [`NostrAuthority`] without acquiring a federated binding. + NotRequired, + /// The actor directly owns the active federated binding. + Direct { + /// Active identity-to-key binding. + /// + /// The binding carries its authoritative lifecycle result, so a caller + /// cannot relabel a binding enrolled in this decision as pre-existing. + binding: VersionedBindingRef, + /// Current assertion accepted by the configured verifier. + assertion: VerifiedFederatedAssertion, + }, + /// The actor is delegated by the owner of an active federated binding. + Delegated { + /// Owner's active binding. + owner: VersionedBindingRef, + /// Current admission resolved for the bound owner. + admission: VerifiedOwnerAdmission, + }, +} + +impl fmt::Debug for FederatedAuthorization { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("FederatedAuthorization") + .field(&"[redacted]") + .finish() + } +} + +/// Authoritative result consumed by the production finalizer. +/// +/// Unlike [`FederatedAuthorization`], this input cannot contain a raw +/// [`VersionedBindingRef`] or a caller-selected authorization reason. +#[derive(PartialEq, Eq)] +pub enum AuthoritativeFederatedResolution { + /// This domain's current policy does not require federated identity. + NotRequired, + /// Direct authority backed by an existing or atomically enrolled binding. + Direct { + /// Typed authoritative lifecycle result. + binding: AuthoritativeBindingResolution, + /// Current verified assertion for the authenticated actor. + assertion: VerifiedFederatedAssertion, + }, + /// Delegated authority backed by an already-active owner binding. + Delegated { + /// Typed authoritative result for the existing owner binding. + owner: AuthoritativeBindingResolution, + /// Current admission resolved for the owner. + admission: VerifiedOwnerAdmission, + }, +} + +impl fmt::Debug for AuthoritativeFederatedResolution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthoritativeFederatedResolution") + .field(&"[redacted]") + .finish() + } +} + +impl AuthoritativeFederatedResolution { + #[allow(dead_code)] + pub(crate) const fn principal(&self) -> Option<&FederatedPrincipal> { + match self { + Self::NotRequired => None, + Self::Direct { assertion, .. } => Some(assertion.principal()), + Self::Delegated { admission, .. } => Some(admission.principal()), + } + } +} + +/// Initial shared authorization-context contract. +#[derive(PartialEq, Eq)] +pub struct AuthContextV1 { + tenant: TenantContext, + correlation_id: Uuid, + transport: AuthTransport, + nostr: NostrAuthority, + federated_policy: ResolvedFederatedPolicy, + federated: FederatedAuthorization, + scopes: Vec, + channel_ids: Option>, +} + +/// Server-verified inputs consumed by the V1 authorization finalizer. +#[derive(PartialEq, Eq)] +pub struct AuthContextInput { + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + community_access: AuthorizedCommunityAccess, +} + +/// Opaque proof that a validated capability snapshot was consumed. +/// +/// Only the crate-owned provider finalizer can construct this value. It keeps +/// the low-level context finalizer public for a stacked contract while making +/// it impossible for downstream code to bypass capability authorization. +pub struct CapabilityFinalizationSeal { + _private: (), +} + +impl CapabilityFinalizationSeal { + #[allow(dead_code)] + pub(crate) const fn new() -> Self { + Self { _private: () } + } +} + +impl fmt::Debug for CapabilityFinalizationSeal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CapabilityFinalizationSeal") + .field(&"[redacted]") + .finish() + } +} + +impl AuthContextInput { + /// Collect evidence after cryptographic authentication and community + /// admission have both succeeded. + pub fn new( + tenant: TenantContext, + correlation_id: Uuid, + nostr_proof: VerifiedNostrProof, + community_access: AuthorizedCommunityAccess, + ) -> Self { + Self { + tenant, + correlation_id, + nostr_proof, + community_access, + } + } + + #[allow(dead_code)] + pub(crate) const fn authorization_domain(&self) -> CommunityId { + self.tenant.community() + } + + #[allow(dead_code)] + pub(crate) const fn nostr_proof_authorization_domain(&self) -> CommunityId { + self.nostr_proof.authorization_domain() + } + + #[allow(dead_code)] + pub(crate) const fn community_access_authorization_domain(&self) -> CommunityId { + self.community_access.authorization_domain() + } + + #[allow(dead_code)] + pub(crate) const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + #[allow(dead_code)] + pub(crate) const fn transport(&self) -> AuthTransport { + self.nostr_proof.authorized_transport() + } + + #[allow(dead_code)] + pub(crate) const fn proof_method(&self) -> AuthMethod { + self.nostr_proof.proof_method() + } + + #[allow(dead_code)] + pub(crate) const fn actor_pubkey(&self) -> PublicKey { + self.nostr_proof.actor_pubkey() + } + + #[allow(dead_code)] + pub(crate) const fn verified_owner_pubkey(&self) -> Option { + match self.nostr_proof.verified_delegation() { + Some(delegation) => Some(delegation.owner_pubkey()), + None => None, + } + } +} + +impl fmt::Debug for AuthContextV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthContextV1") + .field("authorization_domain", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("transport", &self.transport) + .field("nostr", &self.nostr) + .field("federated_policy", &self.federated_policy) + .field("federated", &self.federated) + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .finish() + } +} + +/// Versioned result of successful request or connection authorization. +/// +/// This security-boundary type intentionally has no default or deserialization +/// path. Persisted or transported data must be re-verified and finalized rather +/// than decoded directly into an authorized context. +#[derive(PartialEq, Eq)] +pub enum AuthContext { + /// Initial shared authorization-context contract. + V1(AuthContextV1), +} + +impl fmt::Debug for AuthContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::V1(context) => formatter.debug_tuple("V1").field(context).finish(), + } + } +} + +impl AuthContext { + /// Finalize an immutable V1 context from authoritative binding evidence. + /// + /// A crate-owned authority adapter must resolve the enrollment-policy stamp + /// and binding lifecycle state from current authoritative storage, use the + /// policy epoch as a conditional precondition for any atomic enrollment, + /// and pass the resulting opaque lifecycle outcome here. The finalizer + /// derives the authorization reason; transport code cannot select it or + /// construct authoritative policy and binding outcomes. + /// + /// The opaque seal ensures a production caller first consumed the validated + /// capability decision supplied by the provider contract. + pub fn finalize_authoritative_v1( + _capability: CapabilityFinalizationSeal, + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + resolution: AuthoritativeFederatedResolution, + now_unix_seconds: u64, + ) -> Result { + validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; + let authorization = match resolution { + AuthoritativeFederatedResolution::NotRequired => FederatedAuthorization::NotRequired, + AuthoritativeFederatedResolution::Direct { binding, assertion } => { + FederatedAuthorization::Direct { + binding: VersionedBindingRef::from_authoritative_resolution( + binding, + federated_policy.requirement(), + )?, + assertion, + } + } + AuthoritativeFederatedResolution::Delegated { owner, admission } => { + FederatedAuthorization::Delegated { + owner: VersionedBindingRef::from_existing_authoritative_resolution(owner)?, + admission, + } + } + }; + Self::finalize_v1(input, federated_policy, authorization, now_unix_seconds) + } + + /// Validate all authorization evidence and finalize an immutable V1 context. + /// + /// Adapters must preserve this phase order: cryptographic proof and + /// read-only assertion validation; community admission and capability + /// resolution; atomic binding/enrollment; then finalization. A denial before + /// admission must not create or refresh a binding, claim membership, or + /// publish a public identity assertion. + /// + /// `now_unix_seconds` must come from the server clock for the authorization + /// decision being finalized. + pub(crate) fn finalize_v1( + input: AuthContextInput, + federated_policy: ResolvedFederatedPolicy, + authorization: FederatedAuthorization, + now_unix_seconds: u64, + ) -> Result { + let authorization_domain = input.tenant.community(); + let transport = input.nostr_proof.authorized_transport(); + if input.nostr_proof.authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + validate_federated_policy_stamp(&input, &federated_policy, now_unix_seconds)?; + if input.community_access.authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + if !transport_accepts_proof(transport, input.nostr_proof.proof_method()) { + return Err(AuthContextError::TransportProofMismatch); + } + validate_federated_authorization( + authorization_domain, + transport, + &input.nostr_proof, + &federated_policy, + &authorization, + now_unix_seconds, + )?; + let nostr = NostrAuthority::new(input.nostr_proof); + let (scopes, channel_ids) = input.community_access.into_permissions(); + Ok(Self::V1(AuthContextV1 { + tenant: input.tenant, + correlation_id: input.correlation_id, + transport, + nostr, + federated_policy, + federated: authorization, + scopes, + channel_ids, + })) + } + + /// Contract version represented by this context. + pub const fn version(&self) -> AuthContextVersion { + match self { + Self::V1(_) => AuthContextVersion::V1, + } + } + + /// Server-resolved tenant for the request or connection. + pub const fn tenant(&self) -> &TenantContext { + match self { + Self::V1(context) => &context.tenant, + } + } + + /// Request or connection correlation identifier. + pub const fn correlation_id(&self) -> Uuid { + match self { + Self::V1(context) => context.correlation_id, + } + } + + /// Transport that established this authorization context. + pub const fn transport(&self) -> AuthTransport { + match self { + Self::V1(context) => context.transport, + } + } + + /// Verified Nostr authority. + pub const fn nostr(&self) -> &NostrAuthority { + match self { + Self::V1(context) => &context.nostr, + } + } + + /// Authenticated Nostr actor. + pub const fn pubkey(&self) -> PublicKey { + self.nostr().actor_pubkey() + } + + /// Proof method used to authenticate the Nostr actor. + pub const fn auth_method(&self) -> AuthMethod { + self.nostr().proof_method() + } + + /// Cryptographically verified owner for a delegated Nostr actor. + pub const fn agent_owner_pubkey(&self) -> Option { + self.nostr().verified_owner_pubkey() + } + + /// Federated authorization associated with the Nostr actor. + pub const fn federated_authorization(&self) -> &FederatedAuthorization { + match self { + Self::V1(context) => &context.federated, + } + } + + /// Federated-identity policy resolved for this authorization decision. + pub const fn federated_policy(&self) -> &ResolvedFederatedPolicy { + match self { + Self::V1(context) => &context.federated_policy, + } + } + + /// Stable reason for the successful authorization decision. + pub const fn authorization_reason(&self) -> AuthorizationReason { + match self.federated_authorization() { + FederatedAuthorization::NotRequired => AuthorizationReason::NostrOnly, + FederatedAuthorization::Direct { binding, .. } => binding.authorization_reason(), + FederatedAuthorization::Delegated { .. } => AuthorizationReason::DelegatedOwnerBinding, + } + } + + /// Permission scopes granted to the context. + pub fn scopes(&self) -> &[Scope] { + match self { + Self::V1(context) => &context.scopes, + } + } + + /// Optional channel restriction. + pub fn channel_ids(&self) -> Option<&[Uuid]> { + match self { + Self::V1(context) => context.channel_ids.as_deref(), + } + } + + /// Returns `true` if this context includes the given scope. + pub fn has_scope(&self, scope: &Scope) -> bool { + self.scopes().contains(scope) + } +} + +fn validate_federated_policy_stamp( + input: &AuthContextInput, + federated_policy: &ResolvedFederatedPolicy, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if federated_policy.authorization_domain() != input.tenant.community() { + return Err(AuthContextError::PolicyDomainMismatch); + } + if federated_policy.stamp().correlation_id() != input.correlation_id { + return Err(AuthContextError::FederatedPolicyCorrelationMismatch); + } + if federated_policy + .stamp() + .is_not_yet_effective_at(now_unix_seconds) + { + return Err(AuthContextError::FederatedPolicyNotYetEffective); + } + if federated_policy.stamp().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::FederatedPolicyExpired); + } + Ok(()) +} + +pub(super) const fn transport_accepts_proof( + transport: AuthTransport, + proof_method: AuthMethod, +) -> bool { + match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => { + matches!(proof_method, AuthMethod::Nip42) + } + AuthTransport::HttpBridge | AuthTransport::Git => matches!(proof_method, AuthMethod::Nip98), + AuthTransport::MediaUpload => { + matches!(proof_method, AuthMethod::Nip98 | AuthMethod::Blossom) + } + AuthTransport::MediaDownload => matches!(proof_method, AuthMethod::Nip98), + } +} + +#[cfg(test)] +mod tests; + +fn validate_federated_authorization( + authorization_domain: CommunityId, + authorized_transport: AuthTransport, + nostr_proof: &VerifiedNostrProof, + federated_policy: &ResolvedFederatedPolicy, + authorization: &FederatedAuthorization, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + match (federated_policy.requirement(), authorization) { + (FederatedIdentityRequirement::Required(_), FederatedAuthorization::NotRequired) => { + return Err(AuthContextError::FederatedIdentityRequired); + } + (FederatedIdentityRequirement::NotRequired, FederatedAuthorization::NotRequired) => {} + (FederatedIdentityRequirement::NotRequired, _) => { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + } + (FederatedIdentityRequirement::Required(_), _) => {} + } + + let actor_pubkey = nostr_proof.actor_pubkey(); + let verified_delegation = nostr_proof.verified_delegation(); + match authorization { + FederatedAuthorization::NotRequired => {} + FederatedAuthorization::Direct { binding, assertion } => { + if binding.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if assertion.authorization_domain() != authorization_domain { + return Err(AuthContextError::AssertionDomainMismatch); + } + if assertion.authorized_transport() != authorized_transport { + return Err(AuthContextError::AssertionTransportMismatch); + } + if assertion.principal() != binding.principal() { + return Err(AuthContextError::AssertionPrincipalMismatch); + } + if verified_delegation.is_some() { + return Err(AuthContextError::DirectAuthorizationHasOwner); + } + if binding.bound_pubkey() != actor_pubkey { + return Err(AuthContextError::DirectBindingKeyMismatch); + } + validate_binding_time(binding, now_unix_seconds)?; + validate_assertion_time(assertion, now_unix_seconds)?; + let FederatedIdentityRequirement::Required(enrollment_mode) = + federated_policy.requirement() + else { + return Err(AuthContextError::UnexpectedFederatedAuthorization); + }; + let reason = binding.authorization_reason(); + if !direct_reason_is_valid(reason, enrollment_mode, binding.source()) { + return Err(AuthContextError::InvalidAuthorizationReason); + } + validate_enrollment_key_attestation(reason, binding.source(), assertion, actor_pubkey)?; + } + FederatedAuthorization::Delegated { owner, admission } => { + if owner.authorization_domain() != authorization_domain { + return Err(AuthContextError::BindingDomainMismatch); + } + if admission.authorization_domain() != authorization_domain { + return Err(AuthContextError::OwnerAdmissionDomainMismatch); + } + if admission.principal() != owner.principal() { + return Err(AuthContextError::OwnerAdmissionPrincipalMismatch); + } + let Some(delegation) = verified_delegation else { + return Err(AuthContextError::DelegationRequired); + }; + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(AuthContextError::DelegatedOwnerMismatch); + } + validate_binding_time(owner, now_unix_seconds)?; + if admission.fresh_until().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::OwnerAdmissionExpired); + } + if delegation + .expires_at() + .is_some_and(|expiry| expiry.is_expired_at(now_unix_seconds)) + { + return Err(AuthContextError::DelegationExpired); + } + } + } + Ok(()) +} + +fn validate_binding_time( + binding: &VersionedBindingRef, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if binding + .expires_at() + .is_some_and(|expiry| expiry.is_expired_at(now_unix_seconds)) + { + return Err(AuthContextError::BindingExpired); + } + Ok(()) +} + +fn validate_assertion_time( + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, +) -> Result<(), AuthContextError> { + if assertion + .not_before() + .is_some_and(|not_before| not_before.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(AuthContextError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(AuthContextError::AssertionExpired); + } + Ok(()) +} + +const fn direct_reason_is_valid( + reason: AuthorizationReason, + enrollment_mode: EnrollmentMode, + binding_source: BindingSource, +) -> bool { + match reason { + AuthorizationReason::ExistingBinding => true, + AuthorizationReason::EnrolledAttestedKey => { + matches!(enrollment_mode, EnrollmentMode::AttestedKey) + && matches!(binding_source, BindingSource::AttestedKey) + } + AuthorizationReason::EnrolledTofu => { + matches!(enrollment_mode, EnrollmentMode::Tofu) + && matches!( + binding_source, + // An attested-key binding is stronger provenance than + // TOFU. The decision reason records the enrollment policy + // while the stored source remains truthful and is never + // downgraded to TOFU. + BindingSource::Tofu | BindingSource::AttestedKey + ) + } + AuthorizationReason::NostrOnly | AuthorizationReason::DelegatedOwnerBinding => false, + } +} + +fn validate_enrollment_key_attestation( + reason: AuthorizationReason, + binding_source: BindingSource, + assertion: &VerifiedFederatedAssertion, + actor_pubkey: PublicKey, +) -> Result<(), AuthContextError> { + let requires_attestation = matches!(reason, AuthorizationReason::EnrolledAttestedKey) + || (matches!(reason, AuthorizationReason::EnrolledTofu) + && matches!(binding_source, BindingSource::AttestedKey)); + if let Some(attestation) = assertion.key_attestation() { + if attestation.pubkey() != actor_pubkey { + return Err(AuthContextError::KeyAttestationMismatch); + } + return Ok(()); + } + if requires_attestation { + return Err(AuthContextError::KeyAttestationRequired); + } + Ok(()) +} diff --git a/crates/buzz-auth/src/context/reason.rs b/crates/buzz-auth/src/context/reason.rs new file mode 100644 index 0000000000..fe07e9a68f --- /dev/null +++ b/crates/buzz-auth/src/context/reason.rs @@ -0,0 +1,227 @@ +use std::fmt; + +use thiserror::Error; + +/// Stable reason for an allowed authorization decision. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationReason { + /// Only the configured Nostr proof was required. + NostrOnly, + /// An existing direct federated binding matched. + /// + /// Enrollment policy governs creation of new bindings. Resolution of an + /// existing active binding, including future lease checks, is a separate + /// lifecycle decision. + ExistingBinding, + /// A direct binding was created under attested-key enrollment. + EnrolledAttestedKey, + /// A direct binding was created under trust-on-first-use enrollment. + EnrolledTofu, + /// A verified delegate derived authority from a bound owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for AuthorizationReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationReason") + .field(&"[redacted]") + .finish() + } +} + +impl AuthorizationReason { + /// Stable audit and metric code for this decision. + pub const fn code(self) -> &'static str { + match self { + Self::NostrOnly => "authorization_allow_001", + Self::ExistingBinding => "authorization_allow_002", + Self::EnrolledAttestedKey => "authorization_allow_003", + Self::EnrolledTofu => "authorization_allow_004", + Self::DelegatedOwnerBinding => "authorization_allow_005", + } + } +} + +/// Invalid authorization-context construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum AuthContextError { + /// Issuer was empty. + #[error("federated principal issuer must not be empty")] + EmptyIssuer, + /// Subject was empty. + #[error("federated principal subject must not be empty")] + EmptySubject, + /// Binding version was zero. + #[error("identity binding version must be greater than zero")] + InvalidBindingVersion, + /// Binding identifier was the nil UUID. + #[error("identity binding identifier must not be nil")] + InvalidBindingId, + /// Binding expiry was not a valid Unix timestamp. + #[error("identity binding expiry must be greater than zero")] + InvalidBindingExpiry, + /// Enrollment-policy identifier was nil. + #[error("federated enrollment-policy identifier must not be nil")] + InvalidFederatedPolicyId, + /// Enrollment-policy epoch was zero. + #[error("federated enrollment-policy epoch must be greater than zero")] + InvalidFederatedPolicyEpoch, + /// Enrollment-policy correlation identifier was nil. + #[error("federated enrollment-policy correlation must not be nil")] + InvalidFederatedPolicyCorrelation, + /// Enrollment-policy effective interval was empty or reversed. + #[error("federated enrollment-policy effective interval is invalid")] + InvalidFederatedPolicyInterval, + /// Assertion expiry was not a valid Unix timestamp. + #[error("federated assertion expiry must be greater than zero")] + InvalidAssertionExpiry, + /// Delegation expiry was not a valid Unix timestamp. + #[error("delegation expiry must be greater than zero")] + InvalidDelegationExpiry, + /// Admission expiry was not a valid Unix timestamp. + #[error("admission expiry must be greater than zero")] + InvalidAdmissionExpiry, + /// Assertion had expired when authorization was evaluated. + #[error("federated assertion has expired")] + AssertionExpired, + /// Binding was no longer authorization-eligible when evaluated. + #[error("identity binding has expired")] + BindingExpired, + /// Enrollment policy was resolved for another authorization decision. + #[error("federated enrollment policy does not match the authorization decision")] + FederatedPolicyCorrelationMismatch, + /// Enrollment policy was used before its effective interval. + #[error("federated enrollment policy is not yet effective")] + FederatedPolicyNotYetEffective, + /// Enrollment policy was used after its effective interval. + #[error("federated enrollment policy has expired")] + FederatedPolicyExpired, + /// Assertion was used before its validated not-before bound. + #[error("federated assertion is not yet valid")] + AssertionNotYetValid, + /// Required key-attestation evidence was absent. + #[error("verified key attestation is required for this enrollment result")] + KeyAttestationRequired, + /// Key-attestation evidence named a different Nostr actor. + #[error("verified key attestation does not match the authenticated Nostr key")] + KeyAttestationMismatch, + /// Owner admission was no longer current when authorization was evaluated. + #[error("owner admission is no longer current")] + OwnerAdmissionExpired, + /// Resolved policy required federated identity, but none was supplied. + #[error("federated identity is required by the resolved authorization policy")] + FederatedIdentityRequired, + /// Federated authorization was supplied for a domain that does not use it. + #[error("federated authorization does not match the resolved authorization policy")] + UnexpectedFederatedAuthorization, + /// Delegation had expired when authorization was evaluated. + #[error("verified delegation has expired")] + DelegationExpired, + /// Owner and delegate were the same key. + #[error("delegation owner and delegate must be different keys")] + SelfDelegation, + /// Direct authorization reason did not match its enrollment policy or source. + #[error("federated authorization reason does not match binding provenance")] + InvalidAuthorizationReason, + /// Binding belonged to a different server-resolved authorization domain. + #[error("federated binding does not belong to the authorization domain")] + BindingDomainMismatch, + /// Nostr proof was verified for a different authorization domain. + #[error("Nostr proof does not belong to the authorization domain")] + NostrProofDomainMismatch, + /// Federated policy was resolved for a different authorization domain. + #[error("federated policy does not belong to the authorization domain")] + PolicyDomainMismatch, + /// Community admission was resolved for a different authorization domain. + #[error("community admission does not belong to the authorization domain")] + CommunityAccessDomainMismatch, + /// Assertion was verified for a different authorization domain. + #[error("federated assertion does not belong to the authorization domain")] + AssertionDomainMismatch, + /// Assertion was verified for a different transport. + #[error("federated assertion does not match the authorization transport")] + AssertionTransportMismatch, + /// Owner admission was resolved for a different authorization domain. + #[error("owner admission does not belong to the authorization domain")] + OwnerAdmissionDomainMismatch, + /// Owner admission represented a different bound principal. + #[error("owner admission principal does not match the active binding")] + OwnerAdmissionPrincipalMismatch, + /// Validated assertion principal did not match the active binding. + #[error("federated assertion principal does not match the active binding")] + AssertionPrincipalMismatch, + /// Proof method was not valid for the transport being authorized. + #[error("Nostr proof method does not match authorization transport")] + TransportProofMismatch, + /// Direct federated authorization was attached to a delegated Nostr actor. + #[error("direct federated authorization cannot include a delegated Nostr owner")] + DirectAuthorizationHasOwner, + /// Direct binding key did not match the authenticated actor. + #[error("direct federated binding does not match the authenticated Nostr key")] + DirectBindingKeyMismatch, + /// Delegated authorization named a different actor. + #[error("delegated federated authorization does not match the authenticated Nostr key")] + DelegateKeyMismatch, + /// Delegated federated authorization lacked verified Nostr delegation. + #[error("delegated federated authorization requires verified Nostr delegation")] + DelegationRequired, + /// Delegated authorization did not match the verified Nostr owner. + #[error("delegated federated authorization does not match the verified Nostr owner")] + DelegatedOwnerMismatch, + /// Delegated owner evidence did not resolve an already-active binding. + #[error("delegated federated authorization requires an existing active binding")] + DelegatedBindingNotExistingActive, +} + +impl AuthContextError { + /// Stable audit and metric code for this rejected finalization. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyIssuer => "federated_principal_empty_issuer", + Self::EmptySubject => "federated_principal_empty_subject", + Self::InvalidBindingVersion => "federated_binding_invalid_version", + Self::InvalidBindingId => "federated_binding_invalid_id", + Self::InvalidBindingExpiry => "federated_binding_invalid_expiry", + Self::InvalidFederatedPolicyId => "federated_policy_invalid_id", + Self::InvalidFederatedPolicyEpoch => "federated_policy_invalid_epoch", + Self::InvalidFederatedPolicyCorrelation => "federated_policy_invalid_correlation", + Self::InvalidFederatedPolicyInterval => "federated_policy_invalid_interval", + Self::InvalidAssertionExpiry => "federated_assertion_invalid_expiry", + Self::InvalidDelegationExpiry => "delegation_invalid_expiry", + Self::InvalidAdmissionExpiry => "owner_admission_invalid_expiry", + Self::AssertionExpired => "federated_assertion_expired", + Self::BindingExpired => "federated_binding_expired", + Self::FederatedPolicyCorrelationMismatch => "federated_policy_correlation_mismatch", + Self::FederatedPolicyNotYetEffective => "federated_policy_not_yet_effective", + Self::FederatedPolicyExpired => "federated_policy_expired", + Self::AssertionNotYetValid => "federated_assertion_not_yet_valid", + Self::KeyAttestationRequired => "federated_key_attestation_required", + Self::KeyAttestationMismatch => "federated_key_attestation_mismatch", + Self::OwnerAdmissionExpired => "owner_admission_expired", + Self::FederatedIdentityRequired => "federated_identity_required", + Self::UnexpectedFederatedAuthorization => "federated_authorization_unexpected", + Self::DelegationExpired => "delegation_expired", + Self::SelfDelegation => "delegation_self_reference", + Self::InvalidAuthorizationReason => "federated_binding_invalid_reason", + Self::BindingDomainMismatch => "federated_binding_domain_mismatch", + Self::NostrProofDomainMismatch => "nostr_proof_domain_mismatch", + Self::PolicyDomainMismatch => "federated_policy_domain_mismatch", + Self::CommunityAccessDomainMismatch => "community_access_domain_mismatch", + Self::AssertionDomainMismatch => "federated_assertion_domain_mismatch", + Self::AssertionTransportMismatch => "federated_assertion_transport_mismatch", + Self::OwnerAdmissionDomainMismatch => "owner_admission_domain_mismatch", + Self::OwnerAdmissionPrincipalMismatch => "owner_admission_principal_mismatch", + Self::AssertionPrincipalMismatch => "federated_assertion_principal_mismatch", + Self::TransportProofMismatch => "nostr_transport_proof_mismatch", + Self::DirectAuthorizationHasOwner => "federated_direct_has_owner", + Self::DirectBindingKeyMismatch => "federated_direct_key_mismatch", + Self::DelegateKeyMismatch => "federated_delegate_key_mismatch", + Self::DelegationRequired => "federated_delegation_required", + Self::DelegatedOwnerMismatch => "federated_delegated_owner_mismatch", + Self::DelegatedBindingNotExistingActive => { + "federated_delegated_binding_not_existing_active" + } + } + } +} diff --git a/crates/buzz-auth/src/context/tests.rs b/crates/buzz-auth/src/context/tests.rs new file mode 100644 index 0000000000..528e04e5f5 --- /dev/null +++ b/crates/buzz-auth/src/context/tests.rs @@ -0,0 +1,2019 @@ +use super::*; +use buzz_core::CommunityId; +use nostr::Keys; + +fn tenant(value: u128) -> TenantContext { + TenantContext::resolved(authorization_domain(value), "relay.example") +} + +fn authorization_domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn assertion( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + assertion_in(1, authorized_transport, principal, transport, expiry) +} + +fn assertion_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, +) -> VerifiedFederatedAssertion { + assertion_with_bounds_in( + domain, + authorized_transport, + principal, + transport, + None, + expiry, + ) +} + +fn assertion_with_bounds_in( + domain: u128, + authorized_transport: AuthTransport, + principal: FederatedPrincipal, + transport: AssertionTransport, + not_before: Option, + expiry: u64, +) -> VerifiedFederatedAssertion { + VerifiedFederatedAssertion::new( + authorization_domain(domain), + authorized_transport, + principal, + None, + transport, + not_before.map(AssertionNotBefore::new), + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn assertion_with_attested_key( + principal: FederatedPrincipal, + transport: AssertionTransport, + expiry: u64, + attested_pubkey: PublicKey, +) -> VerifiedFederatedAssertion { + let authorized_transport = match transport { + AssertionTransport::TrustedProxy => AuthTransport::RelayWebSocket, + AssertionTransport::ClientAttached => AuthTransport::HttpBridge, + }; + VerifiedFederatedAssertion::new( + authorization_domain(1), + authorized_transport, + principal, + Some(VerifiedKeyAttestation::new(attested_pubkey)), + transport, + None, + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ) +} + +fn policy_not_required() -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::not_required(authorization_domain(1)) +} + +fn policy_required(enrollment_mode: EnrollmentMode) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::required(authorization_domain(1), enrollment_mode) +} + +fn policy_with_lineage( + enrollment_mode: EnrollmentMode, + correlation_id: Uuid, + effective_from: u64, + effective_until: u64, +) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 7, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + effective_from, + effective_until, + ) + .expect("synthetic federated policy lineage is valid"), + ) +} + +fn binding(pubkey: PublicKey) -> VersionedBindingRef { + binding_in(1, pubkey) +} + +fn authoritative_binding_evidence( + pubkey: PublicKey, + source: BindingSource, +) -> AuthoritativeBindingEvidence { + AuthoritativeBindingEvidence::new( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + ) + .expect("synthetic authoritative binding evidence is valid") +} + +struct TestAuthorityAdapter; + +impl FederatedAuthorityAdapter for TestAuthorityAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + assert_eq!(request.authorization_domain(), authorization_domain(1)); + assert_eq!(request.correlation_id(), Uuid::from_u128(2)); + assert_eq!(request.observed_at(), 100); + assert!(!format!("{request:?}").contains("100")); + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + 7, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + 90, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + assert_eq!(request.policy_id(), Uuid::from_u128(40)); + assert_eq!(request.policy_epoch(), 7); + assert_eq!( + request.policy_requirement(), + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey) + ); + assert!(request.key_attested()); + assert_eq!(request.effective_from(), 90); + assert_eq!(request.effective_until(), 180); + assert_eq!(request.observed_at(), 100); + assert!(!format!("{request:?}").contains("subject-123")); + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(180).expect("synthetic binding expiry is valid")), + BindingSource::AttestedKey, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(180).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .map_err(AuthorityAdapterError::from) + }) + } +} + +fn binding_in(domain: u128, pubkey: PublicKey) -> VersionedBindingRef { + binding_with_source_in(domain, pubkey, BindingSource::AttestedKey) +} + +fn binding_with_source_in( + domain: u128, + pubkey: PublicKey, + source: BindingSource, +) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + authorization_domain(domain), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + ) + .expect("synthetic binding identifier is valid") +} + +fn enrolled_binding( + pubkey: PublicKey, + source: BindingSource, + reason: AuthorizationReason, +) -> VersionedBindingRef { + VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + None, + source, + reason, + ) + .expect("synthetic enrolled binding is valid") +} + +fn expiring_binding(pubkey: PublicKey, expires_at: u64) -> VersionedBindingRef { + VersionedBindingRef::new_existing_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + pubkey, + BindingVersion::INITIAL, + Some(BindingExpiry::new(expires_at).expect("synthetic binding expiry is valid")), + BindingSource::AttestedKey, + ) + .expect("synthetic binding identifier is valid") +} + +fn input( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, +) -> AuthContextInput { + input_with_delegation_expiry(actor_pubkey, transport, verified_owner_pubkey, 200) +} + +fn input_with_delegation_expiry( + actor_pubkey: PublicKey, + transport: AuthTransport, + verified_owner_pubkey: Option, + delegation_expiry: u64, +) -> AuthContextInput { + let verified_delegation = verified_owner_pubkey.map(|owner_pubkey| { + VerifiedTransportDelegation::new_unrestricted( + owner_pubkey, + actor_pubkey, + Some( + DelegationExpiry::new(delegation_expiry) + .expect("synthetic delegation expiry is valid"), + ), + ) + .expect("synthetic owner and delegate are distinct") + }); + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + transport, + actor_pubkey, + proof_method, + verified_delegation, + ) + .expect("synthetic Nostr proof is internally consistent"), + AuthorizedCommunityAccess::new(authorization_domain(1), Scope::all_known(), None), + ) +} + +fn proof_in(domain: u128, transport: AuthTransport, actor_pubkey: PublicKey) -> VerifiedNostrProof { + let proof_method = match transport { + AuthTransport::RelayWebSocket | AuthTransport::Audio => AuthMethod::Nip42, + _ => AuthMethod::Nip98, + }; + VerifiedNostrProof::new( + authorization_domain(domain), + transport, + actor_pubkey, + proof_method, + None, + ) + .expect("synthetic Nostr proof is valid") +} + +fn community_access_in(domain: u128) -> AuthorizedCommunityAccess { + AuthorizedCommunityAccess::new(authorization_domain(domain), Scope::all_known(), None) +} + +fn delegated_authorization( + domain: u128, + owner_pubkey: PublicKey, + admission_principal: FederatedPrincipal, + admission_expiry: u64, +) -> FederatedAuthorization { + FederatedAuthorization::Delegated { + owner: binding_in(domain, owner_pubkey), + admission: VerifiedOwnerAdmission::new( + authorization_domain(domain), + admission_principal, + AdmissionExpiry::new(admission_expiry).expect("synthetic admission expiry is valid"), + ), + } +} + +#[test] +fn context_preserves_server_resolved_authority() { + let keys = Keys::generate(); + let correlation_id = Uuid::from_u128(2); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + correlation_id, + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + keys.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + None, + ), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr-only policy is final authorization"); + + assert_eq!(context.version(), AuthContextVersion::V1); + assert_eq!(context.tenant().community().as_uuid(), &Uuid::from_u128(1)); + assert_eq!(context.correlation_id(), correlation_id); + assert_eq!(context.transport(), AuthTransport::RelayWebSocket); + assert_eq!(context.pubkey(), keys.public_key()); + assert_eq!(context.auth_method(), AuthMethod::Nip42); + assert_eq!( + context.federated_policy().requirement(), + FederatedIdentityRequirement::NotRequired + ); + assert!(context.has_scope(&Scope::MessagesRead)); + assert_eq!( + context.federated_authorization(), + &FederatedAuthorization::NotRequired + ); +} + +#[test] +fn direct_authorization_requires_the_authenticated_key() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(other.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a direct binding for another key must be rejected"); + assert_eq!(error, AuthContextError::DirectBindingKeyMismatch); +} + +#[test] +fn delegated_authorization_requires_the_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect("verified owner and delegate match"); + + assert!(matches!( + context.federated_authorization(), + FederatedAuthorization::Delegated { .. } + )); +} + +#[test] +fn delegated_authorization_requires_verified_delegation() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization requires verifier-issued proof"); + + assert_eq!(error, AuthContextError::DelegationRequired); + assert_eq!(error.code(), "federated_delegation_required"); +} + +#[test] +fn principal_debug_output_redacts_claim_values() { + let principal = FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid"); + let output = format!("{principal:?}"); + + assert_eq!( + output, + "FederatedPrincipal { issuer: \"[redacted]\", subject: \"[redacted]\" }" + ); +} + +#[test] +fn principal_preserves_exact_validated_values() { + let principal = FederatedPrincipal::new(" HTTPS://IDP.EXAMPLE/ ", " Subject-123 ") + .expect("non-empty validated values are accepted exactly"); + + assert_eq!(principal.issuer(), " HTTPS://IDP.EXAMPLE/ "); + assert_eq!(principal.subject(), " Subject-123 "); + assert_eq!( + FederatedPrincipal::new("", "subject-123"), + Err(AuthContextError::EmptyIssuer) + ); + assert_eq!( + FederatedPrincipal::new("https://idp.example", ""), + Err(AuthContextError::EmptySubject) + ); +} + +#[test] +fn context_debug_output_omits_tenant_host() { + let actor = Keys::generate(); + let channel_id = Uuid::from_u128(20); + let context = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic Nostr proof is valid"), + AuthorizedCommunityAccess::new( + authorization_domain(1), + vec![Scope::MessagesRead], + Some(vec![channel_id]), + ), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("matching direct authorization is valid"); + + assert_eq!( + format!("{context:?}"), + concat!( + "V1(AuthContextV1 { authorization_domain: \"[redacted]\", ", + "correlation_id: \"[redacted]\", transport: RelayWebSocket, ", + "nostr: NostrAuthority { actor_pubkey: \"[redacted]\", ", + "proof_method: Nip42, verified_delegation: \"[redacted]\" }, ", + "federated_policy: ResolvedFederatedPolicy { ", + "stamp: FederatedPolicyStamp { authorization_domain: \"[redacted]\", ", + "policy_id: \"[redacted]\", epoch: \"[redacted]\", ", + "correlation_id: \"[redacted]\", requirement: \"[redacted]\", ", + "effective_from: \"[redacted]\", effective_until: \"[redacted]\" } }, ", + "federated: FederatedAuthorization(\"[redacted]\"), ", + "scopes: \"[redacted]\", channel_ids: \"[redacted]\" })" + ) + ); +} + +#[test] +fn direct_authorization_rejects_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("direct authorization cannot derive authority from an owner"); + + assert_eq!(error, AuthContextError::DirectAuthorizationHasOwner); +} + +#[test] +fn delegated_authorization_requires_current_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 100), + 100, + ) + .expect_err("delegated authorization must not survive owner admission expiry"); + + assert_eq!(error, AuthContextError::OwnerAdmissionExpired); +} + +#[test] +fn delegated_authorization_rejects_cross_domain_owner_admission() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("delegated owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} + +#[test] +fn delegated_authorization_requires_the_owner_admission_principal() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let admission_principal = FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), admission_principal, 200), + 100, + ) + .expect_err("current admission must identify the bound owner"); + + assert_eq!(error, AuthContextError::OwnerAdmissionPrincipalMismatch); +} + +#[test] +fn delegated_authorization_rejects_an_expired_proof() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input_with_delegation_expiry( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + 100, + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must not survive delegation expiry"); + + assert_eq!(error, AuthContextError::DelegationExpired); +} + +#[test] +fn verified_nostr_proof_requires_the_authenticated_delegate() { + let actor = Keys::generate(); + let other_delegate = Keys::generate(); + let owner = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + other_delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect_err("verified proof must name the authenticated actor"); + + assert_eq!(error, AuthContextError::DelegateKeyMismatch); +} + +#[test] +fn delegated_authorization_requires_the_bound_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let other_owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(other_owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(1, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("delegated authorization must match the verified owner"); + + assert_eq!(error, AuthContextError::DelegatedOwnerMismatch); +} + +#[test] +fn delegated_binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + delegated_authorization(2, owner.public_key(), principal(), 200), + 100, + ) + .expect_err("a delegated binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); +} + +#[test] +fn zero_binding_version_is_rejected() { + assert_eq!( + BindingVersion::new(0), + Err(AuthContextError::InvalidBindingVersion) + ); +} + +#[test] +fn zero_binding_expiry_is_rejected() { + assert_eq!( + BindingExpiry::new(0), + Err(AuthContextError::InvalidBindingExpiry) + ); +} + +#[test] +fn nil_binding_identifier_is_rejected() { + let actor = Keys::generate(); + let error = VersionedBindingRef::new_existing_active_for_test( + CommunityId::from_uuid(Uuid::from_u128(1)), + Uuid::nil(), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + ) + .expect_err("nil is not a stable binding identifier"); + + assert_eq!(error, AuthContextError::InvalidBindingId); + assert_eq!(error.code(), "federated_binding_invalid_id"); +} + +#[test] +fn evidence_value_debug_output_redacts_numeric_values() { + let assertion_expiry = AssertionExpiry::new(200).expect("synthetic expiry is valid"); + let assertion_not_before = AssertionNotBefore::new(100); + let delegation_expiry = DelegationExpiry::new(300).expect("synthetic expiry is valid"); + let admission_expiry = AdmissionExpiry::new(350).expect("synthetic expiry is valid"); + let binding_expiry = BindingExpiry::new(375).expect("synthetic expiry is valid"); + let binding_version = BindingVersion::new(400).expect("synthetic version is valid"); + + assert_eq!( + format!("{assertion_expiry:?}"), + "AssertionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{assertion_not_before:?}"), + "AssertionNotBefore(\"[redacted]\")" + ); + assert_eq!( + format!("{delegation_expiry:?}"), + "DelegationExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{admission_expiry:?}"), + "AdmissionExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{binding_expiry:?}"), + "BindingExpiry(\"[redacted]\")" + ); + assert_eq!( + format!("{binding_version:?}"), + "BindingVersion(\"[redacted]\")" + ); +} + +#[test] +fn authority_adapter_error_debug_output_redacts_storage_detail() { + let error = AuthorityAdapterError::adapter("private-storage-detail"); + + let rendered = format!("{error:?}"); + assert_eq!( + rendered, + "AuthorityAdapterError { variant: \"Adapter\", detail: \"[redacted]\" }" + ); + assert!(!rendered.contains("private-storage-detail")); +} + +#[test] +fn zero_owner_admission_expiry_is_rejected() { + assert_eq!( + AdmissionExpiry::new(0), + Err(AuthContextError::InvalidAdmissionExpiry) + ); +} + +#[test] +fn owner_admission_debug_output_is_fully_redacted() { + let admission = VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ); + + assert_eq!( + format!("{admission:?}"), + concat!( + "VerifiedOwnerAdmission { authorization_domain: \"[redacted]\", ", + "principal: FederatedPrincipal { issuer: \"[redacted]\", ", + "subject: \"[redacted]\" }, fresh_until: \"[redacted]\" }" + ) + ); +} + +#[test] +fn verified_assertion_debug_output_is_fully_redacted() { + let actor = Keys::generate(); + let assertion = VerifiedFederatedAssertion::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(100)), + AssertionExpiry::new(200).expect("synthetic assertion expiry is valid"), + ); + + assert_eq!( + format!("{assertion:?}"), + concat!( + "VerifiedFederatedAssertion { authorization_domain: \"[redacted]\", ", + "authorized_transport: RelayWebSocket, principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "key_attestation: \"[redacted]\", transport: \"[redacted]\", ", + "not_before: \"[redacted]\", expires_at: \"[redacted]\" }" + ) + ); +} + +#[test] +fn direct_authorization_rejects_expired_assertions() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::ClientAttached, 100), + }, + 100, + ) + .expect_err("authorization must not survive assertion expiry"); + + assert_eq!(error, AuthContextError::AssertionExpired); + assert_eq!(error.code(), "federated_assertion_expired"); +} + +#[test] +fn direct_authorization_rejects_binding_at_exact_expiry() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: expiring_binding(actor.public_key(), 100), + assertion: assertion(principal(), AssertionTransport::ClientAttached, 200), + }, + 100, + ) + .expect_err("authorization must not survive binding expiry"); + + assert_eq!(error, AuthContextError::BindingExpired); + assert_eq!(error.code(), "federated_binding_expired"); +} + +#[test] +fn delegated_authorization_rejects_owner_binding_at_exact_expiry() { + let owner = Keys::generate(); + let delegate = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + delegate.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: expiring_binding(owner.public_key(), 100), + admission: VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("delegated authorization must not survive owner-binding expiry"); + + assert_eq!(error, AuthContextError::BindingExpired); +} + +#[test] +fn direct_authorization_rejects_a_future_assertion() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::HttpBridge, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_with_bounds_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::ClientAttached, + Some(101), + 200, + ), + }, + 100, + ) + .expect_err("authorization must enforce the assertion's not-before bound"); + + assert_eq!(error, AuthContextError::AssertionNotYetValid); + assert_eq!(error.code(), "federated_assertion_not_yet_valid"); +} + +#[test] +fn direct_authorization_requires_the_assertion_principal() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion( + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("the current assertion must identify the bound principal"); + + assert_eq!(error, AuthContextError::AssertionPrincipalMismatch); + assert_eq!(error.code(), "federated_assertion_principal_mismatch"); +} + +#[test] +fn enrolled_reason_must_match_policy_and_binding_source() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Provisioned), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }, + 100, + ) + .expect_err("provisioned mode cannot enroll during authorization"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn existing_active_bindings_are_independent_of_enrollment_mode() { + let actor = Keys::generate(); + let enrollment_modes = [ + EnrollmentMode::AttestedKey, + EnrollmentMode::Provisioned, + EnrollmentMode::Tofu, + ]; + let binding_sources = [ + BindingSource::AttestedKey, + BindingSource::Provisioned, + BindingSource::Tofu, + ]; + + for enrollment_mode in enrollment_modes { + for binding_source in binding_sources { + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(enrollment_mode), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), binding_source), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("enrollment mode governs new bindings, not existing active bindings"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + } + } +} + +#[test] +fn binding_lifecycle_result_owns_the_authorization_reason() { + let actor = Keys::generate(); + let existing = binding(actor.public_key()); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + + let enrolled = enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ); + assert_eq!( + enrolled.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); + + let error = VersionedBindingRef::new_enrolled_active_for_test( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + AuthorizationReason::ExistingBinding, + ) + .expect_err("fresh enrollment cannot be relabeled as an existing binding"); + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn attested_enrollment_requires_matching_verified_key_evidence() { + let actor = Keys::generate(); + let other = Keys::generate(); + + let missing = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("attested enrollment cannot silently accept a missing key claim"); + assert_eq!(missing, AuthContextError::KeyAttestationRequired); + + let mismatch = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("attested enrollment cannot accept another Nostr key"); + assert_eq!(mismatch, AuthContextError::KeyAttestationMismatch); + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }, + 100, + ) + .expect("matching verified key evidence permits attested enrollment"); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); +} + +#[test] +fn present_key_attestation_never_ignores_an_actor_mismatch() { + let actor = Keys::generate(); + let other = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: binding_with_source_in(1, actor.public_key(), BindingSource::Tofu), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + other.public_key(), + ), + }, + 100, + ) + .expect_err("a present but mismatched key claim must never be ignored"); + + assert_eq!(error, AuthContextError::KeyAttestationMismatch); +} + +#[test] +fn tofu_enrollment_uses_tofu_reason_with_attested_provenance() { + let actor = Keys::generate(); + let authorization = FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledTofu, + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + actor.public_key(), + ), + }; + + let context = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + authorization, + 100, + ) + .expect("TOFU policy may retain stronger attested-key provenance"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledTofu + ); +} + +#[test] +fn authorization_reason_codes_are_unique_and_redaction_safe() { + let reasons = [ + AuthorizationReason::NostrOnly, + AuthorizationReason::ExistingBinding, + AuthorizationReason::EnrolledAttestedKey, + AuthorizationReason::EnrolledTofu, + AuthorizationReason::DelegatedOwnerBinding, + ]; + let mut codes = reasons + .iter() + .copied() + .map(AuthorizationReason::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), reasons.len()); + for code in codes { + assert!(code.starts_with("authorization_allow_")); + assert!(!code.contains("tofu")); + assert!(!code.contains("attest")); + assert!(!code.contains("provision")); + assert!(!code.contains("provider")); + } +} + +#[test] +fn authorization_error_codes_are_unique_and_provider_neutral() { + let errors = [ + AuthContextError::EmptyIssuer, + AuthContextError::EmptySubject, + AuthContextError::InvalidBindingVersion, + AuthContextError::InvalidBindingId, + AuthContextError::InvalidBindingExpiry, + AuthContextError::InvalidFederatedPolicyId, + AuthContextError::InvalidFederatedPolicyEpoch, + AuthContextError::InvalidFederatedPolicyCorrelation, + AuthContextError::InvalidFederatedPolicyInterval, + AuthContextError::InvalidAssertionExpiry, + AuthContextError::InvalidDelegationExpiry, + AuthContextError::InvalidAdmissionExpiry, + AuthContextError::AssertionExpired, + AuthContextError::BindingExpired, + AuthContextError::FederatedPolicyCorrelationMismatch, + AuthContextError::FederatedPolicyNotYetEffective, + AuthContextError::FederatedPolicyExpired, + AuthContextError::AssertionNotYetValid, + AuthContextError::KeyAttestationRequired, + AuthContextError::KeyAttestationMismatch, + AuthContextError::OwnerAdmissionExpired, + AuthContextError::FederatedIdentityRequired, + AuthContextError::UnexpectedFederatedAuthorization, + AuthContextError::DelegationExpired, + AuthContextError::SelfDelegation, + AuthContextError::InvalidAuthorizationReason, + AuthContextError::BindingDomainMismatch, + AuthContextError::NostrProofDomainMismatch, + AuthContextError::PolicyDomainMismatch, + AuthContextError::CommunityAccessDomainMismatch, + AuthContextError::AssertionDomainMismatch, + AuthContextError::AssertionTransportMismatch, + AuthContextError::OwnerAdmissionDomainMismatch, + AuthContextError::OwnerAdmissionPrincipalMismatch, + AuthContextError::AssertionPrincipalMismatch, + AuthContextError::TransportProofMismatch, + AuthContextError::DirectAuthorizationHasOwner, + AuthContextError::DirectBindingKeyMismatch, + AuthContextError::DelegateKeyMismatch, + AuthContextError::DelegationRequired, + AuthContextError::DelegatedOwnerMismatch, + AuthContextError::DelegatedBindingNotExistingActive, + ]; + let mut codes = errors + .iter() + .copied() + .map(AuthContextError::code) + .collect::>(); + codes.sort_unstable(); + codes.dedup(); + + assert_eq!(codes.len(), errors.len()); + for code in codes { + assert!(!code.contains("registry")); + assert!(!code.contains("proxy")); + assert!(!code.contains("role")); + assert!(!code.contains("group")); + } +} + +#[test] +fn security_posture_debug_output_is_fully_redacted() { + assert_eq!( + format!("{:?}", EnrollmentMode::AttestedKey), + "EnrollmentMode(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + FederatedIdentityRequirement::Required(EnrollmentMode::Tofu) + ), + "FederatedIdentityRequirement(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", BindingSource::Provisioned), + "BindingSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AssertionTransport::TrustedProxy), + "AssertionTransport(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", AuthorizationReason::EnrolledTofu), + "AuthorizationReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", DelegationCapability::TransportWide), + "DelegationCapability(\"[redacted]\")" + ); + let key = Keys::generate(); + assert_eq!( + format!("{:?}", VerifiedKeyAttestation::new(key.public_key())), + "VerifiedKeyAttestation { pubkey: \"[redacted]\" }" + ); + assert_eq!( + format!("{:?}", binding(key.public_key())), + concat!( + "VersionedBindingRef { authorization_domain: \"[redacted]\", ", + "binding_id: \"[redacted]\", principal: FederatedPrincipal { ", + "issuer: \"[redacted]\", subject: \"[redacted]\" }, ", + "bound_pubkey: \"[redacted]\", binding_version: \"[redacted]\", ", + "expires_at: \"[redacted]\", source: \"[redacted]\", ", + "resolution_reason: \"[redacted]\" }" + ) + ); + assert_eq!( + format!("{:?}", FederatedAuthorization::NotRequired), + "FederatedAuthorization(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", policy_required(EnrollmentMode::AttestedKey)), + concat!( + "ResolvedFederatedPolicy { stamp: FederatedPolicyStamp { ", + "authorization_domain: \"[redacted]\", policy_id: \"[redacted]\", ", + "epoch: \"[redacted]\", correlation_id: \"[redacted]\", ", + "requirement: \"[redacted]\", effective_from: \"[redacted]\", ", + "effective_until: \"[redacted]\" } }" + ) + ); +} + +#[test] +fn federated_policy_must_match_the_authorization_correlation() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(99), 1, 200), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("policy evidence from another decision must not finalize"); + + assert_eq!(error, AuthContextError::FederatedPolicyCorrelationMismatch); +} + +#[test] +fn federated_policy_effective_interval_is_half_open() { + let actor = Keys::generate(); + let not_yet_effective = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 101, 200), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300), + }, + 100, + ) + .expect_err("policy must not authorize before its effective interval"); + assert_eq!( + not_yet_effective, + AuthContextError::FederatedPolicyNotYetEffective + ); + + let expired = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_with_lineage(EnrollmentMode::AttestedKey, Uuid::from_u128(2), 50, 100), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 300), + }, + 100, + ) + .expect_err("policy must deny at its exact exclusive bound"); + assert_eq!(expired, AuthContextError::FederatedPolicyExpired); +} + +#[test] +fn federated_policy_stamp_rejects_invalid_lineage() { + let requirement = FederatedIdentityRequirement::Required(EnrollmentMode::Provisioned); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::nil(), + 1, + Uuid::from_u128(2), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyId) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 0, + Uuid::from_u128(2), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyEpoch) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 1, + Uuid::nil(), + requirement, + 1, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyCorrelation) + ); + assert_eq!( + FederatedPolicyStamp::from_authoritative_state( + authorization_domain(1), + Uuid::from_u128(40), + 1, + Uuid::from_u128(2), + requirement, + 200, + 200, + ), + Err(AuthContextError::InvalidFederatedPolicyInterval) + ); +} + +#[test] +fn authoritative_finalizer_derives_binding_reason() { + let existing_actor = Keys::generate(); + let existing = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + existing_actor.public_key(), + AuthTransport::RelayWebSocket, + None, + ), + policy_required(EnrollmentMode::Tofu), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::existing_active( + authoritative_binding_evidence( + existing_actor.public_key(), + BindingSource::Provisioned, + ), + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect("existing authoritative binding is eligible"); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::ExistingBinding + ); + + let attested_actor = Keys::generate(); + let attested = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + attested_actor.public_key(), + AuthTransport::RelayWebSocket, + None, + ), + policy_required(EnrollmentMode::AttestedKey), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence( + attested_actor.public_key(), + BindingSource::AttestedKey, + ), + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + attested_actor.public_key(), + ), + }, + 100, + ) + .expect("attested enrollment result is eligible"); + assert_eq!( + attested.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); + + let tofu_actor = Keys::generate(); + let tofu = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input(tofu_actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(tofu_actor.public_key(), BindingSource::AttestedKey), + ), + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 200, + tofu_actor.public_key(), + ), + }, + 100, + ) + .expect("stronger attested provenance remains valid under TOFU enrollment"); + assert_eq!( + tofu.authorization_reason(), + AuthorizationReason::EnrolledTofu + ); +} + +#[tokio::test] +async fn cross_crate_authority_adapter_seals_policy_and_binding_outcome() { + let actor = Keys::generate(); + let adapter = TestAuthorityAdapter; + let policy = resolve_current_federated_policy( + &adapter, + authorization_domain(1), + Uuid::from_u128(2), + 100, + ) + .await + .expect("current authoritative policy is valid"); + let binding = authority::resolve_direct_binding( + &adapter, + &policy, + principal(), + actor.public_key(), + true, + 90, + 180, + 100, + ) + .await + .expect("atomic binding resolution is valid"); + let context = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy, + AuthoritativeFederatedResolution::Direct { + binding, + assertion: assertion_with_attested_key( + principal(), + AssertionTransport::TrustedProxy, + 180, + actor.public_key(), + ), + }, + 100, + ) + .expect("sealed adapter output finalizes"); + + assert_eq!( + context.authorization_reason(), + AuthorizationReason::EnrolledAttestedKey + ); +} + +#[tokio::test] +async fn binding_sink_rejects_missing_attestation_for_attested_enrollment() { + struct MissingAttestationAdapter; + + impl FederatedAuthorityAdapter for MissingAttestationAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + 7, + FederatedIdentityRequirement::Required(EnrollmentMode::AttestedKey), + 90, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::AttestedKey, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + _request: BindingResolutionRequest, + _sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async { Err(AuthorityAdapterError::adapter("not called")) }) + } + } + + let actor = Keys::generate(); + let adapter = MissingAttestationAdapter; + let policy = resolve_current_federated_policy( + &adapter, + authorization_domain(1), + Uuid::from_u128(2), + 100, + ) + .await + .expect("current authoritative policy is valid"); + let error = authority::resolve_direct_binding( + &adapter, + &policy, + principal(), + actor.public_key(), + false, + 90, + 180, + 100, + ) + .await + .expect_err("attested-key enrollment requires sealed matching attestation"); + + assert_eq!( + error, + AuthorityAdapterError::Contract(AuthContextError::KeyAttestationRequired) + ); +} + +#[test] +fn authoritative_finalizer_rejects_incompatible_enrollment_result() { + let actor = Keys::generate(); + let error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(actor.public_key(), BindingSource::Provisioned), + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("ordinary finalization cannot relabel provisioned state as enrollment"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn authoritative_finalizer_carries_binding_expiry() { + let actor = Keys::generate(); + let evidence = AuthoritativeBindingEvidence::new( + authorization_domain(1), + Uuid::from_u128(10), + principal(), + actor.public_key(), + BindingVersion::INITIAL, + Some(BindingExpiry::new(100).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .expect("synthetic authoritative binding evidence is valid"); + let error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Direct { + binding: AuthoritativeBindingResolution::existing_active(evidence), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("production finalization must preserve the authoritative binding bound"); + + assert_eq!(error, AuthContextError::BindingExpired); +} + +#[test] +fn authoritative_finalizer_requires_existing_active_delegated_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let admission = || { + VerifiedOwnerAdmission::new( + authorization_domain(1), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ) + }; + let enrolled_error = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Delegated { + owner: AuthoritativeBindingResolution::atomically_enrolled( + authoritative_binding_evidence(owner.public_key(), BindingSource::Provisioned), + ), + admission: admission(), + }, + 100, + ) + .expect_err("a newly enrolled record cannot be relabeled as an existing delegated owner"); + assert_eq!( + enrolled_error, + AuthContextError::DelegatedBindingNotExistingActive + ); + + let existing = AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::Provisioned), + AuthoritativeFederatedResolution::Delegated { + owner: AuthoritativeBindingResolution::existing_active(authoritative_binding_evidence( + owner.public_key(), + BindingSource::Provisioned, + )), + admission: admission(), + }, + 100, + ) + .expect("an existing active owner binding is eligible for delegated finalization"); + assert_eq!( + existing.authorization_reason(), + AuthorizationReason::DelegatedOwnerBinding + ); +} + +#[test] +fn tofu_enrollment_cannot_use_attested_key_policy_reason() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::Tofu), + FederatedAuthorization::Direct { + binding: enrolled_binding( + actor.public_key(), + BindingSource::AttestedKey, + AuthorizationReason::EnrolledAttestedKey, + ), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("TOFU policy must emit the TOFU enrollment reason"); + + assert_eq!(error, AuthContextError::InvalidAuthorizationReason); +} + +#[test] +fn transport_and_proof_method_must_agree() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip98, + None, + ) + .expect_err("HTTP proof must not authorize a relay WebSocket"); + assert_eq!(error, AuthContextError::TransportProofMismatch); +} + +#[test] +fn blossom_upload_proof_cannot_authorize_media_downloads() { + let actor = Keys::generate(); + let error = VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaDownload, + actor.public_key(), + AuthMethod::Blossom, + None, + ) + .expect_err("upload-only proof must not be widened to media download authority"); + assert_eq!(error, AuthContextError::TransportProofMismatch); + + VerifiedNostrProof::new( + authorization_domain(1), + AuthTransport::MediaUpload, + actor.public_key(), + AuthMethod::Blossom, + None, + ) + .expect("Blossom proof may authorize the verified upload operation"); +} + +#[test] +fn binding_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding_in(2, actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("a binding from another domain must be rejected"); + + assert_eq!(error, AuthContextError::BindingDomainMismatch); + assert_eq!(error.code(), "federated_binding_domain_mismatch"); +} + +#[test] +fn nostr_only_authorization_may_preserve_a_verified_owner() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let context = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect("Nostr delegation remains independent of federated policy"); + + assert_eq!(context.agent_owner_pubkey(), Some(owner.public_key())); + assert_eq!( + context.authorization_reason(), + AuthorizationReason::NostrOnly + ); +} + +#[test] +fn transport_delegation_rejects_self_reference() { + let actor = Keys::generate(); + let error = + VerifiedTransportDelegation::new_unrestricted(actor.public_key(), actor.public_key(), None) + .expect_err("an actor cannot be its own verified owner"); + + assert_eq!(error, AuthContextError::SelfDelegation); +} + +#[test] +fn transport_delegation_is_explicitly_transport_wide() { + let owner = Keys::generate(); + let delegate = Keys::generate(); + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + delegate.public_key(), + None, + ) + .expect("synthetic owner and delegate are distinct"); + + assert_eq!(delegation.capability(), DelegationCapability::TransportWide); +} + +#[test] +fn required_policy_rejects_nostr_only_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("required federated identity cannot be bypassed by the caller"); + + assert_eq!(error, AuthContextError::FederatedIdentityRequired); + assert_eq!(error.code(), "federated_identity_required"); +} + +#[test] +fn not_required_policy_rejects_federated_authorization() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_not_required(), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion(principal(), AssertionTransport::TrustedProxy, 200), + }, + 100, + ) + .expect_err("federated evidence cannot override the resolved domain policy"); + + assert_eq!(error, AuthContextError::UnexpectedFederatedAuthorization); + assert_eq!(error.code(), "federated_authorization_unexpected"); +} + +#[test] +fn nostr_proof_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(2, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(1), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("a Nostr proof from another domain must be rejected"); + + assert_eq!(error, AuthContextError::NostrProofDomainMismatch); + assert_eq!(error.code(), "nostr_proof_domain_mismatch"); +} + +#[test] +fn federated_policy_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + ResolvedFederatedPolicy::not_required(authorization_domain(2)), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("policy from another domain must be rejected"); + + assert_eq!(error, AuthContextError::PolicyDomainMismatch); + assert_eq!(error.code(), "federated_policy_domain_mismatch"); +} + +#[test] +fn community_admission_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + AuthContextInput::new( + tenant(1), + Uuid::from_u128(2), + proof_in(1, AuthTransport::RelayWebSocket, actor.public_key()), + community_access_in(2), + ), + policy_not_required(), + FederatedAuthorization::NotRequired, + 100, + ) + .expect_err("community admission from another domain must be rejected"); + + assert_eq!(error, AuthContextError::CommunityAccessDomainMismatch); + assert_eq!(error.code(), "community_access_domain_mismatch"); +} + +#[test] +fn assertion_cannot_cross_authorization_domains() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 2, + AuthTransport::RelayWebSocket, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion from another domain must be rejected"); + + assert_eq!(error, AuthContextError::AssertionDomainMismatch); + assert_eq!(error.code(), "federated_assertion_domain_mismatch"); +} + +#[test] +fn assertion_must_match_the_authorized_transport() { + let actor = Keys::generate(); + let error = AuthContext::finalize_v1( + input(actor.public_key(), AuthTransport::RelayWebSocket, None), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Direct { + binding: binding(actor.public_key()), + assertion: assertion_in( + 1, + AuthTransport::HttpBridge, + principal(), + AssertionTransport::TrustedProxy, + 200, + ), + }, + 100, + ) + .expect_err("an assertion verified for another transport must be rejected"); + + assert_eq!(error, AuthContextError::AssertionTransportMismatch); + assert_eq!(error.code(), "federated_assertion_transport_mismatch"); +} + +#[test] +fn delegated_owner_admission_must_match_the_authorization_domain() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let error = AuthContext::finalize_v1( + input( + actor.public_key(), + AuthTransport::RelayWebSocket, + Some(owner.public_key()), + ), + policy_required(EnrollmentMode::AttestedKey), + FederatedAuthorization::Delegated { + owner: binding(owner.public_key()), + admission: VerifiedOwnerAdmission::new( + authorization_domain(2), + principal(), + AdmissionExpiry::new(200).expect("synthetic admission expiry is valid"), + ), + }, + 100, + ) + .expect_err("owner admission cannot cross authorization domains"); + + assert_eq!(error, AuthContextError::OwnerAdmissionDomainMismatch); +} diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index aed9624d9d..1699555831 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -17,6 +17,8 @@ /// Channel access checking trait and helpers. pub mod access; +/// Versioned, transport-neutral authorization context. +pub mod context; /// Authentication error types. pub mod error; /// NIP-42 challenge–response authentication. @@ -25,12 +27,26 @@ pub mod nip42; pub mod nip98; /// NIP-98 replay protection — shared, community-scoped, atomic seen-set. pub mod nip98_replay; +/// Provider-neutral authorization policy and validated capability snapshots. +pub mod provider; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. pub mod scope; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; +pub use context::{ + resolve_current_federated_policy, AdmissionExpiry, AssertionExpiry, AssertionNotBefore, + AssertionTransport, AuthContext, AuthContextError, AuthContextInput, AuthContextV1, + AuthContextVersion, AuthMethod, AuthTransport, AuthorityAdapterError, AuthorityAdapterFuture, + AuthorizationReason, AuthorizedCommunityAccess, BindingResolutionRequest, BindingSource, + BindingVersion, CapabilityFinalizationSeal, CurrentPolicyRequest, CurrentPolicyResolutionSink, + DelegationCapability, DelegationExpiry, DirectBindingResolutionSink, EnrollmentMode, + ExistingBindingResolutionSink, FederatedAuthorityAdapter, FederatedAuthorization, + FederatedIdentityRequirement, FederatedPrincipal, NostrAuthority, ResolvedFederatedPolicy, + VerifiedFederatedAssertion, VerifiedKeyAttestation, VerifiedNostrProof, VerifiedOwnerAdmission, + VerifiedTransportDelegation, VersionedBindingRef, +}; pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; @@ -38,54 +54,65 @@ pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, }; +pub use provider::{ + AuthorizationAuthority, AuthorizationCapability, AuthorizationClock, AuthorizationDenial, + AuthorizationDenialReason, AuthorizationOutcome, AuthorizationProfileId, AuthorizationProvider, + AuthorizationProviderFuture, AuthorizationRequest, AuthorizationRuntime, CapabilitySet, + CapabilitySnapshot, DecisionSource, PolicyVersion, ProviderAllow, ProviderAllowReason, + ProviderAuthorizationError, ProviderContractError, ProviderDecision, ProviderTimeout, + ProviderUnavailable, ProviderUnavailableReason, RetryAfter, MAX_PROVIDER_FRESHNESS_SECONDS, + MAX_PROVIDER_TIMEOUT, +}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; pub use scope::{parse_scopes, Scope}; -#[cfg(any(test, feature = "test-utils"))] -pub use access::MockAccessChecker; -#[cfg(any(test, feature = "test-utils"))] -pub use nip98_replay::AlwaysFreshReplayGuard; -#[cfg(any(test, feature = "test-utils"))] -pub use rate_limit::AlwaysAllowRateLimiter; - -/// How the connection was authenticated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthMethod { - /// NIP-42 challenge/response — Schnorr signature over kind:22242. - Nip42, - /// NIP-98 HTTP Auth — Schnorr signature over kind:27235. - Nip98, -} - -/// The result of a successful authentication, bound to a connection. -#[derive(Debug, Clone)] -pub struct AuthContext { +/// Existing NIP authentication result stored on a relay connection. +/// +/// This remains separate from [`AuthContext`], which is finalized only after +/// transport authentication and every configured authorization policy pass. +#[derive(Clone)] +pub struct ConnectionAuthContext { /// The authenticated Nostr public key. pub pubkey: nostr::PublicKey, /// Permission scopes granted to this connection. pub scopes: Vec, - /// Channel restriction (reserved for future per-channel access control). - /// - /// `None` means unrestricted. + /// Channel restriction (`None` means unrestricted). pub channel_ids: Option>, /// How the connection was authenticated. pub auth_method: AuthMethod, - /// NIP-OA verified owner pubkey (if authenticated via owner attestation). - /// - /// `None` for direct relay members or non-NIP-OA auth paths. - /// Set by the relay membership gate when NIP-OA fallback succeeds. + /// NIP-OA verified owner pubkey, when present. pub agent_owner_pubkey: Option, } -impl AuthContext { +impl std::fmt::Debug for ConnectionAuthContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ConnectionAuthContext") + .field("pubkey", &"[redacted]") + .field("scopes", &"[redacted]") + .field("channel_ids", &"[redacted]") + .field("auth_method", &self.auth_method) + .field("agent_owner_pubkey", &"[redacted]") + .finish() + } +} + +impl ConnectionAuthContext { /// Returns `true` if this context includes the given [`Scope`]. pub fn has_scope(&self, scope: &Scope) -> bool { self.scopes.contains(scope) } } +#[cfg(any(test, feature = "test-utils"))] +pub use access::MockAccessChecker; +#[cfg(any(test, feature = "test-utils"))] +pub use nip98_replay::AlwaysFreshReplayGuard; +#[cfg(any(test, feature = "test-utils"))] +pub use rate_limit::AlwaysAllowRateLimiter; + /// Top-level authentication configuration, typically loaded from the relay's TOML config file. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct AuthConfig { @@ -112,7 +139,7 @@ impl AuthService { &self.config } - /// Verify a NIP-42 AUTH event and return an [`AuthContext`]. + /// Verify a NIP-42 AUTH event and return a [`ConnectionAuthContext`]. /// /// Pure cryptographic verification — no network calls, no JWT, no tokens. pub async fn verify_auth_event( @@ -120,7 +147,7 @@ impl AuthService { auth_event: nostr::Event, expected_challenge: &str, relay_url: &str, - ) -> Result { + ) -> Result { // Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify) let event_clone = auth_event.clone(); let challenge_owned = expected_challenge.to_string(); @@ -133,12 +160,12 @@ impl AuthService { // In pure Nostr mode, all authenticated connections get full scopes. // Per-channel access is enforced by the relay's membership checks (NIP-29). - Ok(AuthContext { + Ok(ConnectionAuthContext { pubkey: auth_event.pubkey, scopes: Scope::all_known(), channel_ids: None, auth_method: AuthMethod::Nip42, - agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA + agent_owner_pubkey: None, }) } } @@ -183,17 +210,41 @@ mod tests { } #[test] - fn auth_context_scope_check() { + fn connection_auth_context_scope_check() { let keys = Keys::generate(); - let ctx = AuthContext { + let context = ConnectionAuthContext { pubkey: keys.public_key(), scopes: vec![Scope::MessagesRead, Scope::ChannelsRead], channel_ids: None, auth_method: AuthMethod::Nip42, agent_owner_pubkey: None, }; - assert!(ctx.has_scope(&Scope::MessagesRead)); - assert!(!ctx.has_scope(&Scope::MessagesWrite)); + + assert!(context.has_scope(&Scope::MessagesRead)); + assert!(!context.has_scope(&Scope::MessagesWrite)); + } + + #[test] + fn connection_auth_context_debug_redacts_authorization_data() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let channel_id = uuid::Uuid::new_v4(); + let context = ConnectionAuthContext { + pubkey: actor.public_key(), + scopes: vec![Scope::MessagesRead], + channel_ids: Some(vec![channel_id]), + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: Some(owner.public_key()), + }; + + assert_eq!( + format!("{context:?}"), + concat!( + "ConnectionAuthContext { pubkey: \"[redacted]\", scopes: \"[redacted]\", ", + "channel_ids: \"[redacted]\", auth_method: Nip42, ", + "agent_owner_pubkey: \"[redacted]\" }" + ) + ); } #[tokio::test] diff --git a/crates/buzz-auth/src/provider/mod.rs b/crates/buzz-auth/src/provider/mod.rs new file mode 100644 index 0000000000..7b3e032962 --- /dev/null +++ b/crates/buzz-auth/src/provider/mod.rs @@ -0,0 +1,1785 @@ +//! Provider-neutral authorization decisions. +//! +//! This module defines a runtime-neutral boundary between verified identity +//! evidence and deployment-specific policy. It does not select or configure a +//! provider, construct identity evidence, or change any relay handler. + +use std::{fmt, future::Future, pin::Pin, time::Duration}; + +use buzz_core::CommunityId; +use nostr::PublicKey; +use thiserror::Error; +use uuid::Uuid; + +use crate::context::{ + authority::{resolve_direct_binding, resolve_existing_binding}, + resolve_current_federated_policy, AdmissionExpiry, AssertionTransport, AuthContext, + AuthContextError, AuthContextInput, AuthMethod, AuthTransport, AuthoritativeBindingResolution, + AuthoritativeFederatedResolution, AuthorityAdapterError, BindingVersion, + CapabilityFinalizationSeal, FederatedAuthorityAdapter, FederatedPolicyStamp, + FederatedPrincipal, ResolvedFederatedPolicy, VerifiedFederatedAssertion, VerifiedNostrProof, + VerifiedOwnerAdmission, +}; + +const MAX_OPAQUE_ID_BYTES: usize = 256; +const MAX_RETRY_AFTER_SECONDS: u32 = 3_600; +/// Maximum freshness window accepted from an authorization provider. +pub const MAX_PROVIDER_FRESHNESS_SECONDS: u64 = 86_400; +/// Maximum deadline accepted for one authorization-provider call. +pub const MAX_PROVIDER_TIMEOUT: Duration = Duration::from_secs(60); + +/// Portable capability evaluated by an [`AuthorizationProvider`]. +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] +pub enum AuthorizationCapability { + /// Read community content. + CommunityRead, + /// Publish community content. + CommunityWrite, + /// Perform moderation operations. + Moderate, + /// Mint invitations. + InviteMint, + /// Claim an invitation before membership exists. + InviteClaim, + /// Read authenticated media. + MediaRead, + /// Upload media. + MediaWrite, + /// Read Git content. + GitRead, + /// Write Git content. + GitWrite, + /// Join an audio session. + AudioJoin, +} + +impl fmt::Debug for AuthorizationCapability { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationCapability") + .field(&"[redacted]") + .finish() + } +} + +/// Non-empty, normalized set of portable capabilities. +#[derive(Clone, PartialEq, Eq)] +pub struct CapabilitySet(Vec); + +impl CapabilitySet { + /// Build a non-empty set, sorting and removing duplicate capabilities. + pub fn new( + mut capabilities: Vec, + ) -> Result { + capabilities.sort_unstable(); + capabilities.dedup(); + if capabilities.is_empty() { + return Err(ProviderContractError::EmptyCapabilitySet); + } + Ok(Self(capabilities)) + } + + /// Normalized capabilities in stable order. + pub fn as_slice(&self) -> &[AuthorizationCapability] { + &self.0 + } + + fn contains_all(&self, requested: &Self) -> bool { + requested + .as_slice() + .iter() + .all(|capability| self.0.binary_search(capability).is_ok()) + } +} + +impl fmt::Debug for CapabilitySet { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("CapabilitySet") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque identifier for the server-resolved authorization profile. +/// +/// Transport input and provider responses must never select this identifier. +/// Production callers construct it only while loading trusted server +/// configuration, before request handling begins. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct AuthorizationProfileId(String); + +impl AuthorizationProfileId { + /// Preserve a non-empty, bounded profile identifier exactly as configured. + pub fn from_server_configuration( + value: impl Into, + ) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyProfileId); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::ProfileIdTooLong); + } + Ok(Self(value)) + } + /// Exact profile identifier for provider routing. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for AuthorizationProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationProfileId") + .field(&"[redacted]") + .finish() + } +} + +/// Opaque, equality-comparable capability-policy version returned by a provider. +/// +/// This is the typed policy-change seam that later lease and invalidation code +/// can use without assuming a provider-specific numeric ordering. It is a +/// distinct namespace from [`FederatedPolicyStamp::epoch`] and must never be +/// used as enrollment-policy currency evidence. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct PolicyVersion(String); + +impl PolicyVersion { + /// Preserve a non-empty, bounded policy version without interpreting it. + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() { + return Err(ProviderContractError::EmptyPolicyVersion); + } + if value.len() > MAX_OPAQUE_ID_BYTES { + return Err(ProviderContractError::PolicyVersionTooLong); + } + Ok(Self(value)) + } + + /// Exact opaque version bytes. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PolicyVersion { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("PolicyVersion") + .field(&"[redacted]") + .finish() + } +} + +/// Authority whose provider admission is requested. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationAuthority { + /// The authenticated actor matches the admitted principal's key attestation. + Direct, + /// The authenticated actor derives authority from a bound owner. + Delegated { + /// Cryptographically verified and actively bound owner key. + owner_pubkey: PublicKey, + /// Stable identifier of the active owner binding. + binding_id: Uuid, + /// Exact active owner-binding version used for this decision. + binding_version: BindingVersion, + }, +} + +impl fmt::Debug for AuthorizationAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationAuthority") + .field(&"[redacted]") + .finish() + } +} + +/// Redaction-safe description of how the provider request was derived. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum DecisionSource { + /// Current verified assertion for the authenticated actor. + DirectAssertion, + /// Current active binding for a cryptographically verified owner. + DelegatedOwnerBinding, +} + +impl fmt::Debug for DecisionSource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("DecisionSource") + .field(&"[redacted]") + .finish() + } +} + +/// Provider request derived from server-verified identity evidence. +#[derive(PartialEq, Eq)] +pub struct AuthorizationRequest { + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + proof_method: AuthMethod, + authority: AuthorizationAuthority, + principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, + federated_policy: FederatedPolicyStamp, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + decision_source: DecisionSource, + evidence_valid_from: u64, + evidence_valid_until: u64, +} + +impl AuthorizationRequest { + /// Build a direct request from a current assertion and Nostr proof. + /// + /// A matching key claim is preserved for later enrollment, but its absence + /// does not block provider evaluation: an existing active binding can still + /// authorize. Any atomic attested-key enrollment fails closed later unless + /// this assertion carried the exact authenticated key. + /// `now_unix_seconds` must come from the server clock. + pub fn direct( + proof: &VerifiedNostrProof, + assertion: &VerifiedFederatedAssertion, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; + if proof.verified_delegation().is_some() { + return Err(ProviderContractError::DirectRequestHasOwner); + } + if proof.authorization_domain() != assertion.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if proof.authorized_transport() != assertion.authorized_transport() { + return Err(ProviderContractError::TransportMismatch); + } + if assertion + .key_attestation() + .is_some_and(|attestation| attestation.pubkey() != proof.actor_pubkey()) + { + return Err(ProviderContractError::KeyAttestationMismatch); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(ProviderContractError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::AssertionExpired); + } + let evidence_valid_from = + assertion + .not_before() + .map_or(federated_policy.stamp().effective_from(), |bound| { + bound + .unix_seconds() + .max(federated_policy.stamp().effective_from()) + }); + let evidence_valid_until = assertion + .expires_at() + .unix_seconds() + .min(federated_policy.stamp().effective_until()); + Ok(Self { + authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Direct, + principal: assertion.principal().clone(), + key_attested: assertion.key_attestation().is_some(), + assertion_transport: Some(assertion.transport()), + assertion_not_before: assertion.not_before().map(|bound| bound.unix_seconds()), + assertion_expires_at: Some(assertion.expires_at().unix_seconds()), + federated_policy: federated_policy.into_stamp(), + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DirectAssertion, + evidence_valid_from, + evidence_valid_until, + }) + } + + /// Build a delegated request for a cryptographically verified bound owner. + /// + /// This path does not require an owner assertion. The provider resolves + /// current admission for the exact issuer-qualified bound owner. + /// `now_unix_seconds` must come from the server clock. + pub(crate) fn delegated( + proof: &VerifiedNostrProof, + owner: &AuthoritativeBindingResolution, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + now_unix_seconds: u64, + ) -> Result { + if correlation_id.is_nil() { + return Err(ProviderContractError::InvalidCorrelationId); + } + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + now_unix_seconds, + )?; + if proof.authorization_domain() != owner.authorization_domain() { + return Err(ProviderContractError::AuthorizationDomainMismatch); + } + if !owner.is_existing_active() { + return Err(ProviderContractError::DelegatedBindingNotExistingActive); + } + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired); + }; + if delegation.owner_pubkey() != owner.bound_pubkey() { + return Err(ProviderContractError::DelegatedOwnerMismatch); + } + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::DelegationExpired); + } + if owner + .expires_at() + .is_some_and(|bound| bound.is_expired_at(now_unix_seconds)) + { + return Err(ProviderContractError::BindingExpired); + } + let evidence_valid_from = federated_policy.stamp().effective_from(); + let mut evidence_valid_until = federated_policy.stamp().effective_until(); + if let Some(delegation) = delegation.expires_at() { + evidence_valid_until = evidence_valid_until.min(delegation.unix_seconds()); + } + if let Some(binding) = owner.expires_at() { + evidence_valid_until = evidence_valid_until.min(binding.unix_seconds()); + } + Ok(Self { + authorization_domain: proof.authorization_domain(), + transport: proof.authorized_transport(), + actor_pubkey: proof.actor_pubkey(), + proof_method: proof.proof_method(), + authority: AuthorizationAuthority::Delegated { + owner_pubkey: owner.bound_pubkey(), + binding_id: owner.binding_id(), + binding_version: owner.binding_version(), + }, + principal: owner.principal().clone(), + key_attested: false, + assertion_transport: None, + assertion_not_before: None, + assertion_expires_at: None, + federated_policy: federated_policy.into_stamp(), + requested_capabilities, + correlation_id, + decision_source: DecisionSource::DelegatedOwnerBinding, + evidence_valid_from, + evidence_valid_until, + }) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact protected transport authorized by the verified proof. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// Authenticated Nostr actor. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Cryptographic proof method used for the actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Direct or delegated authority whose admission is requested. + pub const fn authority(&self) -> &AuthorizationAuthority { + &self.authority + } + + /// Exact issuer-qualified principal whose admission is requested. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Exact authoritative enrollment-policy lineage bound to this request. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + + /// Portable capabilities requested for this decision. + pub const fn requested_capabilities(&self) -> &CapabilitySet { + &self.requested_capabilities + } + + /// Correlation identifier for this request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Verified source from which this request was derived. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } + + /// Inclusive joined lower validity bound supplied by verified evidence. + pub const fn evidence_valid_from(&self) -> u64 { + self.evidence_valid_from + } + + /// Exclusive joined upper validity bound supplied by verified evidence. + pub const fn evidence_valid_until(&self) -> u64 { + self.evidence_valid_until + } +} + +impl fmt::Debug for AuthorizationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRequest") + .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("authority", &"[redacted]") + .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") + .field("federated_policy", &"[redacted]") + .field("requested_capabilities", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("evidence_valid_from", &"[redacted]") + .field("evidence_valid_until", &"[redacted]") + .finish() + } +} + +/// Resolve an existing delegated owner and build a provider request. +/// +/// The policy is consumed, the owner lifecycle outcome is produced only by the +/// configured authority adapter, and server time is sampled again after the +/// binding read. This path cannot enroll or relabel an owner binding. +#[allow(clippy::too_many_arguments)] +async fn resolve_delegated_authorization_request( + adapter: &A, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + clock: &dyn AuthorizationClock, +) -> Result> { + let Some(before_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + validate_federated_policy( + &federated_policy, + proof.authorization_domain(), + correlation_id, + before_io, + )?; + let Some(delegation) = proof.verified_delegation() else { + return Err(ProviderContractError::DelegationRequired.into()); + }; + if delegation + .expires_at() + .is_some_and(|bound| bound.is_expired_at(before_io)) + { + return Err(ProviderContractError::DelegationExpired.into()); + } + let effective_from = federated_policy.stamp().effective_from(); + let effective_until = + delegation + .expires_at() + .map_or(federated_policy.stamp().effective_until(), |bound| { + bound + .unix_seconds() + .min(federated_policy.stamp().effective_until()) + }); + let owner = resolve_existing_binding( + adapter, + &federated_policy, + principal, + delegation.owner_pubkey(), + effective_from, + effective_until, + before_io, + ) + .await?; + let Some(after_io) = clock.now_unix_seconds() else { + return Err(ProviderAuthorizationError::ClockUnavailable); + }; + AuthorizationRequest::delegated( + proof, + &owner, + federated_policy, + requested_capabilities, + correlation_id, + after_io, + ) + .map_err(ProviderAuthorizationError::from) +} + +/// Provider-produced allowed capability data before crate-owned validation. +#[derive(PartialEq, Eq)] +pub struct ProviderAllow { + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, +} + +impl ProviderAllow { + /// Build a provider allow result with mandatory policy and freshness data. + pub fn new( + authorization_domain: CommunityId, + principal: FederatedPrincipal, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + ) -> Result { + if issued_at == 0 { + return Err(ProviderContractError::InvalidIssuedAt); + } + if fresh_until <= issued_at { + return Err(ProviderContractError::InvalidFreshnessBound); + } + if fresh_until - issued_at > MAX_PROVIDER_FRESHNESS_SECONDS { + return Err(ProviderContractError::FreshnessWindowTooLong); + } + Ok(Self { + authorization_domain, + principal, + profile_id, + capabilities, + policy_version, + issued_at, + fresh_until, + }) + } +} + +impl fmt::Debug for ProviderAllow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderAllow") + .field("authorization_domain", &"[redacted]") + .field("principal", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .finish() + } +} + +/// Stable reason for a denied provider authorization. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationDenialReason { + /// The configured provider denied the request. + ProviderDenied, + /// The provider response named another authorization domain. + AuthorizationDomainMismatch, + /// The provider response named another principal. + PrincipalMismatch, + /// The provider response named another authorization profile. + AuthorizationProfileMismatch, + /// The provider response omitted a requested capability. + MissingCapability, + /// The provider response was already stale. + StaleDecision, + /// The provider response was issued in the future. + FutureDecision, + /// Verified identity evidence expired before the decision became effective. + IdentityEvidenceExpired, + /// Trusted time moved before the joined evidence interval. + IdentityEvidenceNotYetValid, + /// The bound federated enrollment policy was not current after provider I/O. + FederatedPolicyNotCurrent, +} + +impl AuthorizationDenialReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::ProviderDenied => "authorization_provider_deny_001", + Self::AuthorizationDomainMismatch => "authorization_provider_deny_002", + Self::PrincipalMismatch => "authorization_provider_deny_003", + Self::MissingCapability => "authorization_provider_deny_004", + Self::StaleDecision => "authorization_provider_deny_005", + Self::FutureDecision => "authorization_provider_deny_006", + Self::IdentityEvidenceExpired => "authorization_provider_deny_007", + Self::AuthorizationProfileMismatch => "authorization_provider_deny_008", + Self::FederatedPolicyNotCurrent => "authorization_provider_deny_009", + Self::IdentityEvidenceNotYetValid => "authorization_provider_deny_010", + } + } +} + +impl fmt::Debug for AuthorizationDenialReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationDenialReason") + .field(&"[redacted]") + .finish() + } +} + +/// Provider-neutral denial returned to an authorization caller. +#[derive(PartialEq, Eq)] +pub struct AuthorizationDenial { + reason: AuthorizationDenialReason, +} + +impl AuthorizationDenial { + /// Build a denial with a stable provider-neutral reason. + pub const fn new(reason: AuthorizationDenialReason) -> Self { + Self { reason } + } + + /// Stable reason for the denial. + pub const fn reason(&self) -> AuthorizationDenialReason { + self.reason + } +} + +impl fmt::Debug for AuthorizationDenial { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationDenial") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Stable provider-unavailability reason. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderUnavailableReason { + /// The provider is temporarily unavailable. + TemporarilyUnavailable, + /// The provider call exceeded its bounded deadline. + Timeout, + /// A provider dependency is unavailable. + DependencyUnavailable, +} + +impl ProviderUnavailableReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::TemporarilyUnavailable => "authorization_provider_unavailable_001", + Self::Timeout => "authorization_provider_unavailable_002", + Self::DependencyUnavailable => "authorization_provider_unavailable_003", + } + } +} + +impl fmt::Debug for ProviderUnavailableReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderUnavailableReason") + .field(&"[redacted]") + .finish() + } +} + +/// Bounded provider retry hint in seconds. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct RetryAfter(u32); + +impl RetryAfter { + /// Build a non-zero retry hint no greater than one hour. + pub const fn new(seconds: u32) -> Result { + if seconds == 0 || seconds > MAX_RETRY_AFTER_SECONDS { + return Err(ProviderContractError::InvalidRetryAfter); + } + Ok(Self(seconds)) + } + + /// Retry hint in seconds. + pub const fn seconds(self) -> u32 { + self.0 + } +} + +impl fmt::Debug for RetryAfter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RetryAfter") + .field(&"[redacted]") + .finish() + } +} + +/// Explicit finite deadline for one provider call. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct ProviderTimeout(Duration); + +impl ProviderTimeout { + /// Build a provider-call deadline no greater than one minute. + pub fn new(duration: Duration) -> Result { + if duration.is_zero() || duration > MAX_PROVIDER_TIMEOUT { + return Err(ProviderContractError::InvalidProviderTimeout); + } + Ok(Self(duration)) + } + + /// Configured provider-call deadline. + pub const fn duration(self) -> Duration { + self.0 + } +} + +impl fmt::Debug for ProviderTimeout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderTimeout") + .field(&"[redacted]") + .finish() + } +} + +/// Trusted server time used to validate a provider result. +/// +/// The resolver samples this source exactly once for an allowed decision. A +/// source must return current Unix time without reusing a value captured before +/// provider I/O, and must not block the async executor. Returning `None` fails +/// closed as dependency unavailability. +pub trait AuthorizationClock: Send + Sync { + /// Current trusted Unix time, or `None` when it cannot be obtained. + fn now_unix_seconds(&self) -> Option; +} + +/// Fail-closed provider unavailability. +#[derive(PartialEq, Eq)] +pub struct ProviderUnavailable { + reason: ProviderUnavailableReason, + retry_after: Option, +} + +impl ProviderUnavailable { + /// Build an unavailable result with optional bounded retry metadata. + pub const fn new(reason: ProviderUnavailableReason, retry_after: Option) -> Self { + Self { + reason, + retry_after, + } + } + + /// Stable reason for unavailability. + pub const fn reason(&self) -> ProviderUnavailableReason { + self.reason + } + + /// Optional bounded retry hint. + pub const fn retry_after(&self) -> Option { + self.retry_after + } +} + +impl fmt::Debug for ProviderUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderUnavailable") + .field("reason", &"[redacted]") + .field("retry_after", &"[redacted]") + .finish() + } +} + +/// Raw decision returned by an [`AuthorizationProvider`]. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderDecision { + /// Provider policy allowed a capability set. + Allow(ProviderAllow), + /// Provider policy denied the request. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for ProviderDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderDecision") + .field(&"[redacted]") + .finish() + } +} + +/// Boxed provider future used to keep [`AuthorizationProvider`] object-safe. +pub type AuthorizationProviderFuture<'a> = + Pin + Send + 'a>>; + +/// Object-safe, asynchronous, provider-neutral authorization policy. +pub trait AuthorizationProvider: Send + Sync { + /// Profile fixed by trusted server configuration for this provider. + /// + /// Request and transport data must never influence this value. Returning it + /// from the configured provider keeps route selection out of + /// [`AuthorizationRequest`]. + fn profile_id(&self) -> AuthorizationProfileId; + + /// Evaluate one request without mutating identity or community state. + /// + /// Implementations must yield while waiting for I/O and must not block the + /// async executor. The returned future must be cancellation-safe: the + /// caller drops it on timeout, so dropping at any await point must release + /// resources through RAII and must not leave shared state partially + /// updated. Provider evaluation is read-only; cache updates, if any, must + /// become visible atomically. The deadline bounds future polling and cannot + /// preempt blocking synchronous work inside this method. + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a>; +} + +/// Stable reason for a validated allowed decision. +#[derive(Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProviderAllowReason { + /// Current provider policy granted the exact requested capabilities. + CurrentPolicy, +} + +impl ProviderAllowReason { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::CurrentPolicy => "authorization_provider_allow_001", + } + } +} + +impl fmt::Debug for ProviderAllowReason { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ProviderAllowReason") + .field(&"[redacted]") + .finish() + } +} + +/// Fail-closed error while joining provider and authoritative state. +#[derive(PartialEq, Eq)] +pub enum ProviderAuthorizationError { + /// Trusted server time was unavailable. + ClockUnavailable, + /// Provider evidence or snapshot shape violated the contract. + Contract(ProviderContractError), + /// Current policy or binding resolution failed. + Authority(AuthorityAdapterError), + /// Final immutable context validation failed. + Context(AuthContextError), +} + +/// Server-configured provider, authority adapter, and trusted clock. +/// +/// Construct exactly one runtime during server startup and inject it into +/// request handling. Every capability snapshot is privately bound to the +/// runtime that performed provider I/O, so a caller cannot substitute another +/// adapter or clock during finalization. +pub struct AuthorizationRuntime { + authority: A, + clock: C, + provider: P, + binding: Uuid, +} + +impl AuthorizationRuntime { + /// Bind trusted startup configuration into one authorization runtime. + pub fn from_server_configuration(authority: A, clock: C, provider: P) -> Self { + Self { + authority, + clock, + provider, + binding: Uuid::new_v4(), + } + } +} + +impl fmt::Debug for AuthorizationRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizationRuntime") + .field("authority", &"[redacted]") + .field("clock", &"[redacted]") + .field("provider", &"[redacted]") + .field("binding", &"[redacted]") + .finish() + } +} + +impl From for ProviderAuthorizationError { + fn from(error: ProviderContractError) -> Self { + Self::Contract(error) + } +} + +impl From> for ProviderAuthorizationError { + fn from(error: AuthorityAdapterError) -> Self { + Self::Authority(error) + } +} + +impl From for ProviderAuthorizationError { + fn from(error: AuthContextError) -> Self { + Self::Context(error) + } +} + +impl fmt::Debug for ProviderAuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let variant = match self { + Self::ClockUnavailable => "ClockUnavailable", + Self::Contract(_) => "Contract", + Self::Authority(_) => "Authority", + Self::Context(_) => "Context", + }; + formatter + .debug_struct("ProviderAuthorizationError") + .field("variant", &variant) + .field("detail", &"[redacted]") + .finish() + } +} + +/// Validated, request-scoped capability snapshot. +/// +/// This type has no public constructor, default, or deserialization path. Only +/// [`AuthorizationRuntime::resolve_authorization`] can create it after checking +/// the provider response. The move-only snapshot is private finalizer evidence; +/// callers may inspect its bounded metadata but cannot recreate trusted state. +#[derive(PartialEq, Eq)] +pub struct CapabilitySnapshot { + runtime_binding: Uuid, + authorization_domain: CommunityId, + transport: AuthTransport, + actor_pubkey: PublicKey, + owner_pubkey: Option, + binding_id: Option, + binding_version: Option, + proof_method: AuthMethod, + principal: FederatedPrincipal, + key_attested: bool, + assertion_transport: Option, + assertion_not_before: Option, + assertion_expires_at: Option, + federated_policy: FederatedPolicyStamp, + profile_id: AuthorizationProfileId, + capabilities: CapabilitySet, + policy_version: PolicyVersion, + issued_at: u64, + fresh_until: u64, + effective_from: u64, + effective_until: u64, + decision_source: DecisionSource, + correlation_id: Uuid, + reason: ProviderAllowReason, +} + +impl CapabilitySnapshot { + fn validate_runtime(&self, runtime_binding: Uuid) -> Result<(), ProviderContractError> { + if self.runtime_binding != runtime_binding { + return Err(ProviderContractError::AuthorizationRuntimeMismatch); + } + Ok(()) + } + + /// Authorization domain for this decision. + pub const fn authorization_domain(&self) -> CommunityId { + self.authorization_domain + } + + /// Exact protected transport for which this snapshot was resolved. + pub const fn transport(&self) -> AuthTransport { + self.transport + } + + /// Exact authenticated Nostr actor for this decision. + pub const fn actor_pubkey(&self) -> PublicKey { + self.actor_pubkey + } + + /// Exact verified owner for delegated authority, when present. + pub const fn owner_pubkey(&self) -> Option { + self.owner_pubkey + } + + /// Stable active binding identifier for delegated authority. + pub const fn binding_id(&self) -> Option { + self.binding_id + } + + /// Exact active binding version for delegated authority. + /// + /// This is not a lease: later consumers must compare it with current + /// authoritative binding state before reusing a cached snapshot. + pub const fn binding_version(&self) -> Option { + self.binding_version + } + + /// Cryptographic proof method for the authenticated actor. + pub const fn proof_method(&self) -> AuthMethod { + self.proof_method + } + + /// Exact admitted issuer-qualified principal. + pub const fn principal(&self) -> &FederatedPrincipal { + &self.principal + } + + /// Exact authoritative enrollment-policy lineage bound to this decision. + pub const fn federated_policy(&self) -> &FederatedPolicyStamp { + &self.federated_policy + } + + /// Whether a freshly resolved authoritative policy is exactly the policy used here. + /// + /// The authority adapter must additionally compare this stamp with current + /// state and use its epoch as an atomic enrollment precondition. + pub fn is_bound_to_federated_policy(&self, policy: &ResolvedFederatedPolicy) -> bool { + self.federated_policy == *policy.stamp() + } + + /// Server-resolved authorization profile for this decision. + pub const fn profile_id(&self) -> &AuthorizationProfileId { + &self.profile_id + } + + /// Exact request-scoped portable capabilities. + pub const fn capabilities(&self) -> &CapabilitySet { + &self.capabilities + } + + /// Opaque provider policy version. + pub const fn policy_version(&self) -> &PolicyVersion { + &self.policy_version + } + + /// Provider decision issue time in Unix seconds. + pub const fn issued_at(&self) -> u64 { + self.issued_at + } + + /// Provider freshness bound in Unix seconds. + pub const fn fresh_until(&self) -> u64 { + self.fresh_until + } + + /// Inclusive joined lower bound across provider and identity evidence. + pub const fn effective_from(&self) -> u64 { + self.effective_from + } + + /// Exclusive joined upper bound across provider and identity evidence. + pub const fn effective_until(&self) -> u64 { + self.effective_until + } + + /// Verified request source for this snapshot. + pub const fn decision_source(&self) -> DecisionSource { + self.decision_source + } + + /// Correlation identifier binding the snapshot to its request. + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Stable reason for this allowed decision. + pub const fn reason(&self) -> ProviderAllowReason { + self.reason + } + + /// Consume a direct capability decision and finalize authoritative context. + /// + /// The current enrollment policy is reread after provider I/O, then the + /// exact assertion, policy, capability interval, and authenticated key are + /// supplied to the configured binding adapter. Server time is resampled + /// after each awaited authority operation. + async fn finalize_direct_v1( + self, + adapter: &A, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + clock: &dyn AuthorizationClock, + ) -> Result> { + self.validate_embedded_domains(&input)?; + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + self.validate_direct_shape(&input, &assertion, before_policy)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_direct_shape(&input, &assertion, after_policy)?; + self.validate_current_policy(&policy)?; + + let binding = resolve_direct_binding( + adapter, + &policy, + self.principal.clone(), + self.actor_pubkey, + self.key_attested, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_direct_shape(&input, &assertion, after_binding)?; + self.validate_current_policy(&policy)?; + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Direct { binding, assertion }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + /// Consume a delegated capability decision and finalize authoritative context. + /// + /// The bound owner is reread without enrollment after a fresh exact policy + /// read. Binding identifier and version must match the provider decision; + /// provider admission is derived from this snapshot's joined interval. + async fn finalize_delegated_v1( + self, + adapter: &A, + input: AuthContextInput, + clock: &dyn AuthorizationClock, + ) -> Result> { + self.validate_embedded_domains(&input)?; + let before_policy = finalization_time(clock)?; + self.validate_common(&input, before_policy)?; + let owner_pubkey = self.validate_delegated_shape(&input)?; + + let policy = resolve_current_federated_policy( + adapter, + self.authorization_domain, + self.correlation_id, + before_policy, + ) + .await?; + let after_policy = finalization_time(clock)?; + self.validate_common(&input, after_policy)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + + let owner = resolve_existing_binding( + adapter, + &policy, + self.principal.clone(), + owner_pubkey, + self.effective_from, + self.effective_until, + after_policy, + ) + .await?; + + let after_binding = finalization_time(clock)?; + self.validate_common(&input, after_binding)?; + self.validate_delegated_shape(&input)?; + self.validate_current_policy(&policy)?; + if Some(owner.binding_id()) != self.binding_id + || Some(owner.binding_version()) != self.binding_version + || owner + .expires_at() + .is_some_and(|bound| bound.unix_seconds() < self.effective_until) + { + return Err(ProviderContractError::CapabilityBindingChanged.into()); + } + let admission = VerifiedOwnerAdmission::new( + self.authorization_domain, + self.principal, + AdmissionExpiry::new(self.effective_until)?, + ); + AuthContext::finalize_authoritative_v1( + CapabilityFinalizationSeal::new(), + input, + policy, + AuthoritativeFederatedResolution::Delegated { owner, admission }, + after_binding, + ) + .map_err(ProviderAuthorizationError::from) + } + + fn validate_common( + &self, + input: &AuthContextInput, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if input.authorization_domain() != self.authorization_domain + || input.correlation_id() != self.correlation_id + || input.transport() != self.transport + || input.proof_method() != self.proof_method + || input.actor_pubkey() != self.actor_pubkey + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if now_unix_seconds < self.effective_from { + return Err(ProviderContractError::CapabilityNotYetEffective); + } + if now_unix_seconds >= self.effective_until { + return Err(ProviderContractError::CapabilityExpired); + } + Ok(()) + } + + fn validate_embedded_domains(&self, input: &AuthContextInput) -> Result<(), AuthContextError> { + let authorization_domain = input.authorization_domain(); + if input.nostr_proof_authorization_domain() != authorization_domain { + return Err(AuthContextError::NostrProofDomainMismatch); + } + if input.community_access_authorization_domain() != authorization_domain { + return Err(AuthContextError::CommunityAccessDomainMismatch); + } + Ok(()) + } + + fn validate_direct_shape( + &self, + input: &AuthContextInput, + assertion: &VerifiedFederatedAssertion, + now_unix_seconds: u64, + ) -> Result<(), ProviderContractError> { + if self.decision_source != DecisionSource::DirectAssertion + || self.owner_pubkey.is_some() + || self.binding_id.is_some() + || self.binding_version.is_some() + || input.verified_owner_pubkey().is_some() + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + if assertion.authorization_domain() != self.authorization_domain + || assertion.authorized_transport() != self.transport + || Some(assertion.transport()) != self.assertion_transport + || assertion.not_before().map(|bound| bound.unix_seconds()) != self.assertion_not_before + || Some(assertion.expires_at().unix_seconds()) != self.assertion_expires_at + || assertion.key_attestation().is_some() != self.key_attested + { + return Err(ProviderContractError::CapabilityContextMismatch); + } + if assertion.principal() != &self.principal { + return Err(ProviderContractError::CapabilityPrincipalMismatch); + } + if assertion + .key_attestation() + .is_some_and(|attestation| attestation.pubkey() != self.actor_pubkey) + { + return Err(ProviderContractError::KeyAttestationMismatch); + } + if assertion + .not_before() + .is_some_and(|bound| bound.is_not_yet_valid_at(now_unix_seconds)) + { + return Err(ProviderContractError::AssertionNotYetValid); + } + if assertion.expires_at().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::AssertionExpired); + } + Ok(()) + } + + fn validate_delegated_shape( + &self, + input: &AuthContextInput, + ) -> Result { + let Some(owner_pubkey) = self.owner_pubkey else { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + }; + if self.decision_source != DecisionSource::DelegatedOwnerBinding + || self.binding_id.is_none() + || self.binding_version.is_none() + || self.key_attested + || self.assertion_transport.is_some() + || self.assertion_not_before.is_some() + || self.assertion_expires_at.is_some() + || input.verified_owner_pubkey() != Some(owner_pubkey) + { + return Err(ProviderContractError::CapabilityAuthorityMismatch); + } + Ok(owner_pubkey) + } + + fn validate_current_policy( + &self, + policy: &ResolvedFederatedPolicy, + ) -> Result<(), ProviderContractError> { + if !self.is_bound_to_federated_policy(policy) { + return Err(ProviderContractError::FederatedPolicyChanged); + } + Ok(()) + } +} + +fn finalization_time( + clock: &dyn AuthorizationClock, +) -> Result> { + clock + .now_unix_seconds() + .ok_or(ProviderAuthorizationError::ClockUnavailable) +} + +impl AuthorizationRuntime +where + A: FederatedAuthorityAdapter, + C: AuthorizationClock, + P: AuthorizationProvider, +{ + /// Resolve a provider decision using this runtime's fixed provider and clock. + pub async fn resolve_authorization( + &self, + request: &AuthorizationRequest, + timeout: ProviderTimeout, + ) -> AuthorizationOutcome { + resolve_authorization(&self.provider, request, &self.clock, timeout, self.binding).await + } + + /// Resolve an existing delegated owner and build a provider request. + pub async fn resolve_delegated_authorization_request( + &self, + proof: &VerifiedNostrProof, + principal: FederatedPrincipal, + federated_policy: ResolvedFederatedPolicy, + requested_capabilities: CapabilitySet, + correlation_id: Uuid, + ) -> Result> { + resolve_delegated_authorization_request( + &self.authority, + proof, + principal, + federated_policy, + requested_capabilities, + correlation_id, + &self.clock, + ) + .await + } + + /// Consume a runtime-bound direct capability snapshot. + pub async fn finalize_direct_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + assertion: VerifiedFederatedAssertion, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_direct_v1(&self.authority, input, assertion, &self.clock) + .await + } + + /// Consume a runtime-bound delegated capability snapshot. + pub async fn finalize_delegated_v1( + &self, + snapshot: CapabilitySnapshot, + input: AuthContextInput, + ) -> Result> { + snapshot.validate_runtime(self.binding)?; + snapshot + .finalize_delegated_v1(&self.authority, input, &self.clock) + .await + } +} + +impl fmt::Debug for CapabilitySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CapabilitySnapshot") + .field("runtime_binding", &"[redacted]") + .field("authorization_domain", &"[redacted]") + .field("transport", &"[redacted]") + .field("actor_pubkey", &"[redacted]") + .field("owner_pubkey", &"[redacted]") + .field("binding_id", &"[redacted]") + .field("binding_version", &"[redacted]") + .field("proof_method", &"[redacted]") + .field("principal", &"[redacted]") + .field("key_attested", &"[redacted]") + .field("assertion_transport", &"[redacted]") + .field("assertion_not_before", &"[redacted]") + .field("assertion_expires_at", &"[redacted]") + .field("federated_policy", &"[redacted]") + .field("profile_id", &"[redacted]") + .field("capabilities", &"[redacted]") + .field("policy_version", &"[redacted]") + .field("issued_at", &"[redacted]") + .field("fresh_until", &"[redacted]") + .field("effective_from", &"[redacted]") + .field("effective_until", &"[redacted]") + .field("decision_source", &"[redacted]") + .field("correlation_id", &"[redacted]") + .field("reason", &"[redacted]") + .finish() + } +} + +/// Fail-closed result of validating a provider decision. +#[derive(PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationOutcome { + /// Provider policy allowed the exact requested capabilities. + Allow(Box), + /// Provider policy or response validation denied authorization. + Deny(AuthorizationDenial), + /// Provider policy could not be evaluated; callers must not fall back. + Unavailable(ProviderUnavailable), +} + +impl fmt::Debug for AuthorizationOutcome { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("AuthorizationOutcome") + .field(&"[redacted]") + .finish() + } +} + +/// Resolve and validate one provider authorization decision. +/// +/// Unavailability is preserved as a fail-closed outcome. This function never +/// falls back to Nostr-only authorization or applies an implicit grace period. +/// `clock` must be the server's trusted time source. After provider I/O +/// completes, an allowed decision is checked against exactly one fresh sample. +/// Provider freshness and all effective evidence bounds use that same value; +/// callers must not precompute and pass a decision-start timestamp. +async fn resolve_authorization( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + clock: &dyn AuthorizationClock, + timeout: ProviderTimeout, + runtime_binding: Uuid, +) -> AuthorizationOutcome { + let configured_profile = provider.profile_id(); + let decision = match tokio::time::timeout(timeout.duration(), provider.authorize(request)).await + { + Ok(decision) => decision, + Err(_) => { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::Timeout, + None, + )); + } + }; + let allow = match decision { + ProviderDecision::Allow(allow) => allow, + ProviderDecision::Deny(denial) => return AuthorizationOutcome::Deny(denial), + ProviderDecision::Unavailable(unavailable) => { + return AuthorizationOutcome::Unavailable(unavailable); + } + }; + let Some(now_unix_seconds) = clock.now_unix_seconds() else { + return AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )); + }; + + if request + .federated_policy + .is_not_yet_effective_at(now_unix_seconds) + || request.federated_policy.is_expired_at(now_unix_seconds) + { + return deny(AuthorizationDenialReason::FederatedPolicyNotCurrent); + } + + if allow.authorization_domain != request.authorization_domain { + return deny(AuthorizationDenialReason::AuthorizationDomainMismatch); + } + if allow.principal != request.principal { + return deny(AuthorizationDenialReason::PrincipalMismatch); + } + if allow.profile_id != configured_profile { + return deny(AuthorizationDenialReason::AuthorizationProfileMismatch); + } + if allow.issued_at > now_unix_seconds { + return deny(AuthorizationDenialReason::FutureDecision); + } + if allow.fresh_until <= now_unix_seconds { + return deny(AuthorizationDenialReason::StaleDecision); + } + if !allow + .capabilities + .contains_all(&request.requested_capabilities) + { + return deny(AuthorizationDenialReason::MissingCapability); + } + + let effective_from = request.evidence_valid_from.max(allow.issued_at); + let effective_until = request.evidence_valid_until.min(allow.fresh_until); + if now_unix_seconds < effective_from { + return deny(AuthorizationDenialReason::IdentityEvidenceNotYetValid); + } + if effective_until <= now_unix_seconds || effective_from >= effective_until { + return deny(AuthorizationDenialReason::IdentityEvidenceExpired); + } + + AuthorizationOutcome::Allow(Box::new(CapabilitySnapshot { + runtime_binding, + authorization_domain: allow.authorization_domain, + transport: request.transport, + actor_pubkey: request.actor_pubkey, + owner_pubkey: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { owner_pubkey, .. } => Some(*owner_pubkey), + }, + binding_id: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { binding_id, .. } => Some(*binding_id), + }, + binding_version: match &request.authority { + AuthorizationAuthority::Direct => None, + AuthorizationAuthority::Delegated { + binding_version, .. + } => Some(*binding_version), + }, + proof_method: request.proof_method, + principal: allow.principal, + key_attested: request.key_attested, + assertion_transport: request.assertion_transport, + assertion_not_before: request.assertion_not_before, + assertion_expires_at: request.assertion_expires_at, + federated_policy: request.federated_policy.clone(), + profile_id: allow.profile_id, + capabilities: request.requested_capabilities.clone(), + policy_version: allow.policy_version, + issued_at: allow.issued_at, + fresh_until: allow.fresh_until, + effective_from, + effective_until, + decision_source: request.decision_source, + correlation_id: request.correlation_id, + reason: ProviderAllowReason::CurrentPolicy, + })) +} + +const fn deny(reason: AuthorizationDenialReason) -> AuthorizationOutcome { + AuthorizationOutcome::Deny(AuthorizationDenial::new(reason)) +} + +/// Invalid provider request or response construction. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ProviderContractError { + /// A capability set was empty. + #[error("authorization capability set must not be empty")] + EmptyCapabilitySet, + /// The authorization profile identifier was empty. + #[error("authorization profile identifier must not be empty")] + EmptyProfileId, + /// The authorization profile identifier exceeded its size bound. + #[error("authorization profile identifier exceeds the size bound")] + ProfileIdTooLong, + /// The policy version was empty. + #[error("authorization policy version must not be empty")] + EmptyPolicyVersion, + /// The policy version exceeded its size bound. + #[error("authorization policy version exceeds the size bound")] + PolicyVersionTooLong, + /// Provider decision issue time was zero. + #[error("provider decision issue time must be greater than zero")] + InvalidIssuedAt, + /// Provider freshness did not follow issue time. + #[error("provider freshness bound must follow its issue time")] + InvalidFreshnessBound, + /// Provider freshness exceeded the public maximum window. + #[error("provider freshness window exceeds its public bound")] + FreshnessWindowTooLong, + /// Retry metadata was zero or exceeded its public bound. + #[error("provider retry hint is outside its public bound")] + InvalidRetryAfter, + /// Provider call deadline was zero or exceeded its public bound. + #[error("provider call deadline is outside its public bound")] + InvalidProviderTimeout, + /// Correlation identifier was nil. + #[error("provider request correlation identifier must not be nil")] + InvalidCorrelationId, + /// Direct evidence contained delegated authority. + #[error("direct provider request cannot contain a delegated owner")] + DirectRequestHasOwner, + /// Verified evidence belonged to different authorization domains. + #[error("provider request evidence does not share an authorization domain")] + AuthorizationDomainMismatch, + /// Verified assertion and Nostr proof authorized different transports. + #[error("provider request evidence does not share an authorization transport")] + TransportMismatch, + /// Assertion was not yet valid at server time. + #[error("provider request assertion is not yet valid")] + AssertionNotYetValid, + /// Assertion was expired at server time. + #[error("provider request assertion has expired")] + AssertionExpired, + /// Assertion key attestation named another actor. + #[error("provider request key attestation does not match the Nostr actor")] + KeyAttestationMismatch, + /// Delegated owner resolution did not represent an already-active binding. + #[error("delegated provider request requires an existing active binding")] + DelegatedBindingNotExistingActive, + /// Delegated request lacked verified delegation. + #[error("delegated provider request requires verified delegation")] + DelegationRequired, + /// Delegated request named another bound owner. + #[error("delegated provider request does not match the bound owner")] + DelegatedOwnerMismatch, + /// Delegation was expired at server time. + #[error("delegated provider request has expired")] + DelegationExpired, + /// Owner binding was expired at server time. + #[error("delegated provider request owner binding has expired")] + BindingExpired, + /// Enrollment policy belonged to another authorization domain. + #[error("provider request enrollment policy does not match the authorization domain")] + FederatedPolicyDomainMismatch, + /// Enrollment policy belonged to another correlated decision. + #[error("provider request enrollment policy does not match the correlation identifier")] + FederatedPolicyCorrelationMismatch, + /// Enrollment policy was not yet effective at server time. + #[error("provider request enrollment policy is not yet effective")] + FederatedPolicyNotYetEffective, + /// Enrollment policy was expired at server time. + #[error("provider request enrollment policy has expired")] + FederatedPolicyExpired, + /// A capability snapshot was used before its joined effective interval. + #[error("provider capability snapshot is not yet effective")] + CapabilityNotYetEffective, + /// A capability snapshot reached its joined exclusive expiry. + #[error("provider capability snapshot has expired")] + CapabilityExpired, + /// A capability snapshot did not match immutable request context. + #[error("provider capability snapshot does not match authorization context")] + CapabilityContextMismatch, + /// A capability snapshot did not match direct or delegated authority shape. + #[error("provider capability snapshot authority shape is invalid")] + CapabilityAuthorityMismatch, + /// A capability snapshot did not match the sealed assertion principal. + #[error("provider capability snapshot principal is invalid")] + CapabilityPrincipalMismatch, + /// The delegated binding identifier, version, or expiry changed. + #[error("provider capability snapshot binding is no longer current")] + CapabilityBindingChanged, + /// Fresh authoritative policy lineage differed from the capability snapshot. + #[error("provider capability snapshot enrollment policy changed")] + FederatedPolicyChanged, + /// A capability snapshot was presented to a different configured runtime. + #[error("provider capability snapshot does not belong to this authorization runtime")] + AuthorizationRuntimeMismatch, +} + +impl ProviderContractError { + /// Stable provider-neutral audit and metric code. + pub const fn code(self) -> &'static str { + match self { + Self::EmptyCapabilitySet => "authorization_provider_contract_001", + Self::EmptyProfileId => "authorization_provider_contract_002", + Self::ProfileIdTooLong => "authorization_provider_contract_003", + Self::EmptyPolicyVersion => "authorization_provider_contract_004", + Self::PolicyVersionTooLong => "authorization_provider_contract_005", + Self::InvalidIssuedAt => "authorization_provider_contract_006", + Self::InvalidFreshnessBound => "authorization_provider_contract_007", + Self::InvalidRetryAfter => "authorization_provider_contract_008", + Self::DirectRequestHasOwner => "authorization_provider_contract_009", + Self::AuthorizationDomainMismatch => "authorization_provider_contract_010", + Self::TransportMismatch => "authorization_provider_contract_011", + Self::AssertionNotYetValid => "authorization_provider_contract_012", + Self::AssertionExpired => "authorization_provider_contract_013", + Self::KeyAttestationMismatch => "authorization_provider_contract_014", + Self::DelegationRequired => "authorization_provider_contract_015", + Self::DelegatedOwnerMismatch => "authorization_provider_contract_016", + Self::DelegationExpired => "authorization_provider_contract_017", + Self::InvalidProviderTimeout => "authorization_provider_contract_018", + Self::InvalidCorrelationId => "authorization_provider_contract_019", + Self::DelegatedBindingNotExistingActive => "authorization_provider_contract_020", + Self::FreshnessWindowTooLong => "authorization_provider_contract_021", + Self::BindingExpired => "authorization_provider_contract_022", + Self::FederatedPolicyDomainMismatch => "authorization_provider_contract_023", + Self::FederatedPolicyCorrelationMismatch => "authorization_provider_contract_024", + Self::FederatedPolicyNotYetEffective => "authorization_provider_contract_025", + Self::FederatedPolicyExpired => "authorization_provider_contract_026", + Self::CapabilityNotYetEffective => "authorization_provider_contract_027", + Self::CapabilityExpired => "authorization_provider_contract_028", + Self::CapabilityContextMismatch => "authorization_provider_contract_029", + Self::CapabilityAuthorityMismatch => "authorization_provider_contract_030", + Self::CapabilityPrincipalMismatch => "authorization_provider_contract_031", + Self::CapabilityBindingChanged => "authorization_provider_contract_032", + Self::FederatedPolicyChanged => "authorization_provider_contract_033", + Self::AuthorizationRuntimeMismatch => "authorization_provider_contract_034", + } + } +} + +fn validate_federated_policy( + policy: &ResolvedFederatedPolicy, + authorization_domain: CommunityId, + correlation_id: Uuid, + now_unix_seconds: u64, +) -> Result<(), ProviderContractError> { + if policy.authorization_domain() != authorization_domain { + return Err(ProviderContractError::FederatedPolicyDomainMismatch); + } + if policy.stamp().correlation_id() != correlation_id { + return Err(ProviderContractError::FederatedPolicyCorrelationMismatch); + } + if policy.stamp().is_not_yet_effective_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyNotYetEffective); + } + if policy.stamp().is_expired_at(now_unix_seconds) { + return Err(ProviderContractError::FederatedPolicyExpired); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-auth/src/provider/tests.rs b/crates/buzz-auth/src/provider/tests.rs new file mode 100644 index 0000000000..190cdf7da6 --- /dev/null +++ b/crates/buzz-auth/src/provider/tests.rs @@ -0,0 +1,2538 @@ +use std::{ + future::pending, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + Arc, Mutex, + }, + time::Duration, +}; + +use nostr::Keys; + +use super::*; +use crate::context::{ + AssertionExpiry, AssertionNotBefore, AssertionTransport, AuthTransport, + AuthoritativeBindingEvidence, AuthoritativeBindingResolution, BindingExpiry, BindingSource, + BindingVersion, DelegationExpiry, EnrollmentMode, FederatedIdentityRequirement, + FederatedPolicyStamp, ResolvedFederatedPolicy, VerifiedKeyAttestation, + VerifiedTransportDelegation, +}; +use crate::{ + AuthorityAdapterFuture, AuthorizedCommunityAccess, BindingResolutionRequest, + CurrentPolicyRequest, CurrentPolicyResolutionSink, DirectBindingResolutionSink, + ExistingBindingResolutionSink, Scope, +}; + +const NOW: u64 = 100; + +fn domain(value: u128) -> CommunityId { + CommunityId::from_uuid(Uuid::from_u128(value)) +} + +fn principal() -> FederatedPrincipal { + FederatedPrincipal::new("https://idp.example", "subject-123") + .expect("synthetic principal is valid") +} + +fn profile() -> AuthorizationProfileId { + AuthorizationProfileId::from_server_configuration("profile-1") + .expect("synthetic profile is valid") +} + +fn policy_version(value: &str) -> PolicyVersion { + PolicyVersion::new(value).expect("synthetic policy version is valid") +} + +fn federated_policy_with( + domain_value: u128, + correlation_id: Uuid, + epoch: u64, + enrollment_mode: EnrollmentMode, + effective_from: u64, + effective_until: u64, +) -> ResolvedFederatedPolicy { + ResolvedFederatedPolicy::from_authoritative_resolution( + FederatedPolicyStamp::from_authoritative_state( + domain(domain_value), + Uuid::from_u128(40), + epoch, + correlation_id, + FederatedIdentityRequirement::Required(enrollment_mode), + effective_from, + effective_until, + ) + .expect("synthetic federated policy lineage is valid"), + ) +} + +fn federated_policy() -> ResolvedFederatedPolicy { + federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 200, + ) +} + +fn provider_timeout() -> ProviderTimeout { + ProviderTimeout::new(Duration::from_secs(1)).expect("synthetic timeout is finite") +} + +#[derive(Clone)] +struct TestClock { + now: Arc, + available: Arc, + reads: Arc, +} + +impl TestClock { + fn at(now: u64) -> Self { + Self { + now: Arc::new(AtomicU64::new(now)), + available: Arc::new(AtomicBool::new(true)), + reads: Arc::new(AtomicUsize::new(0)), + } + } + + fn set(&self, now: u64) { + self.now.store(now, Ordering::SeqCst); + } + + fn set_available(&self, available: bool) { + self.available.store(available, Ordering::SeqCst); + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } +} + +impl AuthorizationClock for TestClock { + fn now_unix_seconds(&self) -> Option { + self.reads.fetch_add(1, Ordering::SeqCst); + self.available + .load(Ordering::SeqCst) + .then(|| self.now.load(Ordering::SeqCst)) + } +} + +#[derive(Clone)] +struct TestAuthorityAdapter { + policy_epoch: u64, + enrollment_mode: EnrollmentMode, + enroll_direct: bool, + policy_reads: Arc, + direct_calls: Arc, + existing_calls: Arc, + committed_enrollments: Arc, +} + +impl TestAuthorityAdapter { + fn new(policy_epoch: u64, enrollment_mode: EnrollmentMode, enroll_direct: bool) -> Self { + Self { + policy_epoch, + enrollment_mode, + enroll_direct, + policy_reads: Arc::new(AtomicUsize::new(0)), + direct_calls: Arc::new(AtomicUsize::new(0)), + existing_calls: Arc::new(AtomicUsize::new(0)), + committed_enrollments: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl FederatedAuthorityAdapter for TestAuthorityAdapter { + type Error = &'static str; + + fn resolve_current_policy<'a>( + &'a self, + request: CurrentPolicyRequest, + sink: CurrentPolicyResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.policy_reads.fetch_add(1, Ordering::SeqCst); + sink.resolved( + request.authorization_domain(), + Uuid::from_u128(40), + self.policy_epoch, + FederatedIdentityRequirement::Required(self.enrollment_mode), + 1, + 200, + ) + .map_err(AuthorityAdapterError::from) + }) + } + + fn resolve_direct_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: DirectBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.direct_calls.fetch_add(1, Ordering::SeqCst); + let result = if self.enroll_direct { + let source = match self.enrollment_mode { + EnrollmentMode::AttestedKey => BindingSource::AttestedKey, + EnrollmentMode::Tofu => BindingSource::Tofu, + EnrollmentMode::Provisioned => BindingSource::Provisioned, + }; + sink.atomically_enrolled( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + source, + ) + } else { + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + }; + let resolution = result.map_err(AuthorityAdapterError::from)?; + if self.enroll_direct { + self.committed_enrollments.fetch_add(1, Ordering::SeqCst); + } + Ok(resolution) + }) + } + + fn resolve_existing_binding<'a>( + &'a self, + request: BindingResolutionRequest, + sink: ExistingBindingResolutionSink, + ) -> AuthorityAdapterFuture< + 'a, + Result>, + > { + Box::pin(async move { + self.existing_calls.fetch_add(1, Ordering::SeqCst); + sink.existing_active( + request.authorization_domain(), + Uuid::from_u128(10), + request.principal().clone(), + request.bound_pubkey(), + BindingVersion::INITIAL, + None, + BindingSource::Provisioned, + ) + .map_err(AuthorityAdapterError::from) + }) + } +} + +fn direct_evidence( + actor: &Keys, + enrollment_mode: EnrollmentMode, + key_attested: bool, +) -> ( + VerifiedNostrProof, + VerifiedFederatedAssertion, + AuthorizationRequest, +) { + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + key_attested.then(|| VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(90)), + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + federated_policy_with(1, Uuid::from_u128(20), 1, enrollment_mode, 1, 200), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic direct request is valid"); + (proof, assertion, request) +} + +fn finalization_input(proof: VerifiedNostrProof) -> AuthContextInput { + AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(1), Scope::all_known(), None), + ) +} + +async fn resolve_at( + provider: &dyn AuthorizationProvider, + request: &AuthorizationRequest, + now: u64, + timeout: ProviderTimeout, +) -> AuthorizationOutcome { + resolve_authorization( + provider, + request, + &TestClock::at(now), + timeout, + Uuid::from_u128(99), + ) + .await +} + +fn capabilities(values: &[AuthorizationCapability]) -> CapabilitySet { + CapabilitySet::new(values.to_vec()).expect("synthetic capabilities are non-empty") +} + +fn all_capabilities() -> [AuthorizationCapability; 10] { + [ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + AuthorizationCapability::Moderate, + AuthorizationCapability::InviteMint, + AuthorizationCapability::InviteClaim, + AuthorizationCapability::MediaRead, + AuthorizationCapability::MediaWrite, + AuthorizationCapability::GitRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::AudioJoin, + ] +} + +fn capability_coverage_is_exhaustive(capability: AuthorizationCapability) { + match capability { + AuthorizationCapability::CommunityRead + | AuthorizationCapability::CommunityWrite + | AuthorizationCapability::Moderate + | AuthorizationCapability::InviteMint + | AuthorizationCapability::InviteClaim + | AuthorizationCapability::MediaRead + | AuthorizationCapability::MediaWrite + | AuthorizationCapability::GitRead + | AuthorizationCapability::GitWrite + | AuthorizationCapability::AudioJoin => {} + } +} + +fn proof_method_for_transport(transport: AuthTransport) -> AuthMethod { + match transport { + AuthTransport::RelayWebSocket => AuthMethod::Nip42, + AuthTransport::HttpBridge | AuthTransport::Git | AuthTransport::MediaDownload => { + AuthMethod::Nip98 + } + AuthTransport::MediaUpload => AuthMethod::Blossom, + AuthTransport::Audio => AuthMethod::Nip42, + } +} + +fn all_contract_errors() -> [ProviderContractError; 34] { + [ + ProviderContractError::EmptyCapabilitySet, + ProviderContractError::EmptyProfileId, + ProviderContractError::ProfileIdTooLong, + ProviderContractError::EmptyPolicyVersion, + ProviderContractError::PolicyVersionTooLong, + ProviderContractError::InvalidIssuedAt, + ProviderContractError::InvalidFreshnessBound, + ProviderContractError::FreshnessWindowTooLong, + ProviderContractError::InvalidRetryAfter, + ProviderContractError::InvalidProviderTimeout, + ProviderContractError::InvalidCorrelationId, + ProviderContractError::DirectRequestHasOwner, + ProviderContractError::AuthorizationDomainMismatch, + ProviderContractError::TransportMismatch, + ProviderContractError::AssertionNotYetValid, + ProviderContractError::AssertionExpired, + ProviderContractError::KeyAttestationMismatch, + ProviderContractError::DelegatedBindingNotExistingActive, + ProviderContractError::DelegationRequired, + ProviderContractError::DelegatedOwnerMismatch, + ProviderContractError::DelegationExpired, + ProviderContractError::BindingExpired, + ProviderContractError::FederatedPolicyDomainMismatch, + ProviderContractError::FederatedPolicyCorrelationMismatch, + ProviderContractError::FederatedPolicyNotYetEffective, + ProviderContractError::FederatedPolicyExpired, + ProviderContractError::CapabilityNotYetEffective, + ProviderContractError::CapabilityExpired, + ProviderContractError::CapabilityContextMismatch, + ProviderContractError::CapabilityAuthorityMismatch, + ProviderContractError::CapabilityPrincipalMismatch, + ProviderContractError::CapabilityBindingChanged, + ProviderContractError::FederatedPolicyChanged, + ProviderContractError::AuthorizationRuntimeMismatch, + ] +} + +fn direct_request_for_transport( + actor: &Keys, + transport: AuthTransport, + proof_method: AuthMethod, + not_before: Option, + expiry: u64, + requested: CapabilitySet, +) -> Result { + let proof = + VerifiedNostrProof::new(domain(1), transport, actor.public_key(), proof_method, None) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + transport, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + not_before.map(AssertionNotBefore::new), + AssertionExpiry::new(expiry).expect("synthetic assertion expiry is valid"), + ); + AuthorizationRequest::direct( + &proof, + &assertion, + federated_policy(), + requested, + Uuid::from_u128(20), + NOW, + ) +} + +fn direct_request_with_expiry( + actor: &Keys, + expiry: u64, + requested: CapabilitySet, +) -> AuthorizationRequest { + direct_request_for_transport( + actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + None, + expiry, + requested, + ) + .expect("synthetic direct request is valid") +} + +fn direct_request(actor: &Keys) -> AuthorizationRequest { + direct_request_with_expiry( + actor, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) +} + +fn existing_binding(owner: &Keys) -> AuthoritativeBindingResolution { + existing_binding_in(1, owner) +} + +fn existing_binding_in(domain_value: u128, owner: &Keys) -> AuthoritativeBindingResolution { + existing_binding_with_expiry_in(domain_value, owner, None) +} + +fn existing_binding_with_expiry_in( + domain_value: u128, + owner: &Keys, + expires_at: Option, +) -> AuthoritativeBindingResolution { + let evidence = AuthoritativeBindingEvidence::new( + domain(domain_value), + Uuid::from_u128(10), + principal(), + owner.public_key(), + BindingVersion::INITIAL, + expires_at + .map(|expiry| BindingExpiry::new(expiry).expect("synthetic binding expiry is valid")), + BindingSource::Provisioned, + ) + .expect("synthetic binding is valid"); + AuthoritativeBindingResolution::existing_active(evidence) +} + +fn delegated_proof(actor: &Keys, owner: &Keys, expiry: u64) -> VerifiedNostrProof { + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(expiry).expect("synthetic delegation expiry is valid")), + ) + .expect("synthetic delegation is valid"); + VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic delegated proof is valid") +} + +fn delegated_request(actor: &Keys, owner: &Keys, expiry: u64) -> AuthorizationRequest { + let proof = delegated_proof(actor, owner, expiry); + AuthorizationRequest::delegated( + &proof, + &existing_binding(owner), + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("synthetic delegated request is valid") +} + +fn allow_for( + request: &AuthorizationRequest, + granted: CapabilitySet, + version: &str, + issued_at: u64, + fresh_until: u64, +) -> ProviderDecision { + ProviderDecision::Allow( + ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + profile(), + granted, + policy_version(version), + issued_at, + fresh_until, + ) + .expect("synthetic provider allow is structurally valid"), + ) +} + +struct FakeProvider { + decision: Mutex>, +} + +impl FakeProvider { + fn returning(decision: ProviderDecision) -> Self { + Self { + decision: Mutex::new(Some(decision)), + } + } +} + +impl AuthorizationProvider for FakeProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + self.decision + .lock() + .expect("synthetic provider mutex is not poisoned") + .take() + .expect("synthetic provider is called exactly once") + }) + } +} + +struct EchoAllowProvider; + +impl AuthorizationProvider for EchoAllowProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + allow_for( + request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + ) + }) + } +} + +struct AdvancingProvider { + decision: Mutex>, + clock: TestClock, + decision_time: u64, + clock_available: bool, +} + +impl AdvancingProvider { + fn returning_at(decision: ProviderDecision, clock: TestClock, decision_time: u64) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time, + clock_available: true, + } + } + + fn returning_with_clock_failure(decision: ProviderDecision, clock: TestClock) -> Self { + Self { + decision: Mutex::new(Some(decision)), + clock, + decision_time: 0, + clock_available: false, + } + } +} + +impl AuthorizationProvider for AdvancingProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + Box::pin(async move { + tokio::task::yield_now().await; + assert_eq!( + self.clock.reads(), + 0, + "decision time must not be sampled before provider I/O completes" + ); + self.clock.set(self.decision_time); + self.clock.set_available(self.clock_available); + self.decision + .lock() + .expect("synthetic provider mutex is not poisoned") + .take() + .expect("synthetic provider is called exactly once") + }) + } +} + +struct PendingProvider { + calls: Arc, + dropped: Arc, +} + +struct CancellationMarker(Arc); + +impl Drop for CancellationMarker { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl AuthorizationProvider for PendingProvider { + fn profile_id(&self) -> AuthorizationProfileId { + profile() + } + + fn authorize<'a>( + &'a self, + _request: &'a AuthorizationRequest, + ) -> AuthorizationProviderFuture<'a> { + self.calls.fetch_add(1, Ordering::SeqCst); + let marker = CancellationMarker(Arc::clone(&self.dropped)); + Box::pin(async move { + let _marker = marker; + pending().await + }) + } +} + +#[tokio::test] +async fn current_allow_returns_request_scoped_snapshot() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::CommunityWrite, + ]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_eq!(snapshot.authorization_domain(), domain(1)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), None); + assert_eq!(snapshot.binding_id(), None); + assert_eq!(snapshot.binding_version(), None); + assert_eq!(snapshot.proof_method(), AuthMethod::Nip42); + assert_eq!(snapshot.principal(), request.principal()); + assert_eq!(snapshot.profile_id(), &profile()); + assert_eq!( + snapshot.capabilities().as_slice(), + &[AuthorizationCapability::CommunityRead] + ); + assert_eq!(snapshot.policy_version().as_str(), "version-a"); + assert_eq!(snapshot.issued_at(), 90); + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 180); + assert_eq!(snapshot.decision_source(), DecisionSource::DirectAssertion); + assert_eq!(snapshot.correlation_id(), request.correlation_id()); + assert_eq!(snapshot.reason(), ProviderAllowReason::CurrentPolicy); +} + +#[tokio::test] +async fn runtime_finalizer_allows_existing_binding_without_key_claim() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Provisioned, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + + let context = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect("an existing active binding does not require a later key claim"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::ExistingBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn mismatched_embedded_proof_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (_, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let mismatched_proof = VerifiedNostrProof::new( + domain(2), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic mismatched proof is structurally valid"); + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(mismatched_proof), assertion) + .await + .expect_err("embedded proof domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::NostrProofDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn mismatched_community_access_domain_fails_before_authority_io() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("current provider decision must allow"); + }; + let input = AuthContextInput::new( + buzz_core::TenantContext::resolved(domain(1), "relay.example"), + Uuid::from_u128(20), + proof, + AuthorizedCommunityAccess::new(domain(2), Scope::all_known(), None), + ); + + let error = runtime + .finalize_direct_v1(*snapshot, input, assertion) + .await + .expect_err("embedded admission domain mismatch must precede authority I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Context(AuthContextError::CommunityAccessDomainMismatch) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn attested_enrollment_without_sealed_key_claim_fails_before_commit() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::AttestedKey, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::AttestedKey, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("provider evaluation may allow before binding resolution"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("attested-key enrollment requires the sealed matching key claim"); + + assert_eq!( + error, + ProviderAuthorizationError::Authority(AuthorityAdapterError::Contract( + AuthContextError::KeyAttestationRequired + )) + ); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 1); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn fresh_policy_epoch_drift_blocks_binding_mutation() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let authority = TestAuthorityAdapter::new(2, EnrollmentMode::Tofu, true); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + provider, + ); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("request-time policy is current during provider evaluation"); + }; + + let error = runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("fresh authoritative policy drift must fail before binding I/O"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::FederatedPolicyChanged) + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn runtime_binding_rejects_forged_adapter_and_clock_substitution() { + let actor = Keys::generate(); + let (proof, assertion, request) = direct_evidence(&actor, EnrollmentMode::Tofu, false); + let genuine_provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let genuine_runtime = AuthorizationRuntime::from_server_configuration( + TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true), + TestClock::at(NOW), + genuine_provider, + ); + let AuthorizationOutcome::Allow(snapshot) = genuine_runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("genuine runtime must issue the capability snapshot"); + }; + + let forged_authority = TestAuthorityAdapter::new(1, EnrollmentMode::Tofu, true); + let forged_runtime = AuthorizationRuntime::from_server_configuration( + forged_authority.clone(), + TestClock::at(NOW), + FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))), + ); + let error = forged_runtime + .finalize_direct_v1(*snapshot, finalization_input(proof), assertion) + .await + .expect_err("a legitimate snapshot cannot be spliced to a caller-selected runtime"); + + assert_eq!( + error, + ProviderAuthorizationError::Contract(ProviderContractError::AuthorizationRuntimeMismatch) + ); + assert_eq!(forged_authority.policy_reads.load(Ordering::SeqCst), 0); + assert_eq!(forged_authority.direct_calls.load(Ordering::SeqCst), 0); + assert_eq!( + forged_authority + .committed_enrollments + .load(Ordering::SeqCst), + 0 + ); +} + +#[tokio::test] +async fn runtime_resolves_and_refinalizes_existing_delegated_owner() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 180); + let authority = TestAuthorityAdapter::new(1, EnrollmentMode::Provisioned, false); + let runtime = AuthorizationRuntime::from_server_configuration( + authority.clone(), + TestClock::at(NOW), + EchoAllowProvider, + ); + let request = runtime + .resolve_delegated_authorization_request( + &proof, + principal(), + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + ) + .await + .expect("the configured adapter resolves an existing delegated owner"); + let AuthorizationOutcome::Allow(snapshot) = runtime + .resolve_authorization(&request, provider_timeout()) + .await + else { + panic!("the current owner admission must allow"); + }; + + let context = runtime + .finalize_delegated_v1(*snapshot, finalization_input(proof)) + .await + .expect("the owner is reread and finalized without enrollment"); + + assert_eq!( + context.authorization_reason(), + crate::AuthorizationReason::DelegatedOwnerBinding + ); + assert_eq!(authority.policy_reads.load(Ordering::SeqCst), 1); + assert_eq!(authority.existing_calls.load(Ordering::SeqCst), 2); + assert_eq!(authority.committed_enrollments.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn allowed_snapshot_preserves_every_requested_transport_scope() { + let transports = [ + AuthTransport::RelayWebSocket, + AuthTransport::HttpBridge, + AuthTransport::Git, + AuthTransport::MediaUpload, + AuthTransport::MediaDownload, + AuthTransport::Audio, + ]; + + for transport in transports { + let proof_method = proof_method_for_transport(transport); + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + transport, + proof_method, + None, + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("synthetic direct request is valid"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow every transport profile"); + }; + assert_eq!(snapshot.transport(), transport); + assert_eq!(snapshot.proof_method(), proof_method); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + } +} + +#[tokio::test] +async fn explicit_denial_is_preserved() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let provider = FakeProvider::returning(ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + ))); + + let AuthorizationOutcome::Deny(denial) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider denial must fail closed"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::ProviderDenied); +} + +#[tokio::test] +async fn provider_unavailability_never_falls_back_to_allow() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let retry_after = RetryAfter::new(30).expect("synthetic retry hint is bounded"); + let provider = + FakeProvider::returning(ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::TemporarilyUnavailable, + Some(retry_after), + ))); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("provider unavailability must remain fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::TemporarilyUnavailable + ); + assert_eq!(unavailable.retry_after(), Some(retry_after)); +} + +#[tokio::test] +async fn provider_call_deadline_returns_timeout_unavailability() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let calls = Arc::new(AtomicUsize::new(0)); + let dropped = Arc::new(AtomicBool::new(false)); + let provider = PendingProvider { + calls: Arc::clone(&calls), + dropped: Arc::clone(&dropped), + }; + let timeout = + ProviderTimeout::new(Duration::from_millis(1)).expect("synthetic timeout is finite"); + + let AuthorizationOutcome::Unavailable(unavailable) = + resolve_at(&provider, &request, NOW, timeout).await + else { + panic!("provider timeout must remain fail closed"); + }; + assert_eq!(unavailable.reason(), ProviderUnavailableReason::Timeout); + assert_eq!(unavailable.retry_after(), None); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn provider_freshness_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 105, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("a provider decision stale after I/O must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn federated_policy_expiry_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 105, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("federated policy is current when provider I/O begins"); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("federated enrollment policy expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::FederatedPolicyNotCurrent + ); + assert_eq!(clock.reads(), 1); +} + +#[tokio::test] +async fn snapshot_requires_exact_enrollment_policy_lineage() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let current_policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + current_policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "6", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current provider and enrollment policy must allow"); + }; + + let stale_tofu_policy = + federated_policy_with(1, Uuid::from_u128(20), 6, EnrollmentMode::Tofu, 1, 160); + let current_policy_for_comparison = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 160, + ); + assert!(snapshot.is_bound_to_federated_policy(¤t_policy_for_comparison)); + assert!(!snapshot.is_bound_to_federated_policy(&stale_tofu_policy)); + assert_eq!(snapshot.policy_version().as_str(), "6"); + assert_ne!( + snapshot.policy_version().as_str(), + snapshot.federated_policy().epoch().to_string() + ); +} + +#[tokio::test] +async fn enrollment_policy_bounds_snapshot_effective_interval() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let policy = federated_policy_with( + 1, + Uuid::from_u128(20), + 7, + EnrollmentMode::Provisioned, + 1, + 150, + ); + let request = AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("current policy can enter provider evaluation"); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "capability-policy-v1", + 90, + 170, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + + assert_eq!(request.evidence_valid_until(), 150); + assert_eq!(snapshot.effective_until(), 150); +} + +#[tokio::test] +async fn identity_evidence_is_evaluated_after_async_io() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 105, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("identity evidence expired after I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[tokio::test] +async fn owner_binding_expiry_is_evaluated_after_async_io() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(105)); + let request = AuthorizationRequest::delegated( + &proof, + &binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect("owner binding is current at request construction"); + assert_eq!(request.evidence_valid_until(), 105); + + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + 105, + ); + let AuthorizationOutcome::Deny(denial) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("owner binding expired after provider I/O must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[test] +fn delegated_request_rejects_owner_binding_at_exact_expiry() { + let delegate = Keys::generate(); + let owner = Keys::generate(); + let proof = delegated_proof(&delegate, &owner, 140); + let binding = existing_binding_with_expiry_in(1, &owner, Some(NOW)); + + let error = AuthorizationRequest::delegated( + &proof, + &binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + .expect_err("expired owner binding must not enter provider evaluation"); + assert_eq!(error, ProviderContractError::BindingExpired); +} + +#[tokio::test] +async fn decision_issued_during_async_io_is_not_false_future() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_at( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 104, + 180, + ), + clock.clone(), + 105, + ); + + assert!(matches!( + resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99) + ) + .await, + AuthorizationOutcome::Allow(_) + )); +} + +#[tokio::test] +async fn clock_failure_after_provider_io_is_unavailable() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let clock = TestClock::at(NOW); + let provider = AdvancingProvider::returning_with_clock_failure( + allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + ), + clock.clone(), + ); + + let AuthorizationOutcome::Unavailable(unavailable) = resolve_authorization( + &provider, + &request, + &clock, + provider_timeout(), + Uuid::from_u128(99), + ) + .await + else { + panic!("unavailable decision time must fail closed"); + }; + assert_eq!( + unavailable.reason(), + ProviderUnavailableReason::DependencyUnavailable + ); +} + +#[tokio::test] +async fn stale_and_future_provider_decisions_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let stale = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 80, + 90, + )); + let AuthorizationOutcome::Deny(stale_denial) = + resolve_at(&stale, &request, NOW, provider_timeout()).await + else { + panic!("stale decision must deny"); + }; + assert_eq!( + stale_denial.reason(), + AuthorizationDenialReason::StaleDecision + ); + + let future = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(future_denial) = + resolve_at(&future, &request, NOW, provider_timeout()).await + else { + panic!("future decision must deny"); + }; + assert_eq!( + future_denial.reason(), + AuthorizationDenialReason::FutureDecision + ); +} + +#[tokio::test] +async fn provider_time_boundaries_and_current_assertion_are_exact() { + let actor = Keys::generate(); + let request = direct_request_for_transport( + &actor, + AuthTransport::RelayWebSocket, + AuthMethod::Nip42, + Some(NOW), + 200, + capabilities(&[AuthorizationCapability::CommunityRead]), + ) + .expect("assertion with not-before equal to server time is current"); + + let issued_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + NOW, + 180, + )); + assert!(matches!( + resolve_at(&issued_now, &request, NOW, provider_timeout()).await, + AuthorizationOutcome::Allow(_) + )); + + let stale_at_now = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + NOW, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&stale_at_now, &request, NOW, provider_timeout()).await + else { + panic!("freshness ending at server time must deny"); + }; + assert_eq!(denial.reason(), AuthorizationDenialReason::StaleDecision); +} + +#[tokio::test] +async fn domain_principal_and_capability_mismatches_deny() { + let actor = Keys::generate(); + let request = direct_request(&actor); + + let wrong_domain = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(2), + request.principal().clone(), + profile(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&wrong_domain, &request, NOW, provider_timeout()).await + else { + panic!("cross-domain decision must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationDomainMismatch + ); + + let wrong_principal = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + FederatedPrincipal::new("https://idp.example", "other-subject") + .expect("synthetic principal is valid"), + profile(), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&wrong_principal, &request, NOW, provider_timeout()).await + else { + panic!("principal mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::PrincipalMismatch + ); + + let wrong_profile = FakeProvider::returning(ProviderDecision::Allow( + ProviderAllow::new( + domain(1), + request.principal().clone(), + AuthorizationProfileId::from_server_configuration("other-profile") + .expect("synthetic profile is valid"), + request.requested_capabilities().clone(), + policy_version("version-a"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"), + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&wrong_profile, &request, NOW, provider_timeout()).await + else { + panic!("profile mismatch must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::AuthorizationProfileMismatch + ); + + let missing_capability = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::CommunityWrite]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&missing_capability, &request, NOW, provider_timeout()).await + else { + panic!("missing capability must deny"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[tokio::test] +async fn invite_mint_does_not_authorize_invite_claim() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 200, + capabilities(&[AuthorizationCapability::InviteClaim]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[AuthorizationCapability::InviteMint]), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Deny(denial) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("invitation minting must not authorize a claim"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); +} + +#[test] +fn capability_sets_are_normalized_and_deduplicated() { + let normalized = CapabilitySet::new(vec![ + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + AuthorizationCapability::CommunityRead, + ]) + .expect("synthetic capabilities are non-empty"); + + assert_eq!( + normalized.as_slice(), + &[ + AuthorizationCapability::CommunityRead, + AuthorizationCapability::GitWrite, + ] + ); +} + +#[tokio::test] +async fn no_distinct_capability_authorizes_another_capability() { + let actor = Keys::generate(); + for requested in all_capabilities() { + for granted in all_capabilities() { + if requested == granted { + continue; + } + + let request = direct_request_with_expiry(&actor, 200, capabilities(&[requested])); + let provider = FakeProvider::returning(allow_for( + &request, + capabilities(&[granted]), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Deny(denial) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("a distinct capability must not widen provider authority"); + }; + assert_eq!( + denial.reason(), + AuthorizationDenialReason::MissingCapability + ); + } + } +} + +#[tokio::test] +async fn assertion_expiry_bounds_provider_freshness() { + let actor = Keys::generate(); + let request = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current bounded policy must allow"); + }; + assert_eq!(snapshot.fresh_until(), 180); + assert_eq!(snapshot.effective_until(), 120); +} + +#[tokio::test] +async fn identity_evidence_expiring_during_provider_resolution_denies() { + let actor = Keys::generate(); + let direct = direct_request_with_expiry( + &actor, + 120, + capabilities(&[AuthorizationCapability::CommunityRead]), + ); + let direct_provider = FakeProvider::returning(allow_for( + &direct, + direct.requested_capabilities().clone(), + "version-a", + 110, + 180, + )); + let AuthorizationOutcome::Deny(direct_denial) = + resolve_at(&direct_provider, &direct, 120, provider_timeout()).await + else { + panic!("assertion expiring during provider resolution must deny"); + }; + assert_eq!( + direct_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated = delegated_request(&delegate, &owner, 140); + let delegated_provider = FakeProvider::returning(allow_for( + &delegated, + delegated.requested_capabilities().clone(), + "version-a", + 130, + 180, + )); + let AuthorizationOutcome::Deny(delegated_denial) = + resolve_at(&delegated_provider, &delegated, 140, provider_timeout()).await + else { + panic!("delegation expiring during provider resolution must deny"); + }; + assert_eq!( + delegated_denial.reason(), + AuthorizationDenialReason::IdentityEvidenceExpired + ); +} + +#[tokio::test] +async fn delegated_owner_admission_does_not_require_owner_assertion() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let request = delegated_request(&actor, &owner, 140); + assert!(matches!( + request.authority(), + AuthorizationAuthority::Delegated { owner_pubkey, .. } + if *owner_pubkey == owner.public_key() + )); + assert_eq!( + request.decision_source(), + DecisionSource::DelegatedOwnerBinding + ); + + let provider = FakeProvider::returning(allow_for( + &request, + request.requested_capabilities().clone(), + "version-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot) = + resolve_at(&provider, &request, NOW, provider_timeout()).await + else { + panic!("current owner admission must allow delegated authority"); + }; + assert_eq!(snapshot.effective_until(), 140); + assert_eq!(snapshot.actor_pubkey(), actor.public_key()); + assert_eq!(snapshot.owner_pubkey(), Some(owner.public_key())); + assert_eq!(snapshot.binding_id(), Some(Uuid::from_u128(10))); + assert_eq!(snapshot.binding_version(), Some(BindingVersion::INITIAL)); + assert_eq!(snapshot.transport(), AuthTransport::RelayWebSocket); +} + +#[tokio::test] +async fn policy_versions_detect_equality_and_change_without_ordering() { + let actor = Keys::generate(); + let request_a = direct_request(&actor); + let provider_a = FakeProvider::returning(allow_for( + &request_a, + request_a.requested_capabilities().clone(), + "opaque-a", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_a) = + resolve_at(&provider_a, &request_a, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + let request_b = direct_request(&actor); + let provider_b = FakeProvider::returning(allow_for( + &request_b, + request_b.requested_capabilities().clone(), + "opaque-b", + 90, + 180, + )); + let AuthorizationOutcome::Allow(snapshot_b) = + resolve_at(&provider_b, &request_b, NOW, provider_timeout()).await + else { + panic!("current provider policy must allow"); + }; + + assert_ne!(snapshot_a.policy_version(), snapshot_b.policy_version()); + assert_eq!(snapshot_a.policy_version(), &policy_version("opaque-a")); +} + +#[test] +fn provider_contract_rejects_malformed_values() { + assert_eq!( + CapabilitySet::new(Vec::new()), + Err(ProviderContractError::EmptyCapabilitySet) + ); + assert_eq!( + AuthorizationProfileId::from_server_configuration(""), + Err(ProviderContractError::EmptyProfileId) + ); + assert_eq!( + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::ProfileIdTooLong) + ); + assert!( + AuthorizationProfileId::from_server_configuration("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok() + ); + assert_eq!( + PolicyVersion::new(""), + Err(ProviderContractError::EmptyPolicyVersion) + ); + assert_eq!( + PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)), + Err(ProviderContractError::PolicyVersionTooLong) + ); + assert!(PolicyVersion::new("x".repeat(MAX_OPAQUE_ID_BYTES)).is_ok()); + assert_eq!( + RetryAfter::new(0), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS + 1), + Err(ProviderContractError::InvalidRetryAfter) + ); + assert_eq!( + RetryAfter::new(MAX_RETRY_AFTER_SECONDS) + .expect("maximum retry hint is valid") + .seconds(), + MAX_RETRY_AFTER_SECONDS + ); + assert_eq!( + ProviderTimeout::new(Duration::ZERO), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT + Duration::from_nanos(1)), + Err(ProviderContractError::InvalidProviderTimeout) + ); + assert_eq!( + ProviderTimeout::new(MAX_PROVIDER_TIMEOUT) + .expect("maximum provider timeout is valid") + .duration(), + MAX_PROVIDER_TIMEOUT + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 0, + 180, + ), + Err(ProviderContractError::InvalidIssuedAt) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100, + ), + Err(ProviderContractError::InvalidFreshnessBound) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 99, + ), + Err(ProviderContractError::InvalidFreshnessBound) + ); + assert_eq!( + ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS + 1, + ), + Err(ProviderContractError::FreshnessWindowTooLong) + ); + assert!(ProviderAllow::new( + domain(1), + principal(), + profile(), + capabilities(&[AuthorizationCapability::CommunityRead]), + policy_version("version-a"), + 100, + 100 + MAX_PROVIDER_FRESHNESS_SECONDS, + ) + .is_ok()); +} + +#[test] +fn request_construction_rechecks_verified_bounds_and_relationships() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let expired = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &expired, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionExpired) + ); + + let future = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + Some(AssertionNotBefore::new(NOW + 1)), + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ); + assert_eq!( + AuthorizationRequest::direct( + &proof, + &future, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ), + Err(ProviderContractError::AssertionNotYetValid) + ); +} + +#[test] +fn request_construction_rejects_non_current_or_mismatched_federated_policy() { + let actor = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + let assertion = VerifiedFederatedAssertion::new( + domain(1), + AuthTransport::RelayWebSocket, + principal(), + Some(VerifiedKeyAttestation::new(actor.public_key())), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(180).expect("synthetic assertion expiry is valid"), + ); + let request_with = |policy: ResolvedFederatedPolicy| { + AuthorizationRequest::direct( + &proof, + &assertion, + policy, + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + let wrong_domain = federated_policy_with( + 2, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(wrong_domain), + Err(ProviderContractError::FederatedPolicyDomainMismatch) + ); + let wrong_correlation = federated_policy_with( + 1, + Uuid::from_u128(21), + 1, + EnrollmentMode::Provisioned, + 1, + 180, + ); + assert_eq!( + request_with(wrong_correlation), + Err(ProviderContractError::FederatedPolicyCorrelationMismatch) + ); + let future = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + NOW + 1, + 180, + ); + assert_eq!( + request_with(future), + Err(ProviderContractError::FederatedPolicyNotYetEffective) + ); + let expired = federated_policy_with( + 1, + Uuid::from_u128(20), + 1, + EnrollmentMode::Provisioned, + 1, + NOW, + ); + assert_eq!( + request_with(expired), + Err(ProviderContractError::FederatedPolicyExpired) + ); +} + +#[test] +fn request_construction_rejects_mismatched_verified_evidence() { + let actor = Keys::generate(); + let owner = Keys::generate(); + let other = Keys::generate(); + let proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + None, + ) + .expect("synthetic proof is valid"); + + let assertion_in_domain = + |domain_value, transport, attested_pubkey: Option| { + VerifiedFederatedAssertion::new( + domain(domain_value), + transport, + principal(), + attested_pubkey.map(VerifiedKeyAttestation::new), + AssertionTransport::TrustedProxy, + None, + AssertionExpiry::new(NOW + 20).expect("synthetic expiry is valid"), + ) + }; + let request = |proof: &VerifiedNostrProof, assertion: &VerifiedFederatedAssertion| { + AuthorizationRequest::direct( + proof, + assertion, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + + assert_eq!( + request( + &proof, + &assertion_in_domain(2, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::HttpBridge, None), + ), + Err(ProviderContractError::TransportMismatch) + ); + assert_eq!( + request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, Some(other.public_key()),), + ), + Err(ProviderContractError::KeyAttestationMismatch) + ); + assert!(request( + &proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ) + .is_ok()); + + let delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW + 20).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let delegated_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + request( + &delegated_proof, + &assertion_in_domain(1, AuthTransport::RelayWebSocket, None), + ), + Err(ProviderContractError::DirectRequestHasOwner) + ); + + let delegated_request_from = + |proof: &VerifiedNostrProof, binding: &AuthoritativeBindingResolution| { + AuthorizationRequest::delegated( + proof, + binding, + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::from_u128(20), + NOW, + ) + }; + assert_eq!( + AuthorizationRequest::delegated( + &delegated_proof, + &existing_binding(&owner), + federated_policy(), + capabilities(&[AuthorizationCapability::CommunityRead]), + Uuid::nil(), + NOW, + ), + Err(ProviderContractError::InvalidCorrelationId) + ); + assert_eq!( + delegated_request_from(&proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationRequired) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding(&other)), + Err(ProviderContractError::DelegatedOwnerMismatch) + ); + assert_eq!( + delegated_request_from(&delegated_proof, &existing_binding_in(2, &owner)), + Err(ProviderContractError::AuthorizationDomainMismatch) + ); + + let expired_delegation = VerifiedTransportDelegation::new_unrestricted( + owner.public_key(), + actor.public_key(), + Some(DelegationExpiry::new(NOW).expect("synthetic expiry is valid")), + ) + .expect("synthetic delegation is valid"); + let expired_proof = VerifiedNostrProof::new( + domain(1), + AuthTransport::RelayWebSocket, + actor.public_key(), + AuthMethod::Nip42, + Some(expired_delegation), + ) + .expect("synthetic proof is valid"); + assert_eq!( + delegated_request_from(&expired_proof, &existing_binding(&owner)), + Err(ProviderContractError::DelegationExpired) + ); +} + +#[tokio::test] +async fn request_decision_snapshot_and_errors_are_redaction_safe() { + let actor = Keys::generate(); + let request = direct_request(&actor); + let request_debug = concat!( + "AuthorizationRequest { authorization_domain: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "proof_method: \"[redacted]\", ", + "authority: \"[redacted]\", principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", + "requested_capabilities: \"[redacted]\", ", + "correlation_id: \"[redacted]\", decision_source: \"[redacted]\", ", + "evidence_valid_from: \"[redacted]\", ", + "evidence_valid_until: \"[redacted]\" }" + ); + // Keep this exact-shape assertion deliberately: adding a field must fail until + // the disclosure contract explicitly confirms that the new field is redacted. + assert_eq!(format!("{request:?}"), request_debug); + + let delegate = Keys::generate(); + let owner = Keys::generate(); + let delegated_request = delegated_request(&delegate, &owner, 180); + assert_eq!(format!("{delegated_request:?}"), request_debug); + assert_eq!( + format!("{:?}", request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.authority()), + "AuthorizationAuthority(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", request.decision_source()), + "DecisionSource(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", delegated_request.decision_source()), + "DecisionSource(\"[redacted]\")" + ); + + let allow = ProviderAllow::new( + request.authorization_domain(), + request.principal().clone(), + profile(), + request.requested_capabilities().clone(), + policy_version("private-policy-version"), + 90, + 180, + ) + .expect("synthetic provider allow is structurally valid"); + assert_eq!( + format!("{allow:?}"), + concat!( + "ProviderAllow { authorization_domain: \"[redacted]\", ", + "principal: \"[redacted]\", profile_id: \"[redacted]\", ", + "capabilities: \"[redacted]\", policy_version: \"[redacted]\", ", + "issued_at: \"[redacted]\", fresh_until: \"[redacted]\" }" + ) + ); + let decision = ProviderDecision::Allow(allow); + assert_eq!(format!("{decision:?}"), "ProviderDecision(\"[redacted]\")"); + let provider = FakeProvider::returning(decision); + let outcome = resolve_at(&provider, &request, NOW, provider_timeout()).await; + assert_eq!( + format!("{outcome:?}"), + "AuthorizationOutcome(\"[redacted]\")" + ); + let AuthorizationOutcome::Allow(snapshot) = outcome else { + panic!("current provider policy must allow"); + }; + assert_eq!( + format!("{snapshot:?}"), + concat!( + "CapabilitySnapshot { runtime_binding: \"[redacted]\", ", + "authorization_domain: \"[redacted]\", ", + "transport: \"[redacted]\", actor_pubkey: \"[redacted]\", ", + "owner_pubkey: \"[redacted]\", binding_id: \"[redacted]\", ", + "binding_version: \"[redacted]\", proof_method: \"[redacted]\", ", + "principal: \"[redacted]\", ", + "key_attested: \"[redacted]\", assertion_transport: \"[redacted]\", ", + "assertion_not_before: \"[redacted]\", ", + "assertion_expires_at: \"[redacted]\", ", + "federated_policy: \"[redacted]\", ", + "profile_id: \"[redacted]\", capabilities: \"[redacted]\", ", + "policy_version: \"[redacted]\", issued_at: \"[redacted]\", ", + "fresh_until: \"[redacted]\", effective_from: \"[redacted]\", ", + "effective_until: \"[redacted]\", ", + "decision_source: \"[redacted]\", correlation_id: \"[redacted]\", ", + "reason: \"[redacted]\" }" + ) + ); + + let denial = AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied); + assert_eq!( + format!("{denial:?}"), + "AuthorizationDenial { reason: \"[redacted]\" }" + ); + let unavailable = ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + Some(RetryAfter::new(30).expect("synthetic retry hint is bounded")), + ); + assert_eq!( + format!("{unavailable:?}"), + concat!( + "ProviderUnavailable { reason: \"[redacted]\", ", + "retry_after: \"[redacted]\" }" + ) + ); + assert_eq!( + format!( + "{:?}", + ProviderDecision::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + ProviderDecision::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "ProviderDecision(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Deny(AuthorizationDenial::new( + AuthorizationDenialReason::ProviderDenied, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); + assert_eq!( + format!( + "{:?}", + AuthorizationOutcome::Unavailable(ProviderUnavailable::new( + ProviderUnavailableReason::DependencyUnavailable, + None, + )) + ), + "AuthorizationOutcome(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", provider_timeout()), + "ProviderTimeout(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", &profile()), + "AuthorizationProfileId(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.policy_version()), + "PolicyVersion(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", snapshot.capabilities()), + "CapabilitySet(\"[redacted]\")" + ); + + for capability in all_capabilities() { + capability_coverage_is_exhaustive(capability); + assert_eq!( + format!("{capability:?}"), + "AuthorizationCapability(\"[redacted]\")" + ); + } + for reason in [ + AuthorizationDenialReason::ProviderDenied, + AuthorizationDenialReason::AuthorizationDomainMismatch, + AuthorizationDenialReason::PrincipalMismatch, + AuthorizationDenialReason::AuthorizationProfileMismatch, + AuthorizationDenialReason::MissingCapability, + AuthorizationDenialReason::StaleDecision, + AuthorizationDenialReason::FutureDecision, + AuthorizationDenialReason::IdentityEvidenceExpired, + AuthorizationDenialReason::IdentityEvidenceNotYetValid, + AuthorizationDenialReason::FederatedPolicyNotCurrent, + ] { + assert_eq!( + format!("{reason:?}"), + "AuthorizationDenialReason(\"[redacted]\")" + ); + } + for reason in [ + ProviderUnavailableReason::TemporarilyUnavailable, + ProviderUnavailableReason::Timeout, + ProviderUnavailableReason::DependencyUnavailable, + ] { + assert_eq!( + format!("{reason:?}"), + "ProviderUnavailableReason(\"[redacted]\")" + ); + } + assert_eq!( + format!("{:?}", ProviderAllowReason::CurrentPolicy), + "ProviderAllowReason(\"[redacted]\")" + ); + assert_eq!( + format!("{:?}", RetryAfter::new(30).expect("retry hint is valid")), + "RetryAfter(\"[redacted]\")" + ); + + for error in all_contract_errors() { + for rendered in [error.to_string(), format!("{error:?}")] { + for private_value in [ + "idp.example", + "subject-123", + "profile-1", + "private-policy-version", + ] { + assert!(!rendered.contains(private_value)); + } + } + } +} + +#[test] +fn provider_trait_is_object_safe_and_codes_are_unique() { + let provider: Arc = + Arc::new(FakeProvider::returning(ProviderDecision::Deny( + AuthorizationDenial::new(AuthorizationDenialReason::ProviderDenied), + ))); + assert!(Arc::strong_count(&provider) == 1); + + let mut codes = vec![ + ProviderAllowReason::CurrentPolicy.code(), + AuthorizationDenialReason::ProviderDenied.code(), + AuthorizationDenialReason::AuthorizationDomainMismatch.code(), + AuthorizationDenialReason::PrincipalMismatch.code(), + AuthorizationDenialReason::AuthorizationProfileMismatch.code(), + AuthorizationDenialReason::MissingCapability.code(), + AuthorizationDenialReason::StaleDecision.code(), + AuthorizationDenialReason::FutureDecision.code(), + AuthorizationDenialReason::IdentityEvidenceExpired.code(), + AuthorizationDenialReason::IdentityEvidenceNotYetValid.code(), + AuthorizationDenialReason::FederatedPolicyNotCurrent.code(), + ProviderUnavailableReason::TemporarilyUnavailable.code(), + ProviderUnavailableReason::Timeout.code(), + ProviderUnavailableReason::DependencyUnavailable.code(), + ]; + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), 14); + + let contract_errors = all_contract_errors(); + let mut contract_codes = contract_errors + .iter() + .copied() + .map(ProviderContractError::code) + .collect::>(); + contract_codes.sort_unstable(); + contract_codes.dedup(); + assert_eq!(contract_codes.len(), contract_errors.len()); +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c5038..76943c2abf 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -68,6 +68,12 @@ pub const KIND_LONG_FORM: u32 = 30023; /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. pub const KIND_USER_STATUS: u32 = 30315; +/// NIP-85: relay-signed trusted assertion about a user pubkey. +/// +/// Buzz uses this standard user-subject assertion kind to project an active +/// enterprise identity binding without exposing the binding's stable uid. +/// The relay authors the event and keys it by the subject pubkey in `d`. +pub const KIND_USER_TRUSTED_ASSERTION: u32 = 30382; /// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync. /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..fbc842764e 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -9,6 +9,7 @@ use sqlx::{PgPool, Postgres, Row, Transaction}; use uuid::Uuid; use crate::error::{DbError, Result}; +use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; use buzz_core::CommunityId; // Re-export the canonical enum definitions from buzz-core. @@ -385,12 +386,7 @@ pub async fn add_member( role: MemberRole, invited_by: Option<&[u8]>, ) -> Result { - if pubkey.len() != 32 { - return Err(DbError::InvalidData(format!( - "pubkey must be 32 bytes, got {}", - pubkey.len() - ))); - } + validate_member_pubkey(pubkey)?; let mut tx = pool.begin().await?; @@ -398,7 +394,105 @@ pub async fn add_member( // sequence against concurrent membership writes on this channel. acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; - let channel = get_channel_tx(&mut tx, community_id, channel_id).await?; + let record = add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await?; + tx.commit().await?; + Ok(record) +} + +/// Outcome of atomically adding a channel member and binding corporate identity. +#[derive(Debug, Clone)] +pub enum ChannelAdmissionOutcome { + /// Membership and any staged identity binding committed together. + Joined { + /// The committed membership row. + member: MemberRecord, + /// Binding committed in the same transaction, when one was staged. + identity_binding: Option, + }, + /// The staged identity conflicts with an active binding. + IdentityConflict(IdentityBindingConflict), + /// The staged identity principal or key is revoked. + IdentityRevoked, +} + +/// Add a channel member and optional corporate identity binding in one transaction. +pub async fn add_member_with_identity( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: MemberRole, + invited_by: Option<&[u8]>, + identity: Option<&IdentityBindingInput<'_>>, +) -> Result { + validate_member_pubkey(pubkey)?; + if identity.is_some_and(|identity| identity.pubkey != pubkey) { + return Err(DbError::InvalidData( + "channel membership pubkey does not match staged identity key".to_string(), + )); + } + + let mut tx = pool.begin().await?; + // Keep this first: every channel membership writer shares this lock order. + acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + + let member = + match add_member_tx(&mut tx, community_id, channel_id, pubkey, role, invited_by).await { + Ok(member) => member, + Err(error) => { + tx.rollback().await?; + return Err(error); + } + }; + let identity_binding = if let Some(identity) = identity { + match crate::identity_binding::bind_or_validate_identity_tx(&mut tx, community_id, identity) + .await + { + Ok(binding @ (BindIdentityResult::Created | BindIdentityResult::Matched)) => { + Some(binding) + } + Ok(BindIdentityResult::Conflict(conflict)) => { + tx.rollback().await?; + return Ok(ChannelAdmissionOutcome::IdentityConflict(conflict)); + } + Ok(BindIdentityResult::Revoked) => { + tx.rollback().await?; + return Ok(ChannelAdmissionOutcome::IdentityRevoked); + } + Err(error) => { + tx.rollback().await?; + return Err(error); + } + } + } else { + None + }; + tx.commit().await?; + Ok(ChannelAdmissionOutcome::Joined { + member, + identity_binding, + }) +} + +fn validate_member_pubkey(pubkey: &[u8]) -> Result<()> { + if pubkey.len() != 32 { + return Err(DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + pubkey.len() + ))); + } + Ok(()) +} + +async fn add_member_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: MemberRole, + invited_by: Option<&[u8]>, +) -> Result { + let channel = get_channel_tx(tx, community_id, channel_id).await?; let effective_role = if channel.visibility == "private" { let inviter = invited_by.ok_or_else(|| { @@ -409,7 +503,7 @@ pub async fn add_member( let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice(); if !is_creator_bootstrap { - let inviter_role_str = get_active_role_tx(&mut tx, community_id, channel_id, inviter) + let inviter_role_str = get_active_role_tx(tx, community_id, channel_id, inviter) .await? .ok_or_else(|| { DbError::AccessDenied("inviter is not an active member".to_string()) @@ -433,7 +527,7 @@ pub async fn add_member( // elevated roles. Self-join always gets Member. if role.is_elevated() { let granter_role = match invited_by { - Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?, + Some(inv) => get_active_role_tx(tx, community_id, channel_id, inv).await?, None => None, }; match granter_role.as_deref() { @@ -465,10 +559,10 @@ pub async fn add_member( // current authority from a removed row would make soft-deleted ownership a // resurrection token: an owner removed by another owner could self-rejoin // via kind:9021 (`Member, None`) and silently regain ownership. - let current_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let current_role = get_active_role_tx(tx, community_id, channel_id, pubkey).await?; if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) { let actor_role = match invited_by { - Some(inviter) => get_active_role_tx(&mut tx, community_id, channel_id, inviter).await?, + Some(inviter) => get_active_role_tx(tx, community_id, channel_id, inviter).await?, None => None, }; let actor_role: Option = actor_role.and_then(|r| r.parse().ok()); @@ -488,7 +582,7 @@ pub async fn add_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -514,7 +608,7 @@ pub async fn add_member( .bind(pubkey) .bind(effective_role.as_str()) .bind(invited_by) - .execute(&mut *tx) + .execute(&mut **tx) .await?; let row = sqlx::query( @@ -526,11 +620,10 @@ pub async fn add_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_one(&mut *tx) + .fetch_one(&mut **tx) .await?; let record = row_to_member_record(row)?; - tx.commit().await?; Ok(record) } @@ -1530,7 +1623,9 @@ mod tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + let database_url = + std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + PgPool::connect(&database_url) .await .expect("connect to test DB") } @@ -1602,6 +1697,388 @@ mod tests { get_channel(pool, CommunityId::from_uuid(community_id), id).await } + fn identity_for<'a>(pubkey: &'a [u8], uid: &'a str) -> IdentityBindingInput<'a> { + IdentityBindingInput { + issuer: "https://idp.example", + uid, + pubkey, + display_name: Some("private@example.com"), + source: crate::identity_binding::SOURCE_JWT_NPUB, + } + } + + async fn trusted_assertion_count(pool: &PgPool, community: CommunityId) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE community_id = $1 AND kind = $2") + .bind(community.as_uuid()) + .bind(buzz_core::kind::KIND_USER_TRUSTED_ASSERTION as i32) + .fetch_one(pool) + .await + .expect("trusted assertion count") + } + + async fn active_membership_count( + pool: &PgPool, + community: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(*) FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND removed_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel_id) + .bind(pubkey) + .fetch_one(pool) + .await + .expect("active membership count") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn atomic_huddle_admission_membership_failure_leaves_no_identity_state() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let non_member_inviter = random_pubkey(); + let joiner = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "atomic-membership-failure", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + Some(3600), + ) + .await + .expect("create private huddle"); + let identity = identity_for(&joiner, "membership-failure"); + + let error = add_member_with_identity( + &pool, + community, + channel.id, + &joiner, + MemberRole::Member, + Some(&non_member_inviter), + Some(&identity), + ) + .await + .expect_err("non-member inviter must fail admission"); + assert!(matches!(error, DbError::AccessDenied(_)), "{error:?}"); + assert_eq!( + active_membership_count(&pool, community, channel.id, &joiner).await, + 0 + ); + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, community, &joiner, + ) + .await + .expect("binding lookup") + .is_none() + ); + assert_eq!(trusted_assertion_count(&pool, community).await, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn atomic_huddle_admission_identity_conflict_rolls_back_membership() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let bound_key = random_pubkey(); + let joiner = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "atomic-identity-conflict", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + Some(3600), + ) + .await + .expect("create private huddle"); + crate::identity_binding::bind_or_validate_identity( + &pool, + community, + "https://idp.example", + "conflicting-principal", + &bound_key, + Some("bound@example.com"), + crate::identity_binding::SOURCE_JWT_NPUB, + ) + .await + .expect("seed conflicting binding"); + let identity = identity_for(&joiner, "conflicting-principal"); + + let outcome = add_member_with_identity( + &pool, + community, + channel.id, + &joiner, + MemberRole::Member, + Some(&owner), + Some(&identity), + ) + .await + .expect("typed identity conflict"); + assert!(matches!( + outcome, + ChannelAdmissionOutcome::IdentityConflict(_) + )); + assert_eq!( + active_membership_count(&pool, community, channel.id, &joiner).await, + 0 + ); + assert_eq!(trusted_assertion_count(&pool, community).await, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn atomic_huddle_admission_identity_storage_failure_rolls_back_membership() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let joiner = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "atomic-identity-storage-failure", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + Some(3600), + ) + .await + .expect("create private huddle"); + let suffix = community_id.simple(); + let function_name = format!("buzz_test_fail_identity_{suffix}"); + let trigger_name = format!("buzz_test_fail_identity_insert_{suffix}"); + // Identifiers and the literal UUID below are derived only from a generated UUID. + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE FUNCTION {function_name}() RETURNS trigger LANGUAGE plpgsql AS $$ \ + BEGIN RAISE EXCEPTION 'injected identity storage failure'; END $$" + ))) + .execute(&pool) + .await + .expect("create failure function"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE TRIGGER {trigger_name} BEFORE INSERT ON identity_bindings \ + FOR EACH ROW WHEN (NEW.community_id = '{community_id}'::uuid) \ + EXECUTE FUNCTION {function_name}()" + ))) + .execute(&pool) + .await + .expect("create failure trigger"); + let identity = identity_for(&joiner, "storage-failure"); + + let result = add_member_with_identity( + &pool, + community, + channel.id, + &joiner, + MemberRole::Member, + Some(&owner), + Some(&identity), + ) + .await; + + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP TRIGGER {trigger_name} ON identity_bindings" + ))) + .execute(&pool) + .await + .expect("drop failure trigger"); + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP FUNCTION {function_name}()" + ))) + .execute(&pool) + .await + .expect("drop failure function"); + + assert!(matches!(result, Err(DbError::Sqlx(_))), "{result:?}"); + assert_eq!( + active_membership_count(&pool, community, channel.id, &joiner).await, + 0 + ); + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, community, &joiner, + ) + .await + .expect("binding lookup") + .is_none() + ); + assert_eq!(trusted_assertion_count(&pool, community).await, 0); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn atomic_huddle_admission_success_and_retry_commit_each_row_once() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let joiner = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "atomic-success-retry", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + Some(3600), + ) + .await + .expect("create private huddle"); + let identity = identity_for(&joiner, "successful-principal"); + + let first = add_member_with_identity( + &pool, + community, + channel.id, + &joiner, + MemberRole::Member, + Some(&owner), + Some(&identity), + ) + .await + .expect("first admission"); + assert!(matches!( + first, + ChannelAdmissionOutcome::Joined { + identity_binding: Some(BindIdentityResult::Created), + .. + } + )); + + let retry = add_member_with_identity( + &pool, + community, + channel.id, + &joiner, + MemberRole::Member, + Some(&owner), + Some(&identity), + ) + .await + .expect("idempotent retry"); + assert!(matches!( + retry, + ChannelAdmissionOutcome::Joined { + identity_binding: Some(BindIdentityResult::Matched), + .. + } + )); + assert_eq!( + active_membership_count(&pool, community, channel.id, &joiner).await, + 1 + ); + let binding_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM identity_bindings \ + WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(&joiner) + .fetch_one(&pool) + .await + .expect("binding count"); + assert_eq!(binding_count, 1); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn existing_member_and_non_corporate_paths_remain_idempotent() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let owner = random_pubkey(); + let existing_member = random_pubkey(); + let non_corporate_joiner = random_pubkey(); + let channel = create_test_channel( + &pool, + community_id, + "unchanged-admission-paths", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner, + Some(3600), + ) + .await + .expect("create private huddle"); + + add_member( + &pool, + community, + channel.id, + &existing_member, + MemberRole::Member, + Some(&owner), + ) + .await + .expect("existing member add"); + add_member( + &pool, + community, + channel.id, + &existing_member, + MemberRole::Member, + Some(&owner), + ) + .await + .expect("existing member retry"); + assert_eq!( + active_membership_count(&pool, community, channel.id, &existing_member).await, + 1 + ); + + let outcome = add_member_with_identity( + &pool, + community, + channel.id, + &non_corporate_joiner, + MemberRole::Member, + Some(&owner), + None, + ) + .await + .expect("non-corporate admission"); + assert!(matches!( + outcome, + ChannelAdmissionOutcome::Joined { + identity_binding: None, + .. + } + )); + assert_eq!( + active_membership_count(&pool, community, channel.id, &non_corporate_joiner).await, + 1 + ); + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, + community, + &non_corporate_joiner, + ) + .await + .expect("binding lookup") + .is_none() + ); + assert_eq!(trusted_assertion_count(&pool, community).await, 0); + } + async fn insert_channel_with_id( pool: &PgPool, community_id: Uuid, diff --git a/crates/buzz-db/src/identity_binding.rs b/crates/buzz-db/src/identity_binding.rs new file mode 100644 index 0000000000..8b6515bde2 --- /dev/null +++ b/crates/buzz-db/src/identity_binding.rs @@ -0,0 +1,1400 @@ +//! Corporate identity binding persistence. +//! +//! Bindings map an issuer-qualified IdP uid to the currently authorized Nostr +//! pubkey inside one Buzz community. The active uniqueness indexes deliberately +//! model one active pubkey per `(issuer, uid)` principal and one active principal +//! per pubkey. Explicit lifecycle operations distinguish principal disablement, +//! single-key revocation, and authorized rotation; authentication never +//! silently rewrites those states. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, Row, Transaction}; + +use crate::error::{DbError, Result}; +use buzz_core::CommunityId; + +/// Binding source when the IdP JWT carries the pubkey claim. +pub const SOURCE_JWT_NPUB: &str = "jwt_npub"; +/// Binding source when the relay falls back to the stored uid/pubkey binding. +pub const SOURCE_DB_BINDING: &str = "db_binding"; + +/// Active corporate identity binding row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityBinding { + /// Validated identity-provider issuer. + pub issuer: String, + /// Corporate IdP subject or configured stable uid claim. + pub uid: String, + /// Bound Nostr pubkey bytes. + pub pubkey: Vec, + /// Human-readable display claim captured from the latest accepted JWT. + pub display_name: Option, + /// Source that established or last strengthened the active binding. + pub source: String, + /// When the binding was first created. + pub created_at: DateTime, + /// When the binding row was last updated. + pub updated_at: DateTime, + /// When the binding was last seen during authentication. + pub last_seen_at: DateTime, +} + +/// Existing active binding that conflicts with a requested binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityBindingConflict { + /// Existing active issuer. + pub issuer: String, + /// Existing active uid. + pub uid: String, + /// Existing active pubkey bytes. + pub pubkey: Vec, + /// Existing active binding source. + pub source: String, +} + +/// Outcome of creating or validating a corporate identity binding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindIdentityResult { + /// A new active binding was created. + Created, + /// The requested binding matched an existing active binding. + Matched, + /// Another active binding already owns the uid or pubkey. + Conflict(IdentityBindingConflict), + /// The requested uid/pubkey pair was previously revoked. + Revoked, +} + +/// Corporate identity data staged for an atomic admission transaction. +#[derive(Debug, Clone, Copy)] +pub struct IdentityBindingInput<'a> { + /// Validated identity-provider issuer. + pub issuer: &'a str, + /// Stable issuer-qualified principal identifier. + pub uid: &'a str, + /// Authenticated Nostr pubkey bytes. + pub pubkey: &'a [u8], + /// Private display attribute retained in the binding table. + pub display_name: Option<&'a str>, + /// Binding source (`jwt_npub` or `db_binding`). + pub source: &'a str, +} + +fn validate_inputs(issuer: &str, uid: &str, pubkey: &[u8], source: &str) -> Result<()> { + if issuer.trim().is_empty() { + return Err(DbError::InvalidData( + "identity binding issuer must not be empty".to_string(), + )); + } + if uid.trim().is_empty() { + return Err(DbError::InvalidData( + "identity binding uid must not be empty".to_string(), + )); + } + validate_pubkey(pubkey)?; + if !matches!(source, SOURCE_JWT_NPUB | SOURCE_DB_BINDING) { + return Err(DbError::InvalidData(format!( + "invalid identity binding source: {source}" + ))); + } + Ok(()) +} + +fn validate_pubkey(pubkey: &[u8]) -> Result<()> { + if pubkey.len() != 32 { + return Err(DbError::InvalidData( + "identity binding pubkey must be 32 bytes".to_string(), + )); + } + Ok(()) +} + +pub(crate) fn validate_membership_identity_key( + member_pubkey_hex: &str, + identity: Option<&IdentityBindingInput<'_>>, +) -> Result<()> { + let Some(identity) = identity else { + return Ok(()); + }; + let member_pubkey = hex::decode(member_pubkey_hex) + .map_err(|_| DbError::InvalidData("membership pubkey must be 32-byte hex".to_string()))?; + if member_pubkey.len() != 32 || member_pubkey.as_slice() != identity.pubkey { + return Err(DbError::InvalidData( + "membership pubkey does not match staged identity key".to_string(), + )); + } + Ok(()) +} + +fn row_to_binding(row: sqlx::postgres::PgRow) -> Result { + Ok(IdentityBinding { + issuer: row.try_get("issuer")?, + uid: row.try_get("uid")?, + pubkey: row.try_get("pubkey")?, + display_name: row.try_get("display_name")?, + source: row.try_get("source")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + last_seen_at: row.try_get("last_seen_at")?, + }) +} + +async fn active_by_principal_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + uid: &str, +) -> Result> { + let row = sqlx::query( + r#" + SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at + FROM identity_bindings + WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .fetch_optional(&mut **tx) + .await?; + row.map(row_to_binding).transpose() +} + +async fn active_by_pubkey_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + let row = sqlx::query( + r#" + SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at + FROM identity_bindings + WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + row.map(row_to_binding).transpose() +} + +async fn revoked_pair_exists_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + uid: &str, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + r#" + SELECT 1 + FROM identity_bindings + WHERE community_id = $1 + AND issuer = $2 + AND uid = $3 + AND pubkey = $4 + AND revoked_at IS NOT NULL + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + Ok(row.is_some()) +} + +async fn principal_disabled_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + uid: &str, +) -> Result { + let row = sqlx::query( + r#" + SELECT 1 FROM identity_principals + WHERE community_id = $1 AND issuer = $2 AND uid = $3 + AND disabled_at IS NOT NULL + UNION ALL + SELECT 1 FROM identity_bindings + WHERE community_id = $1 AND issuer = $2 AND uid = $3 + AND revoked_at IS NOT NULL AND revocation_scope = 'principal' + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .fetch_optional(&mut **tx) + .await?; + Ok(row.is_some()) +} + +async fn key_revoked_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + pubkey: &[u8], +) -> Result { + let row = sqlx::query( + r#" + SELECT 1 FROM identity_revoked_keys + WHERE community_id = $1 AND pubkey = $2 + UNION ALL + SELECT 1 FROM identity_bindings + WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NOT NULL + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(&mut **tx) + .await?; + Ok(row.is_some()) +} + +async fn principal_requires_rotation_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + uid: &str, +) -> Result { + let row = sqlx::query( + r#" + SELECT 1 + FROM identity_bindings + WHERE community_id = $1 + AND issuer = $2 + AND uid = $3 + AND revoked_at IS NOT NULL + AND revocation_scope = 'key' + AND rotation_completed_at IS NULL + LIMIT 1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .fetch_optional(&mut **tx) + .await?; + Ok(row.is_some()) +} + +fn conflict_from(binding: IdentityBinding) -> IdentityBindingConflict { + IdentityBindingConflict { + issuer: binding.issuer, + uid: binding.uid, + pubkey: binding.pubkey, + source: binding.source, + } +} + +async fn lock_identity_key_strings_tx( + tx: &mut Transaction<'_, Postgres>, + mut keys: Vec, +) -> Result<()> { + keys.sort(); + keys.dedup(); + for key in keys { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('identity_bindings'), hashtext($1))") + .bind(key) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn lock_identity_keys_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + issuer: &str, + uid: &str, + pubkey: &[u8], +) -> Result<()> { + lock_identity_key_strings_tx( + tx, + vec![ + format!("{}:principal:{issuer}:{uid}", community_id.as_uuid()), + format!("{}:pubkey:{}", community_id.as_uuid(), hex::encode(pubkey)), + ], + ) + .await +} + +/// Create or validate an active corporate identity binding. +/// +/// This is a fail-closed auth-time operation: +/// - same issuer + uid + pubkey updates display/last_seen and succeeds; +/// - same issuer + uid with a different pubkey conflicts; +/// - same pubkey with a different issuer-qualified principal conflicts; +/// - principal disablement and unresolved key revocation reject every key; +/// - a previously revoked issuer/uid/pubkey tuple remains revoked; +/// - no active row creates a new binding. +pub async fn bind_or_validate_identity( + pool: &PgPool, + community_id: CommunityId, + issuer: &str, + uid: &str, + pubkey: &[u8], + display_name: Option<&str>, + source: &str, +) -> Result { + let mut tx = pool.begin().await?; + let result = bind_or_validate_identity_tx( + &mut tx, + community_id, + &IdentityBindingInput { + issuer, + uid, + pubkey, + display_name, + source, + }, + ) + .await?; + tx.commit().await?; + Ok(result) +} + +/// Create or validate a binding inside a caller-owned admission transaction. +pub(crate) async fn bind_or_validate_identity_tx( + tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, + identity: &IdentityBindingInput<'_>, +) -> Result { + let IdentityBindingInput { + issuer, + uid, + pubkey, + display_name, + source, + } = *identity; + validate_inputs(issuer, uid, pubkey, source)?; + + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut **tx) + .await?; + lock_identity_keys_tx(tx, community_id, issuer, uid, pubkey).await?; + + if principal_disabled_tx(tx, community_id, issuer, uid).await? { + return Ok(BindIdentityResult::Revoked); + } + if key_revoked_tx(tx, community_id, pubkey).await? { + return Ok(BindIdentityResult::Revoked); + } + if principal_requires_rotation_tx(tx, community_id, issuer, uid).await? { + return Ok(BindIdentityResult::Revoked); + } + + let active_principal = active_by_principal_tx(tx, community_id, issuer, uid).await?; + if let Some(binding) = active_principal { + if binding.pubkey != pubkey { + return Ok(BindIdentityResult::Conflict(conflict_from(binding))); + } + + sqlx::query( + r#" + UPDATE identity_bindings + SET display_name = $5, + source = CASE + WHEN source = 'jwt_npub' AND $6 = 'db_binding' THEN source + ELSE $6 + END, + updated_at = NOW(), + last_seen_at = NOW() + WHERE community_id = $1 + AND issuer = $2 + AND uid = $3 + AND pubkey = $4 + AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(pubkey) + .bind(display_name) + .bind(source) + .execute(&mut **tx) + .await?; + return Ok(BindIdentityResult::Matched); + } + + let active_pubkey = active_by_pubkey_tx(tx, community_id, pubkey).await?; + if let Some(binding) = active_pubkey { + if binding.issuer != issuer || binding.uid != uid { + return Ok(BindIdentityResult::Conflict(conflict_from(binding))); + } + } + + if revoked_pair_exists_tx(tx, community_id, issuer, uid, pubkey).await? { + return Ok(BindIdentityResult::Revoked); + } + + sqlx::query( + r#" + INSERT INTO identity_bindings (community_id, issuer, uid, pubkey, display_name, source) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(pubkey) + .bind(display_name) + .bind(source) + .execute(&mut **tx) + .await?; + Ok(BindIdentityResult::Created) +} + +/// Return the active binding for `pubkey`, if one exists. +pub async fn get_active_identity_binding_by_pubkey( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { + validate_pubkey(pubkey)?; + let row = sqlx::query( + r#" + SELECT issuer, uid, pubkey, display_name, source, created_at, updated_at, last_seen_at + FROM identity_bindings + WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; + row.map(row_to_binding).transpose() +} + +/// Disable an issuer-qualified principal and revoke its active key. +/// +/// A principal disablement is durable: normal authentication with any new key +/// returns [`BindIdentityResult::Revoked`]. Re-enablement requires a separate, +/// explicit operator lifecycle operation rather than first-use enrollment. +pub async fn revoke_identity_principal( + pool: &PgPool, + community_id: CommunityId, + issuer: &str, + uid: &str, + revoked_by: Option<&[u8]>, + reason: &str, +) -> Result { + if issuer.trim().is_empty() || uid.trim().is_empty() || reason.trim().is_empty() { + return Err(DbError::InvalidData( + "identity principal revocation requires issuer, uid, and reason".to_string(), + )); + } + if let Some(pubkey) = revoked_by { + validate_pubkey(pubkey)?; + } + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + // Enrollment and rotation take the principal lock first. Take it before + // discovering the current key so a concurrent first enrollment cannot + // slip between the lookup and the durable principal tombstone. + lock_identity_key_strings_tx( + &mut tx, + vec![format!( + "{}:principal:{issuer}:{uid}", + community_id.as_uuid() + )], + ) + .await?; + let active_pubkey: Option> = sqlx::query_scalar( + "SELECT pubkey FROM identity_bindings WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .fetch_optional(&mut *tx) + .await?; + if let Some(pubkey) = active_pubkey.as_ref() { + lock_identity_key_strings_tx( + &mut tx, + vec![format!( + "{}:pubkey:{}", + community_id.as_uuid(), + hex::encode(pubkey) + )], + ) + .await?; + } + + sqlx::query( + r#" + INSERT INTO identity_principals + (community_id, issuer, uid, disabled_at, disabled_by, disabled_reason) + VALUES ($1, $2, $3, NOW(), $4, $5) + ON CONFLICT (community_id, issuer, uid) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(revoked_by) + .bind(reason) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + UPDATE identity_bindings + SET revoked_at = NOW(), revoked_by = $4, revoked_reason = $5, + revocation_scope = 'principal', updated_at = NOW() + WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(revoked_by) + .bind(reason) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + +/// Revoke one active key without disabling the issuer-qualified principal. +/// A replacement key must still be installed through [`rotate_identity_binding`]. +pub async fn revoke_identity_key( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], + revoked_by: Option<&[u8]>, + reason: &str, +) -> Result { + validate_pubkey(pubkey)?; + if let Some(operator) = revoked_by { + validate_pubkey(operator)?; + } + if reason.trim().is_empty() { + return Err(DbError::InvalidData( + "identity key revocation reason must not be empty".to_string(), + )); + } + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + // A community-scoped key tombstone is the entire correctness boundary. + // Do not acquire a principal lock after it: enrollment and rotation use + // principal→key ordering, and reversing that order can deadlock. + lock_identity_key_strings_tx( + &mut tx, + vec![format!( + "{}:pubkey:{}", + community_id.as_uuid(), + hex::encode(pubkey) + )], + ) + .await?; + sqlx::query( + r#" + INSERT INTO identity_revoked_keys + (community_id, pubkey, revoked_at, revoked_by, reason) + VALUES ($1, $2, NOW(), $3, $4) + ON CONFLICT (community_id, pubkey) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(revoked_by) + .bind(reason) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + UPDATE identity_bindings + SET revoked_at = NOW(), revoked_by = $3, revoked_reason = $4, + revocation_scope = 'key', updated_at = NOW() + WHERE community_id = $1 AND pubkey = $2 AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(revoked_by) + .bind(reason) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + +/// Atomically retire an active key and install an operator-authorized replacement. +#[allow(clippy::too_many_arguments)] +pub async fn rotate_identity_binding( + pool: &PgPool, + community_id: CommunityId, + issuer: &str, + uid: &str, + old_pubkey: &[u8], + new_pubkey: &[u8], + display_name: Option<&str>, + source: &str, + rotated_by: Option<&[u8]>, + reason: &str, +) -> Result<()> { + validate_inputs(issuer, uid, old_pubkey, source)?; + validate_pubkey(new_pubkey)?; + if old_pubkey == new_pubkey { + return Err(DbError::InvalidData( + "identity rotation requires a different replacement key".to_string(), + )); + } + if let Some(operator) = rotated_by { + validate_pubkey(operator)?; + } + if reason.trim().is_empty() { + return Err(DbError::InvalidData( + "identity rotation reason must not be empty".to_string(), + )); + } + + let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + lock_identity_key_strings_tx( + &mut tx, + vec![ + format!("{}:principal:{issuer}:{uid}", community_id.as_uuid()), + format!( + "{}:pubkey:{}", + community_id.as_uuid(), + hex::encode(old_pubkey) + ), + format!( + "{}:pubkey:{}", + community_id.as_uuid(), + hex::encode(new_pubkey) + ), + ], + ) + .await?; + if principal_disabled_tx(&mut tx, community_id, issuer, uid).await? { + return Err(DbError::InvalidData( + "disabled identity principal cannot be rotated".to_string(), + )); + } + if key_revoked_tx(&mut tx, community_id, new_pubkey).await? { + return Err(DbError::InvalidData( + "identity rotation replacement key is revoked".to_string(), + )); + } + let active = active_by_principal_tx(&mut tx, community_id, issuer, uid).await?; + if active + .as_ref() + .is_some_and(|binding| binding.pubkey != old_pubkey) + { + return Err(DbError::InvalidData( + "identity rotation source key does not match active binding".to_string(), + )); + } + if active.is_none() { + let revoked_key = sqlx::query( + r#" + SELECT 1 FROM identity_bindings + WHERE community_id = $1 AND issuer = $2 AND uid = $3 + AND pubkey = $4 AND revoked_at IS NOT NULL + AND revocation_scope = 'key' + FOR UPDATE + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(old_pubkey) + .fetch_optional(&mut *tx) + .await?; + if revoked_key.is_none() { + return Err(DbError::InvalidData( + "identity rotation source is neither active nor key-revoked".to_string(), + )); + } + } + if active_by_pubkey_tx(&mut tx, community_id, new_pubkey) + .await? + .is_some() + { + return Err(DbError::InvalidData( + "identity rotation replacement key is already bound".to_string(), + )); + } + + sqlx::query( + r#" + UPDATE identity_bindings + SET revoked_at = COALESCE(revoked_at, NOW()), + revoked_by = CASE WHEN revoked_at IS NULL THEN $5 ELSE revoked_by END, + revoked_reason = CASE WHEN revoked_at IS NULL THEN $6 ELSE revoked_reason END, + revocation_scope = CASE WHEN revoked_at IS NULL THEN 'rotation' ELSE revocation_scope END, + rotation_completed_at = NOW(), rotated_to_pubkey = $7, + rotation_by = $5, rotation_reason = $6, updated_at = NOW() + WHERE community_id = $1 AND issuer = $2 AND uid = $3 + AND pubkey = $4 + AND (revoked_at IS NULL OR revocation_scope = 'key') + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(old_pubkey) + .bind(rotated_by) + .bind(reason) + .bind(new_pubkey) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + INSERT INTO identity_revoked_keys + (community_id, pubkey, revoked_at, revoked_by, reason) + VALUES ($1, $2, NOW(), $3, $4) + ON CONFLICT (community_id, pubkey) DO NOTHING + "#, + ) + .bind(community_id.as_uuid()) + .bind(old_pubkey) + .bind(rotated_by) + .bind(reason) + .execute(&mut *tx) + .await?; + sqlx::query( + r#" + INSERT INTO identity_bindings + (community_id, issuer, uid, pubkey, display_name, source) + VALUES ($1, $2, $3, $4, $5, $6) + "#, + ) + .bind(community_id.as_uuid()) + .bind(issuer) + .bind(uid) + .bind(new_pubkey) + .bind(display_name) + .bind(source) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_ISSUER: &str = "https://idp.example"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + crate::migration::run_migrations(&pool) + .await + .expect("run migrations"); + pool + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("identity-binding-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + fn random_pubkey() -> Vec { + Keys::generate().public_key().to_bytes().to_vec() + } + + #[test] + fn staged_identity_key_must_match_membership_key() { + let identity_key = [7_u8; 32]; + let other_key = [8_u8; 32]; + let identity = IdentityBindingInput { + issuer: TEST_ISSUER, + uid: "user-1", + pubkey: &identity_key, + display_name: None, + source: SOURCE_JWT_NPUB, + }; + + validate_membership_identity_key(&hex::encode(identity_key), Some(&identity)) + .expect("matching key"); + assert!( + validate_membership_identity_key(&hex::encode(other_key), Some(&identity)).is_err() + ); + assert!(validate_membership_identity_key("not-hex", Some(&identity)).is_err()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_creates_then_matches_idempotently() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + let created = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("first@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + assert_eq!(created, BindIdentityResult::Created); + + let matched = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("second@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("match existing binding"); + assert_eq!(matched, BindIdentityResult::Matched); + + let binding = get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("lookup binding") + .expect("binding exists"); + assert_eq!(binding.uid, "user-1"); + assert_eq!(binding.issuer, TEST_ISSUER); + assert_eq!(binding.display_name.as_deref(), Some("second@example.com")); + assert_eq!(binding.source, SOURCE_JWT_NPUB); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_rejects_uid_conflict() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let original_pubkey = random_pubkey(); + let conflicting_pubkey = random_pubkey(); + + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &original_pubkey, + Some("user@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + + let result = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &conflicting_pubkey, + Some("user@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("uid conflict is a binding result"); + + assert_eq!( + result, + BindIdentityResult::Conflict(IdentityBindingConflict { + issuer: TEST_ISSUER.to_string(), + uid: "user-1".to_string(), + pubkey: original_pubkey, + source: SOURCE_DB_BINDING.to_string(), + }) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_rejects_pubkey_conflict() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("user@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + + let result = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-2", + &pubkey, + Some("other@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("pubkey conflict is a binding result"); + + assert_eq!( + result, + BindIdentityResult::Conflict(IdentityBindingConflict { + issuer: TEST_ISSUER.to_string(), + uid: "user-1".to_string(), + pubkey, + source: SOURCE_DB_BINDING.to_string(), + }) + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_does_not_downgrade_jwt_npub_source() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("create strong binding"); + + let matched = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("user@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("match existing binding"); + assert_eq!(matched, BindIdentityResult::Matched); + + let binding = get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("lookup binding") + .expect("binding exists"); + assert_eq!(binding.source, SOURCE_JWT_NPUB); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_does_not_recreate_revoked_pair() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("create binding"); + + sqlx::query( + r#" + UPDATE identity_bindings + SET revoked_at = NOW(), revoked_reason = 'test revocation' + WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND pubkey = $4 + "#, + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("user-1") + .bind(&pubkey) + .execute(&pool) + .await + .expect("revoke binding"); + + let result = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("revoked pair is a binding result"); + + assert_eq!(result, BindIdentityResult::Revoked); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &pubkey) + .await + .expect("lookup binding") + .is_none() + ); + + let replacement = random_pubkey(); + let replacement_result = bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "user-1", + &replacement, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("principal revocation is a binding result"); + assert_eq!(replacement_result, BindIdentityResult::Revoked); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn authorized_rotation_retires_old_key_and_installs_replacement() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let old_pubkey = random_pubkey(); + let new_pubkey = random_pubkey(); + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "rotating-user", + &old_pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("create binding"); + + rotate_identity_binding( + &pool, + community, + TEST_ISSUER, + "rotating-user", + &old_pubkey, + &new_pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + None, + "device replacement", + ) + .await + .expect("authorized rotation"); + + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &old_pubkey) + .await + .expect("old lookup") + .is_none() + ); + assert_eq!( + get_active_identity_binding_by_pubkey(&pool, community, &new_pubkey) + .await + .expect("new lookup") + .expect("replacement active") + .uid, + "rotating-user" + ); + assert_eq!( + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "rotating-user", + &old_pubkey, + Some("user@example.com"), + SOURCE_JWT_NPUB, + ) + .await + .expect("old key result"), + BindIdentityResult::Revoked + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn key_revocation_requires_explicit_rotation_for_replacement() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let old_pubkey = random_pubkey(); + let new_pubkey = random_pubkey(); + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "key-revoked-user", + &old_pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + assert!( + revoke_identity_key(&pool, community, &old_pubkey, None, "lost device",) + .await + .expect("revoke key") + ); + + assert_eq!( + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "key-revoked-user", + &new_pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("automatic replacement result"), + BindIdentityResult::Revoked + ); + + rotate_identity_binding( + &pool, + community, + TEST_ISSUER, + "key-revoked-user", + &old_pubkey, + &new_pubkey, + None, + SOURCE_DB_BINDING, + None, + "approved replacement", + ) + .await + .expect("explicit rotation after key revocation"); + assert!( + get_active_identity_binding_by_pubkey(&pool, community, &new_pubkey) + .await + .expect("replacement lookup") + .is_some() + ); + let retired = sqlx::query( + "SELECT revoked_reason, revocation_scope, rotation_reason, rotated_to_pubkey \ + FROM identity_bindings \ + WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND pubkey = $4", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("key-revoked-user") + .bind(&old_pubkey) + .fetch_one(&pool) + .await + .expect("retired binding provenance"); + assert_eq!( + retired.try_get::("revoked_reason").unwrap(), + "lost device" + ); + assert_eq!( + retired.try_get::("revocation_scope").unwrap(), + "key" + ); + assert_eq!( + retired.try_get::("rotation_reason").unwrap(), + "approved replacement" + ); + assert_eq!( + retired.try_get::, _>("rotated_to_pubkey").unwrap(), + new_pubkey + ); + let tombstone_reason: String = sqlx::query_scalar( + "SELECT reason FROM identity_revoked_keys WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(&old_pubkey) + .fetch_one(&pool) + .await + .expect("key tombstone provenance"); + assert_eq!(tombstone_reason, "lost device"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn principal_can_be_disabled_before_first_enrollment() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + assert!(revoke_identity_principal( + &pool, + community, + TEST_ISSUER, + "never-enrolled", + None, + "employment ended", + ) + .await + .expect("persist principal tombstone")); + assert_eq!( + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "never-enrolled", + &random_pubkey(), + None, + SOURCE_DB_BINDING, + ) + .await + .expect("disabled principal result"), + BindIdentityResult::Revoked + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn revoked_key_cannot_rebind_to_another_principal() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "first-principal", + &pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + revoke_identity_key(&pool, community, &pubkey, None, "compromised key") + .await + .expect("revoke key"); + + assert_eq!( + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "different-principal", + &pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("revoked key result"), + BindIdentityResult::Revoked + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn legacy_revoked_key_history_blocks_cross_principal_rebind() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let pubkey = random_pubkey(); + bind_or_validate_identity( + &pool, + community, + TEST_ISSUER, + "legacy-principal", + &pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("create binding"); + sqlx::query( + "UPDATE identity_bindings SET revoked_at = NOW(), revoked_reason = 'legacy revoke' \ + WHERE community_id = $1 AND issuer = $2 AND uid = $3 AND pubkey = $4", + ) + .bind(community.as_uuid()) + .bind(TEST_ISSUER) + .bind("legacy-principal") + .bind(&pubkey) + .execute(&pool) + .await + .expect("simulate pre-lifecycle revocation"); + + assert_eq!( + bind_or_validate_identity( + &pool, + community, + "https://other-idp.example", + "different-principal", + &pubkey, + None, + SOURCE_DB_BINDING, + ) + .await + .expect("legacy key tombstone result"), + BindIdentityResult::Revoked + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_identity_qualifies_same_uid_by_issuer() { + let pool = setup_pool().await; + let community = make_community(&pool).await; + let first_pubkey = random_pubkey(); + let second_pubkey = random_pubkey(); + + let first = bind_or_validate_identity( + &pool, + community, + "https://issuer-a.example", + "shared-subject", + &first_pubkey, + Some("first@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create first issuer binding"); + let second = bind_or_validate_identity( + &pool, + community, + "https://issuer-b.example", + "shared-subject", + &second_pubkey, + Some("second@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create second issuer binding"); + + assert_eq!(first, BindIdentityResult::Created); + assert_eq!(second, BindIdentityResult::Created); + assert_eq!( + get_active_identity_binding_by_pubkey(&pool, community, &second_pubkey) + .await + .expect("lookup second binding") + .expect("second binding exists") + .issuer, + "https://issuer-b.example" + ); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9b26876747..7d5d4b81b3 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -27,6 +27,8 @@ pub mod event; pub mod feed; /// Git repository name registry (NIP-34 kind:30617). pub mod git_repo; +/// Corporate identity binding persistence. +pub mod identity_binding; /// Embedded database migrations. pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. @@ -2227,6 +2229,28 @@ impl Db { .await } + /// Adds a channel member and optional corporate identity binding atomically. + pub async fn add_member_with_identity( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + role: channel::MemberRole, + invited_by: Option<&[u8]>, + identity: Option<&identity_binding::IdentityBindingInput<'_>>, + ) -> Result { + channel::add_member_with_identity( + &self.pool, + community_id, + channel_id, + pubkey, + role, + invited_by, + identity, + ) + .await + } + /// Removes a member from a channel. pub async fn remove_member( &self, @@ -2535,6 +2559,99 @@ impl Db { user::search_users(&self.pool, community_id, query, limit).await } + /// Create or validate a corporate identity binding. + pub async fn bind_or_validate_identity( + &self, + community_id: CommunityId, + issuer: &str, + uid: &str, + pubkey: &[u8], + display_name: Option<&str>, + source: &str, + ) -> Result { + identity_binding::bind_or_validate_identity( + &self.pool, + community_id, + issuer, + uid, + pubkey, + display_name, + source, + ) + .await + } + + /// Return the active corporate identity binding for `pubkey`, if any. + pub async fn get_active_identity_binding_by_pubkey( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + identity_binding::get_active_identity_binding_by_pubkey(&self.pool, community_id, pubkey) + .await + } + + /// Disable a corporate principal and revoke its active key. + pub async fn revoke_identity_principal( + &self, + community_id: CommunityId, + issuer: &str, + uid: &str, + revoked_by: Option<&[u8]>, + reason: &str, + ) -> Result { + identity_binding::revoke_identity_principal( + &self.pool, + community_id, + issuer, + uid, + revoked_by, + reason, + ) + .await + } + + /// Revoke one corporate identity key without disabling its principal. + pub async fn revoke_identity_key( + &self, + community_id: CommunityId, + pubkey: &[u8], + revoked_by: Option<&[u8]>, + reason: &str, + ) -> Result { + identity_binding::revoke_identity_key(&self.pool, community_id, pubkey, revoked_by, reason) + .await + } + + /// Atomically rotate a corporate principal to a replacement key. + #[allow(clippy::too_many_arguments)] + pub async fn rotate_identity_binding( + &self, + community_id: CommunityId, + issuer: &str, + uid: &str, + old_pubkey: &[u8], + new_pubkey: &[u8], + display_name: Option<&str>, + source: &str, + rotated_by: Option<&[u8]>, + reason: &str, + ) -> Result<()> { + identity_binding::rotate_identity_binding( + &self.pool, + community_id, + issuer, + uid, + old_pubkey, + new_pubkey, + display_name, + source, + rotated_by, + reason, + ) + .await + } + /// Atomically set agent owner — only if no owner is currently assigned. /// Returns Ok(true) if set, Ok(false) if an owner already exists. pub async fn set_agent_owner( @@ -4072,6 +4189,27 @@ impl Db { .await } + /// Claims invite membership and an optional corporate identity binding in + /// one transaction. + pub async fn claim_relay_membership_with_identity( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + identity: Option<&identity_binding::IdentityBindingInput<'_>>, + ) -> Result { + relay_members::claim_relay_membership_with_identity( + &self.pool, + community, + pubkey, + role, + policy_version, + identity, + ) + .await + } + /// Returns whether a member has persisted acceptance evidence for a policy version. pub async fn has_join_policy_acceptance( &self, @@ -4199,6 +4337,27 @@ impl Db { .await } + /// Atomically claims a v2 invite and commits the staged corporate identity + /// binding in the same transaction as membership and invite consumption. + pub async fn claim_relay_invite_with_identity( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + identity: Option<&identity_binding::IdentityBindingInput<'_>>, + ) -> Result { + relay_invite::claim_relay_invite_with_identity( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + identity, + ) + .await + } + /// Sidecar an accepted product-feedback event, idempotent by event id. pub async fn insert_product_feedback( &self, diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 65ca156721..fc9c836734 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 27); + assert_eq!(migrations.len(), 29); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -940,6 +940,43 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + + // Relay-verified identity bindings are additive and community-scoped. + assert_eq!(migrations[27].version, 28); + assert!(migrations[27] + .sql + .as_str() + .contains("CREATE TABLE identity_bindings")); + assert!(migrations[27] + .sql + .as_str() + .contains("idx_identity_bindings_active_principal")); + assert_eq!(migrations[28].version, 29); + assert!(migrations[28].sql.as_str().contains("revocation_scope")); + assert!(migrations[28] + .sql + .as_str() + .contains("idx_identity_bindings_revoked_principal")); + assert!(migrations[28] + .sql + .as_str() + .contains("CREATE TABLE identity_principals")); + assert!(migrations[28] + .sql + .as_str() + .contains("INSERT INTO identity_principals")); + assert!(migrations[28] + .sql + .as_str() + .contains("INSERT INTO identity_revoked_keys")); + assert!(migrations[28] + .sql + .as_str() + .contains("rotation_completed_at")); + assert!(migrations[28] + .sql + .as_str() + .contains("CREATE TABLE identity_revoked_keys")); } #[test] @@ -1182,7 +1219,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(27)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(29)); } #[tokio::test] @@ -1268,6 +1305,7 @@ mod tests { "communities", "events", "channels", + "identity_bindings", "scheduled_workflow_fires", "audit_log", ] { diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/relay_invite.rs index 82b71b07bb..b617732681 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/relay_invite.rs @@ -25,6 +25,7 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; use crate::CommunityId; /// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are @@ -39,6 +40,8 @@ pub enum ClaimOutcome { use_count: i32, /// Remaining slots, or `None` when the invite is unlimited. uses_remaining: Option, + /// Binding committed in the same transaction, when one was staged. + identity_binding: Option, }, /// The claimer was already a member. `use_count` was NOT incremented. AlreadyMember { @@ -46,6 +49,8 @@ pub enum ClaimOutcome { use_count: i32, /// Remaining slots, or `None` when the invite is unlimited. uses_remaining: Option, + /// Binding committed in the same transaction, when one was staged. + identity_binding: Option, }, /// The invite's `expires_at` has passed. Expired, @@ -53,6 +58,10 @@ pub enum ClaimOutcome { Exhausted, /// No invite row matches `(community_id, token_hash)`. Invalid, + /// The staged identity conflicts with another active principal or pubkey. + IdentityConflict(IdentityBindingConflict), + /// The staged identity principal or key is revoked. + IdentityRevoked, } /// A freshly minted v2 invite, including the plaintext code and metadata. @@ -198,14 +207,19 @@ pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime) -> /// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the /// final slot. Membership insertion, policy evidence, and consumption share /// one commit — a failure in any rolls back all. -pub async fn claim_relay_invite( +pub async fn claim_relay_invite_with_identity( pool: &PgPool, community: CommunityId, token_hash: &[u8; 32], claimer_pubkey: &str, policy_version: Option<&str>, + identity: Option<&IdentityBindingInput<'_>>, ) -> Result { + crate::identity_binding::validate_membership_identity_key(claimer_pubkey, identity)?; let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( @@ -246,6 +260,38 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::Expired); } + let identity_binding = if let Some(identity) = identity { + match crate::identity_binding::bind_or_validate_identity_tx(&mut tx, community, identity) + .await? + { + binding @ (BindIdentityResult::Created | BindIdentityResult::Matched) => Some(binding), + BindIdentityResult::Conflict(conflict) => { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "identity_conflict", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::IdentityConflict(conflict)); + } + BindIdentityResult::Revoked => { + tx.rollback().await?; + log_claim_outcome( + community, + Some(invite_id), + "identity_revoked", + max_uses, + Some(use_count), + ); + return Ok(ClaimOutcome::IdentityRevoked); + } + } + } else { + None + }; + let uses_remaining = || max_uses.map(|mu| mu - use_count); // 5. Check existing membership. @@ -280,6 +326,7 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::AlreadyMember { use_count, uses_remaining: uses_remaining(), + identity_binding, }); } @@ -339,6 +386,7 @@ pub async fn claim_relay_invite( return Ok(ClaimOutcome::AlreadyMember { use_count, uses_remaining: uses_remaining(), + identity_binding, }); } @@ -367,9 +415,29 @@ pub async fn claim_relay_invite( Ok(ClaimOutcome::Joined { use_count: new_use_count, uses_remaining: new_uses_remaining, + identity_binding, }) } +/// Atomically claim a v2 invite without a corporate identity binding. +pub async fn claim_relay_invite( + pool: &PgPool, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, +) -> Result { + claim_relay_invite_with_identity( + pool, + community, + token_hash, + claimer_pubkey, + policy_version, + None, + ) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -401,6 +469,21 @@ mod tests { async fn delete_test_community(pool: &PgPool, community: CommunityId) { let mut tx = pool.begin().await.expect("begin test cleanup"); + sqlx::query("DELETE FROM identity_revoked_keys WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test revoked keys"); + sqlx::query("DELETE FROM identity_bindings WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test identity bindings"); + sqlx::query("DELETE FROM identity_principals WHERE community_id = $1") + .bind(community.as_uuid()) + .execute(&mut *tx) + .await + .expect("delete test identity principals"); sqlx::query("DELETE FROM relay_invites WHERE community_id = $1") .bind(community.as_uuid()) .execute(&mut *tx) @@ -467,6 +550,7 @@ mod tests { ClaimOutcome::Joined { use_count: 1, uses_remaining: Some(0), + identity_binding: None, } ); assert_eq!( @@ -476,6 +560,7 @@ mod tests { ClaimOutcome::AlreadyMember { use_count: 1, uses_remaining: Some(0), + identity_binding: None, } ); assert_eq!( @@ -539,6 +624,77 @@ mod tests { delete_test_community(&pool, community).await; } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn invite_claim_commits_identity_and_membership_atomically() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let claimer = test_pubkey(); + let pubkey = hex::decode(&claimer).expect("test pubkey hex"); + let identity = IdentityBindingInput { + issuer: "https://idp.example", + uid: "atomic-user", + pubkey: &pubkey, + display_name: Some("private@example.com"), + source: crate::identity_binding::SOURCE_JWT_NPUB, + }; + let invalid_hash = [7_u8; 32]; + + assert_eq!( + claim_relay_invite_with_identity( + &pool, + community, + &invalid_hash, + &claimer, + None, + Some(&identity), + ) + .await + .expect("invalid claim result"), + ClaimOutcome::Invalid + ); + assert!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, community, &pubkey, + ) + .await + .expect("binding lookup after invalid claim") + .is_none() + ); + + let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1)) + .await + .expect("mint invite"); + let hash = hash_v2_code(&invite.code); + assert!(matches!( + claim_relay_invite_with_identity( + &pool, + community, + &hash, + &claimer, + None, + Some(&identity), + ) + .await + .expect("valid atomic claim"), + ClaimOutcome::Joined { .. } + )); + assert!(is_relay_member(&pool, community, &claimer) + .await + .expect("membership committed")); + assert_eq!( + crate::identity_binding::get_active_identity_binding_by_pubkey( + &pool, community, &pubkey, + ) + .await + .expect("binding lookup") + .expect("binding committed") + .uid, + "atomic-user" + ); + delete_test_community(&pool, community).await; + } + #[tokio::test] #[ignore = "requires Postgres"] async fn expiry_and_tenant_scope_return_typed_failures() { @@ -633,6 +789,7 @@ mod tests { ClaimOutcome::Joined { use_count: expected_count, uses_remaining: None, + identity_binding: None, } ); } diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 402229cdec..afb31bc890 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -10,6 +10,7 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::identity_binding::{BindIdentityResult, IdentityBindingConflict, IdentityBindingInput}; use crate::CommunityId; /// A single relay member record. @@ -153,7 +154,62 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { + match claim_relay_membership_with_identity(pool, community, pubkey, role, policy_version, None) + .await? + { + MembershipClaimOutcome::Joined { inserted, .. } => Ok(inserted), + MembershipClaimOutcome::IdentityConflict(_) | MembershipClaimOutcome::IdentityRevoked => { + Err(crate::DbError::InvalidData( + "unexpected corporate identity result without staged identity".to_string(), + )) + } + } +} + +/// Outcome of an atomic membership and optional identity claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MembershipClaimOutcome { + /// Membership and any staged binding committed together. + Joined { + /// Whether the membership row was newly inserted. + inserted: bool, + /// Binding committed in the same transaction, when one was staged. + identity_binding: Option, + }, + /// The staged identity conflicts with an active binding. + IdentityConflict(IdentityBindingConflict), + /// The staged identity is revoked. + IdentityRevoked, +} + +/// Claims relay membership and an optional corporate identity in one transaction. +pub async fn claim_relay_membership_with_identity( + pool: &PgPool, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + identity: Option<&IdentityBindingInput<'_>>, +) -> Result { + crate::identity_binding::validate_membership_identity_key(pubkey, identity)?; let mut tx = pool.begin().await?; + let identity_binding = if let Some(identity) = identity { + match crate::identity_binding::bind_or_validate_identity_tx(&mut tx, community, identity) + .await? + { + binding @ (BindIdentityResult::Created | BindIdentityResult::Matched) => Some(binding), + BindIdentityResult::Conflict(conflict) => { + tx.rollback().await?; + return Ok(MembershipClaimOutcome::IdentityConflict(conflict)); + } + BindIdentityResult::Revoked => { + tx.rollback().await?; + return Ok(MembershipClaimOutcome::IdentityRevoked); + } + } + } else { + None + }; let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ @@ -180,7 +236,10 @@ pub async fn claim_relay_membership( } tx.commit().await?; - Ok(inserted) + Ok(MembershipClaimOutcome::Joined { + inserted, + identity_binding, + }) } /// Returns whether a member has persisted acceptance evidence for a policy version. diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 41bdc3b9e9..cc0ac4d8ca 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -37,6 +37,7 @@ tower-http = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +jsonwebtoken = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-opentelemetry = { workspace = true } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..5a8f24df89 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -127,6 +127,13 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +/// Corporate identity enrollment must always start from cryptographic proof of +/// the Nostr key. The development-only `X-Pubkey` fallback is caller-controlled +/// and therefore cannot safely participate in a durable identity binding. +fn bridge_requires_nip98(require_auth_token: bool, require_corporate_identity: bool) -> bool { + require_auth_token || require_corporate_identity +} + /// Check NIP-98 replay and record the event ID atomically. /// /// The correctness boundary is the shared, community-scoped Redis seen-set on @@ -175,6 +182,40 @@ async fn check_nip98_replay_with_guard( } } +async fn verify_bridge_corporate_identity( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result)> { + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + headers, + &state.config.corporate_identity, + ); + crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + .map_err(|e| e.into_api_error()) +} + +async fn finalize_bridge_corporate_identity( + state: &AppState, + tenant: &TenantContext, + pubkey: nostr::PublicKey, + proof: crate::corporate_identity::CorporateIdentityProof, +) -> Result<(), (StatusCode, Json)> { + crate::corporate_identity::finalize_corporate_identity(state, tenant.community(), pubkey, proof) + .await + .map(|_| ()) + .map_err(|e| e.into_api_error()) +} + /// Construct the NIP-98 `u`-tag expected URL for a request bound to `tenant`. /// /// Conformance row 44 obligation: "NIP-98 `u` URL host must match @@ -642,7 +683,10 @@ pub async fn submit_event( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -801,6 +845,16 @@ async fn submit_event_authed( // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + match verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await { + Ok(proof) => proof, + Err(e) => { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } + }; let nip_oa_owner = match super::relay_members::enforce_relay_membership( state, tenant.community(), @@ -823,6 +877,13 @@ async fn submit_event_authed( }; } }; + if let Err(e) = finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await + { + return SubmitOutcome::Err { + status: e.0, + response: e, + }; + } if let Some(owner) = nip_oa_owner { super::relay_members::materialize_nip_oa_owner(state, tenant, &pubkey, &owner).await; } @@ -910,7 +971,10 @@ pub async fn query_events( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -961,6 +1025,8 @@ async fn query_events_authed( let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await?; super::relay_members::enforce_relay_membership( state, tenant.community(), @@ -968,7 +1034,6 @@ async fn query_events_authed( auth_tag, ) .await?; - // 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) @@ -1006,6 +1071,7 @@ async fn query_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; if filters.iter().any(|f| f.search.is_some()) { if has_mixed_search_filters(&filters) { @@ -1353,7 +1419,10 @@ pub async fn count_events( "POST", &url, Some(&body), - state.config.require_auth_token, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), )?; let pubkey_hex = pubkey.to_hex(); @@ -1402,6 +1471,8 @@ async fn count_events_authed( let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + verify_bridge_corporate_identity(state, tenant, headers, pubkey, auth_tag).await?; super::relay_members::enforce_relay_membership( state, tenant.community(), @@ -1409,7 +1480,6 @@ async fn count_events_authed( auth_tag, ) .await?; - let filters: Vec = serde_json::from_slice(body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; @@ -1439,6 +1509,7 @@ async fn count_events_authed( .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + finalize_bridge_corporate_identity(state, tenant, pubkey, identity_proof).await?; let mut total: u64 = 0; for filter in &filters { @@ -2087,11 +2158,23 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let (pubkey, event_id_bytes) = verify_bridge_auth( + headers, + "GET", + &url, + None, + bridge_requires_nip98( + state.config.require_auth_token, + state.config.corporate_identity.require, + ), + )?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + verify_bridge_corporate_identity(state, &tenant, headers, pubkey, auth_tag).await?; + crate::handlers::moderation_authz::authorize_moderation_action( &tenant, state, @@ -2107,6 +2190,7 @@ async fn authorize_moderation_read( "restricted: moderator access required", ) })?; + finalize_bridge_corporate_identity(state, &tenant, pubkey, identity_proof).await?; Ok(tenant) } @@ -2267,6 +2351,33 @@ mod tests { .to_bytes() } + #[test] + fn corporate_identity_disables_x_pubkey_bridge_fallback() { + let keys = Keys::generate(); + let mut headers = HeaderMap::new(); + headers.insert( + "x-pubkey", + keys.public_key() + .to_hex() + .parse() + .expect("valid pubkey header"), + ); + + assert!(!bridge_requires_nip98(false, false)); + assert!(bridge_requires_nip98(true, false)); + assert!(bridge_requires_nip98(false, true)); + + let (status, _) = verify_bridge_auth( + &headers, + "POST", + "https://relay.example/events", + Some(b"{}"), + bridge_requires_nip98(false, true), + ) + .expect_err("corporate identity enrollment must require a signed NIP-98 event"); + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + #[test] fn bridge_detects_mixed_search_and_non_search_filters() { let filters = vec![ @@ -3370,6 +3481,12 @@ mod tests { /// /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { + bridge_handler_test_state_with_corporate_identity(false).await + } + + async fn bridge_handler_test_state_with_corporate_identity( + require_corporate_identity: bool, + ) -> Option> { let mut config = crate::config::Config::from_env().ok()?; config.database_url = TEST_DB_URL.to_string(); // Use the real local Redis so enforce_http_admission can pass. @@ -3378,6 +3495,12 @@ mod tests { config.relay_url = "wss://bridge-test.local".to_string(); config.require_auth_token = false; config.require_relay_membership = false; + config.corporate_identity.require = require_corporate_identity; + if require_corporate_identity { + config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); + config.corporate_identity.issuer = "https://idp.example".to_string(); + config.corporate_identity.audience = "buzz-relay".to_string(); + } let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; let db = buzz_db::Db::from_pool(pool.clone()); @@ -3414,6 +3537,52 @@ mod tests { Some(Arc::new(state)) } + #[test] + #[ignore = "requires Postgres"] + fn moderation_reads_require_corporate_identity_after_nip98_proof() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let state = rt + .block_on(bridge_handler_test_state_with_corporate_identity(true)) + .expect("local Postgres not reachable"); + let host = format!("bridge-moderation-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let signed_url = format!("https://{host}/moderation/reports"); + let event_json = build_nip98_event_json(&keys, &signed_url, "GET"); + let auth = nip98_auth_headers(&event_json) + .get(header::AUTHORIZATION) + .cloned() + .expect("authorization header"); + let response = rt + .block_on( + crate::router::build_router(state).oneshot( + Request::builder() + .method("GET") + .uri("/moderation/reports") + .header(header::HOST, host) + .header(header::AUTHORIZATION, auth) + .body(Body::empty()) + .expect("build request"), + ), + ) + .expect("router oneshot"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "a valid NIP-98 moderator request without an identity JWT must fail before role authorization" + ); + } + /// Drive a single POST /events request through the router and return the /// HTTP status code. async fn post_events( diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d3118d8a76..d525cd33f5 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -74,6 +74,9 @@ pub struct GitAuth { pub pubkey: nostr::PublicKey, /// Server-resolved tenant bound from the request Host before auth checks. pub tenant: TenantContext, + /// Cryptographically verified identity staged until repository policy + /// authorization succeeds. + identity_proof: crate::corporate_identity::CorporateIdentityProof, } impl axum::extract::FromRequestParts> for GitAuth { @@ -211,6 +214,25 @@ impl axum::extract::FromRequestParts> for GitAuth { .get("x-auth-tag") .and_then(|value| value.to_str().ok()); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + &parts.headers, + &state.config.corporate_identity, + ); + let identity_proof = match crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + { + Ok(proof) => proof, + Err(e) => { + warn!(pubkey = %pubkey.to_hex(), error = %e, "git: corporate identity denied"); + return Err((e.status_code(), e.public_message()).into_response()); + } + }; if crate::api::relay_members::enforce_relay_membership( state, tenant.community(), @@ -223,11 +245,29 @@ impl axum::extract::FromRequestParts> for GitAuth { warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } - - Ok(GitAuth { pubkey, tenant }) + Ok(GitAuth { + pubkey, + tenant, + identity_proof, + }) } } +async fn finalize_git_corporate_identity(state: &AppState, auth: &GitAuth) -> Result<(), Response> { + crate::corporate_identity::finalize_corporate_identity( + state, + auth.tenant.community(), + auth.pubkey, + auth.identity_proof.clone(), + ) + .await + .map(|_| ()) + .map_err(|e| { + warn!(pubkey = %auth.pubkey.to_hex(), error = %e, "git: corporate identity finalization denied"); + (e.status_code(), e.public_message()).into_response() + }) +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -684,6 +724,7 @@ pub async fn info_refs( repo_name, ) .await?; + finalize_git_corporate_identity(&state, &auth).await?; // Track C fast path: only for clone advertisement. The receive-pack // advertisement carries a different capability set (report-status, @@ -940,6 +981,7 @@ pub async fn upload_pack( repo_name, ) .await?; + finalize_git_corporate_identity(&state, &auth).await?; let body = decode_git_request_body(&headers, body, UPLOAD_PACK_MAX_DECODED_BYTES); let permit = acquire_git_permit(&state, "upload_pack")?; @@ -1101,6 +1143,7 @@ pub async fn receive_pack( repo_id: repo_name.to_string(), pusher: auth.pubkey, tenant: auth.tenant, + identity_proof: auth.identity_proof, repo_handle: repo, }; Ok(finalize_push(&state, ctx).await) @@ -1692,6 +1735,8 @@ pub(crate) struct PushContext { /// Server-resolved tenant that selected the pointer namespace and owns /// any derived kind:30618 event from this push. pub tenant: TenantContext, + /// Identity proof finalized only after the pre-receive policy hook accepts. + pub identity_proof: crate::corporate_identity::CorporateIdentityProof, /// The hydrated workspace handle. Held until response construction /// (which happens *after* `cas_publish` returns) so the tempdir /// outlives the receive-pack subprocess and the CAS publish. @@ -1738,6 +1783,18 @@ async fn finalize_push(state: &Arc, ctx: PushContext) -> Response { return response; } + if let Err(error) = crate::corporate_identity::finalize_corporate_identity( + state, + ctx.tenant.community(), + ctx.pusher, + ctx.identity_proof.clone(), + ) + .await + { + warn!(pusher = %ctx.pusher.to_hex(), error = %error, "git: post-policy corporate identity finalization denied"); + return (error.status_code(), error.public_message()).into_response(); + } + // Step 7 (CAS). The PushContext binds `parent_state` (observed at // hydrate) to the CAS predicate here — no re-reading of the pointer // between hydrate and CAS. diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cca..83ecb2fcc3 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -232,7 +232,14 @@ async fn authenticate( headers: &HeaderMap, path: &str, body: &[u8], -) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { +) -> Result< + ( + buzz_core::TenantContext, + nostr::PublicKey, + crate::corporate_identity::CorporateIdentityProof, + ), + (StatusCode, Json), +> { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) @@ -257,7 +264,45 @@ async fn authenticate( )?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; - Ok((tenant, pubkey)) + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + headers, + &state.config.corporate_identity, + ); + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + let identity_proof = crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + .map_err(|error| error.into_api_error())?; + + Ok((tenant, pubkey, identity_proof)) +} + +async fn record_atomic_identity_rejection( + state: &AppState, + community_id: buzz_core::CommunityId, + pubkey: nostr::PublicKey, + proof: crate::corporate_identity::CorporateIdentityProof, + binding: buzz_db::identity_binding::BindIdentityResult, +) -> (StatusCode, Json) { + match crate::corporate_identity::finalize_atomic_corporate_identity_result( + state, + community_id, + pubkey, + proof, + Some(binding), + ) + .await + { + Err(error) => error.into_api_error(), + Ok(_) => internal_error("atomic invite identity rejection was unexpectedly accepted"), + } } /// Mint an invite code — `POST /api/invites`, NIP-98 signed by an owner/admin. @@ -269,7 +314,8 @@ pub async fn mint_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; + let (tenant, pubkey, identity_proof) = + authenticate(&state, &headers, "/api/invites", &body).await?; // Authz mirrors kind:9030 (add member): owner or admin only. let sender_hex = pubkey.to_hex(); @@ -298,6 +344,14 @@ pub async fn mint_invite( }; let (ttl, max_uses) = validate_mint_request(&request)?; + crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + .map_err(|error| error.into_api_error())?; // Mint a v2 opaque, database-backed invite. let invite = state @@ -349,7 +403,8 @@ pub async fn claim_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; + let (tenant, pubkey, identity_proof) = + authenticate(&state, &headers, "/api/invites/claim", &body).await?; if claim_rate_limited(&state, tenant.community(), &pubkey) { return Err(api_error( @@ -360,6 +415,15 @@ pub async fn claim_invite( let request: ClaimInviteRequest = serde_json::from_slice(&body) .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid claim JSON: {e}")))?; + // Invite admission must be coupled to the identity being admitted. A + // delegated owner proof can become stale between verification and the + // invite transaction, so bootstrap claims require the joiner's direct JWT. + if crate::corporate_identity::proof_is_delegated(&identity_proof) { + return Err(api_error( + StatusCode::FORBIDDEN, + "direct relay identity required for invite claim", + )); + } let claimer_hex = pubkey.to_hex(); let key = invite_token::derive_invite_key(&state.relay_keypair); @@ -384,9 +448,11 @@ pub async fn claim_invite( } let token_hash = hash_v2_code(&request.code); + let identity_binding = + crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); let outcome = state .db - .claim_relay_invite( + .claim_relay_invite_with_identity( tenant.community(), &token_hash, &claimer_hex, @@ -395,12 +461,24 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + identity_binding.as_ref(), ) .await .map_err(|e| internal_error(&format!("v2 invite claim: {e}")))?; return match outcome { - buzz_db::relay_invite::ClaimOutcome::Joined { .. } => { + buzz_db::relay_invite::ClaimOutcome::Joined { + identity_binding, .. + } => { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; tracing::info!( community = %tenant.community(), member = %claimer_hex, @@ -422,7 +500,18 @@ pub async fn claim_invite( "role": "member", }))) } - buzz_db::relay_invite::ClaimOutcome::AlreadyMember { .. } => { + buzz_db::relay_invite::ClaimOutcome::AlreadyMember { + identity_binding, .. + } => { + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; Ok(Json(serde_json::json!({ "status": "already_member", "community_id": tenant.community().to_string(), @@ -439,6 +528,26 @@ pub async fn claim_invite( buzz_db::relay_invite::ClaimOutcome::Invalid => { Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")) } + buzz_db::relay_invite::ClaimOutcome::IdentityConflict(conflict) => { + Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::Conflict(conflict), + ) + .await) + } + buzz_db::relay_invite::ClaimOutcome::IdentityRevoked => { + Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::Revoked, + ) + .await) + } }; } @@ -463,9 +572,11 @@ pub async fn claim_invite( .map_err(|_| api_error(StatusCode::FORBIDDEN, "join_policy_required"))?; } - let was_inserted = state + let identity_binding = + crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + let claim_outcome = state .db - .claim_relay_membership( + .claim_relay_membership_with_identity( tenant.community(), &claimer_hex, &payload.r, @@ -474,9 +585,45 @@ pub async fn claim_invite( .join_policy .as_ref() .map(|policy| policy.version.as_str()), + identity_binding.as_ref(), ) .await .map_err(|e| internal_error(&format!("invite claim insert: {e}")))?; + let (was_inserted, identity_binding) = match claim_outcome { + buzz_db::relay_members::MembershipClaimOutcome::Joined { + inserted, + identity_binding, + } => (inserted, identity_binding), + buzz_db::relay_members::MembershipClaimOutcome::IdentityConflict(conflict) => { + return Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::Conflict(conflict), + ) + .await); + } + buzz_db::relay_members::MembershipClaimOutcome::IdentityRevoked => { + return Err(record_atomic_identity_rejection( + &state, + tenant.community(), + pubkey, + identity_proof, + buzz_db::identity_binding::BindIdentityResult::Revoked, + ) + .await); + } + }; + crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + identity_binding, + ) + .await + .map_err(|error| error.into_api_error())?; if was_inserted { tracing::info!( diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..b2f633b33e 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -63,6 +63,59 @@ struct MediaReadAuth { tenant: TenantContext, } +async fn verify_media_corporate_identity( + state: &AppState, + tenant: &TenantContext, + headers: &HeaderMap, + pubkey: nostr::PublicKey, +) -> Result { + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + headers, + &state.config.corporate_identity, + ); + crate::corporate_identity::verify_corporate_identity( + state, + tenant.community(), + pubkey, + identity_jwt.as_deref(), + auth_tag, + ) + .await + .map_err(|e| { + tracing::warn!(pubkey = %pubkey.to_hex(), error = %e, "media: corporate identity denied"); + if e.status_code() == StatusCode::UNAUTHORIZED { + MediaError::Unauthorized + } else { + MediaError::RelayMembershipRequired + } + }) +} + +async fn finalize_media_corporate_identity( + state: &AppState, + tenant: &TenantContext, + pubkey: nostr::PublicKey, + proof: crate::corporate_identity::CorporateIdentityProof, +) -> Result<(), MediaError> { + crate::corporate_identity::finalize_corporate_identity( + state, + tenant.community(), + pubkey, + proof, + ) + .await + .map(|_| ()) + .map_err(|e| { + tracing::warn!(pubkey = %pubkey.to_hex(), error = %e, "media: corporate identity finalization denied"); + if e.status_code() == StatusCode::UNAUTHORIZED { + MediaError::Unauthorized + } else { + MediaError::RelayMembershipRequired + } + }) +} + const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { @@ -208,6 +261,9 @@ impl FromRequestParts> for AuthenticatedUpload { // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; + crate::api::relay_members::enforce_relay_membership( state, tenant.community(), @@ -216,7 +272,6 @@ impl FromRequestParts> for AuthenticatedUpload { ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; - if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") .increment(1); @@ -227,6 +282,8 @@ impl FromRequestParts> for AuthenticatedUpload { metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") .increment(1); })?; + finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) + .await?; Ok(AuthenticatedUpload { auth_event, @@ -502,6 +559,8 @@ async fn authenticate_media_read( buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let identity_proof = + verify_media_corporate_identity(state, &tenant, headers, auth_event.pubkey).await?; crate::api::relay_members::enforce_relay_membership( state, tenant.community(), @@ -510,6 +569,7 @@ async fn authenticate_media_read( ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; + finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof).await?; Ok(MediaReadAuth { tenant }) } @@ -946,13 +1006,26 @@ mod tests { } async fn test_state() -> Arc { - test_state_with_media_get_auth(false).await + test_state_with_media_auth(false, false).await } async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { + test_state_with_media_auth(require_media_get_auth, false).await + } + + async fn test_state_with_media_auth( + require_media_get_auth: bool, + require_corporate_identity: bool, + ) -> Arc { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.require_media_get_auth = require_media_get_auth; + config.corporate_identity.require = require_corporate_identity; + if require_corporate_identity { + config.corporate_identity.jwks_uri = "http://127.0.0.1:9/jwks".to_string(); + config.corporate_identity.issuer = "https://idp.example".to_string(); + config.corporate_identity.audience = "buzz-relay".to_string(); + } config.redis_url = "redis://127.0.0.1:1".to_string(); config.media_uploads_per_minute = 1; config.media_max_concurrent_uploads = 2; @@ -1004,6 +1077,16 @@ mod tests { .with_state(state) } + async fn media_get_auth_router_with_corporate_identity() -> axum::Router { + let state = test_state_with_media_auth(true, true).await; + axum::Router::new() + .route( + "/media/{sha256_ext}", + axum::routing::get(get_blob).head(head_blob), + ) + .with_state(state) + } + fn media_get_auth_header(keys: &Keys, tags: Vec) -> String { let event = EventBuilder::new(Kind::from(24242), "Get media") .tags(tags) @@ -1077,6 +1160,23 @@ mod tests { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_media_reads_require_corporate_identity_for_get_and_head() { + let keys = Keys::generate(); + + for method in ["GET", "HEAD"] { + let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); + let response = media_get_auth_router_with_corporate_identity() + .await + .oneshot(media_request(method, Some(auth))) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{method}"); + } + } + #[tokio::test] async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c..7bfd6d4b20 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -86,7 +86,6 @@ pub async fn ws_audio_handler( .into_response(); } }; - let permit = match acquire_audio_connection_permit(&state.conn_semaphore) { Some(permit) => permit, None => { @@ -98,12 +97,23 @@ pub async fn ws_audio_handler( .into_response(); } }; + let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + &headers, + &state.config.corporate_identity, + ); // Keep the parser boundary at the largest message this route accepts. The // checks in the receive loop still distinguish text from binary policy, but // they run after tungstenite has assembled a message. limit_audio_websocket(ws).on_upgrade(move |socket| { - handle_audio_connection(socket, state, tenant, channel_id, permit) + handle_audio_connection( + socket, + state, + tenant, + channel_id, + permit, + corporate_identity_jwt, + ) }) } @@ -141,12 +151,55 @@ fn default_protocol_version() -> u8 { 1 } +/// Remove a denied private admission and release only the exact owner lease +/// that this connection acquired. The room is sealed while it is still the +/// manager-visible instance, so a remote registration that already holds its +/// `Arc` cannot enter between the peer removal and the Redis release. +async fn cleanup_failed_private_audio_admission( + state: &Arc, + tenant: &TenantContext, + channel_id: Uuid, + room: &Arc, + peer_id: Uuid, + acquired_lease: &mut Option, +) { + let directory = state + .mesh() + .map(|mesh| &mesh.directory as &dyn crate::audio::join::HuddleDirectory); + match crate::audio::join::cleanup_failed_admission_lease( + directory, + acquired_lease, + &state.audio_rooms, + tenant.community(), + channel_id, + room, + peer_id, + ) + .await + { + Ok(Some(crate::audio::join::HuddleReleaseOutcome::Released)) | Ok(None) => {} + Ok(Some(crate::audio::join::HuddleReleaseOutcome::NotOwner)) => { + debug!( + channel_id = %channel_id, + "failed audio admission lease already moved; stale cleanup left current owner intact" + ); + } + Err(e) => { + warn!( + channel_id = %channel_id, + "failed audio admission could not release huddle owner lease: {e}" + ); + } + } +} + async fn handle_audio_connection( socket: WebSocket, state: Arc, tenant: TenantContext, channel_id: Uuid, _permit: OwnedSemaphorePermit, + corporate_identity_jwt: Option, ) { let cancel = CancellationToken::new(); let community_id = tenant.community(); @@ -159,7 +212,16 @@ async fn handle_audio_connection( community_id, cancel.clone(), move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_audio_connection(socket, run_state, tenant, channel_id, cancel), + move || { + handle_active_audio_connection( + socket, + run_state, + tenant, + channel_id, + cancel, + corporate_identity_jwt, + ) + }, ) .await; } @@ -170,6 +232,7 @@ async fn handle_active_audio_connection( tenant: TenantContext, channel_id: Uuid, cancel: CancellationToken, + corporate_identity_jwt: Option, ) { let (mut ws_send, mut ws_recv) = socket.split(); @@ -241,6 +304,29 @@ async fn handle_active_audio_connection( let pubkey_bytes = pubkey.to_bytes().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; + let identity_proof = match crate::corporate_identity::verify_corporate_identity( + &state, + tenant.community(), + pubkey, + corporate_identity_jwt.as_deref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => proof, + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } + }; + if crate::api::relay_members::enforce_relay_membership( &state, tenant.community(), @@ -262,7 +348,7 @@ async fn handle_active_audio_connection( } // ── Step 3: membership check / auto-add ─────────────────────────────────── - let parent_id_for_event = match ensure_membership( + let (parent_id_for_event, auto_add_member_by) = match ensure_membership( &state, &tenant, channel_id, @@ -285,6 +371,43 @@ async fn handle_active_audio_connection( } }; + // Existing members and open channels retain the established identity path. + // Private-huddle auto-add is deferred until room admission succeeds, then + // membership and direct identity binding commit in one database transaction. + let deferred_private_admission = if let Some(added_by) = auto_add_member_by { + Some((added_by, identity_proof)) + } else { + let identity_decision = match crate::corporate_identity::finalize_corporate_identity( + &state, + tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + return; + } + }; + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + tenant.community(), + pubkey, + identity_decision, + cancel.clone(), + ); + None + }; + // Huddle cross-pod routing (mesh) OR single-pod guardrail. // // When the mesh is live (`state.mesh()` is `Some`), a huddle can span pods: @@ -548,6 +671,113 @@ async fn handle_active_audio_connection( } }; + if let Some((added_by, identity_proof)) = deferred_private_admission { + let identity_input = + crate::corporate_identity::binding_input_for_proof(&identity_proof, &pubkey); + let outcome = state + .db + .add_member_with_identity( + tenant.community(), + channel_id, + &pubkey_bytes, + MemberRole::Member, + Some(&added_by), + identity_input.as_ref(), + ) + .await; + let committed_binding = match outcome { + Ok(buzz_db::channel::ChannelAdmissionOutcome::Joined { + identity_binding, .. + }) => identity_binding, + Ok(buzz_db::channel::ChannelAdmissionOutcome::IdentityConflict(conflict)) => Some( + buzz_db::identity_binding::BindIdentityResult::Conflict(conflict), + ), + Ok(buzz_db::channel::ChannelAdmissionOutcome::IdentityRevoked) => { + Some(buzz_db::identity_binding::BindIdentityResult::Revoked) + } + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership auto-add failed: {e}"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type":"error","message":"not a member"}) + .to_string() + .into(), + )) + .await; + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close( + stream, + session.fenced(), + session.pubkey(), + ) + .await; + } + cleanup_failed_private_audio_admission( + &state, + &tenant, + channel_id, + &room, + peer_id, + &mut acquired_lease, + ) + .await; + return; + } + }; + let identity_decision = + match crate::corporate_identity::finalize_atomic_corporate_identity_result( + &state, + tenant.community(), + pubkey, + identity_proof, + committed_binding, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, error = %e, "audio: corporate identity finalization denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": e.public_message()}) + .to_string() + .into(), + )) + .await; + if let (Some(session), Some(stream)) = + (remote_session.as_ref(), remote_stream.as_mut()) + { + crate::audio::join::send_clean_close( + stream, + session.fenced(), + session.pubkey(), + ) + .await; + } + cleanup_failed_private_audio_admission( + &state, + &tenant, + channel_id, + &room, + peer_id, + &mut acquired_lease, + ) + .await; + return; + } + }; + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + tenant.community(), + pubkey, + identity_decision, + cancel.clone(), + ); + state.invalidate_membership(&tenant, channel_id, &pubkey_bytes); + } + info!( channel_id = %channel_id, pubkey = %pubkey_hex, @@ -1156,7 +1386,7 @@ async fn ensure_membership( channel_id: Uuid, pubkey_bytes: &[u8], parent_channel_id: Option, -) -> Result { +) -> Result<(Uuid, Option>), String> { // Load channel first — reject archived channels before any membership check. // This ensures auto-ended huddles can't be rejoined by existing members. let channel = state @@ -1199,11 +1429,11 @@ async fn ensure_membership( .map_err(|e| format!("db error: {e}"))?; if is_member { - return Ok(lifecycle_parent_id); + return Ok((lifecycle_parent_id, None)); } if channel.visibility == "open" { - return Ok(lifecycle_parent_id); + return Ok((lifecycle_parent_id, None)); } // Auto-add path: private ephemeral channel + caller is member of parent. @@ -1214,20 +1444,7 @@ async fn ensure_membership( .map_err(|e| format!("db error: {e}"))?; if parent_member { - state - .db - .add_member( - tenant.community(), - channel_id, - pubkey_bytes, - MemberRole::Member, - Some(&channel.created_by), - ) - .await - .map_err(|e| format!("auto-add failed: {e}"))?; - state.invalidate_membership(tenant, channel_id, pubkey_bytes); - - return Ok(lifecycle_parent_id); + return Ok((lifecycle_parent_id, Some(channel.created_by))); } } diff --git a/crates/buzz-relay/src/audio/join.rs b/crates/buzz-relay/src/audio/join.rs index ddadb13f7f..6cf60c3113 100644 --- a/crates/buzz-relay/src/audio/join.rs +++ b/crates/buzz-relay/src/audio/join.rs @@ -233,6 +233,48 @@ pub enum HuddleReleaseOutcome { NotOwner, } +/// Remove a denied admission, atomically seal its room when it was the last +/// peer, and release only the exact freshly acquired owner token before the +/// empty room is evicted. Keeping the sealed room manager-visible during the +/// awaited release prevents a registration holding the old room `Arc` from +/// entering under that generation. The Redis release itself is owner- and +/// generation-matched, so stale cleanup cannot delete a replacement token. +pub async fn cleanup_failed_admission_lease( + directory: Option<&dyn HuddleDirectory>, + acquired_lease: &mut Option, + rooms: &AudioRoomManager, + community_id: CommunityId, + session_id: Uuid, + room: &Arc, + peer_id: Uuid, +) -> Result, MeshError> { + let sealed_empty = room + .remove_peer_and_check_ended(peer_id) + .map(|(_, ended)| ended) + .unwrap_or(false); + if !sealed_empty { + return Ok(None); + } + + let released = if let Some(lease) = acquired_lease.take() { + let result = match directory { + Some(directory) => directory.release(&lease).await, + None => Err(MeshError::Transport( + "acquired huddle lease has no directory".to_string(), + )), + }; + result.map(Some) + } else { + Ok(None) + }; + + // Preserve the pre-existing bounded Redis-error behavior: an unrenewed + // token expires at its TTL, while the empty local room is immediately + // reusable instead of becoming a permanent `ended` tombstone. + rooms.cleanup_if_empty(community_id, session_id); + released +} + /// Result of an ownership acquire attempt. #[derive(Clone, Debug, PartialEq, Eq)] pub enum AcquireOutcome { @@ -1831,6 +1873,7 @@ mod tests { // yields `Renewed` (lease holds). `release` returns the scripted value. renew_outcomes: Mutex>, release_outcome: Mutex>, + release_fails: Mutex, renew_calls: Mutex, release_calls: Mutex, } @@ -1906,6 +1949,9 @@ mod tests { } async fn release(&self, _lease: &HuddleLease) -> Result { *self.release_calls.lock().unwrap() += 1; + if *self.release_fails.lock().unwrap() { + return Err(MeshError::Transport("injected release failure".into())); + } Ok(self .release_outcome .lock() @@ -2571,6 +2617,102 @@ mod tests { ); } + /// Both private-admission failure exits share the same cleanup primitive: + /// seal and evict the failed room, release the exact freshly acquired lease + /// token once, and let an immediate retry acquire the next generation. + #[tokio::test] + async fn failed_identity_admissions_release_lease_and_allow_immediate_retry() { + for failure_case in ["identity_conflict", "identity_storage_failure"] { + let session = Uuid::new_v4(); + let rooms = AudioRoomManager::new(); + let room = rooms.get_or_create(community(), session); + let (peer_id, _, _, _) = room.add_peer(failure_case.into(), 1).unwrap(); + let dir = Arc::new(FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 5, + })); + let mut acquired = Some(lease_for(session, 5)); + + assert_eq!( + cleanup_failed_admission_lease( + Some(&*dir), + &mut acquired, + &rooms, + community(), + session, + &room, + peer_id, + ) + .await + .unwrap(), + Some(HuddleReleaseOutcome::Released), + "{failure_case} must release its exact owner token" + ); + assert!(acquired.is_none()); + assert_eq!(*dir.release_calls.lock().unwrap(), 1); + assert!(rooms.get(community(), session).is_none()); + assert!(matches!( + room.add_peer("stale-room".into(), 1), + Err(AdmissionError::Ended) + )); + + // Model Redis's successful exact-token delete and monotonic next + // generation, then retry immediately in this same task. + *dir.owner.lock().unwrap() = None; + *dir.acquire.lock().unwrap() = Some(AcquireOutcome::Acquired(lease_for(session, 6))); + let registry = HuddleOwnerRegistry::new(); + let retried = tokio::time::timeout( + Duration::from_secs(2), + resolve_join_owner_ready(&*dir, community(), session, rt(1), ®istry), + ) + .await + .expect("retry must not wait for the 30-second lease TTL") + .expect("retry acquires the released huddle"); + assert_eq!(retried.outcome, JoinOutcome::LocalOwner { generation: 6 }); + assert_eq!( + retried.acquired.as_ref().map(HuddleLease::generation), + Some(6) + ); + } + } + + /// A Redis error preserves 037's bounded-TTL fallback without leaving the + /// local manager permanently pinned to the sealed failed room. + #[tokio::test] + async fn failed_admission_release_error_does_not_tombstone_room() { + let session = Uuid::new_v4(); + let rooms = AudioRoomManager::new(); + let room = rooms.get_or_create(community(), session); + let (peer_id, _, _, _) = room.add_peer("failed".into(), 1).unwrap(); + let dir = FakeDir::owned_by(Ownership { + owner_runtime_id: rt(1), + generation: 5, + }); + *dir.release_fails.lock().unwrap() = true; + let mut acquired = Some(lease_for(session, 5)); + + assert!(cleanup_failed_admission_lease( + Some(&dir), + &mut acquired, + &rooms, + community(), + session, + &room, + peer_id, + ) + .await + .is_err()); + assert!(acquired.is_none()); + assert_eq!(*dir.release_calls.lock().unwrap(), 1); + assert!(rooms.get(community(), session).is_none()); + + let replacement = rooms.get_or_create(community(), session); + assert!(!Arc::ptr_eq(&room, &replacement)); + replacement + .add_peer("retry".into(), 1) + .expect("release errors must not permanently tombstone the room"); + } + /// `drain` is generation-fenced like `release`, but unlike room-empty it /// also cancels the drain signal so local owner peers and remote control /// streams can rejoin with an explicit draining cause before the renewer diff --git a/crates/buzz-relay/src/audio/room.rs b/crates/buzz-relay/src/audio/room.rs index d5c4286988..c7d95d43c1 100644 --- a/crates/buzz-relay/src/audio/room.rs +++ b/crates/buzz-relay/src/audio/room.rs @@ -682,6 +682,10 @@ mod tests { .remove_peer_and_check_ended(peer_id) .expect("peer existed"); assert!(ended, "single-peer room should end on its last departure"); + let err = room1 + .add_peer("late-peer".to_string(), 2) + .expect_err("a stale room handle must not admit after empty cleanup seals it"); + assert!(matches!(err, AdmissionError::Ended)); assert!(manager.cleanup_if_empty(community_id, channel_id)); // Next joiner with a different version on the same channel id gets a diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..e0b10a91d2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -13,6 +13,24 @@ use tracing::warn; /// NIP-44 encryption overhead. pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; +/// Default header carrying a corporate identity JWT. +pub const DEFAULT_CORPORATE_IDENTITY_JWT_HEADER: &str = "x-forwarded-identity-token"; +/// Default JWT claim used as the stable corporate uid. +pub const DEFAULT_CORPORATE_IDENTITY_UID_CLAIM: &str = "sub"; +/// Default JWT claim displayed as the verified corporate identity. +pub const DEFAULT_CORPORATE_IDENTITY_DISPLAY_CLAIM: &str = "email"; + +/// Which identity source wins when a request carries both a JWT and a +/// cryptographically verified NIP-OA owner declaration. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum CorporateIdentityAuthPrecedence { + /// Treat the JWT as the signer's identity. This is the provider-neutral default. + #[default] + Direct, + /// Treat the NIP-OA owner binding as the signer's delegated identity. + Delegated, +} + /// Errors that can occur while loading relay configuration. #[derive(Debug, Error)] pub enum ConfigError { @@ -46,6 +64,61 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Source-neutral corporate identity configuration. +/// +/// The relay does not care whether the JWT was injected by a trusted proxy or +/// attached by a first-party client. It only validates the JWT and binds the +/// configured uid claim to the authenticated Nostr pubkey after NIP proof. +#[derive(Debug, Clone)] +pub struct CorporateIdentityConfig { + /// Whether every authenticated request must satisfy corporate identity. + pub require: bool, + /// Header containing the corporate identity JWT. + pub jwt_header: String, + /// Allow agents without JWTs to pass the corporate identity gate through + /// NIP-OA when their owner pubkey already has an active identity binding. + pub allow_delegation: bool, + /// Identity source selected when both a JWT and NIP-OA delegation are present. + pub auth_precedence: CorporateIdentityAuthPrecedence, + /// JWKS URI used to verify JWT signatures. + pub jwks_uri: String, + /// Expected JWT issuer. + pub issuer: String, + /// Expected JWT audience. + pub audience: String, + /// Claim name used as Buzz's stable corporate uid. + pub uid_claim: String, + /// Claim name used for verified display. + /// + /// This value is stored only in the private relay binding table. It is + /// never projected into a public Nostr event unless + /// `public_display_claim` is configured separately. + pub display_claim: String, + /// Optional claim name explicitly approved for public NIP-85 projection. + /// Unset by default so private corporate attributes stay private. + pub public_display_claim: Option, + /// Optional claim name carrying a hex pubkey or `npub1...`. + pub npub_claim: Option, +} + +impl Default for CorporateIdentityConfig { + fn default() -> Self { + Self { + require: false, + jwt_header: DEFAULT_CORPORATE_IDENTITY_JWT_HEADER.to_string(), + allow_delegation: true, + auth_precedence: CorporateIdentityAuthPrecedence::Direct, + jwks_uri: String::new(), + issuer: String::new(), + audience: String::new(), + uid_claim: DEFAULT_CORPORATE_IDENTITY_UID_CLAIM.to_string(), + display_claim: DEFAULT_CORPORATE_IDENTITY_DISPLAY_CLAIM.to_string(), + public_display_claim: None, + npub_claim: None, + } + } +} + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -200,6 +273,9 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Corporate identity verification and uid/pubkey binding. + pub corporate_identity: CorporateIdentityConfig, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -417,6 +493,118 @@ fn ensure_git_path( Ok(git_repo_path) } +fn corporate_env_trimmed(name: &str) -> Result, ConfigError> { + match std::env::var(name) { + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(ConfigError::InvalidValue(format!( + "{name} must be valid UTF-8" + ))), + Ok(value) => { + let value = value.trim(); + if value.is_empty() { + return Err(ConfigError::InvalidValue(format!( + "{name} must not be empty when set" + ))); + } + Ok(Some(value.to_string())) + } + } +} + +fn parse_corporate_bool(name: &str, default: bool) -> Result { + match corporate_env_trimmed(name)? { + None => Ok(default), + Some(value) => match value.to_ascii_lowercase().as_str() { + "true" | "1" | "on" => Ok(true), + "false" | "0" | "off" => Ok(false), + _ => Err(ConfigError::InvalidValue(format!( + "{name} must be true or false" + ))), + }, + } +} + +fn load_corporate_identity_config() -> Result { + let mut config = CorporateIdentityConfig::default(); + config.require = parse_corporate_bool("BUZZ_REQUIRE_CORPORATE_IDENTITY", config.require)?; + config.jwt_header = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_JWT_HEADER")? + .unwrap_or_else(|| config.jwt_header.clone()) + .to_ascii_lowercase(); + config.allow_delegation = parse_corporate_bool( + "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", + config.allow_delegation, + )?; + config.auth_precedence = + match corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE")?.as_deref() { + None | Some("direct") => CorporateIdentityAuthPrecedence::Direct, + Some("delegated") => CorporateIdentityAuthPrecedence::Delegated, + Some(value) => { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE must be direct or delegated, got {value}" + ))); + } + }; + config.jwks_uri = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_JWKS_URI")? + .unwrap_or_else(|| config.jwks_uri.clone()); + config.issuer = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_ISSUER")? + .unwrap_or_else(|| config.issuer.clone()); + config.audience = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_AUDIENCE")? + .unwrap_or_else(|| config.audience.clone()); + config.uid_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_UID_CLAIM")? + .unwrap_or_else(|| config.uid_claim.clone()); + config.display_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM")? + .unwrap_or_else(|| config.display_claim.clone()); + config.public_display_claim = + corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM")?; + config.npub_claim = corporate_env_trimmed("BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM")?; + + if config.require { + let mut missing = Vec::new(); + if config.jwt_header.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_JWT_HEADER"); + } + if config.jwks_uri.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_JWKS_URI"); + } + if config.issuer.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_ISSUER"); + } + if config.audience.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_AUDIENCE"); + } + if config.uid_claim.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_UID_CLAIM"); + } + if config.display_claim.is_empty() { + missing.push("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM"); + } + if !missing.is_empty() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_REQUIRE_CORPORATE_IDENTITY=true but required corporate identity config is missing: {}", + missing.join(", ") + ))); + } + + let jwks_url = url::Url::parse(&config.jwks_uri).map_err(|error| { + ConfigError::InvalidValue(format!( + "BUZZ_CORPORATE_IDENTITY_JWKS_URI must be a valid HTTPS URL: {error}" + )) + })?; + if jwks_url.scheme() != "https" + || jwks_url.host_str().is_none() + || !jwks_url.username().is_empty() + || jwks_url.password().is_some() + { + return Err(ConfigError::InvalidValue( + "BUZZ_CORPORATE_IDENTITY_JWKS_URI must be an HTTPS URL with a host and no credentials" + .to_string(), + )); + } + } + + Ok(config) +} + impl Config { /// Loads configuration from environment variables, falling back to development defaults. pub fn from_env() -> Result { @@ -570,6 +758,8 @@ impl Config { .map(|v| v == "true" || v == "1") .unwrap_or(false); + let corporate_identity = load_corporate_identity_config()?; + // Note: intentionally not prefixed with BUZZ_ — this is a relay-identity // config that may be shared across multiple services (e.g., ACP agent). let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY") @@ -961,6 +1151,7 @@ impl Config { relay_operator_api_origin, relay_operator_pubkeys, allow_nip_oa_auth, + corporate_identity, media, media_max_concurrent_uploads, media_max_concurrent_uploads_per_pubkey, @@ -997,9 +1188,28 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn clear_corporate_identity_env() { + for name in [ + "BUZZ_REQUIRE_CORPORATE_IDENTITY", + "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", + "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", + "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "BUZZ_CORPORATE_IDENTITY_ISSUER", + "BUZZ_CORPORATE_IDENTITY_AUDIENCE", + "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", + "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", + ] { + std::env::remove_var(name); + } + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); let config = Config::from_env().expect("default config"); assert!(config.bind_addr.port() > 0); assert!(!config.database_url.is_empty()); @@ -1051,6 +1261,89 @@ mod tests { config.huddle_audio_available, "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior" ); + assert!( + !config.corporate_identity.require, + "corporate identity should default to disabled" + ); + assert_eq!( + config.corporate_identity.jwt_header, + DEFAULT_CORPORATE_IDENTITY_JWT_HEADER + ); + assert!( + config.corporate_identity.allow_delegation, + "corporate identity delegation should default to true for agents" + ); + assert_eq!( + config.corporate_identity.auth_precedence, + CorporateIdentityAuthPrecedence::Direct, + "an accompanying JWT should identify the signer by default" + ); + assert!( + config.corporate_identity.public_display_claim.is_none(), + "public corporate identity projection must be opt-in" + ); + } + + #[test] + fn corporate_identity_requires_complete_verifier_config() { + let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); + std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); + + let err = Config::from_env().expect_err("incomplete corporate identity config"); + let msg = err.to_string(); + clear_corporate_identity_env(); + + assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_JWKS_URI")); + assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_ISSUER")); + assert!(msg.contains("BUZZ_CORPORATE_IDENTITY_AUDIENCE")); + } + + #[test] + fn corporate_identity_config_can_be_enabled() { + let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); + std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); + std::env::set_var( + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "https://idp.example/.well-known/jwks.json", + ); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_ISSUER", "https://idp.example"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUDIENCE", "buzz-relay"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_UID_CLAIM", "employee_id"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", "email"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", "buzz_npub"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", "delegated"); + + let config = Config::from_env().expect("corporate identity config"); + clear_corporate_identity_env(); + + assert!(config.corporate_identity.require); + assert_eq!(config.corporate_identity.uid_claim, "employee_id"); + assert_eq!( + config.corporate_identity.npub_claim.as_deref(), + Some("buzz_npub") + ); + assert_eq!( + config.corporate_identity.auth_precedence, + CorporateIdentityAuthPrecedence::Delegated + ); + } + + #[test] + fn corporate_identity_rejects_invalid_auth_precedence() { + let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", "automatic"); + + let err = Config::from_env().expect_err("invalid precedence must fail closed"); + clear_corporate_identity_env(); + + assert!(matches!( + err, + ConfigError::InvalidValue(ref message) + if message.contains("BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE") + )); } #[test] @@ -1108,6 +1401,114 @@ mod tests { )); } + #[test] + fn corporate_identity_rejects_malformed_boolean_values() { + let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); + std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "tru"); + let require_error = Config::from_env().expect_err("malformed require flag must fail"); + clear_corporate_identity_env(); + + std::env::set_var("BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", "sometimes"); + let delegation_error = Config::from_env().expect_err("malformed delegation flag must fail"); + clear_corporate_identity_env(); + + assert!(require_error + .to_string() + .contains("BUZZ_REQUIRE_CORPORATE_IDENTITY")); + assert!(delegation_error + .to_string() + .contains("BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION")); + } + + #[test] + fn corporate_identity_rejects_present_empty_values() { + let _guard = ENV_MUTEX.lock().unwrap(); + for name in [ + "BUZZ_REQUIRE_CORPORATE_IDENTITY", + "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", + "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", + "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "BUZZ_CORPORATE_IDENTITY_ISSUER", + "BUZZ_CORPORATE_IDENTITY_AUDIENCE", + "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", + "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", + ] { + clear_corporate_identity_env(); + std::env::set_var(name, " "); + let error = Config::from_env().expect_err("present empty setting must fail closed"); + assert!(error.to_string().contains(name)); + assert!(error.to_string().contains("must not be empty")); + } + clear_corporate_identity_env(); + } + + #[cfg(unix)] + #[test] + fn corporate_identity_rejects_non_utf8_boolean_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + for name in [ + "BUZZ_REQUIRE_CORPORATE_IDENTITY", + "BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION", + ] { + clear_corporate_identity_env(); + std::env::set_var(name, std::ffi::OsString::from_vec(vec![0xff])); + let error = Config::from_env().expect_err("non-UTF-8 boolean must fail closed"); + assert!(error.to_string().contains(name)); + assert!(error.to_string().contains("valid UTF-8")); + } + clear_corporate_identity_env(); + } + + #[cfg(unix)] + #[test] + fn corporate_identity_rejects_non_utf8_string_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + for name in [ + "BUZZ_CORPORATE_IDENTITY_JWT_HEADER", + "BUZZ_CORPORATE_IDENTITY_AUTH_PRECEDENCE", + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "BUZZ_CORPORATE_IDENTITY_ISSUER", + "BUZZ_CORPORATE_IDENTITY_AUDIENCE", + "BUZZ_CORPORATE_IDENTITY_UID_CLAIM", + "BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM", + "BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM", + ] { + clear_corporate_identity_env(); + std::env::set_var(name, std::ffi::OsString::from_vec(vec![0xff])); + let error = Config::from_env().expect_err("non-UTF-8 setting must fail closed"); + assert!(error.to_string().contains(name)); + assert!(error.to_string().contains("valid UTF-8")); + } + clear_corporate_identity_env(); + } + + #[test] + fn corporate_identity_requires_https_jwks_uri() { + let _guard = ENV_MUTEX.lock().unwrap(); + clear_corporate_identity_env(); + std::env::set_var("BUZZ_REQUIRE_CORPORATE_IDENTITY", "true"); + std::env::set_var( + "BUZZ_CORPORATE_IDENTITY_JWKS_URI", + "http://idp.example/.well-known/jwks.json", + ); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_ISSUER", "https://idp.example"); + std::env::set_var("BUZZ_CORPORATE_IDENTITY_AUDIENCE", "buzz-relay"); + + let error = Config::from_env().expect_err("insecure JWKS URL must fail"); + clear_corporate_identity_env(); + + assert!(error.to_string().contains("JWKS_URI must be an HTTPS URL")); + } + #[test] fn redis_pool_size_env_override_and_invalid_fallback() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 96e266779f..55de214e78 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -14,7 +14,7 @@ use tracing::Instrument as _; use tracing::{debug, info, trace, warn}; use uuid::Uuid; -use buzz_auth::{generate_challenge, AuthContext, LimitType}; +use buzz_auth::{generate_challenge, ConnectionAuthContext, LimitType}; use buzz_core::tenant::TenantContext; use nostr::Filter; @@ -41,7 +41,7 @@ pub enum AuthState { challenge: String, }, /// Client has successfully authenticated. - Authenticated(AuthContext), + Authenticated(ConnectionAuthContext), /// Authentication attempt was rejected. Failed, } @@ -59,6 +59,8 @@ pub struct ConnectionState { pub tenant: TenantContext, /// Remote socket address of the client. pub remote_addr: SocketAddr, + /// Optional corporate identity JWT captured from the WebSocket upgrade request. + pub corporate_identity_jwt: Option, /// Current NIP-42 authentication state. pub auth_state: RwLock, /// Active subscriptions keyed by subscription ID. @@ -120,6 +122,7 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, + corporate_identity_jwt: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -133,7 +136,17 @@ pub async fn handle_connection( community_id, cancel.clone(), move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel), + move || { + handle_active_connection( + socket, + run_state, + addr, + tenant, + conn_id, + cancel, + corporate_identity_jwt, + ) + }, ) .await; } @@ -145,6 +158,7 @@ async fn handle_active_connection( tenant: TenantContext, conn_id: Uuid, cancel: CancellationToken, + corporate_identity_jwt: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -168,6 +182,7 @@ async fn handle_active_connection( conn_id, tenant, remote_addr: addr, + corporate_identity_jwt, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), }), diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs new file mode 100644 index 0000000000..1e203eafcb --- /dev/null +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -0,0 +1,2137 @@ +//! Corporate identity verification and uid/pubkey binding. +//! +//! This module is intentionally relay-local. `buzz-auth` remains the generic +//! Nostr proof layer; corporate identity is deployment policy layered after a +//! request proves control of a Nostr key. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::{ + http::{HeaderMap, StatusCode}, + response::Json, +}; +use jsonwebtoken::{ + decode, decode_header, + jwk::{Jwk, JwkSet, KeyAlgorithm, KeyOperations, PublicKeyUse}, + Algorithm, DecodingKey, Validation, +}; +use nostr::{Event, EventBuilder, FromBech32, Kind, PublicKey, Tag, Timestamp}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use thiserror::Error; +use tokio::sync::{Mutex, RwLock}; +use tracing::{debug, warn}; + +use buzz_core::{kind::KIND_USER_TRUSTED_ASSERTION, CommunityId}; +use buzz_db::event::EventQuery; +use buzz_db::identity_binding::{BindIdentityResult, SOURCE_DB_BINDING, SOURCE_JWT_NPUB}; + +use crate::config::{CorporateIdentityAuthPrecedence, CorporateIdentityConfig}; +use crate::state::AppState; + +const JWKS_CACHE_TTL: Duration = Duration::from_secs(300); +const JWKS_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const JWKS_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const JWKS_MAX_RESPONSE_BYTES: usize = 1024 * 1024; +// Permit a bounded issuer/relay clock difference while keeping expiry enforcement explicit. +const JWT_CLOCK_SKEW_LEEWAY_SECS: u64 = 60; +const IDENTITY_ASSERTION_MAX_TTL_SECS: u64 = 60 * 60; +const IDENTITY_SESSION_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone)] +struct CachedJwks { + set: JwkSet, + expires_at: Instant, +} + +/// Validated corporate identity claims used by Buzz. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CorporateJwtClaims { + /// Validated identity-provider issuer. + pub issuer: String, + /// Stable corporate uid claim. + pub uid: String, + /// Human-readable verified identity claim. + pub display_name: String, + /// Optional operator-approved label that may be published in NIP-85. + pub public_display_name: Option, + /// Optional pubkey carried by the IdP. + pub pubkey: Option, + /// JWT expiration as a Unix timestamp. + pub expires_at: u64, +} + +#[derive(Debug, Deserialize)] +struct RawJwtClaims { + #[serde(flatten)] + claims: Map, +} + +/// Service that verifies corporate identity JWTs against configured JWKS. +#[derive(Debug)] +pub struct CorporateIdentityService { + config: CorporateIdentityConfig, + http: Result, + jwks: RwLock>, + refresh: Mutex<()>, +} + +impl CorporateIdentityService { + /// Build a corporate identity verifier from relay config. + pub fn new(config: CorporateIdentityConfig) -> Self { + let http = reqwest::Client::builder() + .connect_timeout(JWKS_CONNECT_TIMEOUT) + .timeout(JWKS_REQUEST_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| error.to_string()); + Self { + config, + http, + jwks: RwLock::new(None), + refresh: Mutex::new(()), + } + } + + /// Validate a JWT and extract the configured corporate identity claims. + pub async fn validate_jwt( + &self, + token: &str, + ) -> Result { + let header = decode_header(token) + .map_err(|e| CorporateIdentityError::InvalidJwt(format!("invalid JWT header: {e}")))?; + if !is_allowed_jwt_algorithm(header.alg) { + return Err(CorporateIdentityError::InvalidJwt(format!( + "unsupported JWT algorithm: {:?}", + header.alg + ))); + } + let kid = header + .kid + .as_deref() + .ok_or(CorporateIdentityError::MissingKid)?; + let jwk = self.jwk_for_kid(kid).await?; + validate_jwk_signature_metadata(&jwk, header.alg)?; + let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|e| { + CorporateIdentityError::InvalidJwt(format!("invalid JWK for kid {kid}: {e}")) + })?; + + let validation = jwt_validation(header.alg, &self.config); + + let decoded = decode::(token, &decoding_key, &validation) + .map_err(|e| CorporateIdentityError::InvalidJwt(e.to_string()))?; + + let issuer = claim_string(&decoded.claims.claims, "iss")?; + let uid = claim_string(&decoded.claims.claims, &self.config.uid_claim)?; + let display_name = claim_string(&decoded.claims.claims, &self.config.display_claim)?; + let public_display_name = self + .config + .public_display_claim + .as_deref() + .map(|claim| claim_string(&decoded.claims.claims, claim)) + .transpose()?; + let pubkey = + configured_pubkey_claim(&decoded.claims.claims, self.config.npub_claim.as_deref())?; + let expires_at = claim_u64(&decoded.claims.claims, "exp")?; + + Ok(CorporateJwtClaims { + issuer, + uid, + display_name, + public_display_name, + pubkey, + expires_at, + }) + } + + async fn jwk_for_kid(&self, kid: &str) -> Result { + let now = Instant::now(); + { + let cache = self.jwks.read().await; + if let Some(cached) = cache.as_ref() { + if cached.expires_at > now { + if let Some(jwk) = cached.set.find(kid) { + return Ok(jwk.clone()); + } + return Err(CorporateIdentityError::Jwks(format!( + "kid not found in fresh JWKS cache: {kid}" + ))); + } + } + } + + // Only one request may refresh at a time. Re-check after acquiring the + // mutex because another waiter may already have populated the cache. + let _refresh = self.refresh.lock().await; + let now = Instant::now(); + { + let cache = self.jwks.read().await; + if let Some(cached) = cache.as_ref() { + if cached.expires_at > now { + return cached.set.find(kid).cloned().ok_or_else(|| { + CorporateIdentityError::Jwks(format!( + "kid not found in fresh JWKS cache: {kid}" + )) + }); + } + } + } + + let set = self.fetch_jwks().await?; + let jwk = set.find(kid).cloned(); + *self.jwks.write().await = Some(CachedJwks { + set, + expires_at: Instant::now() + JWKS_CACHE_TTL, + }); + jwk.ok_or_else(|| CorporateIdentityError::Jwks(format!("kid not found: {kid}"))) + } + + async fn fetch_jwks(&self) -> Result { + let client = self + .http + .as_ref() + .map_err(|error| CorporateIdentityError::Jwks(error.clone()))?; + let mut response = client + .get(&self.config.jwks_uri) + .send() + .await + .map_err(|e| CorporateIdentityError::Jwks(e.to_string()))? + .error_for_status() + .map_err(|e| CorporateIdentityError::Jwks(e.to_string()))?; + if response + .content_length() + .is_some_and(|length| length > JWKS_MAX_RESPONSE_BYTES as u64) + { + return Err(CorporateIdentityError::Jwks( + "JWKS response exceeds size limit".to_string(), + )); + } + + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| CorporateIdentityError::Jwks(e.to_string()))? + { + if body.len().saturating_add(chunk.len()) > JWKS_MAX_RESPONSE_BYTES { + return Err(CorporateIdentityError::Jwks( + "JWKS response exceeds size limit".to_string(), + )); + } + body.extend_from_slice(&chunk); + } + serde_json::from_slice::(&body) + .map_err(|e| CorporateIdentityError::Jwks(e.to_string())) + } +} + +fn jwt_validation(algorithm: Algorithm, config: &CorporateIdentityConfig) -> Validation { + let mut validation = Validation::new(algorithm); + validation.leeway = JWT_CLOCK_SKEW_LEEWAY_SECS; + validation.set_issuer(&[config.issuer.as_str()]); + validation.set_audience(&[config.audience.as_str()]); + validation.set_required_spec_claims(&["exp", "iss", "aud"]); + validation.validate_exp = true; + validation.validate_nbf = true; + validation +} + +/// Read-only result of cryptographically validating corporate identity. +/// +/// Callers must complete admission/authorization before passing this proof to +/// [`finalize_corporate_identity`]. This ordering prevents rejected requests +/// from creating identity bindings or public assertions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorporateIdentityProof { + /// Corporate identity is disabled for this relay. + NotRequired, + /// A JWT was validated, but no binding mutation has occurred yet. + Direct { + /// Validated claims staged for post-authorization binding. + claims: CorporateJwtClaims, + /// Binding source selected from the configured npub policy. + source: &'static str, + }, + /// A NIP-OA owner with an active binding authorized this agent. + Delegated { + /// Bound owner pubkey. + owner_pubkey: PublicKey, + /// Expected issuer of the owner's active binding. + owner_issuer: String, + /// Expected uid of the owner's active binding. + owner_uid: String, + }, +} + +/// Borrow staged direct-identity data for an atomic admission transaction. +pub fn binding_input_for_proof<'a>( + proof: &'a CorporateIdentityProof, + signer: &'a PublicKey, +) -> Option> { + match proof { + CorporateIdentityProof::Direct { claims, source } => { + Some(buzz_db::identity_binding::IdentityBindingInput { + issuer: &claims.issuer, + uid: &claims.uid, + pubkey: signer.as_bytes(), + display_name: Some(&claims.display_name), + source, + }) + } + CorporateIdentityProof::NotRequired | CorporateIdentityProof::Delegated { .. } => None, + } +} + +/// Whether this proof relies on a delegated owner rather than a direct JWT. +pub fn proof_is_delegated(proof: &CorporateIdentityProof) -> bool { + matches!(proof, CorporateIdentityProof::Delegated { .. }) +} + +/// Outcome of corporate identity enforcement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorporateIdentityDecision { + /// Corporate identity is disabled for this relay. + NotRequired, + /// The signer authenticated directly with a corporate identity JWT. + Direct { + /// Validated identity-provider issuer. + issuer: String, + /// Stable corporate uid claim. + uid: String, + /// Verified display claim. + display_name: String, + /// JWT expiration used to bound long-lived sessions. + expires_at: u64, + /// Binding operation outcome. + binding: BindIdentityResult, + }, + /// The signer is an agent admitted through a bound owner pubkey. + Delegated { + /// NIP-OA owner pubkey that already has an active corporate binding. + owner_pubkey: PublicKey, + /// Expected issuer of the owner's active binding. + owner_issuer: String, + /// Expected uid of the owner's active binding. + owner_uid: String, + }, +} + +struct SessionRevalidationPlan { + binding_pubkey: PublicKey, + expected_issuer: String, + expected_uid: String, + expires_at: Option, +} + +fn session_revalidation_plan( + signer: PublicKey, + decision: CorporateIdentityDecision, +) -> Option { + match decision { + CorporateIdentityDecision::NotRequired => None, + CorporateIdentityDecision::Direct { + issuer, + uid, + expires_at, + .. + } => Some(SessionRevalidationPlan { + binding_pubkey: signer, + expected_issuer: issuer, + expected_uid: uid, + expires_at: Some(expires_at), + }), + CorporateIdentityDecision::Delegated { + owner_pubkey, + owner_issuer, + owner_uid, + } => Some(SessionRevalidationPlan { + binding_pubkey: owner_pubkey, + expected_issuer: owner_issuer, + expected_uid: owner_uid, + expires_at: None, + }), + } +} + +async fn cancel_session_at_expiry( + expires_at: u64, + now_secs: u64, + cancel: tokio_util::sync::CancellationToken, +) { + let delay = Duration::from_secs(expires_at.saturating_sub(now_secs)); + tokio::select! { + _ = cancel.cancelled() => {} + _ = tokio::time::sleep(delay) => cancel.cancel(), + } +} + +async fn run_session_binding_revalidation( + interval: Duration, + signer: PublicKey, + binding_pubkey: PublicKey, + expected_issuer: String, + expected_uid: String, + cancel: tokio_util::sync::CancellationToken, + mut lookup: F, +) where + F: FnMut() -> Fut, + Fut: + std::future::Future, E>>, + E: std::fmt::Display, +{ + let mut interval = tokio::time::interval(interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + _ = cancel.cancelled() => return, + _ = interval.tick() => { + match lookup().await { + Ok(Some(binding)) + if binding.issuer == expected_issuer && binding.uid == expected_uid => {} + Ok(Some(_)) | Ok(None) => { + warn!( + signer = %signer.to_hex(), + binding_pubkey = %binding_pubkey.to_hex(), + "corporate identity session evicted after binding revocation" + ); + cancel.cancel(); + return; + } + Err(error) => { + warn!( + signer = %signer.to_hex(), + error = %error, + "corporate identity session revalidation failed closed" + ); + cancel.cancel(); + return; + } + } + } + } + } +} + +/// Revalidate a long-lived corporate identity session until it closes. +/// +/// Direct sessions are cancelled at JWT expiry and when their binding stops +/// being active. Delegated sessions re-check the owner binding, so revoking an +/// owner also evicts every agent session within one bounded interval. +pub fn spawn_session_revalidation( + state: Arc, + community_id: CommunityId, + signer: PublicKey, + decision: CorporateIdentityDecision, + cancel: tokio_util::sync::CancellationToken, +) { + let Some(plan) = session_revalidation_plan(signer, decision) else { + return; + }; + let SessionRevalidationPlan { + binding_pubkey, + expected_issuer, + expected_uid, + expires_at, + } = plan; + + if let Some(expires_at) = expires_at { + let expiry_cancel = cancel.clone(); + tokio::spawn(async move { + cancel_session_at_expiry(expires_at, Timestamp::now().as_secs(), expiry_cancel).await; + }); + } + + let lookup_state = Arc::clone(&state); + let lookup_pubkey = binding_pubkey; + tokio::spawn(run_session_binding_revalidation( + IDENTITY_SESSION_REVALIDATION_INTERVAL, + signer, + binding_pubkey, + expected_issuer, + expected_uid, + cancel, + move || { + let state = Arc::clone(&lookup_state); + async move { + state + .db + .get_active_identity_binding_by_pubkey(community_id, lookup_pubkey.as_bytes()) + .await + } + }, + )); +} + +/// Errors produced by corporate identity verification. +#[derive(Debug, Error)] +pub enum CorporateIdentityError { + /// No JWT was available and delegation did not apply. + #[error("corporate identity JWT missing")] + MissingJwt, + /// JWT header did not include a `kid`. + #[error("corporate identity JWT missing kid")] + MissingKid, + /// JWT signature or claims failed validation. + #[error("invalid corporate identity JWT: {0}")] + InvalidJwt(String), + /// JWKS fetch or lookup failed. + #[error("corporate identity JWKS unavailable: {0}")] + Jwks(String), + /// A configured claim is missing or not a string. + #[error("invalid corporate identity claim {claim}: {reason}")] + InvalidClaim { + /// Claim name. + claim: String, + /// Validation reason. + reason: String, + }, + /// The IdP-provided pubkey does not match the authenticated signer. + #[error("corporate identity npub claim does not match authenticated signer")] + NpubMismatch, + /// The requested uid/pubkey binding conflicts with an active binding. + #[error("corporate identity binding conflict")] + BindingConflict, + /// The requested uid/pubkey binding was previously revoked. + #[error("corporate identity binding revoked")] + BindingRevoked, + /// NIP-OA delegation was present but did not satisfy corporate identity. + #[error("corporate identity delegation denied")] + DelegationDenied, + /// Database operation failed. + #[error("corporate identity database error: {0}")] + Db(#[from] buzz_db::DbError), +} + +impl CorporateIdentityError { + /// HTTP status appropriate for this error. + pub fn status_code(&self) -> StatusCode { + match self { + Self::MissingJwt | Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { + StatusCode::UNAUTHORIZED + } + Self::InvalidClaim { .. } + | Self::NpubMismatch + | Self::BindingConflict + | Self::BindingRevoked + | Self::DelegationDenied => StatusCode::FORBIDDEN, + Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + /// Sanitized message safe to return to clients. + pub fn public_message(&self) -> &'static str { + match self { + Self::MissingJwt => "relay-verified identity required", + Self::MissingKid | Self::InvalidJwt(_) | Self::Jwks(_) => { + "relay identity verification failed" + } + Self::InvalidClaim { .. } => "relay identity claim invalid", + Self::NpubMismatch => "relay identity pubkey mismatch", + Self::BindingConflict => "relay identity binding conflict", + Self::BindingRevoked => "relay identity binding revoked", + Self::DelegationDenied => "relay identity delegation denied", + Self::Db(_) => "relay identity unavailable", + } + } + + /// Convert to the standard API error shape. + pub fn into_api_error(self) -> (StatusCode, Json) { + let status = self.status_code(); + let message = self.public_message(); + if status.is_server_error() { + warn!(error = %self, "corporate identity enforcement failed"); + } + (status, Json(serde_json::json!({ "error": message }))) + } +} + +/// Extract a corporate identity JWT from the configured request header. +pub fn identity_jwt_from_headers( + headers: &HeaderMap, + config: &CorporateIdentityConfig, +) -> Option { + headers + .get(config.jwt_header.as_str()) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .and_then(|raw| { + raw.strip_prefix("Bearer ") + .unwrap_or(raw) + .trim() + .split(',') + .next() + }) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Validate corporate identity without creating bindings or assertions. +pub async fn verify_corporate_identity( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + identity_jwt: Option<&str>, + auth_tag_json: Option<&str>, +) -> Result { + let result = + verify_corporate_identity_inner(state, community_id, signer, identity_jwt, auth_tag_json) + .await; + if let Err(error) = &result { + record_corporate_identity_denial(error); + } + result +} + +async fn verify_corporate_identity_inner( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + identity_jwt: Option<&str>, + auth_tag_json: Option<&str>, +) -> Result { + let Some(service) = state.corporate_identity.as_ref() else { + return Ok(CorporateIdentityProof::NotRequired); + }; + + // Requests can carry both a direct identity JWT and a cryptographically + // verified NIP-OA owner declaration. The deployment selects which identity + // source wins; the provider-neutral default treats the JWT as the signer's + // identity. Delegated precedence supports identity-aware gateways that + // attach an owner's token to requests made by that owner's agents. + if select_identity_auth_path(&service.config, identity_jwt, auth_tag_json) + == IdentityAuthPath::Delegated + { + return verify_delegated_corporate_identity( + &state.db, + &service.config, + community_id, + signer, + auth_tag_json, + ) + .await; + } + + if let Some(token) = identity_jwt { + let claims = service.validate_jwt(token).await?; + let source = binding_source_for_signer(claims.pubkey, signer)?; + return Ok(CorporateIdentityProof::Direct { claims, source }); + } + + verify_delegated_corporate_identity( + &state.db, + &service.config, + community_id, + signer, + auth_tag_json, + ) + .await +} + +/// Commit a previously validated proof after request authorization succeeds. +pub async fn finalize_corporate_identity( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + proof: CorporateIdentityProof, +) -> Result { + let result = finalize_corporate_identity_inner(state, community_id, signer, proof).await; + if let Err(error) = &result { + record_corporate_identity_denial(error); + } + result +} + +/// Complete metrics/assertion/audit work for an identity result produced by an +/// atomic admission transaction. Rejected results were rolled back, but still +/// need the same denial audit as the ordinary finalization path. +pub async fn finalize_atomic_corporate_identity_result( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + proof: CorporateIdentityProof, + committed_binding: Option, +) -> Result { + let result = match proof { + CorporateIdentityProof::NotRequired => Ok(CorporateIdentityDecision::NotRequired), + CorporateIdentityProof::Delegated { + owner_pubkey, + owner_issuer, + owner_uid, + } => Ok(CorporateIdentityDecision::Delegated { + owner_pubkey, + owner_issuer, + owner_uid, + }), + CorporateIdentityProof::Direct { claims, source } => { + let binding = committed_binding.ok_or_else(|| { + buzz_db::DbError::InvalidData( + "atomic identity admission did not return a binding result".to_string(), + ) + })?; + complete_direct_corporate_identity(state, community_id, signer, claims, source, binding) + .await + } + }; + if let Err(error) = &result { + record_corporate_identity_denial(error); + } + result +} + +async fn finalize_corporate_identity_inner( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + proof: CorporateIdentityProof, +) -> Result { + match proof { + CorporateIdentityProof::NotRequired => Ok(CorporateIdentityDecision::NotRequired), + CorporateIdentityProof::Delegated { + owner_pubkey, + owner_issuer, + owner_uid, + } => Ok(CorporateIdentityDecision::Delegated { + owner_pubkey, + owner_issuer, + owner_uid, + }), + CorporateIdentityProof::Direct { claims, source } => { + let binding = state + .db + .bind_or_validate_identity( + community_id, + &claims.issuer, + &claims.uid, + signer.as_bytes(), + Some(&claims.display_name), + source, + ) + .await?; + complete_direct_corporate_identity(state, community_id, signer, claims, source, binding) + .await + } + } +} + +async fn complete_direct_corporate_identity( + state: &AppState, + community_id: CommunityId, + signer: PublicKey, + claims: CorporateJwtClaims, + source: &'static str, + binding: BindIdentityResult, +) -> Result { + let binding = match binding { + BindIdentityResult::Conflict(conflict) => { + metrics::counter!("buzz_corporate_identity_bindings_total", "result" => "conflict") + .increment(1); + record_identity_binding_audit( + state, + community_id, + buzz_audit::AuditAction::CorporateIdentityBindingConflict, + signer, + &claims.issuer, + &claims.uid, + serde_json::json!({ + "source": source, + "issuer": claims.issuer, + "existing_uid": conflict.uid, + "existing_issuer": conflict.issuer, + "existing_pubkey": hex::encode(conflict.pubkey), + "existing_source": conflict.source, + }), + ) + .await; + warn!( + uid = %claims.uid, + signer = %signer.to_hex(), + "corporate identity binding conflict" + ); + return Err(CorporateIdentityError::BindingConflict); + } + BindIdentityResult::Revoked => { + metrics::counter!("buzz_corporate_identity_bindings_total", "result" => "revoked") + .increment(1); + record_identity_binding_audit( + state, + community_id, + buzz_audit::AuditAction::CorporateIdentityBindingRevokedAttempt, + signer, + &claims.issuer, + &claims.uid, + serde_json::json!({ "source": source, "issuer": claims.issuer }), + ) + .await; + warn!( + uid = %claims.uid, + signer = %signer.to_hex(), + "corporate identity binding was previously revoked" + ); + return Err(CorporateIdentityError::BindingRevoked); + } + binding => binding, + }; + record_identity_binding_metric(&binding); + if matches!(binding, BindIdentityResult::Created) { + record_identity_binding_audit( + state, + community_id, + buzz_audit::AuditAction::CorporateIdentityBindingCreated, + signer, + &claims.issuer, + &claims.uid, + serde_json::json!({ "source": source, "issuer": claims.issuer }), + ) + .await; + } + if let Err(error) = ensure_identity_assertion( + state, + community_id, + signer, + claims.public_display_name.as_deref(), + claims.expires_at, + ) + .await + { + // The binding remains the authorization authority. A projection + // failure removes the verified affordance but must not lock an + // otherwise authorized user out of the relay. + warn!( + signer = %signer.to_hex(), + error = %error, + "failed to publish corporate identity assertion" + ); + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "error") + .increment(1); + } + + debug!( + uid = %claims.uid, + signer = %signer.to_hex(), + source, + "corporate identity verified" + ); + Ok(CorporateIdentityDecision::Direct { + issuer: claims.issuer, + uid: claims.uid, + display_name: claims.display_name, + expires_at: claims.expires_at, + binding, + }) +} + +fn build_identity_assertion( + relay_keypair: &nostr::Keys, + subject: PublicKey, + display_name: Option<&str>, + expires_at: u64, + created_at: Timestamp, +) -> Result { + let subject = subject.to_hex(); + let active = if display_name.is_some() { + "true" + } else { + "false" + }; + let expires_at = expires_at.to_string(); + let mut tags = vec![ + Tag::parse(["d", subject.as_str()]), + Tag::parse(["p", subject.as_str()]), + Tag::parse(["verified", "relay"]), + Tag::parse(["active", active]), + Tag::parse(["expiration", expires_at.as_str()]), + ]; + if let Some(display_name) = display_name { + tags.push(Tag::parse(["display_name", display_name])); + } + let tags = tags + .into_iter() + .collect::, _>>() + .map_err(|error| format!("invalid corporate identity assertion tag: {error}"))?; + + EventBuilder::new(Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags(tags) + .custom_created_at(created_at) + .sign_with_keys(relay_keypair) + .map_err(|error| format!("failed to sign corporate identity assertion: {error}")) +} + +fn identity_assertion_matches( + event: &Event, + subject: &str, + display_name: Option<&str>, + expires_at: u64, +) -> bool { + let has_tag = |name: &str, value: &str| { + event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 && parts[0] == name && parts[1] == value + }) + }; + has_tag("d", subject) + && has_tag("p", subject) + && has_tag("verified", "relay") + && has_tag( + "active", + if display_name.is_some() { + "true" + } else { + "false" + }, + ) + && has_tag("expiration", &expires_at.to_string()) + && display_name.is_none_or(|name| has_tag("display_name", name)) +} + +async fn ensure_identity_assertion( + state: &AppState, + community_id: CommunityId, + subject: PublicKey, + display_name: Option<&str>, + jwt_expires_at: u64, +) -> Result<(), String> { + let subject_hex = subject.to_hex(); + let existing = state + .db + .query_events(&EventQuery { + kinds: Some(vec![KIND_USER_TRUSTED_ASSERTION as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + d_tag: Some(subject_hex.clone()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(community_id) + }) + .await + .map_err(|error| error.to_string())? + .into_iter() + .next(); + + // Privacy default: do not publish any assertion unless the operator opted + // into a public label. An inactive replacement is emitted only to retire a + // previously published assertion after that opt-in is removed. + if display_name.is_none() && existing.is_none() { + return Ok(()); + } + + let now = Timestamp::now().as_secs(); + let expires_at = identity_assertion_expiration(display_name, jwt_expires_at, now); + if existing.as_ref().is_some_and(|stored| { + identity_assertion_matches(&stored.event, &subject_hex, display_name, expires_at) + }) { + return Ok(()); + } + + let created_at = existing + .as_ref() + .map(|stored| stored.event.created_at.as_secs().saturating_add(1)) + .unwrap_or(now) + .max(now); + let event = build_identity_assertion( + &state.relay_keypair, + subject, + display_name, + expires_at, + Timestamp::from(created_at), + )?; + + state + .db + .replace_parameterized_event(community_id, &event, &subject_hex, None) + .await + .map_err(|error| error.to_string())?; + metrics::counter!("buzz_corporate_identity_assertions_total", "result" => "published") + .increment(1); + Ok(()) +} + +fn identity_assertion_expiration(display_name: Option<&str>, jwt_expires_at: u64, now: u64) -> u64 { + if display_name.is_some() { + jwt_expires_at.min(now.saturating_add(IDENTITY_ASSERTION_MAX_TTL_SECS)) + } else { + 0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IdentityAuthPath { + Direct, + Delegated, +} + +fn select_identity_auth_path( + config: &CorporateIdentityConfig, + identity_jwt: Option<&str>, + auth_tag_json: Option<&str>, +) -> IdentityAuthPath { + match (identity_jwt.is_some(), auth_tag_json.is_some()) { + (true, true) => match config.auth_precedence { + CorporateIdentityAuthPrecedence::Direct => IdentityAuthPath::Direct, + CorporateIdentityAuthPrecedence::Delegated => IdentityAuthPath::Delegated, + }, + (true, false) => IdentityAuthPath::Direct, + (false, _) => IdentityAuthPath::Delegated, + } +} + +async fn verify_delegated_corporate_identity( + db: &buzz_db::Db, + config: &CorporateIdentityConfig, + community_id: CommunityId, + signer: PublicKey, + auth_tag_json: Option<&str>, +) -> Result { + if config.allow_delegation { + if let Some(owner_pubkey) = extract_unconditional_nip_oa_owner(signer, auth_tag_json) { + let owner_binding = db + .get_active_identity_binding_by_pubkey(community_id, owner_pubkey.as_bytes()) + .await?; + if let Some(owner_binding) = owner_binding { + debug!( + agent = %signer.to_hex(), + owner = %owner_pubkey.to_hex(), + "corporate identity granted via NIP-OA owner binding" + ); + return Ok(CorporateIdentityProof::Delegated { + owner_pubkey, + owner_issuer: owner_binding.issuer, + owner_uid: owner_binding.uid, + }); + } + } + } + if auth_tag_json.is_some() { + Err(CorporateIdentityError::DelegationDenied) + } else { + Err(CorporateIdentityError::MissingJwt) + } +} + +fn extract_unconditional_nip_oa_owner( + signer: PublicKey, + auth_tag_json: Option<&str>, +) -> Option { + let tag_json = auth_tag_json?; + let tag: Vec = serde_json::from_str(tag_json).ok()?; + if tag.len() != 4 || tag.get(2).and_then(Value::as_str) != Some("") { + return None; + } + buzz_sdk::nip_oa::verify_auth_tag(tag_json, &signer).ok() +} + +fn is_allowed_jwt_algorithm(algorithm: Algorithm) -> bool { + matches!( + algorithm, + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 + | Algorithm::ES256 + | Algorithm::ES384 + | Algorithm::EdDSA + ) +} + +fn validate_jwk_signature_metadata( + jwk: &Jwk, + token_algorithm: Algorithm, +) -> Result<(), CorporateIdentityError> { + if jwk + .common + .public_key_use + .as_ref() + .is_some_and(|key_use| key_use != &PublicKeyUse::Signature) + { + return Err(CorporateIdentityError::InvalidJwt( + "JWK use must be sig for JWT verification".to_string(), + )); + } + if jwk + .common + .key_operations + .as_ref() + .is_some_and(|operations| !operations.contains(&KeyOperations::Verify)) + { + return Err(CorporateIdentityError::InvalidJwt( + "JWK key_ops must include verify for JWT verification".to_string(), + )); + } + if jwk + .common + .key_algorithm + .is_some_and(|algorithm| !jwk_algorithm_matches(algorithm, token_algorithm)) + { + return Err(CorporateIdentityError::InvalidJwt(format!( + "JWT algorithm {token_algorithm:?} does not match JWK algorithm" + ))); + } + Ok(()) +} + +fn jwk_algorithm_matches(key: KeyAlgorithm, token: Algorithm) -> bool { + matches!( + (key, token), + (KeyAlgorithm::RS256, Algorithm::RS256) + | (KeyAlgorithm::RS384, Algorithm::RS384) + | (KeyAlgorithm::RS512, Algorithm::RS512) + | (KeyAlgorithm::PS256, Algorithm::PS256) + | (KeyAlgorithm::PS384, Algorithm::PS384) + | (KeyAlgorithm::PS512, Algorithm::PS512) + | (KeyAlgorithm::ES256, Algorithm::ES256) + | (KeyAlgorithm::ES384, Algorithm::ES384) + | (KeyAlgorithm::EdDSA, Algorithm::EdDSA) + ) +} + +fn binding_source_for_signer( + claim_pubkey: Option, + signer: PublicKey, +) -> Result<&'static str, CorporateIdentityError> { + match claim_pubkey { + Some(claim_pubkey) => { + if claim_pubkey != signer { + warn!( + signer = %signer.to_hex(), + claim_pubkey = %claim_pubkey.to_hex(), + "corporate identity JWT npub claim does not match signer" + ); + return Err(CorporateIdentityError::NpubMismatch); + } + Ok(SOURCE_JWT_NPUB) + } + None => Ok(SOURCE_DB_BINDING), + } +} + +fn claim_string( + claims: &Map, + claim: &str, +) -> Result { + let value = claims + .get(claim) + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: "missing".to_string(), + })?; + let value = value + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: "must be a non-empty string".to_string(), + })?; + Ok(value.to_string()) +} + +fn configured_pubkey_claim( + claims: &Map, + claim: Option<&str>, +) -> Result, CorporateIdentityError> { + match claim { + Some(claim) => claim_string(claims, claim) + .and_then(|raw| parse_pubkey_claim(claim, &raw)) + .map(Some), + None => Ok(None), + } +} + +fn claim_u64(claims: &Map, claim: &str) -> Result { + claims + .get(claim) + .and_then(Value::as_u64) + .ok_or_else(|| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: "must be an unsigned integer".to_string(), + }) +} + +fn parse_pubkey_claim(claim: &str, value: &str) -> Result { + if value.starts_with("npub1") { + PublicKey::from_bech32(value).map_err(|e| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: format!("invalid npub: {e}"), + }) + } else { + PublicKey::from_hex(value).map_err(|e| CorporateIdentityError::InvalidClaim { + claim: claim.to_string(), + reason: format!("invalid pubkey hex: {e}"), + }) + } +} + +/// Create an optional service from config. +pub fn service_from_config( + config: &CorporateIdentityConfig, +) -> Option> { + config + .require + .then(|| Arc::new(CorporateIdentityService::new(config.clone()))) +} + +fn record_identity_binding_metric(binding: &BindIdentityResult) { + let result = match binding { + BindIdentityResult::Created => "created", + BindIdentityResult::Matched => "matched", + BindIdentityResult::Conflict(_) => "conflict", + BindIdentityResult::Revoked => "revoked", + }; + metrics::counter!("buzz_corporate_identity_bindings_total", "result" => result).increment(1); +} + +fn record_corporate_identity_denial(error: &CorporateIdentityError) { + let reason = match error { + CorporateIdentityError::MissingJwt => "missing_jwt", + CorporateIdentityError::MissingKid => "missing_kid", + CorporateIdentityError::InvalidJwt(_) => "invalid_jwt", + CorporateIdentityError::Jwks(_) => "jwks", + CorporateIdentityError::InvalidClaim { .. } => "invalid_claim", + CorporateIdentityError::NpubMismatch => "npub_mismatch", + CorporateIdentityError::BindingConflict => "binding_conflict", + CorporateIdentityError::BindingRevoked => "binding_revoked", + CorporateIdentityError::DelegationDenied => "delegation_denied", + CorporateIdentityError::Db(_) => "db", + }; + metrics::counter!("buzz_auth_failures_total", "reason" => "corporate_identity_denied") + .increment(1); + metrics::counter!("buzz_corporate_identity_denials_total", "reason" => reason).increment(1); +} + +async fn record_identity_binding_audit( + state: &AppState, + community_id: CommunityId, + action: buzz_audit::AuditAction, + actor: PublicKey, + issuer: &str, + uid: &str, + detail: serde_json::Value, +) { + let Some(audit_tx) = &state.audit_tx else { + return; + }; + if let Err(e) = audit_tx + .send(buzz_audit::NewAuditEntry { + community_id, + action, + actor_pubkey: Some(actor.to_bytes().to_vec()), + object_id: Some(format!("{issuer}|{uid}")), + detail, + }) + .await + { + warn!("Corporate identity audit channel closed — entry lost: {e}"); + metrics::counter!("buzz_audit_send_errors_total").increment(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + + use axum::http::{HeaderMap, HeaderName, HeaderValue}; + use base64::Engine as _; + use jsonwebtoken::jwk::JwkSet; + use jsonwebtoken::{encode, EncodingKey, Header}; + use nostr::Keys; + use sqlx::PgPool; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + fn test_config() -> CorporateIdentityConfig { + CorporateIdentityConfig { + require: true, + jwt_header: "x-buzz-identity-token".to_string(), + allow_delegation: true, + auth_precedence: CorporateIdentityAuthPrecedence::Direct, + jwks_uri: "http://127.0.0.1:9/jwks".to_string(), + issuer: "https://idp.example".to_string(), + audience: "buzz-relay".to_string(), + uid_claim: "sub".to_string(), + display_claim: "email".to_string(), + public_display_claim: None, + npub_claim: Some("buzz_npub".to_string()), + } + } + + fn test_identity_binding( + issuer: &str, + uid: &str, + pubkey: PublicKey, + ) -> buzz_db::identity_binding::IdentityBinding { + let now = chrono::Utc::now(); + buzz_db::identity_binding::IdentityBinding { + issuer: issuer.to_string(), + uid: uid.to_string(), + pubkey: pubkey.to_bytes().to_vec(), + display_name: None, + source: SOURCE_DB_BINDING.to_string(), + created_at: now, + updated_at: now, + last_seen_at: now, + } + } + + fn spawn_test_revalidation( + signer: PublicKey, + plan: SessionRevalidationPlan, + cancel: tokio_util::sync::CancellationToken, + result: Result, &'static str>, + ) -> (tokio::task::JoinHandle<()>, Arc) { + let lookups = Arc::new(AtomicUsize::new(0)); + let task_lookups = Arc::clone(&lookups); + let task = tokio::spawn(run_session_binding_revalidation( + IDENTITY_SESSION_REVALIDATION_INTERVAL, + signer, + plan.binding_pubkey, + plan.expected_issuer, + plan.expected_uid, + cancel, + move || { + task_lookups.fetch_add(1, Ordering::SeqCst); + let result = result.clone(); + async move { result } + }, + )); + (task, lookups) + } + + #[tokio::test(start_paused = true)] + async fn direct_session_stays_live_before_expiry_and_cancels_at_expiry() { + let cancel = tokio_util::sync::CancellationToken::new(); + let task = tokio::spawn(cancel_session_at_expiry(110, 100, cancel.clone())); + tokio::task::yield_now().await; + + tokio::time::advance(Duration::from_secs(9)).await; + tokio::task::yield_now().await; + assert!(!cancel.is_cancelled()); + + tokio::time::advance(Duration::from_secs(1)).await; + cancel.cancelled().await; + task.await.expect("expiry task"); + } + + #[tokio::test(start_paused = true)] + async fn matching_session_binding_stays_live() { + let signer = Keys::generate().public_key(); + let plan = SessionRevalidationPlan { + binding_pubkey: signer, + expected_issuer: "https://idp.example".to_string(), + expected_uid: "user-1".to_string(), + expires_at: None, + }; + let binding = test_identity_binding("https://idp.example", "user-1", signer); + let cancel = tokio_util::sync::CancellationToken::new(); + let (task, lookups) = + spawn_test_revalidation(signer, plan, cancel.clone(), Ok(Some(binding))); + + while lookups.load(Ordering::SeqCst) == 0 { + tokio::task::yield_now().await; + } + assert!(!cancel.is_cancelled()); + cancel.cancel(); + task.await.expect("revalidation task"); + } + + #[tokio::test(start_paused = true)] + async fn missing_or_mismatched_session_binding_cancels() { + for binding in [ + None, + Some(test_identity_binding( + "https://idp.example", + "different-user", + Keys::generate().public_key(), + )), + ] { + let signer = Keys::generate().public_key(); + let plan = SessionRevalidationPlan { + binding_pubkey: signer, + expected_issuer: "https://idp.example".to_string(), + expected_uid: "user-1".to_string(), + expires_at: None, + }; + let cancel = tokio_util::sync::CancellationToken::new(); + let (task, _) = spawn_test_revalidation(signer, plan, cancel.clone(), Ok(binding)); + + cancel.cancelled().await; + task.await.expect("revalidation task"); + } + } + + #[tokio::test(start_paused = true)] + async fn delegated_session_cancels_when_owner_binding_is_revoked() { + let signer = Keys::generate().public_key(); + let owner = Keys::generate().public_key(); + let plan = session_revalidation_plan( + signer, + CorporateIdentityDecision::Delegated { + owner_pubkey: owner, + owner_issuer: "https://idp.example".to_string(), + owner_uid: "owner-1".to_string(), + }, + ) + .expect("delegated session plan"); + assert_eq!(plan.binding_pubkey, owner); + let cancel = tokio_util::sync::CancellationToken::new(); + let (task, _) = spawn_test_revalidation(signer, plan, cancel.clone(), Ok(None)); + + cancel.cancelled().await; + task.await.expect("revalidation task"); + } + + #[tokio::test(start_paused = true)] + async fn session_revalidation_database_error_cancels_fail_closed() { + let signer = Keys::generate().public_key(); + let plan = SessionRevalidationPlan { + binding_pubkey: signer, + expected_issuer: "https://idp.example".to_string(), + expected_uid: "user-1".to_string(), + expires_at: None, + }; + let cancel = tokio_util::sync::CancellationToken::new(); + let (task, _) = + spawn_test_revalidation(signer, plan, cancel.clone(), Err("database unavailable")); + + cancel.cancelled().await; + task.await.expect("revalidation task"); + } + + #[test] + fn identity_projects_as_relay_signed_nip85_assertion_without_provider_details() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let event = build_identity_assertion( + &relay, + subject, + Some("Example User"), + 456, + Timestamp::from(123), + ) + .unwrap(); + + assert_eq!(event.kind.as_u16() as u32, KIND_USER_TRUSTED_ASSERTION); + assert_eq!(event.pubkey, relay.public_key()); + assert!(event.verify_id()); + assert!(event.verify_signature()); + assert!(identity_assertion_matches( + &event, + &subject.to_hex(), + Some("Example User"), + 456, + )); + assert!( + !event + .tags + .iter() + .any(|tag| tag.as_slice().first().is_some_and(|name| name == "uid")), + "the public assertion must not expose the stable corporate uid" + ); + assert!( + !event + .tags + .iter() + .any(|tag| tag.as_slice().first().is_some_and(|name| name == "issuer")), + "the public assertion must not expose the upstream identity provider" + ); + } + + #[test] + fn identity_assertions_are_bounded_and_can_be_retired() { + let relay = Keys::generate(); + let subject = Keys::generate().public_key(); + let now = 1_000; + + assert_eq!( + identity_assertion_expiration( + Some("Example User"), + now + IDENTITY_ASSERTION_MAX_TTL_SECS + 1, + now, + ), + now + IDENTITY_ASSERTION_MAX_TTL_SECS, + ); + assert_eq!( + identity_assertion_expiration(Some("Example User"), now + 60, now), + now + 60, + ); + assert_eq!(identity_assertion_expiration(None, u64::MAX, now), 0); + + let retired = build_identity_assertion(&relay, subject, None, 0, Timestamp::from(now)) + .expect("build inactive assertion"); + assert!(identity_assertion_matches( + &retired, + &subject.to_hex(), + None, + 0, + )); + assert!(retired.tags.iter().any(|tag| { + tag.as_slice().first().is_some_and(|part| part == "active") + && tag.as_slice().get(1).is_some_and(|part| part == "false") + })); + assert!(!retired.tags.iter().any(|tag| { + tag.as_slice() + .first() + .is_some_and(|part| part == "display_name") + })); + } + + #[test] + fn direct_jwt_precedes_delegation_by_default() { + let config = test_config(); + assert_eq!( + select_identity_auth_path(&config, Some("jwt"), Some("auth-tag")), + IdentityAuthPath::Direct + ); + } + + #[test] + fn deployment_can_select_delegated_owner_precedence() { + let mut config = test_config(); + config.auth_precedence = CorporateIdentityAuthPrecedence::Delegated; + assert_eq!( + select_identity_auth_path(&config, Some("jwt"), Some("auth-tag")), + IdentityAuthPath::Delegated + ); + assert_eq!( + select_identity_auth_path(&config, Some("jwt"), None), + IdentityAuthPath::Direct + ); + } + + #[test] + fn rejects_hmac_jwt_algorithms_in_allowlist() { + assert!(!is_allowed_jwt_algorithm(Algorithm::HS256)); + assert!(!is_allowed_jwt_algorithm(Algorithm::HS384)); + assert!(!is_allowed_jwt_algorithm(Algorithm::HS512)); + assert!(is_allowed_jwt_algorithm(Algorithm::RS256)); + } + + #[tokio::test] + async fn validate_jwt_rejects_hs256_before_jwks_lookup() { + let service = CorporateIdentityService::new(test_config()); + let mut header = Header::new(Algorithm::HS256); + header.kid = Some("hs256-kid".to_string()); + let token = encode( + &header, + &serde_json::json!({ + "iss": "https://idp.example", + "aud": "buzz-relay", + "sub": "user-1", + "email": "user@example.com", + }), + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("encode test jwt"); + + let err = service + .validate_jwt(&token) + .await + .expect_err("HS256 must be rejected"); + assert!(matches!(err, CorporateIdentityError::InvalidJwt(_))); + } + + #[tokio::test] + async fn validate_jwt_accepts_matching_rs256_jwk() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let token = rsa_test_jwt(&key, "rsa-key"); + let claims = validate_rsa_jwt(&token, rsa_test_jwk(&key, "rsa-key")) + .await + .expect("matching RSA JWT must validate"); + + assert_eq!(claims.uid, "user-1"); + assert_eq!(claims.display_name, "user@example.com"); + } + + #[tokio::test] + async fn validate_jwt_rejects_rs256_token_signed_by_wrong_key() { + let signing_key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let advertised_key = rsa_private_key(include_str!("testdata/rsa_private_key_2.der.b64")); + let token = rsa_test_jwt(&signing_key, "rsa-key"); + + let error = validate_rsa_jwt(&token, rsa_test_jwk(&advertised_key, "rsa-key")) + .await + .expect_err("JWT signed by another RSA key must fail"); + assert!(matches!(error, CorporateIdentityError::InvalidJwt(_))); + } + + #[tokio::test] + async fn validate_jwt_rejects_jwk_advertised_algorithm_mismatch() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let token = rsa_test_jwt(&key, "rsa-key"); + let mut jwk = rsa_test_jwk(&key, "rsa-key"); + jwk.common.key_algorithm = Some(KeyAlgorithm::RS512); + + let error = validate_rsa_jwt(&token, jwk) + .await + .expect_err("JWK alg must agree with JWT alg"); + assert!(matches!( + error, + CorporateIdentityError::InvalidJwt(ref message) + if message.contains("does not match JWK algorithm") + )); + } + + #[tokio::test] + async fn validate_jwt_accepts_jwk_with_omitted_algorithm() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let token = rsa_test_jwt(&key, "rsa-key"); + let mut jwk = rsa_test_jwk(&key, "rsa-key"); + jwk.common.key_algorithm = None; + + validate_rsa_jwt(&token, jwk) + .await + .expect("an omitted optional JWK alg must not prevent RSA verification"); + } + + #[test] + fn validate_jwk_requires_signature_use_and_verify_operation_when_present() { + let key = rsa_private_key(include_str!("testdata/rsa_private_key_1.der.b64")); + let mut jwk = rsa_test_jwk(&key, "rsa-key"); + jwk.common.public_key_use = Some(PublicKeyUse::Encryption); + assert!(matches!( + validate_jwk_signature_metadata(&jwk, Algorithm::RS256), + Err(CorporateIdentityError::InvalidJwt(ref message)) + if message.contains("use must be sig") + )); + + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + jwk.common.key_operations = Some(vec![KeyOperations::Sign]); + assert!(matches!( + validate_jwk_signature_metadata(&jwk, Algorithm::RS256), + Err(CorporateIdentityError::InvalidJwt(ref message)) + if message.contains("key_ops must include verify") + )); + + jwk.common.key_operations = Some(vec![KeyOperations::Sign, KeyOperations::Verify]); + validate_jwk_signature_metadata(&jwk, Algorithm::RS256) + .expect("JWK key_ops containing verify must be accepted"); + } + + #[test] + fn jwt_validation_rejects_missing_and_malformed_audience_claims() { + let now = Timestamp::now().as_secs(); + let missing = serde_json::json!({ + "iss": "https://idp.example", + "sub": "user-1", + "email": "user@example.com", + "exp": now + 3_600, + }); + let malformed = serde_json::json!({ + "iss": "https://idp.example", + "aud": 42, + "sub": "user-1", + "email": "user@example.com", + "exp": now + 3_600, + }); + + for claims in [missing, malformed] { + decode_test_jwt(claims, Algorithm::HS256, b"test-secret", b"test-secret") + .expect_err("invalid audience must not enroll an identity binding"); + } + } + + #[test] + fn jwt_validation_requires_expiration_issuer_and_audience() { + let now = Timestamp::now().as_secs(); + for claim in ["exp", "iss", "aud"] { + let mut claims = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + claims.remove(claim); + assert!( + decode_test_jwt( + Value::Object(claims), + Algorithm::HS256, + b"test-secret", + b"test-secret", + ) + .is_err(), + "missing {claim} must fail closed", + ); + } + } + + #[test] + fn jwt_validation_pins_clock_skew_leeway() { + let validation = jwt_validation(Algorithm::RS256, &test_config()); + assert_eq!(validation.leeway, JWT_CLOCK_SKEW_LEEWAY_SECS); + } + + #[test] + fn jwt_validation_rejects_malformed_registered_claim_types() { + let now = Timestamp::now().as_secs(); + for (claim, value) in [ + ("iss", Value::from(42)), + ("aud", Value::from(42)), + ("exp", Value::String("tomorrow".to_string())), + ("nbf", Value::String("tomorrow".to_string())), + ] { + let mut claims = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + claims.insert(claim.to_string(), value); + assert!( + decode_test_jwt( + Value::Object(claims), + Algorithm::HS256, + b"test-secret", + b"test-secret", + ) + .is_err(), + "malformed {claim} must fail closed", + ); + } + } + + #[test] + fn jwt_validation_rejects_future_and_malformed_not_before_claims() { + let now = Timestamp::now().as_secs(); + let mut future = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + future.insert("nbf".to_string(), Value::from(now + 3_600)); + + let mut malformed = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + malformed.insert("nbf".to_string(), Value::String("tomorrow".to_string())); + + for claims in [Value::Object(future), Value::Object(malformed)] { + decode_test_jwt(claims, Algorithm::HS256, b"test-secret", b"test-secret") + .expect_err("invalid nbf must fail closed"); + } + } + + #[test] + fn jwt_validation_rejects_wrong_issuer_audience_and_expiry() { + let now = Timestamp::now().as_secs(); + for (claim, value) in [ + ("iss", Value::String("https://attacker.example".to_string())), + ("aud", Value::String("some-other-service".to_string())), + ("exp", Value::from(now.saturating_sub(3_600))), + ] { + let mut claims = valid_test_claims(now) + .as_object() + .expect("claims object") + .clone(); + claims.insert(claim.to_string(), value); + assert!( + decode_test_jwt( + Value::Object(claims), + Algorithm::HS256, + b"test-secret", + b"test-secret", + ) + .is_err(), + "invalid {claim} must fail closed", + ); + } + } + + #[test] + fn jwt_validation_rejects_algorithm_and_key_mismatch() { + let claims = valid_test_claims(Timestamp::now().as_secs()); + + decode_test_jwt( + claims.clone(), + Algorithm::HS384, + b"test-secret", + b"test-secret", + ) + .expect_err("the token algorithm must match verifier policy"); + decode_test_jwt( + claims, + Algorithm::HS256, + b"signing-secret", + b"different-verification-secret", + ) + .expect_err("a token signed by a different key must fail"); + } + + #[test] + fn extracts_bearer_token_from_comma_list_header() { + let config = test_config(); + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-buzz-identity-token"), + HeaderValue::from_static("Bearer token-a, Bearer token-b"), + ); + + assert_eq!( + identity_jwt_from_headers(&headers, &config).as_deref(), + Some("token-a") + ); + } + + #[test] + fn missing_required_claim_is_invalid() { + let claims = Map::new(); + let err = claim_string(&claims, "sub").expect_err("missing claim"); + assert!(matches!( + err, + CorporateIdentityError::InvalidClaim { ref claim, .. } if claim == "sub" + )); + } + + #[test] + fn configured_npub_claim_is_required_and_malformed_value_is_invalid() { + let mut claims = Map::new(); + let missing = configured_pubkey_claim(&claims, Some("buzz_npub")) + .expect_err("configured claim must be present"); + assert!(matches!( + missing, + CorporateIdentityError::InvalidClaim { ref claim, .. } if claim == "buzz_npub" + )); + + claims.insert( + "buzz_npub".to_string(), + Value::String("not-an-npub".to_string()), + ); + let err = configured_pubkey_claim(&claims, Some("buzz_npub")) + .expect_err("present malformed claim must fail"); + assert!(matches!( + err, + CorporateIdentityError::InvalidClaim { ref claim, .. } if claim == "buzz_npub" + )); + } + + #[test] + fn npub_claim_must_match_authenticated_signer() { + let signer = Keys::generate().public_key(); + let other = Keys::generate().public_key(); + + assert!(matches!( + binding_source_for_signer(Some(other), signer), + Err(CorporateIdentityError::NpubMismatch) + )); + assert_eq!( + binding_source_for_signer(Some(signer), signer).expect("match"), + SOURCE_JWT_NPUB + ); + assert_eq!( + binding_source_for_signer(None, signer).expect("db fallback"), + SOURCE_DB_BINDING + ); + } + + #[tokio::test] + async fn fresh_jwks_cache_miss_does_not_refetch() { + let service = CorporateIdentityService::new(test_config()); + *service.jwks.write().await = Some(CachedJwks { + set: JwkSet { keys: Vec::new() }, + expires_at: Instant::now() + Duration::from_secs(60), + }); + + let err = service + .jwk_for_kid("attacker-controlled-kid") + .await + .expect_err("fresh cache miss should fail without network fetch"); + assert!(matches!( + err, + CorporateIdentityError::Jwks(ref msg) if msg.contains("fresh JWKS cache") + )); + } + + #[tokio::test] + async fn jwks_refresh_is_single_flight() { + let body = r#"{"keys":[{"kty":"RSA","n":"AQAB","e":"AQAB","kid":"test-kid","alg":"RS256","use":"sig"}]}"#; + let response = http_response("200 OK", &["Content-Type: application/json"], body); + let (uri, requests, server) = spawn_http_server(response).await; + let mut config = test_config(); + config.jwks_uri = uri; + let service = CorporateIdentityService::new(config); + + let (first, second, third, fourth) = tokio::join!( + service.jwk_for_kid("test-kid"), + service.jwk_for_kid("test-kid"), + service.jwk_for_kid("test-kid"), + service.jwk_for_kid("test-kid"), + ); + for result in [first, second, third, fourth] { + result.expect("all waiters should reuse the refreshed JWKS"); + } + assert_eq!(requests.load(Ordering::SeqCst), 1); + server.abort(); + } + + #[tokio::test] + async fn jwks_response_content_length_is_capped_before_buffering() { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + JWKS_MAX_RESPONSE_BYTES + 1, + ); + let (uri, _requests, server) = spawn_http_server(response).await; + let mut config = test_config(); + config.jwks_uri = uri; + let service = CorporateIdentityService::new(config); + + let error = service + .fetch_jwks() + .await + .expect_err("oversized JWKS must fail before buffering the body"); + assert!(matches!( + error, + CorporateIdentityError::Jwks(ref message) if message.contains("size limit") + )); + server.abort(); + } + + #[tokio::test] + async fn jwks_streaming_response_is_capped_without_content_length() { + let response = format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{}", + " ".repeat(JWKS_MAX_RESPONSE_BYTES + 1), + ); + let (uri, _requests, server) = spawn_http_server(response).await; + let mut config = test_config(); + config.jwks_uri = uri; + let service = CorporateIdentityService::new(config); + + let error = service + .fetch_jwks() + .await + .expect_err("streamed oversized JWKS must stop at the cap"); + assert!(matches!( + error, + CorporateIdentityError::Jwks(ref message) if message.contains("size limit") + )); + server.abort(); + } + + #[test] + fn transport_wide_delegation_rejects_conditional_nip_oa_tags() { + let owner = Keys::generate(); + let agent = Keys::generate().public_key(); + let unconditional = + buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent, "").expect("unconditional auth tag"); + let conditional = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent, "kind=1") + .expect("conditional auth tag"); + + assert_eq!( + extract_unconditional_nip_oa_owner(agent, Some(&unconditional)), + Some(owner.public_key()), + ); + assert_eq!( + extract_unconditional_nip_oa_owner(agent, Some(&conditional)), + None, + ); + } + + fn valid_test_claims(now: u64) -> Value { + serde_json::json!({ + "iss": "https://idp.example", + "aud": "buzz-relay", + "sub": "user-1", + "email": "user@example.com", + "exp": now + 3_600, + }) + } + + fn decode_test_jwt( + claims: Value, + signing_algorithm: Algorithm, + signing_key: &[u8], + verification_key: &[u8], + ) -> Result<(), jsonwebtoken::errors::Error> { + let token = encode( + &Header::new(signing_algorithm), + &claims, + &EncodingKey::from_secret(signing_key), + )?; + decode::( + &token, + &DecodingKey::from_secret(verification_key), + &jwt_validation(Algorithm::HS256, &test_config()), + )?; + Ok(()) + } + + fn rsa_private_key(encoded: &str) -> Vec { + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .expect("decode RSA test key") + } + + fn rsa_test_jwk(private_key: &[u8], kid: &str) -> Jwk { + let encoding_key = EncodingKey::from_rsa_der(private_key); + let mut jwk = Jwk::from_encoding_key(&encoding_key, Algorithm::RS256) + .expect("derive RSA JWK from test key"); + jwk.common.key_id = Some(kid.to_string()); + jwk.common.public_key_use = Some(PublicKeyUse::Signature); + jwk.common.key_operations = Some(vec![KeyOperations::Verify]); + jwk + } + + fn rsa_test_jwt(private_key: &[u8], kid: &str) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some(kid.to_string()); + encode( + &header, + &valid_test_claims(Timestamp::now().as_secs()), + &EncodingKey::from_rsa_der(private_key), + ) + .expect("encode RSA test JWT") + } + + async fn validate_rsa_jwt( + token: &str, + jwk: Jwk, + ) -> Result { + let body = + serde_json::to_string(&JwkSet { keys: vec![jwk] }).expect("serialize RSA test JWKS"); + let response = http_response("200 OK", &["Content-Type: application/json"], &body); + let (uri, _requests, server) = spawn_http_server(response).await; + let mut config = test_config(); + config.jwks_uri = uri; + config.npub_claim = None; + let result = CorporateIdentityService::new(config) + .validate_jwt(token) + .await; + server.abort(); + result + } + + fn http_response(status: &str, headers: &[&str], body: &str) -> String { + format!( + "HTTP/1.1 {status}\r\n{}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + headers + .iter() + .map(|header| format!("{header}\r\n")) + .collect::(), + body.len(), + ) + } + + async fn spawn_http_server( + response: String, + ) -> (String, Arc, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test HTTP server"); + let address = listener.local_addr().expect("test server address"); + let requests = Arc::new(AtomicUsize::new(0)); + let request_count = requests.clone(); + let server = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + request_count.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 2_048]; + let Ok(bytes_read) = stream.read(&mut request).await else { + return; + }; + if bytes_read == 0 { + return; + } + if stream.write_all(response.as_bytes()).await.is_err() { + return; + } + } + }); + (format!("http://{address}/jwks"), requests, server) + } + + async fn setup_db() -> (buzz_db::Db, PgPool) { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + db.migrate().await.expect("run migrations"); + (db, pool) + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("relay-identity-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delegation_requires_owner_identity_binding() { + let (db, pool) = setup_db().await; + let community = make_community(&pool).await; + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, "").unwrap(); + let config = test_config(); + + let err = verify_delegated_corporate_identity( + &db, + &config, + community, + agent_pubkey, + Some(&auth_tag), + ) + .await + .expect_err("owner without binding should be denied"); + assert!(matches!(err, CorporateIdentityError::DelegationDenied)); + + db.bind_or_validate_identity( + community, + &config.issuer, + "owner-uid", + owner_keys.public_key().as_bytes(), + Some("owner@example.com"), + SOURCE_DB_BINDING, + ) + .await + .expect("create owner binding"); + + let decision = verify_delegated_corporate_identity( + &db, + &config, + community, + agent_pubkey, + Some(&auth_tag), + ) + .await + .expect("owner binding admits agent"); + assert_eq!( + decision, + CorporateIdentityProof::Delegated { + owner_pubkey: owner_keys.public_key(), + owner_issuer: config.issuer.clone(), + owner_uid: "owner-uid".to_string(), + } + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn missing_jwt_without_auth_tag_is_missing_jwt() { + let (db, pool) = setup_db().await; + let community = make_community(&pool).await; + let signer = Keys::generate().public_key(); + let config = test_config(); + + let err = verify_delegated_corporate_identity(&db, &config, community, signer, None) + .await + .expect_err("no JWT and no delegation tag"); + assert!(matches!(err, CorporateIdentityError::MissingJwt)); + } +} diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..1aa79aafea 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -183,6 +183,28 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } + let identity_proof = match crate::corporate_identity::verify_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + conn.corporate_identity_jwt.as_deref(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(proof) => proof, + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + }; + // Pubkey allowlist gate — only for pubkey-only auth. if state.config.pubkey_allowlist_enabled && auth_ctx.auth_method == buzz_auth::AuthMethod::Nip42 @@ -237,6 +259,34 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; + let identity_decision = match crate::corporate_identity::finalize_corporate_identity( + &state, + conn.tenant.community(), + pubkey, + identity_proof, + ) + .await + { + Ok(decision) => decision, + Err(e) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), error = %e, "corporate identity finalization denied"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + &format!("restricted: {}", e.public_message()), + )); + return; + } + }; + if let crate::corporate_identity::CorporateIdentityDecision::Delegated { + owner_pubkey, + .. + } = &identity_decision + { + auth_ctx.agent_owner_pubkey = Some(*owner_pubkey); + } + // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping // (needed for observer frame auth). Only runs on open relays — on closed // relays, enforce_relay_membership already handles NIP-OA delegation. @@ -279,6 +329,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: state .conn_manager .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); + crate::corporate_identity::spawn_session_revalidation( + Arc::clone(&state), + conn.tenant.community(), + pubkey, + identity_decision, + conn.cancel.clone(), + ); conn.send(RelayMessage::ok(&event_id_hex, true, "")); } Err(e) => { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..288129fd62 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1386,8 +1386,9 @@ mod tests { conn_id: Uuid::new_v4(), tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), + corporate_identity_jwt: None, auth_state: RwLock::new(crate::connection::AuthState::Authenticated( - buzz_auth::AuthContext { + buzz_auth::ConnectionAuthContext { pubkey: agent.public_key(), scopes: vec![], channel_ids: None, diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..904af74803 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -17,6 +17,8 @@ pub mod config; pub mod conformance; /// WebSocket connection lifecycle and state. pub mod connection; +/// Corporate identity verification and uid/pubkey binding. +pub mod corporate_identity; /// Relay error types. pub mod error; /// WebSocket message handlers for NIP-01 client commands. diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7ba..c1e62b33b6 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -304,7 +304,14 @@ async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option, bool) { - let has_stable_key = state.config.relay_private_key.is_some(); + // Production relays are stable when an explicit key is configured. Dev + // relays are also stable: main.rs deliberately uses the deterministic + // secp256k1 key `1` whenever token auth is disabled so relay-authored + // addressable events survive restarts. NIP-11 must advertise that key too, + // otherwise clients cannot verify those events (including identity + // assertions) even though their signer is stable. + let has_stable_key = + state.config.relay_private_key.is_some() || !state.config.require_auth_token; let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex()); let advertise_nip43 = has_stable_key && state.config.require_relay_membership; (relay_self, advertise_nip43) diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..7737604495 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -1,13 +1,15 @@ //! axum routers — app (WebSocket + REST), health (K8s probes), metrics (Prometheus). +mod route_policy; + use std::sync::atomic::Ordering; use std::sync::Arc; use axum::{ body::Body, - extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade}, + extract::{ConnectInfo, FromRequest, MatchedPath, State, WebSocketUpgrade}, http::{HeaderMap, Request, StatusCode}, - middleware, + middleware::{self, Next}, response::{IntoResponse, Json}, routing::{get, post, put}, Router, @@ -187,21 +189,83 @@ pub fn build_router(state: Arc) -> Router { } merged + // Every registered route must be present in the centralized policy + // inventory. When the gate is enabled, an unclassified route fails + // before its handler can perform reads or writes. + .route_layer(middleware::from_fn_with_state( + state.clone(), + enforce_corporate_identity_route_inventory, + )) .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +async fn enforce_corporate_identity_route_inventory( + State(state): State>, + request: Request, + next: Next, +) -> axum::response::Response { + enforce_route_inventory_for_requirement(state.config.corporate_identity.require, request, next) + .await +} + +async fn enforce_route_inventory_for_requirement( + corporate_identity_required: bool, + request: Request, + next: Next, +) -> axum::response::Response { + if !corporate_identity_required { + return next.run(request).await; + } + let matched_path = request.extensions().get::(); + let policy = matched_path + .and_then(|path| route_policy::classify_matched_route(request.method(), path.as_str())); + if policy.is_some() { + return next.run(request).await; + } + if matched_path.is_some_and(|path| route_policy::is_known_matched_path(path.as_str())) { + // Axum's method fallback also runs route layers. Return its semantic + // equivalent directly, while still preventing an accidentally added + // unclassified method handler from executing. + let allow = matched_path + .and_then(|path| route_policy::allowed_methods(path.as_str())) + .unwrap_or_default(); + return axum::response::Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header(axum::http::header::ALLOW, allow) + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::METHOD_NOT_ALLOWED.into_response()); + } + tracing::error!( + method = %request.method(), + matched_path = matched_path.map(|path| path.as_str()).unwrap_or(""), + "rejecting route missing corporate identity policy classification" + ); + ( + StatusCode::SERVICE_UNAVAILABLE, + "route unavailable: identity policy is not configured", + ) + .into_response() +} + fn http_trace_layer() -> TraceLayer) -> tracing::Span> { TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request) -> tracing::Span) } fn make_http_span(request: &Request) -> tracing::Span { + let corporate_identity_policy = request + .extensions() + .get::() + .and_then(|path| route_policy::classify_matched_route(request.method(), path.as_str())) + .map(route_policy::CorporateIdentityRoutePolicy::trace_label) + .unwrap_or("unclassified"); tracing::info_span!( target: "buzz_relay", "http.request", otel.kind = "server", http.request.method = %request.method(), + buzz.corporate_identity.route_policy = corporate_identity_policy, ) } @@ -310,6 +374,10 @@ async fn nip11_or_ws_handler( .into_response(); } }; + let corporate_identity_jwt = crate::corporate_identity::identity_jwt_from_headers( + &headers, + &state.config.corporate_identity, + ); let max_frame_bytes = state.config.max_frame_bytes; match WebSocketUpgrade::from_request(req, &state).await { @@ -324,7 +392,9 @@ async fn nip11_or_ws_handler( return (StatusCode::SERVICE_UNAVAILABLE, "relay restarting").into_response(); } limit_relay_websocket(ws, max_frame_bytes) - .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) + .on_upgrade(move |socket| { + handle_connection(socket, state, addr, tenant, corporate_identity_jwt) + }) .into_response() } Err(_) => { @@ -447,7 +517,10 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { #[cfg(test)] mod tests { - use axum::{routing::get, Router}; + use axum::{ + routing::{get, post}, + Router, + }; use futures_util::SinkExt; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; @@ -460,6 +533,49 @@ mod tests { use super::*; + async fn require_route_inventory( + request: Request, + next: Next, + ) -> axum::response::Response { + enforce_route_inventory_for_requirement(true, request, next).await + } + + #[tokio::test] + async fn route_inventory_preserves_405_and_rejects_new_unclassified_handlers() { + let app = Router::new() + .route("/events", post(|| async { StatusCode::OK })) + .route("/new-unclassified-route", get(|| async { StatusCode::OK })) + .route_layer(middleware::from_fn(require_route_inventory)); + + let allowed = app + .clone() + .oneshot(Request::post("/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(allowed.status(), StatusCode::OK); + + let unsupported = app + .clone() + .oneshot(Request::delete("/events").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(unsupported.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!( + unsupported.headers().get(axum::http::header::ALLOW), + Some(&axum::http::HeaderValue::from_static("POST")) + ); + + let unclassified = app + .oneshot( + Request::get("/new-unclassified-route") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unclassified.status(), StatusCode::SERVICE_UNAVAILABLE); + } + #[test] fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { assert!(is_invite_landing_path("/invite/payload.mac")); diff --git a/crates/buzz-relay/src/router/route_policy.rs b/crates/buzz-relay/src/router/route_policy.rs new file mode 100644 index 0000000000..38859e10a9 --- /dev/null +++ b/crates/buzz-relay/src/router/route_policy.rs @@ -0,0 +1,499 @@ +//! Central inventory of corporate-identity policy at the HTTP routing boundary. +//! +//! This module classifies axum's *matched route template* (for example, +//! `/media/{sha256_ext}`), not an untrusted literal request path. Keeping the +//! complete inventory here makes every authenticated surface and every +//! deliberate exemption reviewable in one place. The handlers remain the +//! enforcement point because they have the authenticated principal, resolved +//! tenant, and admission result needed to finalize an identity safely. + +use axum::http::Method; + +/// Why a route deliberately does not use tenant corporate-identity auth. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum CorporateIdentityExemption { + /// Public relay metadata (NIP-05, NIP-11-adjacent information). + PublicMetadata, + /// Kubernetes/service health endpoint. + HealthProbe, + /// Public pre-membership policy and policy-acceptance bootstrap. + JoinBootstrap, + /// Deployment-global operator NIP-98 allowlist, outside tenant auth. + OperatorAuth, + /// Deployment-admin host/session authentication, outside tenant auth. + AdminAuth, + /// Per-workflow secret authentication. + WebhookSecret, + /// Loopback-only, HMAC-authenticated Git hook callback. + LocalHookCallback, + /// Disabled-by-default mesh testbed endpoint. + TestbedOnly, +} + +/// Corporate-identity policy for a registered route. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum CorporateIdentityRoutePolicy { + /// Authenticate and enforce corporate identity during this HTTP request. + Required, + /// Enforce when the upgraded WebSocket performs its protocol auth flow. + RequiredAtSessionAuth, + /// Public only when protected media reads are disabled; otherwise required. + RequiredWhenMediaReadsProtected, + /// Deliberately outside tenant corporate-identity authentication. + Exempt(CorporateIdentityExemption), +} + +impl CorporateIdentityRoutePolicy { + /// Stable, low-cardinality label used on HTTP trace spans. + pub(super) const fn trace_label(self) -> &'static str { + match self { + Self::Required => "required", + Self::RequiredAtSessionAuth => "required_at_session_auth", + Self::RequiredWhenMediaReadsProtected => "required_when_media_reads_protected", + Self::Exempt(CorporateIdentityExemption::PublicMetadata) => "exempt_public_metadata", + Self::Exempt(CorporateIdentityExemption::HealthProbe) => "exempt_health_probe", + Self::Exempt(CorporateIdentityExemption::JoinBootstrap) => "exempt_join_bootstrap", + Self::Exempt(CorporateIdentityExemption::OperatorAuth) => "exempt_operator_auth", + Self::Exempt(CorporateIdentityExemption::AdminAuth) => "exempt_admin_auth", + Self::Exempt(CorporateIdentityExemption::WebhookSecret) => "exempt_webhook_secret", + Self::Exempt(CorporateIdentityExemption::LocalHookCallback) => { + "exempt_local_hook_callback" + } + Self::Exempt(CorporateIdentityExemption::TestbedOnly) => "exempt_testbed_only", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct RoutePolicyRule { + method: &'static str, + matched_path: &'static str, + policy: CorporateIdentityRoutePolicy, +} + +const REQUIRED: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::Required; +const SESSION: CorporateIdentityRoutePolicy = CorporateIdentityRoutePolicy::RequiredAtSessionAuth; +const PROTECTED_MEDIA: CorporateIdentityRoutePolicy = + CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected; + +const fn exempt(exemption: CorporateIdentityExemption) -> CorporateIdentityRoutePolicy { + CorporateIdentityRoutePolicy::Exempt(exemption) +} + +/// Exhaustive inventory of registered relay routes. +/// +/// Static UI fallback paths are intentionally absent: they do not have an +/// axum `MatchedPath` and cannot reach an API handler. A missing API entry is +/// visible as `unclassified` in the HTTP trace span and must be added here as +/// part of registering the route. +const ROUTE_POLICY_RULES: &[RoutePolicyRule] = &[ + // Protocol and public metadata. + RoutePolicyRule { + method: "GET", + matched_path: "/", + policy: SESSION, + }, + RoutePolicyRule { + method: "GET", + matched_path: "/info", + policy: exempt(CorporateIdentityExemption::PublicMetadata), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/.well-known/nostr.json", + policy: exempt(CorporateIdentityExemption::PublicMetadata), + }, + // Health routes on the primary and health-only listeners. + RoutePolicyRule { + method: "GET", + matched_path: "/health", + policy: exempt(CorporateIdentityExemption::HealthProbe), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/_liveness", + policy: exempt(CorporateIdentityExemption::HealthProbe), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/_readiness", + policy: exempt(CorporateIdentityExemption::HealthProbe), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/_status", + policy: exempt(CorporateIdentityExemption::HealthProbe), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/_mesh", + policy: exempt(CorporateIdentityExemption::HealthProbe), + }, + // NIP-98 HTTP bridge. + RoutePolicyRule { + method: "POST", + matched_path: "/events", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "POST", + matched_path: "/query", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "POST", + matched_path: "/count", + policy: REQUIRED, + }, + // Deployment-global operator control plane. + RoutePolicyRule { + method: "GET", + matched_path: "/operator/communities", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/operator/communities", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/operator/communities/archive", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/operator/communities/unarchive", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/operator/communities/availability", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/operator/communities/transfer", + policy: exempt(CorporateIdentityExemption::OperatorAuth), + }, + // Invite admission and its deliberately public pre-join policy surface. + RoutePolicyRule { + method: "POST", + matched_path: "/api/invites", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "POST", + matched_path: "/api/invites/claim", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/join-policy", + policy: exempt(CorporateIdentityExemption::JoinBootstrap), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/join-policy/terms", + policy: exempt(CorporateIdentityExemption::JoinBootstrap), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/join-policy/privacy", + policy: exempt(CorporateIdentityExemption::JoinBootstrap), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/api/invites/accept-policy", + policy: exempt(CorporateIdentityExemption::JoinBootstrap), + }, + // Moderation data is tenant-authenticated even though it is not event data. + RoutePolicyRule { + method: "GET", + matched_path: "/moderation/reports", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "GET", + matched_path: "/moderation/audit", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "GET", + matched_path: "/moderation/restricted", + policy: REQUIRED, + }, + // Alternate-auth and test-only callbacks. + RoutePolicyRule { + method: "POST", + matched_path: "/hooks/{id}", + policy: exempt(CorporateIdentityExemption::WebhookSecret), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/_mesh/demo/echo", + policy: exempt(CorporateIdentityExemption::TestbedOnly), + }, + RoutePolicyRule { + method: "POST", + matched_path: "/internal/git/policy", + policy: exempt(CorporateIdentityExemption::LocalHookCallback), + }, + // Huddle authentication is performed inside the upgraded socket. + RoutePolicyRule { + method: "GET", + matched_path: "/huddle/{channel_id}/audio", + policy: SESSION, + }, + // Blossom media: writes are always authenticated; reads are configurable. + RoutePolicyRule { + method: "PUT", + matched_path: "/upload", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "PUT", + matched_path: "/media/upload", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "GET", + matched_path: "/media/{sha256_ext}", + policy: PROTECTED_MEDIA, + }, + RoutePolicyRule { + method: "HEAD", + matched_path: "/media/{sha256_ext}", + policy: PROTECTED_MEDIA, + }, + // Git smart HTTP is tenant-authenticated on every request. + RoutePolicyRule { + method: "GET", + matched_path: "/git/{owner}/{repo}/info/refs", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "POST", + matched_path: "/git/{owner}/{repo}/git-upload-pack", + policy: REQUIRED, + }, + RoutePolicyRule { + method: "POST", + matched_path: "/git/{owner}/{repo}/git-receive-pack", + policy: REQUIRED, + }, + // Deployment-admin APIs use the dedicated admin-host auth middleware. + RoutePolicyRule { + method: "GET", + matched_path: "/api/admin/v1/reports", + policy: exempt(CorporateIdentityExemption::AdminAuth), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/admin/v1/reports/{id}", + policy: exempt(CorporateIdentityExemption::AdminAuth), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/admin/v1/feedback", + policy: exempt(CorporateIdentityExemption::AdminAuth), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/admin/v1/feedback/{id}", + policy: exempt(CorporateIdentityExemption::AdminAuth), + }, + RoutePolicyRule { + method: "GET", + matched_path: "/api/admin/v1/feedback/{id}/attachments/{sha256}", + policy: exempt(CorporateIdentityExemption::AdminAuth), + }, +]; + +/// Classify a registered method and axum matched-path template. +pub(super) fn classify_matched_route( + method: &Method, + matched_path: &str, +) -> Option { + let exact = ROUTE_POLICY_RULES + .iter() + .find(|rule| rule.method == method.as_str() && rule.matched_path == matched_path) + .map(|rule| rule.policy); + if exact.is_some() || method != Method::HEAD { + return exact; + } + + // axum automatically serves HEAD through GET routes when no explicit HEAD + // handler is registered. Mirror that routing fallback so those requests + // cannot appear unclassified. The explicit protected-media HEAD rule above + // wins before this branch. + ROUTE_POLICY_RULES + .iter() + .find(|rule| rule.method == "GET" && rule.matched_path == matched_path) + .map(|rule| rule.policy) +} + +/// Whether this matched template is registered in the inventory for any +/// method. An unknown method on a known template is a 405, not a new route. +pub(super) fn is_known_matched_path(matched_path: &str) -> bool { + ROUTE_POLICY_RULES + .iter() + .any(|rule| rule.matched_path == matched_path) +} + +/// RFC 9110 `Allow` value for a known matched template. GET routes include +/// Axum's implicit HEAD support. +pub(super) fn allowed_methods(matched_path: &str) -> Option { + let mut methods = Vec::new(); + for rule in ROUTE_POLICY_RULES + .iter() + .filter(|rule| rule.matched_path == matched_path) + { + if !methods.contains(&rule.method) { + methods.push(rule.method); + } + if rule.method == "GET" && !methods.contains(&"HEAD") { + methods.push("HEAD"); + } + } + (!methods.is_empty()).then(|| methods.join(", ")) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + fn policy(method: Method, path: &str) -> CorporateIdentityRoutePolicy { + classify_matched_route(&method, path) + .unwrap_or_else(|| panic!("missing route policy for {method} {path}")) + } + + #[test] + fn every_policy_rule_has_a_unique_method_and_path() { + let mut seen = HashSet::new(); + for rule in ROUTE_POLICY_RULES { + assert!( + seen.insert((rule.method, rule.matched_path)), + "duplicate route policy for {} {}", + rule.method, + rule.matched_path + ); + } + } + + #[test] + fn every_tenant_authenticated_http_route_requires_corporate_identity() { + let routes = [ + (Method::POST, "/events"), + (Method::POST, "/query"), + (Method::POST, "/count"), + (Method::POST, "/api/invites"), + (Method::POST, "/api/invites/claim"), + (Method::GET, "/moderation/reports"), + (Method::GET, "/moderation/audit"), + (Method::GET, "/moderation/restricted"), + (Method::PUT, "/upload"), + (Method::PUT, "/media/upload"), + (Method::GET, "/git/{owner}/{repo}/info/refs"), + (Method::POST, "/git/{owner}/{repo}/git-upload-pack"), + (Method::POST, "/git/{owner}/{repo}/git-receive-pack"), + ]; + for (method, path) in routes { + assert_eq!(policy(method, path), CorporateIdentityRoutePolicy::Required); + } + } + + #[test] + fn websocket_and_media_policies_capture_deferred_and_conditional_auth() { + assert_eq!( + policy(Method::GET, "/"), + CorporateIdentityRoutePolicy::RequiredAtSessionAuth + ); + assert_eq!( + policy(Method::GET, "/huddle/{channel_id}/audio"), + CorporateIdentityRoutePolicy::RequiredAtSessionAuth + ); + for method in [Method::GET, Method::HEAD] { + assert_eq!( + policy(method, "/media/{sha256_ext}"), + CorporateIdentityRoutePolicy::RequiredWhenMediaReadsProtected + ); + } + } + + #[test] + fn privileged_non_tenant_surfaces_have_narrow_named_exemptions() { + let routes = [ + ( + Method::POST, + "/operator/communities/archive", + CorporateIdentityExemption::OperatorAuth, + ), + ( + Method::GET, + "/api/admin/v1/reports", + CorporateIdentityExemption::AdminAuth, + ), + ( + Method::POST, + "/hooks/{id}", + CorporateIdentityExemption::WebhookSecret, + ), + ( + Method::POST, + "/internal/git/policy", + CorporateIdentityExemption::LocalHookCallback, + ), + ]; + for (method, path, exemption) in routes { + assert_eq!( + policy(method, path), + CorporateIdentityRoutePolicy::Exempt(exemption) + ); + } + } + + #[test] + fn public_routes_are_explicit_and_unknown_routes_are_unclassified() { + assert_eq!( + policy(Method::GET, "/.well-known/nostr.json"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata) + ); + assert_eq!( + policy(Method::GET, "/_readiness"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::HealthProbe) + ); + assert_eq!( + policy(Method::GET, "/api/join-policy"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::JoinBootstrap) + ); + assert_eq!( + policy(Method::POST, "/_mesh/demo/echo"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::TestbedOnly) + ); + assert_eq!( + policy(Method::HEAD, "/info"), + CorporateIdentityRoutePolicy::Exempt(CorporateIdentityExemption::PublicMetadata), + "axum's automatic GET-to-HEAD fallback inherits the GET policy" + ); + assert_eq!(classify_matched_route(&Method::GET, "/events"), None); + assert_eq!(classify_matched_route(&Method::GET, "/unknown"), None); + assert_eq!( + classify_matched_route(&Method::GET, "/media/literal-sha"), + None, + "the classifier accepts trusted matched templates, not literal paths" + ); + } + + #[test] + fn known_path_detection_distinguishes_method_fallbacks_from_new_routes() { + assert!(is_known_matched_path("/events")); + assert!(is_known_matched_path("/health")); + assert!(!is_known_matched_path("/new-unclassified-route")); + assert_eq!(allowed_methods("/events").as_deref(), Some("POST")); + assert_eq!(allowed_methods("/info").as_deref(), Some("GET, HEAD")); + assert_eq!(allowed_methods("/new-unclassified-route"), None); + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 58a869a995..6271d6cdec 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -32,6 +32,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; use crate::connection::ConnectionSubscriptions; +use crate::corporate_identity::CorporateIdentityService; use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); @@ -498,6 +499,8 @@ pub struct AppState { pub pubsub: Arc, /// Authentication service. pub auth: Arc, + /// Optional corporate identity verifier. + pub corporate_identity: Option>, /// Full-text search service. pub search: Arc, /// Registry of active client subscriptions. @@ -649,6 +652,8 @@ impl AppState { let max_connections = config.max_connections; let max_concurrent_handlers = config.max_concurrent_handlers; let search_arc = Arc::new(search); + let corporate_identity = + crate::corporate_identity::service_from_config(&config.corporate_identity); let audit_arc = audit.into().map(Arc::new); let (audit_tx, mut audit_rx) = mpsc::channel::(1000); @@ -719,6 +724,7 @@ impl AppState { audit: audit_arc, pubsub, auth: Arc::new(auth), + corporate_identity, search: search_arc, sub_registry: Arc::new(SubscriptionRegistry::new()), conn_manager: Arc::new(ConnectionManager::new()), @@ -1357,6 +1363,7 @@ mod tests { "test.local".to_string(), ), remote_addr: "127.0.0.1:1234".parse().unwrap(), + corporate_identity_jwt: None, auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), send_tx: tx.clone(), diff --git a/crates/buzz-relay/src/testdata/rsa_private_key_1.der.b64 b/crates/buzz-relay/src/testdata/rsa_private_key_1.der.b64 new file mode 100644 index 0000000000..a7c7aba310 --- /dev/null +++ b/crates/buzz-relay/src/testdata/rsa_private_key_1.der.b64 @@ -0,0 +1 @@ +MIIEpAIBAAKCAQEAyRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5/CYYi/cvI+SXVT9kPWSKXxJXBXd/4LkvcPuUakBoAkfh+eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG/AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi+yUod+j8MtvIj812dkS4QMiRVN/by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQIDAQABAoIBAHREk0I0O9DvECKdWUpAmF3mY7oY9PNQiu44Yaf+AoSuyRpRUGTMIgc3u3eivOE8ALX0BmYUO5JtuRNZDpvt4SAwqCnVUinIf6C+eH/wSurCpapSM0BAHp4aOA7igptyOMgMPYBHNA1e9A7jE0dCxKWMl3DSWNyjQTk4zeRGEAEfbNjHrq6YCtjHSZSLmWiG80hnfnYos9hOr5JnLnyS7ZmFE/5P3XVrxLc/tQ5zum0R4cbrgzHiQP5RgfxGJaEi7XcgherCCOgurJSSbYH29Gz8u5fFbS+Yg8s+OiCss3cs1rSgJ9/eHZuzGEdUZVARH6hVMjSuwvqVTFaE8AgtleECgYEA+uLMn4kNqHlJS2A5uAnCkj90ZxEtNm3E8hAxUrhssktY5XSOAPBlxyf5RuRGIImGtUVIr4HuJSa5TX48n3Vdt9MYCprO/iYl6moNRSPt5qowIIOJmIjY2mqPDfDt/zw+fcDD3lmCJrFlzcnh0uea1CohxEbQnL3cypeLt+WbU6kCgYEAzSp19m1ajieFkqgoB0YTpt/OroDx38vvI5unInJlEeOjQ+oIAQdN2wpxBvTrRorMU6P07mFUbt1j+Co6CbNiw+X8HcCaqYLR5clbJOOWNR36PuzOpQLkfK8woupBxzW9B8gZmY8rB1mbJ+/WTPrEJy6YGmIEBkWylQ2VpW8O4O0CgYEApdbvvfFBlwD9YxbrcGz7MeNCFbMz+MucqQntIKoKJ91ImPxvtc0y6e/Rhnv0oyNlaUOwJVu0yNgNG117w0g4t/+Q38mvVC5xV7/cn7x9UMFk6MkqVir3dYGEqIl/OP1grY2Tq9HtB5iyG9L8NIamQOLMyUqqMUILxdthHyFmiGkCgYEAn9+PjpjGMPHxL0gj8Q8VbzsFtou6b1deIRRA2CHmSltltR1gYVTMwXxQeUhPMmgkMqUXzs4/WijgpthY44hK1TaZEKIuoxrS70nJ4WQLf5a9k1065fDsFZD6yGjdGxvwEmlGMZgTwqV7t1I4X0Ilqhav5hcs5apYL7gnPYPeRz0CgYALHCj/Ji8XSsDoF/MhVhnGdIs2P99NNdmo3R2Pv0CuZbDKMU559LJHUvrKS8WkuWRDuKrz1W/EQKApFjDGpdqToZqriUFQzwy7mR3ayIiogzNtHcvbDHx8oFnGY0OFksX/ye0/XGpy2SFxYRwGU98HPYeBvAQQrVjdkzfy7BmXQQ== diff --git a/crates/buzz-relay/src/testdata/rsa_private_key_2.der.b64 b/crates/buzz-relay/src/testdata/rsa_private_key_2.der.b64 new file mode 100644 index 0000000000..90ada9a41f --- /dev/null +++ b/crates/buzz-relay/src/testdata/rsa_private_key_2.der.b64 @@ -0,0 +1 @@ +MIIEpAIBAAKCAQEAuJHF+YJVeiqBYJOXwH2dVRc6KX1gnugROTfrMyIrZJqocAVn2NZE88/yMc2hyh1wKYMQeQlduezrXlTGDLw/d6Ujut9L3ORz8FaaCreIKnwzKCyMjLYW4hjkUwBA0kaUcZPNaBJF7wdB76nI5NV4pXCKaJWyYdVVsj0ygDJGMzwBiYmptBw+HFrDEuR6v30ffU99ACYtAbrcmC+VFApFnEcZmITDXBc+zfYGI6v1k3YIvTDXuIc9/817hfmgHBvJ7bxDWu0aJOi7giP0oyXUtXO/vmgSuyPq4kNJJzTrMeW3lCx++Xda6TGwoLOIUoWS8VfdJh9pCDemoXshUhh4WQIDAQABAoIBABzCHfJUJARuhg0pwh3slKyy+02GqxzndPOQ6nVjsBYzYOZfeUBYlpLUxlyLOVfYQWc+dD0fv/pd14ixtdA7LrpyQUB3VYc8E3KR09uyoCVah9ANLPMp1iPxk/X41qDM/Yk66ej62+m0HEp/Dn3VY0CH6hEErjA/QOSOU4WVD8ogniilnE6//a3dAvRSpd7bJawvzb/zR0G9Eg6LQa2IDZ4gTvBA7dKpY06FPJrnCcxlgkZzLKQY6kCcj/Ln+R2KyQyYwTZCELXlbKmRTaZFxiugMEskB4ffjDJTVO1D62twltX4TsRpfBnS+6sw9n5arKo7EGMLa5wNmCQS1WlBdJ8CgYEA4Gxzzpa/VMaXDT7WZwqhrtQwoLXy0rddTEsw2qITwfvZKEmpYXA6w4Pqxv4JcaBYWnq4pNRr0VGD19uGPZdNJOAEzpdbdwAD5S4Wxj2oDztihARvaESVnobyYKvNDszjbdaK8A6mYMrIat6O8WqvzO5VGF9iF7v8SZR6elFatJMCgYEA0onOZUpOOOw0E9tPZDSWVaO7LBFqts9go81bYXC0I2Ea5bloJbn+ACyCud9ivtPiQtL8SgYM2aiQWNvAnJrlaxhgTktG9Ii7G2u7EoEh9GwoOWyffQbOLJ8d1a1i2tgA9wi5V/I+QX9Kj+xE9mNv9vNJTK1GAbOwkoU5rIdOfuMCgYEAi8sijAIc5nrZppeIyCC4PAXS0Jjly9oKVLbVlKq28fOl/lF8H8Tf5d/rQ88EJPJDdwDQuWPUUUuce74zrXPsytZ8SA/CGqs4we5mo0/OusY8BI4as3FdXaUjn5IEpn58AHROkWAexVYrZ16A3eKd5WJkQU1Q9gXUDiVd8Ylxnd8CgYEAwfLrLMpP1wZZTzWIJIKBPzFO2uDMks3lc+BY3yGpALKSya+MLrzxLY3Te5E68RpV5ENi4HpEWjp7hzAhduMGlyrkhRu5qMlQvIj406ob8oO0ZnoXTmD3i4mlPVO1rm6wLOJfg5IIIeQ2dvEr8mJWIYOrMbSpuiWjcsbCA5q+CAsCgYBjGyJnqilCJ547DIZrW1D5MK1pMGGqdJInmhleHEH8ES/2/O/PnCM5ilxBKjSoSFw8p1Zz0M7QCbVs/wjyCIQfsRvbw/IdyMr2VSL89iMCG/VPls/u403NDH8aeWDChcbkAbEq8BLHT0km0BM9M3HFPjMIQ1xoQvwZWE4GcRWeFQ== diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index ef67fac570..c870c8f280 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; -use buzz_core_pkg::PresenceStatus; +use buzz_core_pkg::{kind::KIND_USER_TRUSTED_ASSERTION, PresenceStatus}; use serde_json::Value; use tauri::State; use crate::{ app_state::AppState, + commands::identity_archive::fetch_relay_self, events, managed_agents::persona_events::monotonic_created_at, models::{ProfileInfo, SearchUsersResponse, UserNotesResponse, UsersBatchResponse}, @@ -16,24 +17,152 @@ use crate::{ }, }; +async fn query_profiles_with_assertions( + state: &AppState, + pubkeys: &[String], +) -> Result<(Vec, Option), String> { + if pubkeys.is_empty() { + return Ok((Vec::new(), None)); + } + + let relay_self = fetch_relay_self(state).await.unwrap_or(None); + let mut filters = vec![serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + })]; + if let Some(author) = relay_self.as_ref() { + filters.push(serde_json::json!({ + "kinds": [KIND_USER_TRUSTED_ASSERTION], + "authors": [author], + "#d": pubkeys, + })); + } + Ok((query_relay(state, &filters).await?, relay_self)) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct VerifiedIdentity { + display_name: String, + expires_at: u64, +} + +fn verified_identities( + events: &[nostr::Event], + relay_self: Option<&str>, +) -> HashMap { + let Some(relay_self) = relay_self else { + return HashMap::new(); + }; + let mut verified = HashMap::)>::new(); + let now = nostr::Timestamp::now().as_secs(); + for event in events { + if event.kind.as_u16() as u32 != KIND_USER_TRUSTED_ASSERTION + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self) + || !event.verify_id() + || !event.verify_signature() + { + continue; + } + // The coordinate is recoverable even when the newer payload is + // malformed (for example, an overlong `d` tag). That malformed head + // must suppress the prior assertion rather than being skipped. + let Some(subject) = event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + (parts.first().is_some_and(|part| part == "d")) + .then(|| parts.get(1).map(|part| part.as_str())) + .flatten() + }) else { + continue; + }; + if subject.len() != 64 || !subject.chars().all(|value| value.is_ascii_hexdigit()) { + continue; + } + let tag_value = |name: &str| { + let mut matches = event.tags.iter().filter(|tag| { + let parts = tag.as_slice(); + parts.first().is_some_and(|part| part == name) + }); + let tag = matches.next()?; + if matches.next().is_some() { + return None; + } + let parts = tag.as_slice(); + (parts.len() == 2).then(|| parts[1].as_str()) + }; + // Select the signed replaceable-event head before validating its + // payload. Otherwise a newer malformed assertion could be skipped and + // silently resurrect the older active label returned alongside it. + let identity = match (tag_value("d"), tag_value("verified"), tag_value("p")) { + (Some(assertion_d), Some("relay"), Some(asserted_subject)) + if assertion_d == subject && asserted_subject == subject => + { + match tag_value("active") { + Some("false") => None, + Some("true") => match ( + tag_value("expiration") + .and_then(|value| value.parse::().ok()) + .filter(|expiration| *expiration > now), + tag_value("display_name") + .map(str::trim) + .filter(|value| !value.is_empty()), + ) { + (Some(expires_at), Some(display_name)) => Some(VerifiedIdentity { + display_name: display_name.to_string(), + expires_at, + }), + _ => None, + }, + _ => None, + } + } + _ => None, + }; + let created_at = event.created_at.as_secs(); + let event_id = event.id.to_hex(); + // NIP-01 replaceable-event ordering: greatest timestamp wins; equal + // timestamps are resolved by the lowest event id. This stays stable + // regardless of relay response order. + match verified.entry(subject.to_ascii_lowercase()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((created_at, event_id, identity)); + } + std::collections::hash_map::Entry::Occupied(mut entry) + if created_at > entry.get().0 + || (created_at == entry.get().0 && event_id < entry.get().1) => + { + entry.insert((created_at, event_id, identity)); + } + std::collections::hash_map::Entry::Occupied(_) => {} + } + } + verified + .into_iter() + .filter_map(|(pubkey, (_, _, identity))| identity.map(|value| (pubkey, value))) + .collect() +} + +fn apply_verified_identity(profile: &mut ProfileInfo, identity: Option) { + profile.verified_name = identity + .as_ref() + .map(|value| value.display_name.to_string()); + profile.verified_name_expires_at = identity.map(|value| value.expires_at); +} + #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { let my_pubkey = current_pubkey_hex(&state)?; - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": [my_pubkey], - "limit": 1 - })], - ) - .await?; + let (events, relay_self) = + query_profiles_with_assertions(&state, std::slice::from_ref(&my_pubkey)).await?; - Ok(events - .first() + let mut profile = events + .iter() + .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == my_pubkey) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) + .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state))); + let identity = verified_identities(&events, relay_self.as_deref()).remove(&profile.pubkey); + apply_verified_identity(&mut profile, identity); + Ok(profile) } #[tauri::command] @@ -187,21 +316,18 @@ pub async fn get_user_profile( None => current_pubkey_hex(&state)?, }; - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": [target.clone()], - "limit": 1 - })], - ) - .await?; + let (events, relay_self) = + query_profiles_with_assertions(&state, std::slice::from_ref(&target)).await?; - Ok(events - .first() + let mut profile = events + .iter() + .find(|event| event.kind.as_u16() == 0 && event.pubkey.to_hex() == target) .map(nostr_convert::profile_info_from_event) .transpose()? - .unwrap_or_else(|| empty_profile_info(&target))) + .unwrap_or_else(|| empty_profile_info(&target)); + let identity = verified_identities(&events, relay_self.as_deref()).remove(&profile.pubkey); + apply_verified_identity(&mut profile, identity); + Ok(profile) } #[tauri::command] @@ -215,16 +341,17 @@ pub async fn get_users_batch( missing: Vec::new(), }); } - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [0], - "authors": pubkeys, - })], - ) - .await?; - - Ok(nostr_convert::users_batch_from_events(&events, &pubkeys)) + let (events, relay_self) = query_profiles_with_assertions(&state, &pubkeys).await?; + + let mut response = nostr_convert::users_batch_from_events(&events, &pubkeys); + let verified = verified_identities(&events, relay_self.as_deref()); + for (pubkey, profile) in &mut response.profiles { + if let Some(identity) = verified.get(pubkey) { + profile.verified_name = Some(identity.display_name.to_string()); + profile.verified_name_expires_at = Some(identity.expires_at); + } + } + Ok(response) } #[tauri::command] @@ -406,6 +533,8 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { ProfileInfo { pubkey: pubkey.to_string(), display_name: None, + verified_name: None, + verified_name_expires_at: None, avatar_url: None, about: None, nip05_handle: None, @@ -418,6 +547,210 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { mod tests { use super::*; + #[test] + fn verified_identity_requires_relay_signed_nip85_assertion() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let expires_at = nostr::Timestamp::now().as_secs() + 60; + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .sign_with_keys(&relay) + .unwrap(); + + let verified = verified_identities(&[event], Some(&relay.public_key().to_hex())); + assert_eq!( + verified.get(&subject), + Some(&VerifiedIdentity { + display_name: "Example User".to_string(), + expires_at, + }) + ); + } + + #[test] + fn expired_verified_identity_is_rejected() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = nostr::Timestamp::now().as_secs().saturating_sub(1); + let prior_expiration = created_at + 120; + let prior = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &prior_expiration.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Prior User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let expired = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Expired User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + + assert!( + verified_identities(&[prior, expired], Some(&relay.public_key().to_hex())).is_empty() + ); + } + + #[test] + fn newer_inactive_assertion_removes_verified_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let active = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let inactive = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "false"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + + assert!( + verified_identities(&[active, inactive], Some(&relay.public_key().to_hex())).is_empty() + ); + } + + #[test] + fn newer_malformed_assertion_does_not_resurrect_older_identity() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let wrong_subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let active = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let malformed = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", wrong_subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Malformed User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + + assert!(verified_identities( + &[active.clone(), malformed], + Some(&relay.public_key().to_hex()) + ) + .is_empty()); + + let overlong_d = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str(), "unexpected"]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Malformed User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at + 1)) + .sign_with_keys(&relay) + .unwrap(); + assert!( + verified_identities(&[active, overlong_d], Some(&relay.public_key().to_hex())) + .is_empty() + ); + } + + #[test] + fn equal_timestamp_assertions_use_lowest_event_id_independent_of_response_order() { + let relay = nostr::Keys::generate(); + let subject = nostr::Keys::generate().public_key().to_hex(); + let created_at = nostr::Timestamp::now().as_secs(); + let expires_at = created_at + 60; + let active = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "true"]).unwrap(), + nostr::Tag::parse(["expiration", &expires_at.to_string()]).unwrap(), + nostr::Tag::parse(["display_name", "Example User"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let inactive = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_USER_TRUSTED_ASSERTION as u16), "") + .tags([ + nostr::Tag::parse(["d", subject.as_str()]).unwrap(), + nostr::Tag::parse(["p", subject.as_str()]).unwrap(), + nostr::Tag::parse(["verified", "relay"]).unwrap(), + nostr::Tag::parse(["active", "false"]).unwrap(), + ]) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&relay) + .unwrap(); + let relay_pubkey = relay.public_key().to_hex(); + let expected_active = active.id.to_hex() < inactive.id.to_hex(); + + for events in [ + vec![active.clone(), inactive.clone()], + vec![inactive.clone(), active.clone()], + ] { + let actual = verified_identities(&events, Some(&relay_pubkey)); + assert_eq!(actual.contains_key(&subject), expected_active); + } + } + #[test] fn deferred_profile_signer_is_captured_and_rejects_wrong_identity() { let state = crate::app_state::build_app_state(); diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 3f04d3d7a1..c284c0da22 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -30,6 +30,11 @@ pub struct IdentityInfo { pub struct ProfileInfo { pub pubkey: String, pub display_name: Option, + #[serde(default)] + pub verified_name: Option, + /// Unix timestamp (seconds) after which `verified_name` must not be shown. + #[serde(default)] + pub verified_name_expires_at: Option, pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, @@ -44,6 +49,11 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + #[serde(default)] + pub verified_name: Option, + /// Unix timestamp (seconds) after which `verified_name` must not be shown. + #[serde(default)] + pub verified_name_expires_at: Option, /// Kind-0 `name` field, carried separately from `display_name` so clients /// can match @mention text against either alias (agents and the CLI /// resolve mentions server-side against `display_name` *or* `name`). @@ -66,6 +76,11 @@ pub struct UsersBatchResponse { pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, + #[serde(default)] + pub verified_name: Option, + /// Unix timestamp (seconds) after which `verified_name` must not be shown. + #[serde(default)] + pub verified_name_expires_at: Option, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..6e6d2e4d9e 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -280,10 +280,7 @@ pub fn channel_members_from_event(event: &Event) -> Result Result { let v: Value = serde_json::from_str(&event.content) .map_err(|e| format!("kind:0 content is not valid JSON: {e}"))?; @@ -300,6 +297,8 @@ pub fn profile_info_from_event(event: &Event) -> Result { Ok(ProfileInfo { pubkey: event.pubkey.to_hex(), display_name, + verified_name: None, + verified_name_expires_at: None, avatar_url, about, nip05_handle, @@ -308,10 +307,8 @@ pub fn profile_info_from_event(event: &Event) -> Result { }) } -/// Convert multiple kind:0 events to [`UsersBatchResponse`]. -/// -/// `requested_pubkeys` lets us populate `missing` for any pubkey that had -/// no metadata event in the input set. +/// Convert the most recent kind:0 event per pubkey to [`UsersBatchResponse`]. +/// Requested pubkeys without metadata are returned separately. pub fn users_batch_from_events( events: &[Event], requested_pubkeys: &[String], @@ -319,6 +316,9 @@ pub fn users_batch_from_events( // Keep only the most recent kind:0 per pubkey. let mut latest: HashMap = HashMap::new(); for ev in events { + if ev.kind.as_u16() != 0 { + continue; + } let pk = ev.pubkey.to_hex(); let take = match latest.get(&pk) { None => true, @@ -339,6 +339,8 @@ pub fn users_batch_from_events( .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + verified_name: None, + verified_name_expires_at: None, name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), @@ -546,8 +548,7 @@ pub fn relay_members_from_event(event: &Event) -> Value { pub(crate) fn timestamp_to_iso(secs: u64) -> String { use std::time::{Duration, SystemTime, UNIX_EPOCH}; let dt = UNIX_EPOCH + Duration::from_secs(secs); - // Format manually as RFC-3339 — the `time` crate is already a transitive - // dep, but using SystemTime keeps this self-contained. + // Format manually as RFC-3339; SystemTime keeps this self-contained. let dur = dt .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default(); @@ -561,8 +562,7 @@ pub(crate) fn timestamp_to_iso(secs: u64) -> String { format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") } -/// Convert days-since-1970-01-01 to (year, month, day) using the civil-from-days -/// algorithm by Howard Hinnant (public domain). +/// Convert epoch days to a date using Howard Hinnant's public-domain algorithm. fn days_to_ymd(days: i64) -> (i64, u32, u32) { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288abb..fef06e66c8 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -18,6 +18,8 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .and_then(Value::as_str) .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), + verified_name: None, + verified_name_expires_at: None, avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 9f55e712f1..4a7bab5441 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -12,7 +12,10 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + resolveUserVerification, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { @@ -32,6 +35,7 @@ import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAu import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; @@ -477,6 +481,9 @@ export const MessageRow = React.memo( ) : ( {message.author} ); + const verifiedName = message.pubkey + ? resolveUserVerification({ pubkey: message.pubkey, profiles }) + : null; const agentOwnerNode = message.isAgent ? ( + ) : null} {agentOwnerNode} {inlineMetadataNode} {message.personaDisplayName && diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 7a456fb259..2acb1ec3f4 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -46,12 +46,77 @@ import { } from "@/features/profile/lib/userLabelStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import { updateCachedChannelMemberDisplayName } from "@/features/channels/channelMemberProfileCache"; +import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; +import { + type VerifiedIdentityFields, + withCurrentVerifiedIdentity, +} from "@/shared/lib/verifiedIdentity"; export const profileQueryKey = ["profile"] as const; export const contactListQueryKey = (pubkey: string) => ["contact-list", pubkey] as const; export const allPulseTimelinesQueryKey = ["pulse-timeline"] as const; +function useCurrentVerifiedIdentity( + identity: T | undefined, +): T | undefined { + const revision = useVerifiedIdentityExpiryRevision([ + identity?.verifiedNameExpiresAt, + ]); + return React.useMemo(() => { + // `revision` is the timer-driven cache key for an otherwise unchanged + // React Query value. + void revision; + return identity ? withCurrentVerifiedIdentity(identity) : undefined; + }, [identity, revision]); +} + +function useCurrentVerifiedIdentityRecord( + identities: Record | undefined, +): Record | undefined { + const revision = useVerifiedIdentityExpiryRevision( + identities + ? Object.values(identities).map( + (identity) => identity.verifiedNameExpiresAt, + ) + : [], + ); + return React.useMemo(() => { + void revision; + if (!identities) return undefined; + + let changed = false; + const current = Object.fromEntries( + Object.entries(identities).map(([pubkey, identity]) => { + const next = withCurrentVerifiedIdentity(identity); + changed ||= next !== identity; + return [pubkey, next]; + }), + ); + return changed ? current : identities; + }, [identities, revision]); +} + +function useCurrentVerifiedIdentityList( + identities: T[] | undefined, +): T[] | undefined { + const revision = useVerifiedIdentityExpiryRevision( + identities?.map((identity) => identity.verifiedNameExpiresAt) ?? [], + ); + return React.useMemo(() => { + void revision; + if (!identities) return undefined; + + let changed = false; + const current = identities.map((identity) => { + const next = withCurrentVerifiedIdentity(identity); + changed ||= next !== identity; + return next; + }); + return changed ? current : identities; + }, [identities, revision]); +} + /** * Persists a freshly-fetched profile to localStorage as the offline fallback. * Reuses an existing avatar data URL when the avatar URL is unchanged to avoid @@ -140,7 +205,7 @@ export function useProfileQuery(enabled = true) { ? { initialData, initialDataUpdatedAt: cached?.updatedAt } : {}; - return useQuery({ + const query = useQuery({ enabled, queryKey: profileQueryKey, queryFn: async () => { @@ -153,6 +218,8 @@ export function useProfileQuery(enabled = true) { staleTime: 30_000, ...seedOptions, }); + const profile = useCurrentVerifiedIdentity(query.data); + return profile === query.data ? query : { ...query, data: profile }; } /** @@ -274,12 +341,14 @@ export function useUnfollowMutation(currentPubkey?: string) { } export function useUserProfileQuery(pubkey?: string) { - return useQuery({ + const query = useQuery({ enabled: typeof pubkey === "string" && pubkey.length > 0, queryKey: ["user-profile", pubkey?.toLowerCase() ?? ""], queryFn: () => getUserProfile(pubkey), staleTime: 60_000, }); + const profile = useCurrentVerifiedIdentity(query.data); + return profile === query.data ? query : { ...query, data: profile }; } // Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch. @@ -409,7 +478,15 @@ export function useUsersBatchQuery( } }, [query.data, query.dataUpdatedAt, queryClient]); - return query; + const profiles = useCurrentVerifiedIdentityRecord(query.data?.profiles); + return profiles === query.data?.profiles + ? query + : { + ...query, + data: query.data + ? { ...query.data, profiles: profiles ?? {} } + : query.data, + }; } export function useUserSearchQuery( @@ -425,7 +502,7 @@ export function useUserSearchQuery( (options?.enabled ?? true) && (options?.allowEmpty === true || normalizedQuery.length > 0); - return useQuery({ + const searchQuery = useQuery({ enabled, queryKey: ["user-search", normalizedQuery, options?.limit ?? 8], queryFn: async () => @@ -433,6 +510,10 @@ export function useUserSearchQuery( staleTime: 30_000, gcTime: 5 * 60 * 1_000, }); + const users = useCurrentVerifiedIdentityList(searchQuery.data); + return users === searchQuery.data + ? searchQuery + : { ...searchQuery, data: users }; } export function useInfiniteUserSearchQuery( diff --git a/desktop/src/features/profile/lib/identity.test.mjs b/desktop/src/features/profile/lib/identity.test.mjs index da0259a66f..126eb195d2 100644 --- a/desktop/src/features/profile/lib/identity.test.mjs +++ b/desktop/src/features/profile/lib/identity.test.mjs @@ -1,10 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts"; +import { + formatOwnerLabel, + formatVerifiedUserLabel, + profileLookupsEqual, + resolveUserLabel, +} from "./identity.ts"; const OWNER_PUBKEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const USER_PUBKEY = "11".repeat(32); +const NOW_MS = 1_800_000_000_000; +const FUTURE_EXPIRATION = NOW_MS / 1_000 + 60; const summary = (over = {}) => ({ displayName: "Ada", @@ -58,6 +66,8 @@ test("profileLookupsEqual: same count, different keys is not equal", () => { test("profileLookupsEqual: a changed field is not equal", () => { for (const field of [ "displayName", + "verifiedName", + "verifiedNameExpiresAt", "avatarUrl", "nip05Handle", "ownerPubkey", @@ -120,3 +130,40 @@ test("stabiliser: a real profile change swaps the reference (re-render fires)", const held = stabilise({ p1: summary({ displayName: "Grace" }) }); assert.equal(held, changed, "must re-stabilise around the new value"); }); + +test("formats a chosen name followed by the authoritative display name", () => { + assert.equal( + formatVerifiedUserLabel("Example", "example", FUTURE_EXPIRATION, NOW_MS), + "Example (example)", + ); +}); + +test("does not duplicate equal chosen and authoritative names", () => { + assert.equal( + formatVerifiedUserLabel("example", "example", FUTURE_EXPIRATION, NOW_MS), + "example", + ); +}); + +test("expired authoritative names fail closed", () => { + assert.equal( + formatVerifiedUserLabel("Example", "example", NOW_MS / 1_000, NOW_MS), + "Example", + ); +}); + +test("resolved user labels keep the chosen name first", () => { + assert.equal( + resolveUserLabel({ + pubkey: USER_PUBKEY, + profiles: { + [USER_PUBKEY]: summary({ + displayName: "Example", + verifiedName: "example", + verifiedNameExpiresAt: Math.floor(Date.now() / 1_000) + 60, + }), + }, + }), + "Example (example)", + ); +}); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index d2e0a4fdd3..95d9b0aeb6 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,10 +1,44 @@ import type { Profile, UserProfileSummary } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export type UserProfileLookup = Record; export { truncatePubkey }; +export function formatVerifiedUserLabel( + chosenName: string | null | undefined, + verifiedName: string | null | undefined, + verifiedNameExpiresAt: number | null | undefined, + nowMs = Date.now(), +): string | null { + const chosen = chosenName?.trim(); + const verified = getCurrentVerifiedName( + verifiedName, + verifiedNameExpiresAt, + nowMs, + ); + + if (chosen && verified && chosen !== verified) { + return `${chosen} (${verified})`; + } + + return chosen || verified || null; +} + +export function formatVerifiedProfileLabel( + profile: + | Pick + | null + | undefined, +): string | null { + return formatVerifiedUserLabel( + profile?.displayName, + profile?.verifiedName, + profile?.verifiedNameExpiresAt, + ); +} + /** * Deep-equal two profile lookups by value. Used to stabilise the merged * `messageProfiles` reference at the ChannelScreen boundary: the underlying @@ -37,6 +71,8 @@ export function profileLookupsEqual( if ( next === undefined || prev.displayName !== next.displayName || + prev.verifiedName !== next.verifiedName || + prev.verifiedNameExpiresAt !== next.verifiedNameExpiresAt || prev.name !== next.name || prev.avatarUrl !== next.avatarUrl || prev.nip05Handle !== next.nip05Handle || @@ -64,7 +100,15 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick + | Pick< + Profile, + | "pubkey" + | "displayName" + | "verifiedName" + | "verifiedNameExpiresAt" + | "avatarUrl" + | "nip05Handle" + > | null | undefined, ) { @@ -76,6 +120,8 @@ export function mergeCurrentProfileIntoLookup( ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { displayName: currentProfile.displayName, + verifiedName: currentProfile.verifiedName ?? null, + verifiedNameExpiresAt: currentProfile.verifiedNameExpiresAt ?? null, // `Profile` does not carry the kind-0 `name`; keep whatever the batch // lookup already resolved so mention aliases survive the merge. name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null, @@ -114,23 +160,31 @@ export function resolveUserLabel(input: { const profile = getResolvedProfile(pubkey, profiles); const displayName = profile?.displayName?.trim(); - if (displayName) { - return displayName; - } - const nip05Handle = profile?.nip05Handle?.trim(); - if (nip05Handle) { - return nip05Handle; - } - const safeFallback = fallbackName?.trim(); - if (safeFallback) { - return safeFallback; + const label = formatVerifiedUserLabel( + displayName || nip05Handle || safeFallback, + profile?.verifiedName, + profile?.verifiedNameExpiresAt, + ); + if (label) { + return label; } return truncatePubkey(pubkey); } +export function resolveUserVerification(input: { + pubkey: string; + profiles?: UserProfileLookup; +}): string | null { + const profile = getResolvedProfile(input.pubkey, input.profiles); + return getCurrentVerifiedName( + profile?.verifiedName, + profile?.verifiedNameExpiresAt, + ); +} + /** * Returns true when the current user owns the agent that authored a message. * Mirrors the relay's `is_agent_owner` gate: ownership is determined by the diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index d70b05fc98..456ea42a31 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -17,11 +17,14 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { isMacPlatform } from "@/shared/lib/platform"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; + verifiedName?: string | null; + verifiedNameExpiresAt?: number | null; avatarUrl: string | null; avatarDataUrl?: string | null; currentStatus: PresenceStatus; @@ -52,6 +55,8 @@ export function ProfilePopover({ open, onOpenChange, displayName, + verifiedName, + verifiedNameExpiresAt, avatarUrl, avatarDataUrl, currentStatus, @@ -139,9 +144,17 @@ export function ProfilePopover({ />
-

- {displayName} -

+
+

+ {displayName} +

+ {verifiedName ? ( + + ) : null} +
{/* ── Presence chip (opens status chooser) ─────────── */} = { goose: "Goose", @@ -47,6 +50,7 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", + "Relay-verified identity", "Managed by", "NIP-05", "Agent type", @@ -174,6 +178,28 @@ export function buildPublicFields({ }); } + const verifiedName = getCurrentVerifiedName( + profile?.verifiedName, + profile?.verifiedNameExpiresAt, + ); + if (verifiedName) { + fields.push({ + displayValue: verifiedName, + icon: BadgeCheck, + label: "Relay-verified identity", + testId: "user-profile-relay-verified-identity", + trailingNode: ( + + + Binding active + + ), + }); + } + if (profile?.nip05Handle) { fields.push({ copyValue: profile.nip05Handle, @@ -411,16 +437,19 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const visibilityLabel = "Visibility"; const publicKeyLabel = "Public key"; + const relayVerifiedIdentityLabel = "Relay-verified identity"; const managedByLabel = "Managed by"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === visibilityLabel), ...fields.filter((field) => field.label === publicKeyLabel), + ...fields.filter((field) => field.label === relayVerifiedIdentityLabel), ...fields.filter((field) => field.label === managedByLabel), ...fields.filter( (field) => field.label !== visibilityLabel && field.label !== publicKeyLabel && + field.label !== relayVerifiedIdentityLabel && field.label !== managedByLabel && field.copyValue, ), @@ -429,6 +458,7 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === visibilityLabel || field.label === publicKeyLabel || + field.label === relayVerifiedIdentityLabel || field.label === managedByLabel || field.label === statusLabel ) { diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 22647eb286..a2a3fcac8b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -51,11 +51,11 @@ import type { import { cn } from "@/shared/lib/cn"; import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; +import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; -// ── Summary view ───────────────────────────────────────────────────────────── - export type ProfileSummaryViewProps = { activityAgent: ProfileActivityAgent | null; callerChannelId: string | null; @@ -475,8 +475,6 @@ export function ProfileSummaryView({ ); } -// ── Hero & metadata ────────────────────────────────────────────────────────── - function ProfileHero({ displayName, isBot, @@ -491,6 +489,10 @@ function ProfileHero({ userStatus: ProfileSummaryViewProps["userStatus"]; }) { const presenceDotClassName = isBot ? "h-4.5 w-4.5" : "h-3.5 w-3.5"; + const verifiedName = getCurrentVerifiedName( + profile?.verifiedName, + profile?.verifiedNameExpiresAt, + ); return (
@@ -541,6 +543,19 @@ function ProfileHero({ ) : null}
+ {verifiedName ? ( +
+ {verifiedName} + +
+ ) : null} + {profile?.about?.trim() ? (
+ {profile?.verifiedName ? ( + + ) : null} {isBotProfile && botIdenticonValue ? ( @@ -504,7 +505,7 @@ export function AppSidebar({ streamChannels, }); const resolvedDisplayName = - profile?.displayName?.trim() || + formatVerifiedProfileLabel(profile) || fallbackDisplayName?.trim() || "Current identity"; const isCreatingAny = diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 9c6ba0f9b6..46ba9608e6 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -16,6 +16,7 @@ import { useMyRelayMembershipLookupQuery } from "@/features/community-members/ho import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type SidebarProfileCardProps = { activeCommunity: Community | null; @@ -152,6 +153,8 @@ export function SidebarProfileCard({ avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} displayName={resolvedDisplayName} + verifiedName={profile?.verifiedName} + verifiedNameExpiresAt={profile?.verifiedNameExpiresAt} isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} @@ -190,12 +193,20 @@ export function SidebarProfileCard({ data-testid="open-settings" type="button" > -

- {resolvedDisplayName} -

+ + + {resolvedDisplayName} + + {profile?.verifiedName ? ( + + ) : null} + diff --git a/desktop/src/shared/api/tauriProfiles.ts b/desktop/src/shared/api/tauriProfiles.ts index c8e52f5169..abc6cbd8d7 100644 --- a/desktop/src/shared/api/tauriProfiles.ts +++ b/desktop/src/shared/api/tauriProfiles.ts @@ -11,6 +11,8 @@ import type { type RawProfile = { pubkey: string; display_name: string | null; + verified_name?: string | null; + verified_name_expires_at?: number | null; avatar_url: string | null; about: string | null; nip05_handle: string | null; @@ -39,6 +41,8 @@ function fromRawProfile(profile: RawProfile): Profile { return { pubkey: profile.pubkey, displayName: profile.display_name, + verifiedName: profile.verified_name ?? null, + verifiedNameExpiresAt: profile.verified_name_expires_at ?? null, avatarUrl: profile.avatar_url, about: profile.about, nip05Handle: profile.nip05_handle, @@ -52,6 +56,8 @@ function fromRawUserProfileSummary( ): UserProfileSummary { return { displayName: profile.display_name, + verifiedName: profile.verified_name ?? null, + verifiedNameExpiresAt: profile.verified_name_expires_at ?? null, name: profile.name ?? null, avatarUrl: profile.avatar_url, nip05Handle: profile.nip05_handle, @@ -64,6 +70,8 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { return { pubkey: user.pubkey, displayName: user.display_name, + verifiedName: user.verified_name ?? null, + verifiedNameExpiresAt: user.verified_name_expires_at ?? null, avatarUrl: user.avatar_url, nip05Handle: user.nip05_handle, ownerPubkey: user.owner_pubkey, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 78f5d1aa3f..904bc8952a 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -108,6 +108,8 @@ export type { Identity, IdentityStorage } from "./identityTypes"; export type Profile = { pubkey: string; displayName: string | null; + verifiedName?: string | null; + verifiedNameExpiresAt?: number | null; avatarUrl: string | null; about: string | null; nip05Handle: string | null; @@ -121,6 +123,8 @@ export type Profile = { export type UserProfileSummary = { displayName: string | null; + verifiedName?: string | null; + verifiedNameExpiresAt?: number | null; /** Kind-0 `name` field, kept separate from `displayName` so @mention text * can be matched against either alias (agents/CLI resolve mentions against * `display_name` *or* `name` at send time). */ @@ -139,6 +143,8 @@ export type UsersBatchResponse = { export type UserSearchResult = { pubkey: string; displayName: string | null; + verifiedName?: string | null; + verifiedNameExpiresAt?: number | null; avatarUrl: string | null; nip05Handle: string | null; ownerPubkey: string | null; @@ -972,14 +978,10 @@ export type ThreadRepliesResponse = { }; /** - * Composite backward keyset cursor for channel-timeline paging via the bridge - * (`getChannelMessagesBefore`). - * - * The event-id tiebreak is load-bearing for the dense-second case: the relay - * orders `created_at DESC, id ASC` and advances past a second denser than one - * page with `id > eventId`. A bare `createdAt` (`until`) cursor cannot escape - * such a second — it re-returns the same slice forever, leaving older history - * unreachable. `(createdAt, eventId)` moves strictly older every page. + * Composite backward keyset cursor for channel-timeline paging via + * `getChannelMessagesBefore`. The relay orders `created_at DESC, id ASC`; the + * event-id tiebreak advances through a second denser than one page. A timestamp- + * only cursor would re-return the same slice forever. */ export type ChannelPageCursor = { createdAt: number; @@ -996,12 +998,9 @@ export type ChannelMessagesPageResponse = { // ── Global agent configuration ──────────────────────────────────────────────── /** - * Global agent configuration defaults applied to ALL agents. - * - * Lowest user-settable layer — per-agent and persona values win on any key - * collision. Mirrors the Rust `GlobalAgentConfig` struct. - * - * Precedence: baked floor < global < persona < per-agent. + * Global defaults applied to all agents. Persona and per-agent values win on + * collisions. Precedence: baked floor < global < persona < per-agent. + * Mirrors the Rust `GlobalAgentConfig` struct. */ export type GlobalAgentConfig = { /** Global env vars injected into all agents unconditionally. */ diff --git a/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts b/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts new file mode 100644 index 0000000000..4012f6fc5d --- /dev/null +++ b/desktop/src/shared/hooks/useVerifiedIdentityExpiry.ts @@ -0,0 +1,44 @@ +import * as React from "react"; + +import { millisecondsUntilVerifiedIdentityExpiry } from "@/shared/lib/verifiedIdentity"; + +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Force a render at the earliest assertion cutoff. Callers then re-run the + * local-clock sanitizer, even when React Query is serving an offline cache. + */ +export function useVerifiedIdentityExpiryRevision( + expirations: ReadonlyArray, +): number { + const nowMs = Date.now(); + let nextDelayMs: number | null = null; + for (const expiresAt of expirations) { + const delayMs = millisecondsUntilVerifiedIdentityExpiry(expiresAt, nowMs); + if ( + delayMs !== null && + delayMs > 0 && + (nextDelayMs === null || delayMs < nextDelayMs) + ) { + nextDelayMs = delayMs; + } + } + + const [revision, setRevision] = React.useState(0); + React.useEffect(() => { + // Re-arm after a timer tick even if a wall-clock adjustment happens to + // produce the same remaining delay as the previous render. + void revision; + if (nextDelayMs === null) { + return; + } + + const timeout = setTimeout( + () => setRevision((current) => current + 1), + Math.min(nextDelayMs + 1, MAX_TIMEOUT_MS), + ); + return () => clearTimeout(timeout); + }, [nextDelayMs, revision]); + + return revision; +} diff --git a/desktop/src/shared/lib/verifiedIdentity.test.mjs b/desktop/src/shared/lib/verifiedIdentity.test.mjs new file mode 100644 index 0000000000..db640aa8d9 --- /dev/null +++ b/desktop/src/shared/lib/verifiedIdentity.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getCurrentVerifiedName, + millisecondsUntilVerifiedIdentityExpiry, + withCurrentVerifiedIdentity, +} from "./verifiedIdentity.ts"; + +const NOW_MS = 1_800_000_000_000; +const NOW_SECONDS = NOW_MS / 1_000; + +test("returns a verified name only before its local expiration", () => { + assert.equal( + getCurrentVerifiedName(" Example ", NOW_SECONDS + 60, NOW_MS), + "Example", + ); + assert.equal(getCurrentVerifiedName("Example", NOW_SECONDS, NOW_MS), null); +}); + +test("missing and malformed expirations fail closed", () => { + assert.equal(getCurrentVerifiedName("Example", null, NOW_MS), null); + assert.equal( + getCurrentVerifiedName("Example", NOW_SECONDS + 0.5, NOW_MS), + null, + ); + assert.equal(getCurrentVerifiedName("Example", Number.NaN, NOW_MS), null); +}); + +test("computes the exact cutoff delay used by expiry render timers", () => { + assert.equal( + millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS + 60, NOW_MS), + 60_000, + ); + assert.equal( + millisecondsUntilVerifiedIdentityExpiry(NOW_SECONDS - 1, NOW_MS), + 0, + ); +}); + +test("sanitizes cached identity objects without churning valid values", () => { + const valid = { + verifiedName: "Example", + verifiedNameExpiresAt: NOW_SECONDS + 60, + }; + assert.equal(withCurrentVerifiedIdentity(valid, NOW_MS), valid); + assert.deepEqual(withCurrentVerifiedIdentity(valid, NOW_MS + 60_000), { + verifiedName: null, + verifiedNameExpiresAt: NOW_SECONDS + 60, + }); +}); diff --git a/desktop/src/shared/lib/verifiedIdentity.ts b/desktop/src/shared/lib/verifiedIdentity.ts new file mode 100644 index 0000000000..2c0312d7a3 --- /dev/null +++ b/desktop/src/shared/lib/verifiedIdentity.ts @@ -0,0 +1,57 @@ +export type VerifiedIdentityFields = { + verifiedName?: string | null; + verifiedNameExpiresAt?: number | null; +}; + +function verifiedIdentityExpiryMs( + expiresAt: number | null | undefined, +): number | null { + if (!Number.isSafeInteger(expiresAt) || (expiresAt ?? 0) <= 0) { + return null; + } + + const expiresAtMs = (expiresAt as number) * 1_000; + return Number.isSafeInteger(expiresAtMs) ? expiresAtMs : null; +} + +/** + * Return a verified name only while its relay assertion is still valid. + * Missing or malformed expirations fail closed so old cached responses cannot + * keep a trust label alive while the relay is unreachable. + */ +export function getCurrentVerifiedName( + verifiedName: string | null | undefined, + expiresAt: number | null | undefined, + nowMs = Date.now(), +): string | null { + const name = verifiedName?.trim(); + const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); + if (!name || expiresAtMs === null || expiresAtMs <= nowMs) { + return null; + } + + return name; +} + +export function millisecondsUntilVerifiedIdentityExpiry( + expiresAt: number | null | undefined, + nowMs = Date.now(), +): number | null { + const expiresAtMs = verifiedIdentityExpiryMs(expiresAt); + return expiresAtMs === null ? null : Math.max(0, expiresAtMs - nowMs); +} + +/** Preserve object identity until the verified-name view actually changes. */ +export function withCurrentVerifiedIdentity( + identity: T, + nowMs = Date.now(), +): T { + const verifiedName = getCurrentVerifiedName( + identity.verifiedName, + identity.verifiedNameExpiresAt, + nowMs, + ); + return identity.verifiedName === verifiedName + ? identity + : { ...identity, verifiedName }; +} diff --git a/desktop/src/shared/ui/VerifiedBadge.tsx b/desktop/src/shared/ui/VerifiedBadge.tsx new file mode 100644 index 0000000000..359639ac0f --- /dev/null +++ b/desktop/src/shared/ui/VerifiedBadge.tsx @@ -0,0 +1,55 @@ +import { useVerifiedIdentityExpiryRevision } from "@/shared/hooks/useVerifiedIdentityExpiry"; +import { getCurrentVerifiedName } from "@/shared/lib/verifiedIdentity"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export function VerifiedBadge({ + verifiedName, + verifiedNameExpiresAt, +}: { + verifiedName: string; + verifiedNameExpiresAt: number | null | undefined; +}) { + useVerifiedIdentityExpiryRevision([verifiedNameExpiresAt]); + const currentVerifiedName = getCurrentVerifiedName( + verifiedName, + verifiedNameExpiresAt, + ); + if (!currentVerifiedName) { + return null; + } + + return ( + + + + + + + +

Verified as {currentVerifiedName}

+
+
+ ); +} diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 458689a5e0..0ccc74b3c3 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1077,7 +1077,9 @@ test("right-click menus expose distinct selectors for links, relay video, and of sha: MENU_OFF_RELAY_VIDEO_SHA, filename: "external-clip.mp4", }); - const offRelayPlayer = page.getByTestId("video-player").last(); + const offRelayPlayer = page + .getByTestId("video-player") + .filter({ has: page.locator(`video[src="${MENU_OFF_RELAY_VIDEO_URL}"]`) }); await expect(offRelayPlayer).toBeVisible(); await offRelayPlayer.click({ button: "right", force: true }); diff --git a/docs/CORPORATE_IDENTITY.md b/docs/CORPORATE_IDENTITY.md new file mode 100644 index 0000000000..00b47b42bd --- /dev/null +++ b/docs/CORPORATE_IDENTITY.md @@ -0,0 +1,78 @@ +# Corporate identity + +Corporate identity is an optional relay policy enabled with +`BUZZ_REQUIRE_CORPORATE_IDENTITY=true`. The relay verifies an asymmetric JWT +after the request proves control of a Nostr key, then admits the request only +when the existing community policy also succeeds. + +## Required JWT policy + +- `BUZZ_CORPORATE_IDENTITY_JWKS_URI` must be HTTPS and contain no credentials. +- JWTs must have a supported asymmetric algorithm, a `kid`, and valid `exp`, + `iss`, and `aud` claims. A present `nbf` claim is enforced. +- `BUZZ_CORPORATE_IDENTITY_NPUB_CLAIM`, when configured, is mandatory and must + equal the authenticated Nostr key. Leaving it unset enables first-use + uid-to-key enrollment in the private binding table. +- JWKS requests have connect and total timeouts, reject redirects, cap the + response at 1 MiB, cache keys for five minutes, and coalesce refreshes. + +`BUZZ_REQUIRE_CORPORATE_IDENTITY` and +`BUZZ_ALLOW_CORPORATE_IDENTITY_DELEGATION` are strict booleans. Misspellings and +non-UTF-8 values stop configuration loading instead of silently disabling a +gate. + +## Binding and revocation lifecycle + +JWT validation is read-only. The relay creates or refreshes a binding only +after admission, allowlist, role, and community membership checks succeed. +Invite claims commit the binding, membership, policy evidence, and invite use +in one PostgreSQL transaction. + +Revocation has three explicit meanings: + +- `principal` disables every key for an issuer-qualified uid. Normal + authentication cannot re-enroll the principal with another key. +- `key` revokes one key but does not silently authorize a replacement. +- `rotation` is the audit state written by an explicit atomic old-key to + new-key rotation. + +WebSocket and audio sessions revalidate the authoritative binding at least +every 30 seconds. Direct sessions also close at JWT expiry. Delegated sessions +check the owner's binding, so disabling an owner evicts the owner's agents as +well as the direct owner session. + +Corporate NIP-OA delegation is transport-wide and therefore accepts only an +empty conditions string. Conditional tags must be evaluated for a specific +operation and are not treated as blanket corporate identity authority. + +## Privacy and public assertions + +`BUZZ_CORPORATE_IDENTITY_DISPLAY_CLAIM` is private. Its default (`email`) is +stored only in the community-scoped binding table and audit data; it is not +published to Nostr. + +Public projection is separately opt-in with +`BUZZ_CORPORATE_IDENTITY_PUBLIC_DISPLAY_CLAIM`. When set, that claim is +published as a relay-signed NIP-85 label. Assertions carry both `active=true` +and an `expiration` no later than one hour or the JWT's expiry, whichever comes +first. Clients require the relay signature, active status, and a future +expiration. Removing the opt-in publishes an inactive replacement only when a +prior public assertion exists. + +NIP-85 events are replaceable relay events and may also remain in downstream +caches or archives after replacement. Operators must choose a non-sensitive, +user-approved public label and account for that retention when configuring the +public claim. + +## Route policy + +Corporate identity applies to authenticated WebSocket and audio connections, +the NIP-98 event/query/count bridge, moderation reads, invite mint and claim, +Git smart HTTP, media uploads, and protected media reads. + +Intentional exemptions are public media reads when media GET authentication is +disabled, health/readiness/metrics endpoints, NIP-11 and NIP-05 discovery, +operator and admin control planes with their own authentication, secret-backed +workflow hooks, public join-policy documents, invite policy-acceptance +callbacks, and static local web callbacks. These exemptions must remain in the +central route-policy test matrix when routes change. diff --git a/migrations/0028_identity_bindings.sql b/migrations/0028_identity_bindings.sql new file mode 100644 index 0000000000..0ff3cde733 --- /dev/null +++ b/migrations/0028_identity_bindings.sql @@ -0,0 +1,38 @@ +-- Relay-verified identity bindings. +-- +-- This is the relay-side foundation for mapping an issuer-qualified IdP +-- subject to a Nostr pubkey. It is intentionally not a full grant/session +-- model: lifecycle operations such as admin revocation, rotation workflows, +-- and live connection eviction are follow-up work, but the columns/indexes +-- below preserve those states without requiring a later destructive schema +-- rewrite. + +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + uid TEXT NOT NULL, + pubkey BYTEA NOT NULL, + display_name TEXT, + source TEXT NOT NULL CHECK (source IN ('jwt_npub', 'db_binding')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + revoked_by BYTEA, + revoked_reason TEXT, + CONSTRAINT chk_identity_bindings_issuer_not_empty CHECK (length(issuer) > 0), + CONSTRAINT chk_identity_bindings_uid_not_empty CHECK (length(uid) > 0), + CONSTRAINT chk_identity_bindings_pubkey_len CHECK (length(pubkey) = 32), + CONSTRAINT chk_identity_bindings_revoked_by_len CHECK (revoked_by IS NULL OR length(revoked_by) = 32) +); + +CREATE UNIQUE INDEX idx_identity_bindings_active_principal + ON identity_bindings (community_id, issuer, uid) + WHERE revoked_at IS NULL; + +CREATE UNIQUE INDEX idx_identity_bindings_active_pubkey + ON identity_bindings (community_id, pubkey) + WHERE revoked_at IS NULL; + +CREATE INDEX idx_identity_bindings_pubkey + ON identity_bindings (community_id, pubkey); diff --git a/migrations/0029_identity_binding_lifecycle.sql b/migrations/0029_identity_binding_lifecycle.sql new file mode 100644 index 0000000000..a1bc42bf7d --- /dev/null +++ b/migrations/0029_identity_binding_lifecycle.sql @@ -0,0 +1,94 @@ +-- Explicit corporate identity revocation and rotation semantics. +-- +-- principal: disables every key for the issuer-qualified principal. +-- key: revokes only this key; a different key still requires an explicit +-- operator rotation because ordinary authentication never replaces an +-- active binding. +-- rotation: records the old key retired by an authorized atomic rotation. + +ALTER TABLE identity_bindings + ADD COLUMN revocation_scope TEXT NOT NULL DEFAULT 'principal' + CHECK (revocation_scope IN ('principal', 'key', 'rotation')), + ADD COLUMN rotation_completed_at TIMESTAMPTZ, + ADD COLUMN rotated_to_pubkey BYTEA, + ADD COLUMN rotation_by BYTEA, + ADD COLUMN rotation_reason TEXT, + ADD CONSTRAINT chk_identity_bindings_rotation_state CHECK ( + (rotation_completed_at IS NULL + AND rotated_to_pubkey IS NULL + AND rotation_by IS NULL + AND rotation_reason IS NULL) + OR + (rotation_completed_at IS NOT NULL + AND rotated_to_pubkey IS NOT NULL + AND length(rotated_to_pubkey) = 32 + AND (rotation_by IS NULL OR length(rotation_by) = 32) + AND rotation_reason IS NOT NULL + AND length(rotation_reason) > 0) + ); + +CREATE INDEX idx_identity_bindings_revoked_principal + ON identity_bindings (community_id, issuer, uid) + WHERE revoked_at IS NOT NULL AND revocation_scope = 'principal'; + +-- Principal status is separate from key history so operators can disable a +-- principal before first enrollment and after a single-key revocation. +CREATE TABLE identity_principals ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + uid TEXT NOT NULL, + disabled_at TIMESTAMPTZ, + disabled_by BYTEA, + disabled_reason TEXT, + PRIMARY KEY (community_id, issuer, uid), + CHECK (length(issuer) > 0), + CHECK (length(uid) > 0), + CHECK (disabled_by IS NULL OR length(disabled_by) = 32), + CHECK ((disabled_at IS NULL) = (disabled_reason IS NULL)) +); + +-- Rows revoked before this lifecycle migration represented principal-level +-- disablement. Preserve that security state instead of allowing the same uid +-- to re-enroll with a fresh key after upgrade. +INSERT INTO identity_principals + (community_id, issuer, uid, disabled_at, disabled_by, disabled_reason) +SELECT DISTINCT ON (community_id, issuer, uid) + community_id, + issuer, + uid, + revoked_at, + revoked_by, + COALESCE(NULLIF(revoked_reason, ''), 'legacy principal revocation') +FROM identity_bindings +WHERE revoked_at IS NOT NULL +ORDER BY community_id, issuer, uid, revoked_at ASC; + +-- A revoked credential cannot be rebound to a different principal in the +-- same community. Explicit rotation may consume an old revoked key, but may +-- never select a revoked key as the replacement. +CREATE TABLE identity_revoked_keys ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, pubkey), + CHECK (length(pubkey) = 32), + CHECK (revoked_by IS NULL OR length(revoked_by) = 32), + CHECK (length(reason) > 0) +); + +-- Preserve every pre-migration revoked credential as a community-wide key +-- tombstone. This prevents a legacy-revoked key from binding to a different +-- issuer-qualified principal after upgrade. +INSERT INTO identity_revoked_keys + (community_id, pubkey, revoked_at, revoked_by, reason) +SELECT DISTINCT ON (community_id, pubkey) + community_id, + pubkey, + revoked_at, + revoked_by, + COALESCE(NULLIF(revoked_reason, ''), 'legacy key revocation') +FROM identity_bindings +WHERE revoked_at IS NOT NULL +ORDER BY community_id, pubkey, revoked_at ASC; diff --git a/schema/schema.sql b/schema/schema.sql index 9f3449b066..9b18bc730e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -190,6 +190,89 @@ CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) WHERE okta_user_id IS NOT NULL; +-- ── Relay-verified identity bindings ───────────────────────────────────────── +-- Conformance: verified identity is community-scoped. An issuer-qualified uid +-- is the stable product/user-management identity; a Nostr pubkey is the +-- protocol credential currently bound to it. This table is intentionally a +-- binding and lifecycle authority. Revocation scope distinguishes principal +-- disablement, a single-key revocation, and an operator-authorized rotation. + +CREATE TABLE identity_bindings ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + uid TEXT NOT NULL, + pubkey BYTEA NOT NULL, + display_name TEXT, + source TEXT NOT NULL CHECK (source IN ('jwt_npub', 'db_binding')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + revoked_by BYTEA, + revoked_reason TEXT, + revocation_scope TEXT NOT NULL DEFAULT 'principal' + CHECK (revocation_scope IN ('principal', 'key', 'rotation')), + rotation_completed_at TIMESTAMPTZ, + rotated_to_pubkey BYTEA, + rotation_by BYTEA, + rotation_reason TEXT, + CONSTRAINT chk_identity_bindings_issuer_not_empty CHECK (length(issuer) > 0), + CONSTRAINT chk_identity_bindings_uid_not_empty CHECK (length(uid) > 0), + CONSTRAINT chk_identity_bindings_pubkey_len CHECK (length(pubkey) = 32), + CONSTRAINT chk_identity_bindings_revoked_by_len CHECK (revoked_by IS NULL OR length(revoked_by) = 32), + CONSTRAINT chk_identity_bindings_rotation_state CHECK ( + (rotation_completed_at IS NULL + AND rotated_to_pubkey IS NULL + AND rotation_by IS NULL + AND rotation_reason IS NULL) + OR + (rotation_completed_at IS NOT NULL + AND rotated_to_pubkey IS NOT NULL + AND length(rotated_to_pubkey) = 32 + AND (rotation_by IS NULL OR length(rotation_by) = 32) + AND rotation_reason IS NOT NULL + AND length(rotation_reason) > 0) + ) +); + +CREATE UNIQUE INDEX idx_identity_bindings_active_principal + ON identity_bindings (community_id, issuer, uid) + WHERE revoked_at IS NULL; +CREATE UNIQUE INDEX idx_identity_bindings_active_pubkey + ON identity_bindings (community_id, pubkey) + WHERE revoked_at IS NULL; +CREATE INDEX idx_identity_bindings_pubkey + ON identity_bindings (community_id, pubkey); +CREATE INDEX idx_identity_bindings_revoked_principal + ON identity_bindings (community_id, issuer, uid) + WHERE revoked_at IS NOT NULL AND revocation_scope = 'principal'; + +CREATE TABLE identity_principals ( + community_id UUID NOT NULL REFERENCES communities(id), + issuer TEXT NOT NULL, + uid TEXT NOT NULL, + disabled_at TIMESTAMPTZ, + disabled_by BYTEA, + disabled_reason TEXT, + PRIMARY KEY (community_id, issuer, uid), + CHECK (length(issuer) > 0), + CHECK (length(uid) > 0), + CHECK (disabled_by IS NULL OR length(disabled_by) = 32), + CHECK ((disabled_at IS NULL) = (disabled_reason IS NULL)) +); + +CREATE TABLE identity_revoked_keys ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_by BYTEA, + reason TEXT NOT NULL, + PRIMARY KEY (community_id, pubkey), + CHECK (length(pubkey) = 32), + CHECK (revoked_by IS NULL OR length(revoked_by) = 32), + CHECK (length(reason) > 0) +); + -- ── Events (partitioned by month on created_at) ────────────────────────────── -- Conformance: "Channel-less global events and DMs". `community_id` leads the -- PK and every hot-path index. Partition stays BY RANGE (created_at) — the