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-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..eb0482163c 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 9d15fccfc8..ba754a0498 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. @@ -387,12 +388,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?; @@ -400,7 +396,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(|| { @@ -411,7 +505,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()) @@ -439,7 +533,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() { @@ -471,10 +565,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()); @@ -494,7 +588,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 { @@ -520,7 +614,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( @@ -532,11 +626,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) } @@ -1536,7 +1629,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") } @@ -1608,6 +1703,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 53e3f59463..5ccdc88604 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,13 +245,31 @@ 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()); } - deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; - 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() + }) +} + /// Deny banned principals on every Git HTTP request. /// /// Git runs outside the WebSocket authentication path, so a valid NIP-98 @@ -769,6 +809,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, @@ -1025,6 +1066,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")?; @@ -1186,6 +1228,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) @@ -1777,6 +1820,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. @@ -1823,6 +1868,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..1b536271a3 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -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..2c2f5b02fd 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1386,6 +1386,7 @@ 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 { pubkey: agent.public_key(), 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 24ef625783..d0fd51c267 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