From 236d4a938f6460c5700bd40803a21b65180f22ce Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Thu, 9 Apr 2026 09:21:26 -0600 Subject: [PATCH 1/7] feat: corporate proxy identity mode (cf-doorman integration) Add proxy/hybrid identity mode where the relay sits behind a trusted reverse proxy (cf-doorman) that injects identity JWTs. The relay derives deterministic Nostr keypairs from the corporate UID claim via HMAC-SHA256. Backend: - sprout-auth: IdentityMode enum, IdentityConfig, derive_keypair_from_uid - sprout-relay: config env vars, POST /api/identity/bootstrap endpoint, WS + REST pre-auth from x-forwarded-identity-token header - sprout-db: ensure_user_with_verified_name for trusted display names - NIP-11: advertise identity_mode for desktop auto-discovery - Automatically force require_auth_token when identity mode is active Desktop: - IdentityGate component gates app on successful bootstrap - Tauri identity commands: bootstrap_identity, set_identity_from_secret_key - WARP connectivity error detection with user-friendly messaging Security: - Relay trusts x-forwarded-identity-token unconditionally (must be behind cf-doorman); startup warning documents the trusted-proxy assumption - Bootstrap response uses POST + Cache-Control: no-store to prevent intermediary caching of secret keys - Identity secret never leaves the relay; each user gets only their own key Amp-Thread-ID: https://ampcode.com/threads/T-019d7027-eacf-763c-8e72-7361f6f14bf4 Co-authored-by: Amp --- .env.example | 17 ++ Cargo.lock | 1 + crates/sprout-auth/Cargo.toml | 1 + crates/sprout-auth/src/identity.rs | 274 +++++++++++++++++++ crates/sprout-auth/src/lib.rs | 80 ++++++ crates/sprout-db/src/channel.rs | 5 +- crates/sprout-db/src/lib.rs | 9 + crates/sprout-db/src/user.rs | 59 +++- crates/sprout-relay/src/api/events.rs | 1 + crates/sprout-relay/src/api/identity.rs | 121 ++++++++ crates/sprout-relay/src/api/mod.rs | 59 ++++ crates/sprout-relay/src/api/users.rs | 4 + crates/sprout-relay/src/config.rs | 50 ++++ crates/sprout-relay/src/connection.rs | 52 +++- crates/sprout-relay/src/handlers/ingest.rs | 2 + crates/sprout-relay/src/nip11.rs | 3 + crates/sprout-relay/src/router.rs | 52 +++- desktop/scripts/check-file-sizes.mjs | 4 +- desktop/src-tauri/src/app_state.rs | 5 + desktop/src-tauri/src/commands/identity.rs | 164 ++++++++++- desktop/src-tauri/src/lib.rs | 2 + desktop/src/app/App.tsx | 7 +- desktop/src/app/IdentityGate.tsx | 77 ++++++ desktop/src/features/profile/lib/identity.ts | 10 +- desktop/src/shared/api/relayClientSession.ts | 41 +-- desktop/src/shared/api/tauri.ts | 26 ++ desktop/src/shared/api/types.ts | 10 + desktop/src/testing/e2eBridge.ts | 6 + schema/schema.sql | 1 + 29 files changed, 1092 insertions(+), 51 deletions(-) create mode 100644 crates/sprout-auth/src/identity.rs create mode 100644 crates/sprout-relay/src/api/identity.rs create mode 100644 desktop/src/app/IdentityGate.tsx diff --git a/.env.example b/.env.example index 7a9c0970..6de0712c 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,23 @@ OKTA_AUDIENCE=sprout-desktop # OKTA_AUDIENCE=sprout-api # OKTA_PUBKEY_CLAIM=nostr_pubkey +# ── Corporate identity mode ───────────────────────────────────────────────── +# Identity mode: "disabled" (default), "proxy", "hybrid". +# proxy — relay validates x-forwarded-identity-token JWT on every request +# (injected by cf-doorman). Nostr keypairs are derived from the uid claim +# using a secret-backed HMAC. The desktop obtains its derived key via +# POST /api/identity/bootstrap (also behind cf-doorman). +# Requires OKTA_JWKS_URI/ISSUER/AUDIENCE pointed at cf-doorman's JWKS. +# SPROUT_IDENTITY_MODE=disabled +# High-entropy secret for deterministic keypair derivation (REQUIRED in proxy mode). +# Generate with: openssl rand -hex 32 +# SPROUT_IDENTITY_SECRET= +# Domain-separation context string (versioned for rotation). +# SPROUT_IDENTITY_CONTEXT=sprout/nostr-id/v1 +# JWT claim names for uid (key derivation) and username (display only). +# SPROUT_IDENTITY_UID_CLAIM=uid +# SPROUT_IDENTITY_USER_CLAIM=user + # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) # ----------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index c0704b83..32a668a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3683,6 +3683,7 @@ version = "0.1.0" dependencies = [ "chrono", "hex", + "hmac", "jsonwebtoken", "nostr", "rand 0.10.0", diff --git a/crates/sprout-auth/Cargo.toml b/crates/sprout-auth/Cargo.toml index 69522691..cd64c0f9 100644 --- a/crates/sprout-auth/Cargo.toml +++ b/crates/sprout-auth/Cargo.toml @@ -22,6 +22,7 @@ tracing = { workspace = true } thiserror = { workspace = true } jsonwebtoken = { workspace = true } sha2 = { workspace = true } +hmac = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } rand = { workspace = true } diff --git a/crates/sprout-auth/src/identity.rs b/crates/sprout-auth/src/identity.rs new file mode 100644 index 00000000..b9ee8959 --- /dev/null +++ b/crates/sprout-auth/src/identity.rs @@ -0,0 +1,274 @@ +//! Corporate identity mode for the Sprout relay. +//! +//! Supports proxy-based identity where an upstream reverse proxy (e.g. cf-doorman) +//! injects identity JWTs. The relay derives deterministic Nostr keypairs from +//! the corporate UID claim, so users don't need to manage Nostr keys directly. + +use std::fmt; +use std::str::FromStr; + +use hmac::Mac; +use serde::{Deserialize, Serialize}; + +use crate::error::AuthError; + +/// How corporate identity is resolved for incoming connections. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum IdentityMode { + /// Identity mode is disabled — standard Nostr key-based authentication. + #[default] + Disabled, + /// A reverse proxy (e.g. cf-doorman) injects identity JWTs into requests. + /// All connections **must** present a valid identity JWT — no fallback. + Proxy, + /// Transitional mode: proxy identity is preferred for human users, but + /// connections without an identity JWT fall through to standard auth + /// (API tokens, Okta JWTs, NIP-42). Use this while agents lack JWTs. + Hybrid, +} + +impl IdentityMode { + /// Returns `true` if proxy identity JWT validation is active + /// (either strict `Proxy` or transitional `Hybrid` mode). + pub fn is_proxy(&self) -> bool { + matches!(self, Self::Proxy | Self::Hybrid) + } +} + +impl fmt::Display for IdentityMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disabled => write!(f, "disabled"), + Self::Proxy => write!(f, "proxy"), + Self::Hybrid => write!(f, "hybrid"), + } + } +} + +impl FromStr for IdentityMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "disabled" => Ok(Self::Disabled), + "proxy" => Ok(Self::Proxy), + "hybrid" => Ok(Self::Hybrid), + other => Err(format!("unknown identity mode: {other}")), + } + } +} + +/// Configuration for corporate identity resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentityConfig { + /// The identity mode to use. + #[serde(default = "default_mode")] + pub mode: IdentityMode, + /// JWT claim name containing the corporate user ID. + #[serde(default = "default_uid_claim")] + pub uid_claim: String, + /// JWT claim name containing the human-readable username. + #[serde(default = "default_user_claim")] + pub user_claim: String, + /// High-entropy secret used as the HMAC key for deterministic keypair derivation. + /// + /// **Required** when `mode = Proxy`. Without this, anyone who knows a UID could + /// derive that user's private key. The secret ensures that UID alone is insufficient. + #[serde(default)] + pub secret: String, + /// Domain-separation context string for keypair derivation (versioned for rotation). + #[serde(default = "default_context")] + pub context: String, +} + +impl Default for IdentityConfig { + fn default() -> Self { + Self { + mode: default_mode(), + uid_claim: default_uid_claim(), + user_claim: default_user_claim(), + secret: String::new(), + context: default_context(), + } + } +} + +fn default_mode() -> IdentityMode { + IdentityMode::Disabled +} + +fn default_uid_claim() -> String { + "uid".to_string() +} + +fn default_user_claim() -> String { + "user".to_string() +} + +fn default_context() -> String { + "sprout/nostr-id/v1".to_string() +} + +// Custom serde for IdentityMode as a lowercase string. +impl Serialize for IdentityMode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for IdentityMode { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +/// Derive a deterministic Nostr keypair from a corporate UID using a secret-backed HMAC. +/// +/// Uses HMAC-SHA256 with `secret` as the key and `context:uid` as the message. +/// The `secret` must be high-entropy material (≥32 bytes recommended) — without it, +/// anyone who knows a UID could derive that user's private key. +/// +/// The `context` string provides domain separation and version namespacing +/// (e.g. `"sprout/nostr-id/v1"`), enabling key rotation by changing the context. +/// +/// The 32-byte HMAC output is used directly as a secp256k1 secret key. +/// +/// # Errors +/// +/// Returns [`AuthError::Internal`] if `uid` or `secret` is empty, or key derivation fails. +pub fn derive_keypair_from_uid( + secret: &str, + context: &str, + uid: &str, +) -> Result { + if uid.is_empty() { + return Err(AuthError::Internal("uid must not be empty".into())); + } + if secret.is_empty() { + return Err(AuthError::Internal( + "identity secret must not be empty — set SPROUT_IDENTITY_SECRET".into(), + )); + } + + let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) + .map_err(|e| AuthError::Internal(format!("HMAC init failed: {e}")))?; + mac.update(context.as_bytes()); + mac.update(b":"); + mac.update(uid.as_bytes()); + let result = mac.finalize().into_bytes(); + + let secret_key = nostr::SecretKey::from_slice(&result) + .map_err(|e| AuthError::Internal(format!("key derivation failed: {e}")))?; + Ok(nostr::Keys::new(secret_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_SECRET: &str = "test-secret-at-least-32-bytes-long!!"; + + #[test] + fn derive_keypair_deterministic() { + let a = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); + let b = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); + assert_eq!(a.public_key(), b.public_key()); + } + + #[test] + fn derive_keypair_different_uid() { + let a = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); + let b = derive_keypair_from_uid(TEST_SECRET, "ctx", "bob").unwrap(); + assert_ne!(a.public_key(), b.public_key()); + } + + #[test] + fn derive_keypair_different_context() { + let a = derive_keypair_from_uid(TEST_SECRET, "ctx-a", "alice").unwrap(); + let b = derive_keypair_from_uid(TEST_SECRET, "ctx-b", "alice").unwrap(); + assert_ne!(a.public_key(), b.public_key()); + } + + #[test] + fn derive_keypair_different_secret() { + let a = + derive_keypair_from_uid("secret-one-xxxxxxxxxxxxxxxxxxxxxxx", "ctx", "alice").unwrap(); + let b = + derive_keypair_from_uid("secret-two-xxxxxxxxxxxxxxxxxxxxxxx", "ctx", "alice").unwrap(); + assert_ne!(a.public_key(), b.public_key()); + } + + #[test] + fn derive_keypair_empty_uid_fails() { + let result = derive_keypair_from_uid(TEST_SECRET, "ctx", ""); + assert!(result.is_err()); + } + + #[test] + fn derive_keypair_empty_secret_fails() { + let result = derive_keypair_from_uid("", "ctx", "alice"); + assert!(result.is_err()); + } + + #[test] + fn identity_mode_from_str() { + assert_eq!( + "disabled".parse::().unwrap(), + IdentityMode::Disabled + ); + assert_eq!( + "proxy".parse::().unwrap(), + IdentityMode::Proxy + ); + assert_eq!( + "Proxy".parse::().unwrap(), + IdentityMode::Proxy + ); + assert_eq!( + "hybrid".parse::().unwrap(), + IdentityMode::Hybrid + ); + assert_eq!( + "Hybrid".parse::().unwrap(), + IdentityMode::Hybrid + ); + assert!("unknown".parse::().is_err()); + } + + #[test] + fn identity_mode_is_proxy() { + assert!(!IdentityMode::Disabled.is_proxy()); + assert!(IdentityMode::Proxy.is_proxy()); + assert!(IdentityMode::Hybrid.is_proxy()); + } + + /// Golden vector: pin the derivation output so relay and desktop bootstrap stay in sync. + /// If this test breaks, all existing proxy-mode identities will rotate. + #[test] + fn derive_keypair_golden_vector() { + let keys = derive_keypair_from_uid( + "golden-test-secret-do-not-change!", + "sprout/nostr-id/v1", + "12345", + ) + .unwrap(); + let hex = keys.public_key().to_hex(); + // Pin the value — changing this means all existing proxy-mode identities rotate. + // Computed with: HMAC-SHA256(key="golden-test-secret-do-not-change!", msg="sprout/nostr-id/v1:12345") + assert_eq!( + hex, + "92458a8e3e8e3203b3c8d0c8772bf948f124d5ab973dc666d2a9e62a94c6d29d" + ); + } + + #[test] + fn identity_config_defaults() { + let config = IdentityConfig::default(); + assert_eq!(config.mode, IdentityMode::Disabled); + assert_eq!(config.uid_claim, "uid"); + assert_eq!(config.user_claim, "user"); + assert!(config.secret.is_empty()); + assert_eq!(config.context, "sprout/nostr-id/v1"); + } +} diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index e0912fee..55e26b83 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -19,6 +19,8 @@ pub mod access; /// Authentication error types. pub mod error; +/// Corporate identity mode (proxy-injected JWTs, deterministic keypair derivation). +pub mod identity; /// NIP-42 challenge–response authentication. pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). @@ -34,6 +36,7 @@ pub mod token; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; pub use error::AuthError; +pub use identity::{derive_keypair_from_uid, IdentityConfig, IdentityMode}; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig}; @@ -59,6 +62,8 @@ pub enum AuthMethod { Nip42Okta, /// NIP-42 with a `sprout_` API token in the `auth_token` tag. Nip42ApiToken, + /// Proxy identity — pubkey derived from a proxy-injected identity JWT. + ProxyIdentity, } /// The result of a successful authentication, bound to a WebSocket connection. @@ -92,6 +97,9 @@ pub struct AuthConfig { /// Per-user and per-IP rate limit thresholds. #[serde(default)] pub rate_limits: RateLimitConfig, + /// Corporate identity mode (proxy JWT, deterministic keypair derivation). + #[serde(default)] + pub identity: IdentityConfig, } /// Primary authentication service. @@ -321,6 +329,78 @@ impl AuthService { let scopes = parse_scopes(scopes_raw); Ok((*owner_pubkey, scopes)) } + + /// Returns a reference to the identity configuration. + pub fn identity_config(&self) -> &IdentityConfig { + &self.config.identity + } + + /// Validate a proxy-injected identity JWT and derive the Nostr pubkey. + /// + /// Used in proxy identity mode where cf-doorman injects `x-forwarded-identity-token`. + /// Validates the JWT via JWKS (same infrastructure as Okta), extracts the `uid` claim, + /// and derives a deterministic Nostr keypair via HMAC-SHA256. + /// + /// Returns `(pubkey, all_known_scopes, username)` on success. The username is + /// extracted from the `user` claim for display purposes; it is not used for + /// key derivation (UIDs are immutable, usernames are not). + pub async fn validate_identity_jwt( + &self, + jwt: &str, + ) -> Result<(nostr::PublicKey, Vec, String), AuthError> { + let (keys, scopes, username) = self.validate_identity_jwt_keys(jwt).await?; + Ok((keys.public_key(), scopes, username)) + } + + /// Like [`validate_identity_jwt`] but returns the full [`nostr::Keys`] (including + /// the secret key) instead of just the public key. + /// + /// Used by the identity bootstrap endpoint to return the derived secret key to + /// the desktop client. The secret key travels over TLS behind cf-doorman. + pub async fn validate_identity_jwt_keys( + &self, + jwt: &str, + ) -> Result<(nostr::Keys, Vec, String), AuthError> { + let cached = self + .jwks_cache + .get_or_refresh( + &self.config.okta.jwks_uri, + self.config.okta.jwks_refresh_secs, + &self.http_client, + ) + .await?; + + let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?; + + let uid = claims + .get(&self.config.identity.uid_claim) + .and_then(|v| { + v.as_str() + .map(String::from) + .or_else(|| v.as_u64().map(|n| n.to_string())) + }) + .ok_or_else(|| { + AuthError::InvalidJwt(format!( + "missing '{}' claim in identity JWT", + self.config.identity.uid_claim + )) + })?; + + let username = claims + .get(&self.config.identity.user_claim) + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let keys = derive_keypair_from_uid( + &self.config.identity.secret, + &self.config.identity.context, + &uid, + )?; + let scopes = Scope::all_known(); + + Ok((keys, scopes, username)) + } } /// Derive a deterministic Nostr pubkey from a username string. diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index 46b2cb5e..a62392da 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -668,6 +668,8 @@ pub struct UserRecord { pub pubkey: Vec, /// Optional display name. pub display_name: Option, + /// Verified corporate name derived from the identity JWT. + pub verified_name: Option, /// Optional avatar image URL. pub avatar_url: Option, /// Optional NIP-05 identifier (e.g. `user@example.com`). @@ -805,7 +807,7 @@ pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec]) -> Result>() .join(", "); let sql = - format!("SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})"); + format!("SELECT pubkey, display_name, verified_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})"); let mut q = sqlx::query(&sql); for pk in pubkeys { @@ -819,6 +821,7 @@ pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec]) -> Result Result<()> { + user::ensure_user_with_verified_name(&self.pool, pubkey, verified_name).await + } + /// Get a single user record by pubkey. pub async fn get_user(&self, pubkey: &[u8]) -> Result> { user::get_user(&self.pool, pubkey).await diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 721e9c4e..8815e13a 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -11,6 +11,8 @@ pub struct UserProfile { pub pubkey: Vec, /// Human-readable display name chosen by the user. pub display_name: Option, + /// Verified corporate name derived from the identity JWT. + pub verified_name: Option, /// URL of the user's avatar image. pub avatar_url: Option, /// Short bio or description provided by the user. @@ -26,12 +28,41 @@ pub struct UserSearchProfile { pub pubkey: Vec, /// Human-readable display name chosen by the user. pub display_name: Option, + /// Verified corporate name derived from the identity JWT. + pub verified_name: Option, /// URL of the user's avatar image. pub avatar_url: Option, /// NIP-05 identifier (user@domain). pub nip05_handle: Option, } +/// Ensure a user record exists for the given pubkey and sync the verified +/// corporate name derived from the identity JWT. On initial insert the +/// `display_name` is also seeded from the verified name so the user has a +/// visible name immediately. On conflict (user already exists), only the +/// `verified_name` column is updated — `display_name` is left alone so +/// user-chosen names are preserved. +pub async fn ensure_user_with_verified_name( + pool: &PgPool, + pubkey: &[u8], + verified_name: &str, +) -> Result<()> { + sqlx::query( + r#" + INSERT INTO users (pubkey, verified_name, display_name) + VALUES ($1, NULLIF($2, ''), NULLIF($2, '')) + ON CONFLICT (pubkey) DO UPDATE + SET verified_name = EXCLUDED.verified_name + WHERE users.verified_name IS DISTINCT FROM EXCLUDED.verified_name + "#, + ) + .bind(pubkey) + .bind(verified_name) + .execute(pool) + .await?; + Ok(()) +} + /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> { @@ -58,10 +89,11 @@ pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result, Option, Option, + Option, ), >( r#" - SELECT pubkey, display_name, avatar_url, about, nip05_handle + SELECT pubkey, display_name, verified_name, avatar_url, about, nip05_handle FROM users WHERE pubkey = $1 "#, @@ -71,9 +103,10 @@ pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result, Option, Option, + Option, ), >( r#" - SELECT pubkey, display_name, avatar_url, about, nip05_handle + SELECT pubkey, display_name, verified_name, avatar_url, about, nip05_handle FROM users WHERE LOWER(nip05_handle) = LOWER($1) LIMIT 1 @@ -180,9 +214,10 @@ pub async fn get_user_by_nip05( .await?; Ok(row.map( - |(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile { + |(pubkey, display_name, verified_name, avatar_url, about, nip05_handle)| UserProfile { pubkey, display_name, + verified_name, avatar_url, about, nip05_handle, @@ -220,9 +255,18 @@ pub async fn search_users( let prefix_pattern = format!("{escaped}%"); let limit = limit.clamp(1, 50) as i64; - let rows = sqlx::query_as::<_, (Vec, Option, Option, Option)>( + let rows = sqlx::query_as::< + _, + ( + Vec, + Option, + Option, + Option, + Option, + ), + >( r#" - SELECT pubkey, display_name, avatar_url, nip05_handle + SELECT pubkey, display_name, verified_name, avatar_url, nip05_handle FROM users WHERE LOWER(COALESCE(display_name, '')) LIKE $1 ESCAPE '\' OR LOWER(COALESCE(nip05_handle, '')) LIKE $1 ESCAPE '\' @@ -251,9 +295,10 @@ pub async fn search_users( Ok(rows .into_iter() .map( - |(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile { + |(pubkey, display_name, verified_name, avatar_url, nip05_handle)| UserSearchProfile { pubkey, display_name, + verified_name, avatar_url, nip05_handle, }, diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs index 4f9ee189..be0c5aa1 100644 --- a/crates/sprout-relay/src/api/events.rs +++ b/crates/sprout-relay/src/api/events.rs @@ -122,6 +122,7 @@ pub async fn submit_event( RestAuthMethod::ApiToken => HttpAuthMethod::ApiToken, RestAuthMethod::OktaJwt => HttpAuthMethod::OktaJwt, RestAuthMethod::DevPubkey => HttpAuthMethod::DevPubkey, + RestAuthMethod::ProxyIdentity => HttpAuthMethod::ProxyIdentity, RestAuthMethod::Nip98 => { return Err(api_error( StatusCode::BAD_REQUEST, diff --git a/crates/sprout-relay/src/api/identity.rs b/crates/sprout-relay/src/api/identity.rs new file mode 100644 index 00000000..c05847b0 --- /dev/null +++ b/crates/sprout-relay/src/api/identity.rs @@ -0,0 +1,121 @@ +//! Identity bootstrap endpoint for proxy/hybrid identity mode. +//! +//! In proxy mode, the desktop client cannot derive its own Nostr keypair because +//! the derivation secret is held only by the relay. This endpoint validates the +//! client's identity JWT (injected by cf-doorman) and returns the derived secret +//! key so the client can sign events locally. +//! +//! The endpoint is only available when `SPROUT_IDENTITY_MODE=proxy` or `hybrid`. +//! +//! # Trusted-proxy assumption +//! +//! The relay trusts the `x-forwarded-identity-token` header unconditionally. +//! It MUST be deployed behind a trusted reverse proxy (cf-doorman) that is the +//! sole source of this header. If the relay port is directly reachable, an +//! attacker could inject arbitrary identity headers. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; + +use crate::state::AppState; + +/// `POST /api/identity/bootstrap` +/// +/// Validates the caller's `x-forwarded-identity-token` JWT and returns the +/// derived Nostr secret key (hex-encoded) for that user. +/// +/// Uses POST (not GET) because the response contains a secret key that must +/// never be cached by intermediaries. +/// +/// # Security +/// +/// - Only available when `SPROUT_IDENTITY_MODE=proxy` or `hybrid`. +/// - The identity JWT is validated via JWKS (signature, issuer, audience, expiry). +/// - Each caller only receives **their own** derived key — never another user's. +/// - The derivation secret (`SPROUT_IDENTITY_SECRET`) never leaves the relay. +/// - Transport is TLS behind cf-doorman; the secret key travels only over the +/// authenticated, encrypted channel. +/// - Response includes `Cache-Control: no-store` to prevent intermediary caching. +/// +/// # Response +/// +/// ```json +/// { +/// "pubkey": "abcd1234…", +/// "secret_key": "ef012345…", +/// "username": "alice" +/// } +/// ``` +pub async fn identity_bootstrap( + State(state): State>, + headers: HeaderMap, +) -> Result<(StatusCode, HeaderMap, Json), (StatusCode, Json)> +{ + if !state.auth.identity_config().mode.is_proxy() { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "not_available", + "message": "identity bootstrap is only available in proxy identity mode" + })), + )); + } + + let identity_jwt = headers + .get("x-forwarded-identity-token") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "identity_token_required", + "message": "x-forwarded-identity-token header is required" + })), + ) + })?; + + let (keys, _scopes, username) = state + .auth + .validate_identity_jwt_keys(identity_jwt) + .await + .map_err(|e| { + tracing::warn!("identity bootstrap: JWT validation failed: {e}"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "identity_token_invalid" })), + ) + })?; + + let pubkey_bytes = keys.public_key().serialize().to_vec(); + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &username) + .await + { + tracing::warn!("identity bootstrap: ensure_user_with_verified_name failed: {e}"); + } + + // ⚠️ SECURITY: secret_key is logged at no level — it is sensitive material. + // The response travels over TLS behind cf-doorman. + let mut resp_headers = HeaderMap::new(); + resp_headers.insert( + axum::http::header::CACHE_CONTROL, + "no-store, private, max-age=0".parse().unwrap(), + ); + resp_headers.insert(axum::http::header::PRAGMA, "no-cache".parse().unwrap()); + + Ok(( + StatusCode::OK, + resp_headers, + Json(serde_json::json!({ + "pubkey": keys.public_key().to_hex(), + "secret_key": keys.secret_key().to_secret_hex(), + "username": username, + })), + )) +} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 2081d0cd..eed4d07a 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -26,6 +26,8 @@ pub mod dms; pub mod events; /// Personalized home feed endpoint. pub mod feed; +/// Identity bootstrap endpoint (proxy mode). +pub mod identity; /// Blossom-compatible media upload, retrieval, and existence check endpoints. pub mod media; /// Channel membership endpoints. @@ -102,6 +104,8 @@ pub enum RestAuthMethod { Nip98, /// `X-Pubkey: ` — dev mode only (`require_auth_token=false`). DevPubkey, + /// `x-forwarded-identity-token` — proxy identity mode (cf-doorman). + ProxyIdentity, } /// Full authentication context returned to REST handlers. @@ -150,6 +154,61 @@ pub(crate) async fn extract_auth_context( ) -> Result)> { let require_auth = state.config.require_auth_token; + // ── 0. Proxy / hybrid identity mode ────────────────────────────────── + // When identity_mode is proxy or hybrid, cf-doorman injects + // x-forwarded-identity-token for human users. + // - Proxy: header is mandatory — reject if missing. + // - Hybrid: header is preferred — fall through to standard auth if missing. + // In both modes, a present-but-invalid header is a hard 401. + let identity_mode = &state.auth.identity_config().mode; + if identity_mode.is_proxy() { + if let Some(identity_jwt) = headers + .get("x-forwarded-identity-token") + .and_then(|v| v.to_str().ok()) + { + match state.auth.validate_identity_jwt(identity_jwt).await { + Ok((pubkey, scopes, username)) => { + let pubkey_bytes = pubkey.serialize().to_vec(); + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &username) + .await + { + tracing::warn!("ensure_user_with_verified_name failed: {e}"); + } + return Ok(RestAuthContext { + pubkey, + pubkey_bytes, + scopes, + auth_method: RestAuthMethod::ProxyIdentity, + token_id: None, + channel_ids: None, + }); + } + Err(e) => { + tracing::warn!("auth: identity JWT validation failed: {e}"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "identity_token_invalid" })), + )); + } + } + } else if *identity_mode == sprout_auth::IdentityMode::Proxy { + tracing::warn!( + "auth: proxy mode enabled but x-forwarded-identity-token header missing" + ); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "identity_token_required", + "message": "x-forwarded-identity-token header is required in proxy identity mode" + })), + )); + } + // Hybrid mode: no identity token — fall through to standard auth + // so agents can authenticate via API tokens, Okta JWTs, etc. + } + if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) { // ── 1. Reject NIP-98 on non-token endpoints ─────────────────────────── // NIP-98 auth is only valid for POST /api/tokens (handled directly in diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs index 1f9e033f..623a0b05 100644 --- a/crates/sprout-relay/src/api/users.rs +++ b/crates/sprout-relay/src/api/users.rs @@ -53,6 +53,7 @@ pub async fn get_profile( Ok(Json(serde_json::json!({ "pubkey": nostr_hex::encode(&p.pubkey), "display_name": p.display_name, + "verified_name": p.verified_name, "avatar_url": p.avatar_url, "about": p.about, "nip05_handle": p.nip05_handle, @@ -98,6 +99,7 @@ pub async fn get_user_profile( Ok(Json(serde_json::json!({ "pubkey": nostr_hex::encode(&profile.pubkey), "display_name": profile.display_name, + "verified_name": profile.verified_name, "avatar_url": profile.avatar_url, "about": profile.about, "nip05_handle": profile.nip05_handle, @@ -185,6 +187,7 @@ pub async fn get_users_batch( hex, serde_json::json!({ "display_name": r.display_name, + "verified_name": r.verified_name, "avatar_url": r.avatar_url, "nip05_handle": r.nip05_handle, }), @@ -235,6 +238,7 @@ pub async fn search_users( serde_json::json!({ "pubkey": nostr_hex::encode(&user.pubkey), "display_name": user.display_name, + "verified_name": user.verified_name, "avatar_url": user.avatar_url, "nip05_handle": user.nip05_handle, }) diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 95a379ac..a7aeea8a 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -11,6 +11,12 @@ pub enum ConfigError { /// The `SPROUT_BIND_ADDR` environment variable could not be parsed as a socket address. #[error("invalid SPROUT_BIND_ADDR: {0}")] InvalidBindAddr(String), + /// The `SPROUT_IDENTITY_MODE` environment variable contains an unrecognised value. + #[error("invalid SPROUT_IDENTITY_MODE: {0}")] + InvalidIdentityMode(String), + /// `SPROUT_IDENTITY_SECRET` is required when identity mode is `proxy`. + #[error("SPROUT_IDENTITY_SECRET is required when SPROUT_IDENTITY_MODE=proxy or hybrid")] + MissingIdentitySecret, } /// Relay runtime configuration, loaded from environment variables. @@ -129,6 +135,50 @@ impl Config { auth.okta.jwks_uri = jwks_uri; } + // ── Identity mode ────────────────────────────────────────────────────── + let identity_mode = std::env::var("SPROUT_IDENTITY_MODE") + .unwrap_or_else(|_| "disabled".to_string()) + .parse::() + .map_err(ConfigError::InvalidIdentityMode)?; + + auth.identity.mode = identity_mode.clone(); + + if let Ok(secret) = std::env::var("SPROUT_IDENTITY_SECRET") { + auth.identity.secret = secret; + } + if let Ok(ctx) = std::env::var("SPROUT_IDENTITY_CONTEXT") { + auth.identity.context = ctx; + } + if let Ok(uid_claim) = std::env::var("SPROUT_IDENTITY_UID_CLAIM") { + auth.identity.uid_claim = uid_claim; + } + if let Ok(user_claim) = std::env::var("SPROUT_IDENTITY_USER_CLAIM") { + auth.identity.user_claim = user_claim; + } + + // When identity mode is active the relay sits behind a trusted proxy + // (cf-doorman) — force require_auth_token so the NIP-42 fallback path + // cannot be used with bare keypair-only auth. + let require_auth_token = if identity_mode.is_proxy() { + if auth.identity.secret.is_empty() { + return Err(ConfigError::MissingIdentitySecret); + } + if !require_auth_token { + tracing::info!( + "Identity mode: {identity_mode} — overriding SPROUT_REQUIRE_AUTH_TOKEN to true" + ); + } + tracing::warn!( + "Identity mode: {identity_mode} — relay trusts x-forwarded-identity-token headers. \ + Ensure the relay is reachable ONLY via the trusted reverse proxy (cf-doorman). \ + Direct access to the relay port would allow header injection." + ); + auth.okta.require_token = true; + true + } else { + require_auth_token + }; + if !require_auth_token { warn!( "SPROUT_REQUIRE_AUTH_TOKEN is false — relay accepts unauthenticated connections. \ diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index 9aea3608..2556e0ae 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -104,7 +104,16 @@ impl ConnectionState { /// /// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge, /// then drives the send, heartbeat, and receive loops until the connection closes. -pub async fn handle_connection(socket: WebSocket, state: Arc, addr: SocketAddr) { +/// +/// If `pre_auth` is `Some`, the connection is immediately authenticated (skipping +/// the NIP-42 challenge–response). Used in proxy identity mode where the upstream +/// reverse proxy has already validated the user's identity. +pub async fn handle_connection( + socket: WebSocket, + state: Arc, + addr: SocketAddr, + pre_auth: Option, +) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -114,7 +123,6 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So }; let conn_id = Uuid::new_v4(); - let challenge = generate_challenge(); let cancel = CancellationToken::new(); let (tx, rx) = mpsc::channel::(state.config.send_buffer_size); @@ -125,12 +133,27 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); + // Pre-authenticated connections (proxy identity mode) skip NIP-42 entirely. + let initial_auth_state = match pre_auth { + Some(ctx) => { + info!(conn_id = %conn_id, addr = %addr, pubkey = %ctx.pubkey.to_hex(), + "WebSocket pre-authenticated via proxy identity"); + AuthState::Authenticated(ctx) + } + None => AuthState::Pending { + challenge: generate_challenge(), + }, + }; + + let challenge = match &initial_auth_state { + AuthState::Pending { challenge } => Some(challenge.clone()), + _ => None, + }; + let conn = Arc::new(ConnectionState { conn_id, remote_addr: addr, - auth_state: RwLock::new(AuthState::Pending { - challenge: challenge.clone(), - }), + auth_state: RwLock::new(initial_auth_state), subscriptions: Arc::clone(&subscriptions), send_tx: tx.clone(), ctrl_tx: ctrl_tx.clone(), @@ -141,14 +164,17 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); metrics::counter!("sprout_ws_connections_total").increment(1); - let challenge_msg = RelayMessage::auth_challenge(&challenge); - if tx - .send(WsMessage::Text(challenge_msg.into())) - .await - .is_err() - { - warn!(conn_id = %conn_id, "Failed to send AUTH challenge — client disconnected immediately"); - return; + // Only send NIP-42 challenge if not pre-authenticated. + if let Some(ref challenge_str) = challenge { + let challenge_msg = RelayMessage::auth_challenge(challenge_str); + if tx + .send(WsMessage::Text(challenge_msg.into())) + .await + .is_err() + { + warn!(conn_id = %conn_id, "Failed to send AUTH challenge — client disconnected immediately"); + return; + } } // Gauge incremented AFTER challenge send succeeds — early disconnects diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index d6ddfb9f..e69cf61d 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -41,6 +41,8 @@ pub enum HttpAuthMethod { OktaJwt, /// `X-Pubkey: ` dev-mode header. DevPubkey, + /// `x-forwarded-identity-token` proxy identity mode. + ProxyIdentity, } /// Authentication context for event ingestion — transport-neutral. diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index c9cf2e62..9fe47293 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -28,6 +28,8 @@ pub struct RelayInfo { pub version: String, /// Protocol and resource limits advertised to clients. pub limitation: Option, + /// Corporate identity mode: `"proxy"` or `"disabled"`. + pub identity_mode: String, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -75,6 +77,7 @@ impl RelayInfo { payment_required: false, restricted_writes: true, }), + identity_mode: config.auth.identity.mode.to_string(), } } } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 169537e3..a0fda6e1 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -156,6 +156,12 @@ pub fn build_router(state: Arc) -> Router { .route("/api/users/batch", post(api::get_users_batch)) // Feed route .route("/api/feed", get(api::feed_handler)) + // Identity bootstrap (proxy mode — returns derived signing key). + // POST prevents intermediary caching of the secret key response. + .route( + "/api/identity/bootstrap", + post(api::identity::identity_bootstrap), + ) // Reject request bodies larger than 1 MB to prevent resource exhaustion. .layer(RequestBodyLimitLayer::new(1024 * 1024)) .with_state(state.clone()); @@ -190,6 +196,9 @@ pub fn build_health_router(state: Arc) -> Router { /// UDS connections have no `SocketAddr`, so the extractor would panic. /// TCP connections populate it via `into_make_service_with_connect_info`; UDS /// connections fall back to `0.0.0.0:0`. +/// +/// In proxy identity mode, the `x-forwarded-identity-token` header is validated +/// at upgrade time and the connection is pre-authenticated (NIP-42 is skipped). async fn nip11_or_ws_handler( State(state): State>, headers: HeaderMap, @@ -212,9 +221,50 @@ async fn nip11_or_ws_handler( return Json(info).into_response(); } + // ── Proxy / hybrid identity: validate at upgrade time ────────────── + // - Proxy: identity token mandatory — reject if missing. + // - Hybrid: identity token preferred — fall through to NIP-42 if missing. + let identity_mode = &state.auth.identity_config().mode; + let pre_auth = if identity_mode.is_proxy() { + match headers + .get("x-forwarded-identity-token") + .and_then(|v| v.to_str().ok()) + { + Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { + Ok((pubkey, scopes, username)) => { + let pubkey_bytes = pubkey.serialize().to_vec(); + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &username) + .await + { + tracing::warn!("ws: ensure_user_with_verified_name failed: {e}"); + } + Some(sprout_auth::AuthContext { + pubkey, + scopes, + auth_method: sprout_auth::AuthMethod::ProxyIdentity, + }) + } + Err(e) => { + tracing::warn!("ws: proxy identity JWT validation failed: {e}"); + return (StatusCode::UNAUTHORIZED, "identity token invalid").into_response(); + } + }, + None if *identity_mode == sprout_auth::IdentityMode::Proxy => { + tracing::warn!("ws: proxy mode enabled but x-forwarded-identity-token missing"); + return (StatusCode::UNAUTHORIZED, "identity token required").into_response(); + } + // Hybrid: no identity token — proceed to NIP-42 auth. + None => None, + } + } else { + None + }; + match WebSocketUpgrade::from_request(req, &state).await { Ok(ws) => ws - .on_upgrade(move |socket| handle_connection(socket, state, addr)) + .on_upgrade(move |socket| handle_connection(socket, state, addr, pre_auth)) .into_response(), Err(_) => { // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6fe53e0b..b262a6cc 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 835], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + ["src/shared/api/relayClientSession.ts", 840], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + identity bootstrap ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests @@ -56,7 +56,7 @@ const overrides = new Map([ ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display - ["src/shared/api/types.ts", 550], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + ["src/shared/api/types.ts", 555], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + identity types ["src-tauri/src/events.rs", 555], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave ["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index d2b05a12..34b88c01 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -11,6 +11,10 @@ pub struct AppState { pub http_client: reqwest::Client, pub configured_api_token: Option, pub session_token: Mutex>, + /// Display name resolved during identity bootstrap (e.g. JWT username). + /// Used by `get_identity` so the UI shows the real name instead of a + /// truncated npub. + pub display_name: Mutex>, pub managed_agents_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, @@ -64,6 +68,7 @@ pub fn build_app_state() -> AppState { http_client: reqwest::Client::new(), configured_api_token: api_token, session_token: Mutex::new(None), + display_name: Mutex::new(None), managed_agents_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 482fc4fb..826fd928 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -1,4 +1,4 @@ -use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32}; +use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag, ToBech32}; use tauri::State; use crate::{ @@ -7,19 +7,28 @@ use crate::{ relay::{relay_api_base_url, relay_ws_url}, }; +fn truncated_npub(pubkey: &PublicKey) -> String { + let bech32 = pubkey.to_bech32().unwrap_or_else(|_| pubkey.to_hex()); + if bech32.len() > 16 { + format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + } else { + bech32 + } +} + #[tauri::command] pub fn get_identity(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; let pubkey = keys.public_key(); let pubkey_hex = pubkey.to_hex(); - let bech32 = pubkey - .to_bech32() - .map_err(|error| format!("bech32 encode failed: {error}"))?; - let display_name = if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) - } else { - bech32 - }; + + // Prefer the display name set during identity bootstrap (e.g. JWT username). + let display_name = state + .display_name + .lock() + .map_err(|e| e.to_string())? + .clone() + .unwrap_or_else(|| truncated_npub(&pubkey)); Ok(IdentityInfo { pubkey: pubkey_hex, @@ -59,6 +68,143 @@ pub fn sign_event( Ok(event.as_json()) } +/// Set the signing identity from a hex-encoded secret key. +/// +/// Used in proxy identity mode: the desktop calls the relay's +/// `POST /api/identity/bootstrap` endpoint (which validates the identity JWT +/// and derives the keypair server-side), then passes the returned secret key +/// here to install it as the active signing identity. +#[tauri::command] +pub fn set_identity_from_secret_key( + secret_key_hex: String, + state: State<'_, AppState>, +) -> Result { + let secret_key = nostr::SecretKey::from_hex(&secret_key_hex) + .map_err(|e| format!("invalid secret key: {e}"))?; + let keys = nostr::Keys::new(secret_key); + let pubkey_hex = keys.public_key().to_hex(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; + Ok(pubkey_hex) +} + +#[derive(serde::Serialize)] +pub struct InitializedIdentity { + pubkey: String, + display_name: String, + identity_mode: Option, + ws_auth_mode: String, +} + +#[tauri::command] +pub async fn initialize_identity( + state: State<'_, AppState>, +) -> Result { + let identity_mode = discover_identity_mode(&state).await?; + + match identity_mode.as_str() { + "proxy" | "hybrid" => { + let base_url = crate::relay::relay_api_base_url(); + let url = format!("{base_url}/api/identity/bootstrap"); + + let response = state + .http_client + .post(&url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|e| format!("identity bootstrap request failed: {e}"))?; + + if !response.status().is_success() { + let msg = crate::relay::relay_error_message(response).await; + return Err(format!("identity bootstrap failed: {msg}")); + } + + #[derive(serde::Deserialize)] + struct BootstrapResponse { + #[allow(dead_code)] + pubkey: String, + secret_key: String, + username: String, + } + + let body: BootstrapResponse = response + .json() + .await + .map_err(|e| format!("failed to parse bootstrap response: {e}"))?; + + // Install the derived secret key as the active signing identity. + let secret_key = nostr::SecretKey::from_hex(&body.secret_key) + .map_err(|e| format!("invalid secret key from bootstrap: {e}"))?; + let keys = nostr::Keys::new(secret_key); + let pubkey_hex = keys.public_key().to_hex(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; + + // Persist the bootstrap display name so get_identity returns it + // instead of a truncated npub. + *state.display_name.lock().map_err(|e| e.to_string())? = Some(body.username.clone()); + + Ok(InitializedIdentity { + pubkey: pubkey_hex, + display_name: body.username, + identity_mode: Some(identity_mode), + ws_auth_mode: "preauthenticated".to_string(), + }) + } + _ => { + // Normal mode: keys are already loaded (from env var or persisted file). + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey = keys.public_key(); + let pubkey_hex = pubkey.to_hex(); + let display_name = truncated_npub(&pubkey); + + Ok(InitializedIdentity { + pubkey: pubkey_hex, + display_name, + identity_mode: None, + ws_auth_mode: "nip42".to_string(), + }) + } + } +} + +/// Discover the relay's identity mode from the NIP-11 info document. +/// Falls back to the local `SPROUT_IDENTITY_MODE` env var if the relay +/// is unreachable (e.g. offline dev). +async fn discover_identity_mode(state: &State<'_, AppState>) -> Result { + let base_url = crate::relay::relay_api_base_url(); + let url = format!("{base_url}/info"); + + #[derive(serde::Deserialize)] + struct RelayInfoPartial { + #[serde(default)] + identity_mode: Option, + } + + match state + .http_client + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + if let Ok(info) = resp.json::().await { + if let Some(mode) = info.identity_mode.filter(|m| !m.is_empty()) { + return Ok(mode); + } + } + Ok("disabled".to_string()) + } + _ => { + // Relay unreachable — fall back to local env var. + Ok(std::env::var("SPROUT_IDENTITY_MODE") + .ok() + .filter(|s| !s.is_empty() && s != "disabled") + .unwrap_or_else(|| "disabled".to_string())) + } + } +} + #[tauri::command] pub fn get_nsec(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 34c2c311..02c975fd 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -549,6 +549,8 @@ pub fn run() { discover_managed_agent_prereqs, sign_event, create_auth_event, + set_identity_from_secret_key, + initialize_identity, get_channels, create_channel, open_dm, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index df0072e6..a4a77ebc 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -2,6 +2,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { RouterProvider } from "@tanstack/react-router"; import { useLayoutEffect } from "react"; +import { IdentityGate } from "@/app/IdentityGate"; import { router } from "@/app/router"; export function App() { @@ -9,5 +10,9 @@ export function App() { void getCurrentWindow().show(); }, []); - return ; + return ( + + + + ); } diff --git a/desktop/src/app/IdentityGate.tsx b/desktop/src/app/IdentityGate.tsx new file mode 100644 index 00000000..82e142c2 --- /dev/null +++ b/desktop/src/app/IdentityGate.tsx @@ -0,0 +1,77 @@ +import type * as React from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { initializeIdentity } from "@/shared/api/tauri"; +import { relayClient } from "@/shared/api/relayClient"; +import type { Identity } from "@/shared/api/types"; + +type IdentityGateProps = { + children: React.ReactNode; +}; + +export function IdentityGate({ children }: IdentityGateProps) { + const queryClient = useQueryClient(); + const identityInit = useQuery({ + queryKey: ["identity-init"], + queryFn: async () => { + const result = await initializeIdentity(); + + relayClient.configure({ authMode: result.wsAuthMode }); + + queryClient.setQueryData(["identity"], { + pubkey: result.pubkey, + displayName: result.displayName, + }); + + return result; + }, + staleTime: Number.POSITIVE_INFINITY, + retry: 2, + }); + + if (identityInit.isPending) { + return ( +
+

Connecting…

+
+ ); + } + + if (identityInit.isError) { + const errorMsg = + identityInit.error instanceof Error + ? identityInit.error.message + : String(identityInit.error); + const isNetworkOrParseError = + /failed to parse bootstrap response|error decoding|request failed|connection|timed? ?out|dns|resolve/i.test( + errorMsg, + ); + + return ( +
+

+ Failed to initialize identity. +

+ {isNetworkOrParseError ? ( +

+ Could not reach the relay. Make sure you are connected to Cloudflare + WARP and try again. +

+ ) : ( +

+ {errorMsg} +

+ )} + +
+ ); + } + + return children; +} diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index adee22e3..2693e9aa 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -21,7 +21,10 @@ function getResolvedProfile( export function mergeCurrentProfileIntoLookup( profiles: UserProfileLookup | undefined, currentProfile: - | Pick + | Pick< + Profile, + "pubkey" | "displayName" | "verifiedName" | "avatarUrl" | "nip05Handle" + > | null | undefined, ) { @@ -33,6 +36,7 @@ export function mergeCurrentProfileIntoLookup( ...(profiles ?? {}), [normalizePubkey(currentProfile.pubkey)]: { displayName: currentProfile.displayName, + verifiedName: currentProfile.verifiedName, avatarUrl: currentProfile.avatarUrl, nip05Handle: currentProfile.nip05Handle, }, @@ -64,6 +68,10 @@ export function resolveUserLabel(input: { } const profile = getResolvedProfile(pubkey, profiles); + const verifiedName = profile?.verifiedName?.trim(); + if (verifiedName) { + return verifiedName; + } const displayName = profile?.displayName?.trim(); if (displayName) { return displayName; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 721cce43..3a14415d 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -47,6 +47,7 @@ export class RelayClient { private hasConnectedOnce = false; private notifyReconnectListeners = false; private onMessageChannel: Channel | null = null; + private authMode: "nip42" | "preauthenticated" = "nip42"; async fetchChannelHistory(channelId: string, limit = 50) { return this.fetchHistory(this.buildChannelFilter(channelId, limit)); @@ -249,6 +250,10 @@ export class RelayClient { }; } + configure(options: { authMode: "nip42" | "preauthenticated" }) { + this.authMode = options.authMode; + } + private async ensureConnected() { if (this.connectPromise) { return this.connectPromise; @@ -290,22 +295,26 @@ export class RelayClient { config: {}, }); - await new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - this.authRequest = null; - this.resetConnection( - new Error("Timed out while waiting for relay authentication."), - ); - reject(new Error("Timed out while waiting for relay authentication.")); - }, 8_000); - - this.authRequest = { - pendingEventId: "", - resolve, - reject, - timeout, - }; - }); + if (this.authMode === "nip42") { + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + this.authRequest = null; + this.resetConnection( + new Error("Timed out while waiting for relay authentication."), + ); + reject( + new Error("Timed out while waiting for relay authentication."), + ); + }, 8_000); + + this.authRequest = { + pendingEventId: "", + resolve, + reject, + timeout, + }; + }); + } this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; await this.replayLiveSubscriptions(); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 561eede8..f0500f3a 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -14,6 +14,7 @@ import type { GetHomeFeedInput, HomeFeedResponse, Identity, + InitializedIdentity, MintTokenInput, MintTokenResponse, MintManagedAgentTokenInput, @@ -55,6 +56,7 @@ type RawIdentity = { type RawProfile = { pubkey: string; display_name: string | null; + verified_name: string | null; avatar_url: string | null; about: string | null; nip05_handle: string | null; @@ -62,6 +64,7 @@ type RawProfile = { type RawUserProfileSummary = { display_name: string | null; + verified_name: string | null; avatar_url: string | null; nip05_handle: string | null; }; @@ -74,6 +77,7 @@ type RawUsersBatchResponse = { type RawUserSearchResult = { pubkey: string; display_name: string | null; + verified_name: string | null; avatar_url: string | null; nip05_handle: string | null; }; @@ -417,6 +421,7 @@ function fromRawProfile(profile: RawProfile): Profile { return { pubkey: profile.pubkey, displayName: profile.display_name, + verifiedName: profile.verified_name, avatarUrl: profile.avatar_url, about: profile.about, nip05Handle: profile.nip05_handle, @@ -428,6 +433,7 @@ function fromRawUserProfileSummary( ): UserProfileSummary { return { displayName: profile.display_name, + verifiedName: profile.verified_name, avatarUrl: profile.avatar_url, nip05Handle: profile.nip05_handle, }; @@ -437,6 +443,7 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { return { pubkey: user.pubkey, displayName: user.display_name, + verifiedName: user.verified_name, avatarUrl: user.avatar_url, nip05Handle: user.nip05_handle, }; @@ -455,6 +462,25 @@ export async function getNsec(): Promise { return invokeTauri("get_nsec"); } +type RawInitializedIdentity = { + pubkey: string; + display_name: string; + identity_mode: string | null; + ws_auth_mode: "nip42" | "preauthenticated"; +}; + +export async function initializeIdentity(): Promise { + const result = await invokeTauri( + "initialize_identity", + ); + return { + pubkey: result.pubkey, + displayName: result.display_name, + identityMode: result.identity_mode, + wsAuthMode: result.ws_auth_mode, + }; +} + export async function getProfile(): Promise { const profile = await invokeTauri("get_profile"); return fromRawProfile(profile); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4e59b3ed..5313e505 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -103,9 +103,17 @@ export type Identity = { displayName: string; }; +export type InitializedIdentity = { + pubkey: string; + displayName: string; + identityMode: string | null; + wsAuthMode: "nip42" | "preauthenticated"; +}; + export type Profile = { pubkey: string; displayName: string | null; + verifiedName: string | null; avatarUrl: string | null; about: string | null; nip05Handle: string | null; @@ -113,6 +121,7 @@ export type Profile = { export type UserProfileSummary = { displayName: string | null; + verifiedName: string | null; avatarUrl: string | null; nip05Handle: string | null; }; @@ -125,6 +134,7 @@ export type UsersBatchResponse = { export type UserSearchResult = { pubkey: string; displayName: string | null; + verifiedName: string | null; avatarUrl: string | null; nip05Handle: string | null; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 953fbe35..d5d9cddc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -37,6 +37,7 @@ type RawMockTokenSeed = { type RawProfile = { pubkey: string; display_name: string | null; + verified_name: string | null; avatar_url: string | null; about: string | null; nip05_handle: string | null; @@ -44,6 +45,7 @@ type RawProfile = { type RawUserProfileSummary = { display_name: string | null; + verified_name: string | null; avatar_url: string | null; nip05_handle: string | null; }; @@ -741,6 +743,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null { return { pubkey: normalizedPubkey, display_name: mockDisplayNames.get(normalizedPubkey) ?? null, + verified_name: null, avatar_url: null, about: null, nip05_handle: null, @@ -1309,6 +1312,7 @@ const mockProfiles = new Map([ { pubkey: MOCK_IDENTITY_PUBKEY, display_name: DEFAULT_MOCK_IDENTITY.display_name, + verified_name: null, avatar_url: null, about: null, nip05_handle: null, @@ -1405,6 +1409,7 @@ function ensureMockProfile(config: E2eConfig | undefined): RawProfile { const profile = { pubkey, display_name: getMockMemberDisplayName(config), + verified_name: null, avatar_url: null, about: null, nip05_handle: null, @@ -2136,6 +2141,7 @@ async function handleGetUsersBatch( profiles[normalizedPubkey] = { display_name: profile.display_name, + verified_name: profile.verified_name, avatar_url: profile.avatar_url, nip05_handle: profile.nip05_handle, }; diff --git a/schema/schema.sql b/schema/schema.sql index a7503839..7c9dfb40 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -72,6 +72,7 @@ CREATE TABLE users ( pubkey BYTEA PRIMARY KEY, nip05_handle VARCHAR(255) UNIQUE, display_name VARCHAR(255), + verified_name VARCHAR(255), avatar_url TEXT, about TEXT, agent_type VARCHAR(255), From 07b2d38db8f1acdf2b133d2ad8ce098f812adc98 Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Thu, 9 Apr 2026 11:19:49 -0600 Subject: [PATCH 2/7] refactor: pivot identity mode from server-derived keys to client-generated pubkey binding Replace the relay-derived HMAC keypair approach with client-generated keys and server-side identity binding. The relay no longer knows private keys. Key changes: - New identity_bindings table: maps (uid, device_cn) to pubkey, enabling multi-device support under one corporate identity - POST /api/identity/register replaces /api/identity/bootstrap: client proves pubkey ownership via NIP-98, relay binds it to JWT identity - WS proxy mode now requires NIP-42 AUTH (no more pre-authentication): proxy identity claims are stashed at upgrade time, binding resolved when client sends AUTH event with its own key - REST proxy auth resolves pubkey from binding lookup instead of HMAC - Desktop generates/persists its own keypair locally and registers it with the relay on startup (no secret key returned from server) - Removed: HMAC key derivation, SPROUT_IDENTITY_SECRET/CONTEXT env vars, bootstrap endpoint, set_identity_from_secret_key Tauri command Security improvements: - Relay never sees or transmits private keys - No server-side secret whose compromise would rotate all identities - Per-device key isolation via (uid, device_cn) binding - Proof-of-possession required for both WS (NIP-42) and REST (NIP-98) Amp-Thread-ID: https://ampcode.com/threads/T-019d731d-8157-752e-adf2-387f5d48f3a5 Co-authored-by: Amp --- .env.example | 13 +- Cargo.lock | 1 - crates/sprout-auth/Cargo.toml | 1 - crates/sprout-auth/src/identity.rs | 133 +----------- crates/sprout-auth/src/lib.rs | 38 +--- crates/sprout-db/src/identity_binding.rs | 162 ++++++++++++++ crates/sprout-db/src/lib.rs | 32 +++ crates/sprout-relay/src/api/identity.rs | 237 ++++++++++++++++----- crates/sprout-relay/src/api/mod.rs | 87 +++++--- crates/sprout-relay/src/config.rs | 12 -- crates/sprout-relay/src/connection.rs | 75 ++++--- crates/sprout-relay/src/handlers/auth.rs | 104 ++++++++- crates/sprout-relay/src/router.rs | 38 ++-- desktop/src-tauri/src/commands/identity.rs | 73 ++++--- desktop/src-tauri/src/lib.rs | 1 - schema/schema.sql | 17 ++ 16 files changed, 686 insertions(+), 338 deletions(-) create mode 100644 crates/sprout-db/src/identity_binding.rs diff --git a/.env.example b/.env.example index 6de0712c..1e9e18de 100644 --- a/.env.example +++ b/.env.example @@ -84,17 +84,12 @@ OKTA_AUDIENCE=sprout-desktop # ── Corporate identity mode ───────────────────────────────────────────────── # Identity mode: "disabled" (default), "proxy", "hybrid". # proxy — relay validates x-forwarded-identity-token JWT on every request -# (injected by cf-doorman). Nostr keypairs are derived from the uid claim -# using a secret-backed HMAC. The desktop obtains its derived key via -# POST /api/identity/bootstrap (also behind cf-doorman). +# (injected by cf-doorman). Clients generate their own Nostr keypairs +# and register them via POST /api/identity/register. The relay binds +# (uid, device_cn) → pubkey in the identity_bindings table. # Requires OKTA_JWKS_URI/ISSUER/AUDIENCE pointed at cf-doorman's JWKS. # SPROUT_IDENTITY_MODE=disabled -# High-entropy secret for deterministic keypair derivation (REQUIRED in proxy mode). -# Generate with: openssl rand -hex 32 -# SPROUT_IDENTITY_SECRET= -# Domain-separation context string (versioned for rotation). -# SPROUT_IDENTITY_CONTEXT=sprout/nostr-id/v1 -# JWT claim names for uid (key derivation) and username (display only). +# JWT claim names for uid (identity binding key) and username (display only). # SPROUT_IDENTITY_UID_CLAIM=uid # SPROUT_IDENTITY_USER_CLAIM=user diff --git a/Cargo.lock b/Cargo.lock index 32a668a1..c0704b83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3683,7 +3683,6 @@ version = "0.1.0" dependencies = [ "chrono", "hex", - "hmac", "jsonwebtoken", "nostr", "rand 0.10.0", diff --git a/crates/sprout-auth/Cargo.toml b/crates/sprout-auth/Cargo.toml index cd64c0f9..69522691 100644 --- a/crates/sprout-auth/Cargo.toml +++ b/crates/sprout-auth/Cargo.toml @@ -22,7 +22,6 @@ tracing = { workspace = true } thiserror = { workspace = true } jsonwebtoken = { workspace = true } sha2 = { workspace = true } -hmac = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } rand = { workspace = true } diff --git a/crates/sprout-auth/src/identity.rs b/crates/sprout-auth/src/identity.rs index b9ee8959..bcc5b338 100644 --- a/crates/sprout-auth/src/identity.rs +++ b/crates/sprout-auth/src/identity.rs @@ -1,17 +1,14 @@ //! Corporate identity mode for the Sprout relay. //! //! Supports proxy-based identity where an upstream reverse proxy (e.g. cf-doorman) -//! injects identity JWTs. The relay derives deterministic Nostr keypairs from -//! the corporate UID claim, so users don't need to manage Nostr keys directly. +//! injects identity JWTs. The relay extracts corporate identity claims and binds +//! the client's self-generated pubkey to them. use std::fmt; use std::str::FromStr; -use hmac::Mac; use serde::{Deserialize, Serialize}; -use crate::error::AuthError; - /// How corporate identity is resolved for incoming connections. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum IdentityMode { @@ -70,15 +67,6 @@ pub struct IdentityConfig { /// JWT claim name containing the human-readable username. #[serde(default = "default_user_claim")] pub user_claim: String, - /// High-entropy secret used as the HMAC key for deterministic keypair derivation. - /// - /// **Required** when `mode = Proxy`. Without this, anyone who knows a UID could - /// derive that user's private key. The secret ensures that UID alone is insufficient. - #[serde(default)] - pub secret: String, - /// Domain-separation context string for keypair derivation (versioned for rotation). - #[serde(default = "default_context")] - pub context: String, } impl Default for IdentityConfig { @@ -87,8 +75,6 @@ impl Default for IdentityConfig { mode: default_mode(), uid_claim: default_uid_claim(), user_claim: default_user_claim(), - secret: String::new(), - context: default_context(), } } } @@ -105,10 +91,6 @@ fn default_user_claim() -> String { "user".to_string() } -fn default_context() -> String { - "sprout/nostr-id/v1".to_string() -} - // Custom serde for IdentityMode as a lowercase string. impl Serialize for IdentityMode { fn serialize(&self, serializer: S) -> Result { @@ -123,94 +105,22 @@ impl<'de> Deserialize<'de> for IdentityMode { } } -/// Derive a deterministic Nostr keypair from a corporate UID using a secret-backed HMAC. -/// -/// Uses HMAC-SHA256 with `secret` as the key and `context:uid` as the message. -/// The `secret` must be high-entropy material (≥32 bytes recommended) — without it, -/// anyone who knows a UID could derive that user's private key. -/// -/// The `context` string provides domain separation and version namespacing -/// (e.g. `"sprout/nostr-id/v1"`), enabling key rotation by changing the context. +/// Claims extracted from a validated proxy identity JWT. /// -/// The 32-byte HMAC output is used directly as a secp256k1 secret key. -/// -/// # Errors -/// -/// Returns [`AuthError::Internal`] if `uid` or `secret` is empty, or key derivation fails. -pub fn derive_keypair_from_uid( - secret: &str, - context: &str, - uid: &str, -) -> Result { - if uid.is_empty() { - return Err(AuthError::Internal("uid must not be empty".into())); - } - if secret.is_empty() { - return Err(AuthError::Internal( - "identity secret must not be empty — set SPROUT_IDENTITY_SECRET".into(), - )); - } - - let mut mac = hmac::Hmac::::new_from_slice(secret.as_bytes()) - .map_err(|e| AuthError::Internal(format!("HMAC init failed: {e}")))?; - mac.update(context.as_bytes()); - mac.update(b":"); - mac.update(uid.as_bytes()); - let result = mac.finalize().into_bytes(); - - let secret_key = nostr::SecretKey::from_slice(&result) - .map_err(|e| AuthError::Internal(format!("key derivation failed: {e}")))?; - Ok(nostr::Keys::new(secret_key)) +/// Used by the relay to identify the corporate user without deriving keys. +/// The relay binds the client's self-generated pubkey to these claims. +#[derive(Debug, Clone)] +pub struct ProxyIdentityClaims { + /// Corporate user identifier (stable, immutable). + pub uid: String, + /// Human-readable username for display purposes. + pub username: String, } #[cfg(test)] mod tests { use super::*; - const TEST_SECRET: &str = "test-secret-at-least-32-bytes-long!!"; - - #[test] - fn derive_keypair_deterministic() { - let a = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); - let b = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); - assert_eq!(a.public_key(), b.public_key()); - } - - #[test] - fn derive_keypair_different_uid() { - let a = derive_keypair_from_uid(TEST_SECRET, "ctx", "alice").unwrap(); - let b = derive_keypair_from_uid(TEST_SECRET, "ctx", "bob").unwrap(); - assert_ne!(a.public_key(), b.public_key()); - } - - #[test] - fn derive_keypair_different_context() { - let a = derive_keypair_from_uid(TEST_SECRET, "ctx-a", "alice").unwrap(); - let b = derive_keypair_from_uid(TEST_SECRET, "ctx-b", "alice").unwrap(); - assert_ne!(a.public_key(), b.public_key()); - } - - #[test] - fn derive_keypair_different_secret() { - let a = - derive_keypair_from_uid("secret-one-xxxxxxxxxxxxxxxxxxxxxxx", "ctx", "alice").unwrap(); - let b = - derive_keypair_from_uid("secret-two-xxxxxxxxxxxxxxxxxxxxxxx", "ctx", "alice").unwrap(); - assert_ne!(a.public_key(), b.public_key()); - } - - #[test] - fn derive_keypair_empty_uid_fails() { - let result = derive_keypair_from_uid(TEST_SECRET, "ctx", ""); - assert!(result.is_err()); - } - - #[test] - fn derive_keypair_empty_secret_fails() { - let result = derive_keypair_from_uid("", "ctx", "alice"); - assert!(result.is_err()); - } - #[test] fn identity_mode_from_str() { assert_eq!( @@ -243,32 +153,11 @@ mod tests { assert!(IdentityMode::Hybrid.is_proxy()); } - /// Golden vector: pin the derivation output so relay and desktop bootstrap stay in sync. - /// If this test breaks, all existing proxy-mode identities will rotate. - #[test] - fn derive_keypair_golden_vector() { - let keys = derive_keypair_from_uid( - "golden-test-secret-do-not-change!", - "sprout/nostr-id/v1", - "12345", - ) - .unwrap(); - let hex = keys.public_key().to_hex(); - // Pin the value — changing this means all existing proxy-mode identities rotate. - // Computed with: HMAC-SHA256(key="golden-test-secret-do-not-change!", msg="sprout/nostr-id/v1:12345") - assert_eq!( - hex, - "92458a8e3e8e3203b3c8d0c8772bf948f124d5ab973dc666d2a9e62a94c6d29d" - ); - } - #[test] fn identity_config_defaults() { let config = IdentityConfig::default(); assert_eq!(config.mode, IdentityMode::Disabled); assert_eq!(config.uid_claim, "uid"); assert_eq!(config.user_claim, "user"); - assert!(config.secret.is_empty()); - assert_eq!(config.context, "sprout/nostr-id/v1"); } } diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 55e26b83..3ec1e456 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -19,7 +19,7 @@ pub mod access; /// Authentication error types. pub mod error; -/// Corporate identity mode (proxy-injected JWTs, deterministic keypair derivation). +/// Corporate identity mode (proxy-injected JWTs, identity claims extraction). pub mod identity; /// NIP-42 challenge–response authentication. pub mod nip42; @@ -36,7 +36,7 @@ pub mod token; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; pub use error::AuthError; -pub use identity::{derive_keypair_from_uid, IdentityConfig, IdentityMode}; +pub use identity::{IdentityConfig, IdentityMode, ProxyIdentityClaims}; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig}; @@ -97,7 +97,7 @@ pub struct AuthConfig { /// Per-user and per-IP rate limit thresholds. #[serde(default)] pub rate_limits: RateLimitConfig, - /// Corporate identity mode (proxy JWT, deterministic keypair derivation). + /// Corporate identity mode (proxy JWT, identity claims extraction). #[serde(default)] pub identity: IdentityConfig, } @@ -335,32 +335,17 @@ impl AuthService { &self.config.identity } - /// Validate a proxy-injected identity JWT and derive the Nostr pubkey. + /// Validate a proxy-injected identity JWT and extract the corporate identity claims. /// /// Used in proxy identity mode where cf-doorman injects `x-forwarded-identity-token`. - /// Validates the JWT via JWKS (same infrastructure as Okta), extracts the `uid` claim, - /// and derives a deterministic Nostr keypair via HMAC-SHA256. + /// Validates the JWT via JWKS (same infrastructure as Okta), extracts the `uid` and + /// `user` claims. /// - /// Returns `(pubkey, all_known_scopes, username)` on success. The username is - /// extracted from the `user` claim for display purposes; it is not used for - /// key derivation (UIDs are immutable, usernames are not). + /// Returns `(claims, all_known_scopes)` on success. pub async fn validate_identity_jwt( &self, jwt: &str, - ) -> Result<(nostr::PublicKey, Vec, String), AuthError> { - let (keys, scopes, username) = self.validate_identity_jwt_keys(jwt).await?; - Ok((keys.public_key(), scopes, username)) - } - - /// Like [`validate_identity_jwt`] but returns the full [`nostr::Keys`] (including - /// the secret key) instead of just the public key. - /// - /// Used by the identity bootstrap endpoint to return the derived secret key to - /// the desktop client. The secret key travels over TLS behind cf-doorman. - pub async fn validate_identity_jwt_keys( - &self, - jwt: &str, - ) -> Result<(nostr::Keys, Vec, String), AuthError> { + ) -> Result<(identity::ProxyIdentityClaims, Vec), AuthError> { let cached = self .jwks_cache .get_or_refresh( @@ -392,14 +377,9 @@ impl AuthService { .unwrap_or("unknown") .to_string(); - let keys = derive_keypair_from_uid( - &self.config.identity.secret, - &self.config.identity.context, - &uid, - )?; let scopes = Scope::all_known(); - Ok((keys, scopes, username)) + Ok((identity::ProxyIdentityClaims { uid, username }, scopes)) } } diff --git a/crates/sprout-db/src/identity_binding.rs b/crates/sprout-db/src/identity_binding.rs new file mode 100644 index 00000000..910979ea --- /dev/null +++ b/crates/sprout-db/src/identity_binding.rs @@ -0,0 +1,162 @@ +//! Identity binding persistence for proxy identity mode. +//! +//! Maps (corporate_uid, device_cn) pairs to Nostr pubkeys. Each device +//! gets its own binding, enabling multi-device support under one corporate +//! identity. + +use crate::error::Result; +use sqlx::PgPool; + +/// Result of attempting to bind a pubkey to a (uid, device_cn) pair. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingResult { + /// No prior binding existed; a new one was created. + Created, + /// A binding already existed and the pubkey matches. + Matched, + /// A binding already existed but for a different pubkey. + Mismatch { + /// The pubkey that is already bound to this (uid, device_cn). + existing_pubkey: Vec, + }, +} + +/// A stored identity binding record. +#[derive(Debug, Clone)] +pub struct IdentityBinding { + /// Corporate user identifier. + pub uid: String, + /// Device common name from client certificate. + pub device_cn: String, + /// Bound Nostr public key (32 bytes). + pub pubkey: Vec, + /// Cached username from the identity JWT. + pub username: Option, +} + +/// Look up a binding by (uid, device_cn). +pub async fn get_identity_binding( + pool: &PgPool, + uid: &str, + device_cn: &str, +) -> Result> { + let row = sqlx::query_as::<_, (String, String, Vec, Option)>( + r#" + SELECT uid, device_cn, pubkey, username + FROM identity_bindings + WHERE uid = $1 AND device_cn = $2 + "#, + ) + .bind(uid) + .bind(device_cn) + .fetch_optional(pool) + .await?; + + Ok(row.map(|(uid, device_cn, pubkey, username)| IdentityBinding { + uid, + device_cn, + pubkey, + username, + })) +} + +/// Bind a pubkey to (uid, device_cn), or validate an existing binding. +/// +/// Uses `SELECT ... FOR UPDATE` inside a transaction to prevent race conditions +/// on first bind. +/// +/// Returns: +/// - `Created` if no prior binding existed and a new one was inserted. +/// - `Matched` if the existing binding's pubkey matches. +/// - `Mismatch` if the existing binding has a different pubkey. +pub async fn bind_or_validate_identity( + pool: &PgPool, + uid: &str, + device_cn: &str, + pubkey: &[u8], + username: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let existing = sqlx::query_as::<_, (Vec,)>( + r#" + SELECT pubkey + FROM identity_bindings + WHERE uid = $1 AND device_cn = $2 + FOR UPDATE + "#, + ) + .bind(uid) + .bind(device_cn) + .fetch_optional(&mut *tx) + .await?; + + let result = match existing { + Some((existing_pubkey,)) => { + if existing_pubkey == pubkey { + // Update last_seen_at and username on successful match. + sqlx::query( + r#" + UPDATE identity_bindings + SET last_seen_at = NOW(), username = NULLIF($3, '') + WHERE uid = $1 AND device_cn = $2 + "#, + ) + .bind(uid) + .bind(device_cn) + .bind(username) + .execute(&mut *tx) + .await?; + BindingResult::Matched + } else { + BindingResult::Mismatch { existing_pubkey } + } + } + None => { + sqlx::query( + r#" + INSERT INTO identity_bindings (uid, device_cn, pubkey, username) + VALUES ($1, $2, $3, NULLIF($4, '')) + "#, + ) + .bind(uid) + .bind(device_cn) + .bind(pubkey) + .bind(username) + .execute(&mut *tx) + .await?; + BindingResult::Created + } + }; + + tx.commit().await?; + Ok(result) +} + +/// Get all bindings for a given uid (all devices). +pub async fn get_bindings_for_uid( + pool: &PgPool, + uid: &str, +) -> Result> { + let rows = sqlx::query_as::<_, (String, String, Vec, Option)>( + r#" + SELECT uid, device_cn, pubkey, username + FROM identity_bindings + WHERE uid = $1 + ORDER BY created_at + "#, + ) + .bind(uid) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(uid, device_cn, pubkey, username)| IdentityBinding { + uid, + device_cn, + pubkey, + username, + }) + .collect()) +} diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index c401e217..4ebbf8f6 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -21,6 +21,8 @@ pub mod error; pub mod event; /// Home feed queries. pub mod feed; +/// Identity binding persistence (corporate UID + device → pubkey). +pub mod identity_binding; /// Monthly table partition management. pub mod partition; /// Reaction persistence. @@ -34,6 +36,7 @@ pub mod workflow; pub use error::{DbError, Result}; pub use event::EventQuery; +pub use identity_binding::{BindingResult, IdentityBinding}; use chrono::{DateTime, Utc}; use sqlx::postgres::PgPoolOptions; @@ -509,6 +512,35 @@ impl Db { user::ensure_user_with_verified_name(&self.pool, pubkey, verified_name).await } + /// Look up an identity binding by (uid, device_cn). + pub async fn get_identity_binding( + &self, + uid: &str, + device_cn: &str, + ) -> Result> { + identity_binding::get_identity_binding(&self.pool, uid, device_cn).await + } + + /// Bind a pubkey to (uid, device_cn) or validate an existing binding. + pub async fn bind_or_validate_identity( + &self, + uid: &str, + device_cn: &str, + pubkey: &[u8], + username: &str, + ) -> Result { + identity_binding::bind_or_validate_identity(&self.pool, uid, device_cn, pubkey, username) + .await + } + + /// Get all identity bindings for a given uid. + pub async fn get_bindings_for_uid( + &self, + uid: &str, + ) -> Result> { + identity_binding::get_bindings_for_uid(&self.pool, uid).await + } + /// Get a single user record by pubkey. pub async fn get_user(&self, pubkey: &[u8]) -> Result> { user::get_user(&self.pool, pubkey).await diff --git a/crates/sprout-relay/src/api/identity.rs b/crates/sprout-relay/src/api/identity.rs index c05847b0..ac794295 100644 --- a/crates/sprout-relay/src/api/identity.rs +++ b/crates/sprout-relay/src/api/identity.rs @@ -1,18 +1,17 @@ -//! Identity bootstrap endpoint for proxy/hybrid identity mode. +//! Identity registration endpoint for proxy/hybrid identity mode. //! -//! In proxy mode, the desktop client cannot derive its own Nostr keypair because -//! the derivation secret is held only by the relay. This endpoint validates the -//! client's identity JWT (injected by cf-doorman) and returns the derived secret -//! key so the client can sign events locally. +//! In proxy mode, the desktop client generates its own Nostr keypair locally. +//! This endpoint binds the client's public key to its corporate identity +//! (UID + device) so the relay can resolve identity on subsequent requests. //! //! The endpoint is only available when `SPROUT_IDENTITY_MODE=proxy` or `hybrid`. //! //! # Trusted-proxy assumption //! -//! The relay trusts the `x-forwarded-identity-token` header unconditionally. +//! The relay trusts the `x-forwarded-identity-token` and +//! `x-block-client-cert-subject-cn` headers unconditionally. //! It MUST be deployed behind a trusted reverse proxy (cf-doorman) that is the -//! sole source of this header. If the relay port is directly reachable, an -//! attacker could inject arbitrary identity headers. +//! sole source of these headers. use std::sync::Arc; @@ -21,51 +20,53 @@ use axum::{ http::{HeaderMap, StatusCode}, response::Json, }; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use crate::state::AppState; -/// `POST /api/identity/bootstrap` +/// `POST /api/identity/register` /// -/// Validates the caller's `x-forwarded-identity-token` JWT and returns the -/// derived Nostr secret key (hex-encoded) for that user. +/// Binds the caller's Nostr public key to their corporate identity (UID + device). +/// The caller proves key ownership via a NIP-98 signed event in the `Authorization` +/// header. /// -/// Uses POST (not GET) because the response contains a secret key that must -/// never be cached by intermediaries. +/// # Headers /// -/// # Security +/// - `x-forwarded-identity-token`: Corporate identity JWT (injected by cf-doorman) +/// - `x-block-client-cert-subject-cn`: Device identifier from client certificate +/// - `Authorization: Nostr `: NIP-98 signed event proving pubkey ownership /// -/// - Only available when `SPROUT_IDENTITY_MODE=proxy` or `hybrid`. -/// - The identity JWT is validated via JWKS (signature, issuer, audience, expiry). -/// - Each caller only receives **their own** derived key — never another user's. -/// - The derivation secret (`SPROUT_IDENTITY_SECRET`) never leaves the relay. -/// - Transport is TLS behind cf-doorman; the secret key travels only over the -/// authenticated, encrypted channel. -/// - Response includes `Cache-Control: no-store` to prevent intermediary caching. +/// # Binding semantics +/// +/// - First request from a (UID, device) pair: creates a new binding. +/// - Subsequent requests with the same pubkey: succeeds (idempotent). +/// - Request with a different pubkey for an already-bound (UID, device): returns +/// 409 Conflict with `identity_binding_mismatch`. /// /// # Response /// /// ```json /// { /// "pubkey": "abcd1234…", -/// "secret_key": "ef012345…", -/// "username": "alice" +/// "username": "alice", +/// "binding_status": "created" /// } /// ``` -pub async fn identity_bootstrap( +pub async fn identity_register( State(state): State>, headers: HeaderMap, -) -> Result<(StatusCode, HeaderMap, Json), (StatusCode, Json)> -{ +) -> Result, (StatusCode, Json)> { if !state.auth.identity_config().mode.is_proxy() { return Err(( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not_available", - "message": "identity bootstrap is only available in proxy identity mode" + "message": "identity registration is only available in proxy identity mode" })), )); } + // 1. Validate proxy identity JWT → extract uid + username let identity_jwt = headers .get("x-forwarded-identity-token") .and_then(|v| v.to_str().ok()) @@ -79,43 +80,177 @@ pub async fn identity_bootstrap( ) })?; - let (keys, _scopes, username) = state + let (identity_claims, _scopes) = state .auth - .validate_identity_jwt_keys(identity_jwt) + .validate_identity_jwt(identity_jwt) .await .map_err(|e| { - tracing::warn!("identity bootstrap: JWT validation failed: {e}"); + tracing::warn!("identity register: JWT validation failed: {e}"); ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_token_invalid" })), ) })?; - let pubkey_bytes = keys.public_key().serialize().to_vec(); + // 2. Extract device identifier from client certificate CN + let device_cn = headers + .get("x-block-client-cert-subject-cn") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "device_cn_required", + "message": "x-block-client-cert-subject-cn header is required" + })), + ) + })?; + + // 3. Verify NIP-98 auth to prove pubkey ownership + let auth_header = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "authorization_required", + "message": "Authorization: Nostr header is required for identity registration" + })), + ) + })?; + + let nostr_b64 = auth_header.strip_prefix("Nostr ").ok_or_else(|| { + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "nip98_required", + "message": "identity registration requires NIP-98 auth (Authorization: Nostr )" + })), + ) + })?; + + let decoded_bytes = BASE64.decode(nostr_b64).map_err(|_| { + tracing::warn!("identity register: NIP-98 base64 decode failed"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "nip98_invalid" })), + ) + })?; + + let event_json = String::from_utf8(decoded_bytes).map_err(|_| { + tracing::warn!("identity register: NIP-98 decoded bytes are not valid UTF-8"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "nip98_invalid" })), + ) + })?; + + let canonical_url = reconstruct_canonical_url(&headers, &state); + + let pubkey = + sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", None).map_err( + |e| { + tracing::warn!("identity register: NIP-98 verification failed: {e}"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "nip98_invalid" })), + ) + }, + )?; + + let pubkey_bytes = pubkey.serialize().to_vec(); + + // 4. Bind or validate the identity + let result = state + .db + .bind_or_validate_identity( + &identity_claims.uid, + device_cn, + &pubkey_bytes, + &identity_claims.username, + ) + .await + .map_err(|e| { + tracing::error!("identity register: DB error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "internal_error" })), + ) + })?; + + match result { + sprout_db::BindingResult::Mismatch { existing_pubkey } => { + let existing_hex = nostr::PublicKey::from_slice(&existing_pubkey) + .map(|pk| pk.to_hex()) + .unwrap_or_else(|_| hex::encode(&existing_pubkey)); + tracing::warn!( + uid = %identity_claims.uid, + device_cn = %device_cn, + presented = %pubkey.to_hex(), + existing = %existing_hex, + "identity binding mismatch" + ); + return Err(( + StatusCode::CONFLICT, + Json(serde_json::json!({ + "error": "identity_binding_mismatch", + "message": "this device is already bound to a different pubkey" + })), + )); + } + ref status => { + tracing::info!( + uid = %identity_claims.uid, + device_cn = %device_cn, + pubkey = %pubkey.to_hex(), + status = ?status, + "identity binding resolved" + ); + } + } + + // 5. Ensure user record exists with verified name if let Err(e) = state .db - .ensure_user_with_verified_name(&pubkey_bytes, &username) + .ensure_user_with_verified_name(&pubkey_bytes, &identity_claims.username) .await { - tracing::warn!("identity bootstrap: ensure_user_with_verified_name failed: {e}"); + tracing::warn!("identity register: ensure_user_with_verified_name failed: {e}"); } - // ⚠️ SECURITY: secret_key is logged at no level — it is sensitive material. - // The response travels over TLS behind cf-doorman. - let mut resp_headers = HeaderMap::new(); - resp_headers.insert( - axum::http::header::CACHE_CONTROL, - "no-store, private, max-age=0".parse().unwrap(), - ); - resp_headers.insert(axum::http::header::PRAGMA, "no-cache".parse().unwrap()); - - Ok(( - StatusCode::OK, - resp_headers, - Json(serde_json::json!({ - "pubkey": keys.public_key().to_hex(), - "secret_key": keys.secret_key().to_secret_hex(), - "username": username, - })), - )) + let binding_status = match result { + sprout_db::BindingResult::Created => "created", + sprout_db::BindingResult::Matched => "existing", + sprout_db::BindingResult::Mismatch { .. } => unreachable!(), + }; + + Ok(Json(serde_json::json!({ + "pubkey": pubkey.to_hex(), + "username": identity_claims.username, + "binding_status": binding_status, + }))) +} + +/// Reconstruct the canonical URL for NIP-98 verification from proxy headers. +fn reconstruct_canonical_url(headers: &HeaderMap, state: &AppState) -> String { + let proto = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("https"); + let host = headers + .get("x-forwarded-host") + .or_else(|| headers.get("host")) + .and_then(|v| v.to_str().ok()); + + if let Some(host) = host { + format!("{proto}://{host}/api/identity/register") + } else { + let base = state + .config + .relay_url + .replace("wss://", "https://") + .replace("ws://", "http://"); + format!("{base}/api/identity/register") + } } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index eed4d07a..72e1ea6d 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -156,7 +156,8 @@ pub(crate) async fn extract_auth_context( // ── 0. Proxy / hybrid identity mode ────────────────────────────────── // When identity_mode is proxy or hybrid, cf-doorman injects - // x-forwarded-identity-token for human users. + // x-forwarded-identity-token for human users. The relay validates the JWT, + // extracts uid + device_cn, and looks up the pubkey binding from the DB. // - Proxy: header is mandatory — reject if missing. // - Hybrid: header is preferred — fall through to standard auth if missing. // In both modes, a present-but-invalid header is a hard 401. @@ -166,33 +167,71 @@ pub(crate) async fn extract_auth_context( .get("x-forwarded-identity-token") .and_then(|v| v.to_str().ok()) { - match state.auth.validate_identity_jwt(identity_jwt).await { - Ok((pubkey, scopes, username)) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state - .db - .ensure_user_with_verified_name(&pubkey_bytes, &username) - .await - { - tracing::warn!("ensure_user_with_verified_name failed: {e}"); - } - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes, - auth_method: RestAuthMethod::ProxyIdentity, - token_id: None, - channel_ids: None, - }); - } - Err(e) => { + let (identity_claims, scopes) = + state.auth.validate_identity_jwt(identity_jwt).await.map_err(|e| { tracing::warn!("auth: identity JWT validation failed: {e}"); - return Err(( + ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_token_invalid" })), - )); - } + ) + })?; + + let device_cn = headers + .get("x-block-client-cert-subject-cn") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + + // Look up the pubkey binding for this (uid, device_cn). + let binding = state + .db + .get_identity_binding(&identity_claims.uid, device_cn) + .await + .map_err(|e| { + tracing::error!("auth: identity binding lookup failed: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "internal_error" })), + ) + })? + .ok_or_else(|| { + tracing::warn!( + uid = %identity_claims.uid, + device_cn = %device_cn, + "no identity binding — call POST /api/identity/register first" + ); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "identity_binding_required", + "message": "no identity binding for this device — call POST /api/identity/register first" + })), + ) + })?; + + let pubkey = nostr::PublicKey::from_slice(&binding.pubkey).map_err(|e| { + tracing::error!("auth: stored binding pubkey invalid: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "internal_error" })), + ) + })?; + let pubkey_bytes = binding.pubkey; + + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &identity_claims.username) + .await + { + tracing::warn!("ensure_user_with_verified_name failed: {e}"); } + return Ok(RestAuthContext { + pubkey, + pubkey_bytes, + scopes, + auth_method: RestAuthMethod::ProxyIdentity, + token_id: None, + channel_ids: None, + }); } else if *identity_mode == sprout_auth::IdentityMode::Proxy { tracing::warn!( "auth: proxy mode enabled but x-forwarded-identity-token header missing" diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index a7aeea8a..23ebe549 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -14,9 +14,6 @@ pub enum ConfigError { /// The `SPROUT_IDENTITY_MODE` environment variable contains an unrecognised value. #[error("invalid SPROUT_IDENTITY_MODE: {0}")] InvalidIdentityMode(String), - /// `SPROUT_IDENTITY_SECRET` is required when identity mode is `proxy`. - #[error("SPROUT_IDENTITY_SECRET is required when SPROUT_IDENTITY_MODE=proxy or hybrid")] - MissingIdentitySecret, } /// Relay runtime configuration, loaded from environment variables. @@ -143,12 +140,6 @@ impl Config { auth.identity.mode = identity_mode.clone(); - if let Ok(secret) = std::env::var("SPROUT_IDENTITY_SECRET") { - auth.identity.secret = secret; - } - if let Ok(ctx) = std::env::var("SPROUT_IDENTITY_CONTEXT") { - auth.identity.context = ctx; - } if let Ok(uid_claim) = std::env::var("SPROUT_IDENTITY_UID_CLAIM") { auth.identity.uid_claim = uid_claim; } @@ -160,9 +151,6 @@ impl Config { // (cf-doorman) — force require_auth_token so the NIP-42 fallback path // cannot be used with bare keypair-only auth. let require_auth_token = if identity_mode.is_proxy() { - if auth.identity.secret.is_empty() { - return Err(ConfigError::MissingIdentitySecret); - } if !require_auth_token { tracing::info!( "Identity mode: {identity_mode} — overriding SPROUT_REQUIRE_AUTH_TOKEN to true" diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index 2556e0ae..b928f18a 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -27,6 +27,24 @@ pub(crate) const SLOW_CLIENT_GRACE_LIMIT: u8 = 3; /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; +/// Proxy identity claims stashed on the connection at upgrade time. +/// +/// In proxy/hybrid mode the JWT and device_cn headers are validated during +/// the HTTP → WS upgrade, but the client's pubkey is not yet known. The +/// claims are held here until the NIP-42 AUTH event arrives, at which point +/// the relay can bind (uid, device_cn) → pubkey. +#[derive(Debug, Clone)] +pub struct PendingProxyIdentity { + /// Corporate user identifier extracted from the identity JWT. + pub uid: String, + /// Human-readable username from the identity JWT. + pub username: String, + /// Device common name from the `x-block-client-cert-subject-cn` header. + pub device_cn: String, + /// Permission scopes granted by the identity JWT. + pub scopes: Vec, +} + /// NIP-42 authentication state for a single connection. #[derive(Debug, Clone)] pub enum AuthState { @@ -34,6 +52,10 @@ pub enum AuthState { Pending { /// The random challenge string sent to the client. challenge: String, + /// If set, proxy identity was validated at upgrade time and the + /// AUTH handler should resolve the pubkey binding instead of + /// performing standard JWT/token auth. + proxy_identity: Option, }, /// Client has successfully authenticated. Authenticated(AuthContext), @@ -105,14 +127,14 @@ impl ConnectionState { /// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge, /// then drives the send, heartbeat, and receive loops until the connection closes. /// -/// If `pre_auth` is `Some`, the connection is immediately authenticated (skipping -/// the NIP-42 challenge–response). Used in proxy identity mode where the upstream -/// reverse proxy has already validated the user's identity. +/// If `proxy_identity` is `Some`, the connection has a validated corporate +/// identity from the proxy but still requires NIP-42 to prove the client's +/// Nostr pubkey. The AUTH handler will bind (uid, device_cn) → pubkey. pub async fn handle_connection( socket: WebSocket, state: Arc, addr: SocketAddr, - pre_auth: Option, + proxy_identity: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -133,21 +155,16 @@ pub async fn handle_connection( let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); - // Pre-authenticated connections (proxy identity mode) skip NIP-42 entirely. - let initial_auth_state = match pre_auth { - Some(ctx) => { - info!(conn_id = %conn_id, addr = %addr, pubkey = %ctx.pubkey.to_hex(), - "WebSocket pre-authenticated via proxy identity"); - AuthState::Authenticated(ctx) - } - None => AuthState::Pending { - challenge: generate_challenge(), - }, - }; - - let challenge = match &initial_auth_state { - AuthState::Pending { challenge } => Some(challenge.clone()), - _ => None, + // All connections start in Pending state with a NIP-42 challenge. + // In proxy mode the validated identity claims are stashed so the AUTH + // handler can resolve the pubkey binding after the client proves its key. + let challenge = generate_challenge(); + if proxy_identity.is_some() { + info!(conn_id = %conn_id, addr = %addr, "WebSocket connection with proxy identity — awaiting NIP-42 AUTH"); + } + let initial_auth_state = AuthState::Pending { + challenge: challenge.clone(), + proxy_identity, }; let conn = Arc::new(ConnectionState { @@ -164,17 +181,15 @@ pub async fn handle_connection( info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); metrics::counter!("sprout_ws_connections_total").increment(1); - // Only send NIP-42 challenge if not pre-authenticated. - if let Some(ref challenge_str) = challenge { - let challenge_msg = RelayMessage::auth_challenge(challenge_str); - if tx - .send(WsMessage::Text(challenge_msg.into())) - .await - .is_err() - { - warn!(conn_id = %conn_id, "Failed to send AUTH challenge — client disconnected immediately"); - return; - } + // Send NIP-42 challenge — all connections require it now (including proxy mode). + let challenge_msg = RelayMessage::auth_challenge(&challenge); + if tx + .send(WsMessage::Text(challenge_msg.into())) + .await + .is_err() + { + warn!(conn_id = %conn_id, "Failed to send AUTH challenge — client disconnected immediately"); + return; } // Gauge incremented AFTER challenge send succeeds — early disconnects diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 8d155353..74fa784d 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -20,10 +20,13 @@ fn verify_api_token_nip42_binding( /// Handle a NIP-42 AUTH message: verify the challenge response and transition the connection to authenticated state. pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { let event_id_hex_early = event.id.to_hex(); - let (challenge, conn_id) = { + let (challenge, proxy_identity, conn_id) = { let auth = conn.auth_state.read().await; match &*auth { - AuthState::Pending { challenge } => (challenge.clone(), conn.conn_id), + AuthState::Pending { + challenge, + proxy_identity, + } => (challenge.clone(), proxy_identity.clone(), conn.conn_id), AuthState::Authenticated(_) => { debug!(conn_id = %conn.conn_id, "AUTH received but already authenticated"); conn.send(RelayMessage::ok( @@ -60,7 +63,102 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }); - metrics::counter!("sprout_auth_attempts_total", "method" => if auth_token.as_ref().is_some_and(|t| t.starts_with("sprout_")) { "api_token" } else { "nip42" }).increment(1); + metrics::counter!("sprout_auth_attempts_total", "method" => if proxy_identity.is_some() { "proxy_identity" } else if auth_token.as_ref().is_some_and(|t| t.starts_with("sprout_")) { "api_token" } else { "nip42" }).increment(1); + + // ── Proxy identity path ───────────────────────────────────────────── + // When proxy identity claims were validated at upgrade time, the AUTH + // event only needs to prove the client owns its pubkey. No JWT or API + // token tag is required — the identity was already established from the + // proxy headers. After signature verification, the relay creates or + // validates the (uid, device_cn) → pubkey binding. + if let Some(proxy) = proxy_identity { + // Verify event structure + signature + challenge + relay URL (no token check). + let event_clone = event.clone(); + let challenge_owned = challenge.clone(); + let relay_owned = relay_url.clone(); + let nip42_ok = tokio::task::spawn_blocking(move || { + sprout_auth::verify_nip42_event(&event_clone, &challenge_owned, &relay_owned) + }) + .await + .ok() + .and_then(|r| r.ok()); + + if nip42_ok.is_none() { + warn!(conn_id = %conn_id, "proxy identity NIP-42 verification failed"); + metrics::counter!("sprout_auth_failures_total", "reason" => "proxy_nip42_invalid") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + + // Resolve the (uid, device_cn) → pubkey binding. + let pubkey_bytes = event.pubkey.serialize().to_vec(); + match state + .db + .bind_or_validate_identity( + &proxy.uid, + &proxy.device_cn, + &pubkey_bytes, + &proxy.username, + ) + .await + { + Ok(sprout_db::BindingResult::Created) => { + info!(conn_id = %conn_id, uid = %proxy.uid, device_cn = %proxy.device_cn, + pubkey = %event.pubkey.to_hex(), "identity binding created"); + } + Ok(sprout_db::BindingResult::Matched) => { + info!(conn_id = %conn_id, uid = %proxy.uid, pubkey = %event.pubkey.to_hex(), + "identity binding matched"); + } + Ok(sprout_db::BindingResult::Mismatch { .. }) => { + warn!(conn_id = %conn_id, uid = %proxy.uid, device_cn = %proxy.device_cn, + pubkey = %event.pubkey.to_hex(), "identity binding mismatch"); + metrics::counter!("sprout_auth_failures_total", "reason" => "binding_mismatch") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: identity binding mismatch — this device is bound to a different key", + )); + return; + } + Err(e) => { + warn!(conn_id = %conn_id, error = %e, "identity binding DB error"); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: verification failed", + )); + return; + } + } + + // Ensure user record exists with verified name. + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &proxy.username) + .await + { + warn!(conn_id = %conn_id, error = %e, "ensure_user_with_verified_name failed"); + } + + let auth_ctx = sprout_auth::AuthContext { + pubkey: event.pubkey, + scopes: proxy.scopes, + auth_method: sprout_auth::AuthMethod::ProxyIdentity, + }; + *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + return; + } if let Some(ref token) = auth_token { if token.starts_with("sprout_") { diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index a0fda6e1..2c30e6df 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -156,11 +156,10 @@ pub fn build_router(state: Arc) -> Router { .route("/api/users/batch", post(api::get_users_batch)) // Feed route .route("/api/feed", get(api::feed_handler)) - // Identity bootstrap (proxy mode — returns derived signing key). - // POST prevents intermediary caching of the secret key response. + // Identity registration (proxy mode — binds client pubkey to corporate identity). .route( - "/api/identity/bootstrap", - post(api::identity::identity_bootstrap), + "/api/identity/register", + post(api::identity::identity_register), ) // Reject request bodies larger than 1 MB to prevent resource exhaustion. .layer(RequestBodyLimitLayer::new(1024 * 1024)) @@ -221,29 +220,28 @@ async fn nip11_or_ws_handler( return Json(info).into_response(); } - // ── Proxy / hybrid identity: validate at upgrade time ────────────── + // ── Proxy / hybrid identity: validate JWT + device_cn at upgrade time ── // - Proxy: identity token mandatory — reject if missing. // - Hybrid: identity token preferred — fall through to NIP-42 if missing. + // NIP-42 challenge is always sent; the AUTH handler resolves the pubkey binding. let identity_mode = &state.auth.identity_config().mode; - let pre_auth = if identity_mode.is_proxy() { + let proxy_identity = if identity_mode.is_proxy() { match headers .get("x-forwarded-identity-token") .and_then(|v| v.to_str().ok()) { Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { - Ok((pubkey, scopes, username)) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state - .db - .ensure_user_with_verified_name(&pubkey_bytes, &username) - .await - { - tracing::warn!("ws: ensure_user_with_verified_name failed: {e}"); - } - Some(sprout_auth::AuthContext { - pubkey, + Ok((identity_claims, scopes)) => { + let device_cn = headers + .get("x-block-client-cert-subject-cn") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown") + .to_string(); + Some(crate::connection::PendingProxyIdentity { + uid: identity_claims.uid, + username: identity_claims.username, + device_cn, scopes, - auth_method: sprout_auth::AuthMethod::ProxyIdentity, }) } Err(e) => { @@ -255,7 +253,7 @@ async fn nip11_or_ws_handler( tracing::warn!("ws: proxy mode enabled but x-forwarded-identity-token missing"); return (StatusCode::UNAUTHORIZED, "identity token required").into_response(); } - // Hybrid: no identity token — proceed to NIP-42 auth. + // Hybrid: no identity token — proceed to standard NIP-42 auth. None => None, } } else { @@ -264,7 +262,7 @@ async fn nip11_or_ws_handler( match WebSocketUpgrade::from_request(req, &state).await { Ok(ws) => ws - .on_upgrade(move |socket| handle_connection(socket, state, addr, pre_auth)) + .on_upgrade(move |socket| handle_connection(socket, state, addr, proxy_identity)) .into_response(), Err(_) => { // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 826fd928..0a4b2d7a 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -1,3 +1,4 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag, ToBech32}; use tauri::State; @@ -68,25 +69,6 @@ pub fn sign_event( Ok(event.as_json()) } -/// Set the signing identity from a hex-encoded secret key. -/// -/// Used in proxy identity mode: the desktop calls the relay's -/// `POST /api/identity/bootstrap` endpoint (which validates the identity JWT -/// and derives the keypair server-side), then passes the returned secret key -/// here to install it as the active signing identity. -#[tauri::command] -pub fn set_identity_from_secret_key( - secret_key_hex: String, - state: State<'_, AppState>, -) -> Result { - let secret_key = nostr::SecretKey::from_hex(&secret_key_hex) - .map_err(|e| format!("invalid secret key: {e}"))?; - let keys = nostr::Keys::new(secret_key); - let pubkey_hex = keys.public_key().to_hex(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - Ok(pubkey_hex) -} - #[derive(serde::Serialize)] pub struct InitializedIdentity { pubkey: String, @@ -103,43 +85,64 @@ pub async fn initialize_identity( match identity_mode.as_str() { "proxy" | "hybrid" => { + // Client-generated key — the key is already persisted locally by + // resolve_persisted_identity(). We just need to register it with + // the relay so the relay binds (uid, device_cn) → pubkey. let base_url = crate::relay::relay_api_base_url(); - let url = format!("{base_url}/api/identity/bootstrap"); + let register_url = format!("{base_url}/api/identity/register"); + + // Sign a NIP-98 event proving ownership of our pubkey. + let nip98_b64 = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let tags = vec![ + Tag::parse(vec!["u", ®ister_url]) + .map_err(|e| format!("u tag: {e}"))?, + Tag::parse(vec!["method", "POST"]) + .map_err(|e| format!("method tag: {e}"))?, + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(&keys) + .map_err(|e| format!("NIP-98 sign failed: {e}"))?; + BASE64.encode(event.as_json().as_bytes()) + }; let response = state .http_client - .post(&url) + .post(®ister_url) + .header("Authorization", format!("Nostr {nip98_b64}")) .timeout(std::time::Duration::from_secs(10)) .send() .await - .map_err(|e| format!("identity bootstrap request failed: {e}"))?; + .map_err(|e| format!("identity register request failed: {e}"))?; if !response.status().is_success() { let msg = crate::relay::relay_error_message(response).await; - return Err(format!("identity bootstrap failed: {msg}")); + return Err(format!("identity registration failed: {msg}")); } #[derive(serde::Deserialize)] - struct BootstrapResponse { + struct RegisterResponse { #[allow(dead_code)] pubkey: String, - secret_key: String, username: String, + #[allow(dead_code)] + binding_status: String, } - let body: BootstrapResponse = response + let body: RegisterResponse = response .json() .await - .map_err(|e| format!("failed to parse bootstrap response: {e}"))?; + .map_err(|e| format!("failed to parse register response: {e}"))?; - // Install the derived secret key as the active signing identity. - let secret_key = nostr::SecretKey::from_hex(&body.secret_key) - .map_err(|e| format!("invalid secret key from bootstrap: {e}"))?; - let keys = nostr::Keys::new(secret_key); - let pubkey_hex = keys.public_key().to_hex(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; + let pubkey_hex = state + .keys + .lock() + .map_err(|e| e.to_string())? + .public_key() + .to_hex(); - // Persist the bootstrap display name so get_identity returns it + // Persist the display name so get_identity returns it // instead of a truncated npub. *state.display_name.lock().map_err(|e| e.to_string())? = Some(body.username.clone()); @@ -147,7 +150,7 @@ pub async fn initialize_identity( pubkey: pubkey_hex, display_name: body.username, identity_mode: Some(identity_mode), - ws_auth_mode: "preauthenticated".to_string(), + ws_auth_mode: "nip42".to_string(), }) } _ => { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 02c975fd..ef4253ac 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -549,7 +549,6 @@ pub fn run() { discover_managed_agent_prereqs, sign_event, create_auth_event, - set_identity_from_secret_key, initialize_identity, get_channels, create_channel, diff --git a/schema/schema.sql b/schema/schema.sql index 7c9dfb40..be9ecc97 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -327,3 +327,20 @@ CREATE TABLE pubkey_allowlist ( added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), note TEXT ); + +-- ── Identity bindings (proxy mode: corporate UID + device → pubkey) ─────────── + +CREATE TABLE identity_bindings ( + uid TEXT NOT NULL, + device_cn TEXT NOT NULL, + pubkey BYTEA NOT NULL, + username VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + PRIMARY KEY (uid, device_cn), + CONSTRAINT chk_identity_bindings_pubkey_len CHECK (LENGTH(pubkey) = 32) +); + +CREATE INDEX idx_identity_bindings_pubkey ON identity_bindings(pubkey); +CREATE INDEX idx_identity_bindings_uid ON identity_bindings(uid); From ef504212d930587940b1c1385ebdd358454ca584 Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Fri, 10 Apr 2026 12:25:51 -0600 Subject: [PATCH 3/7] feat: harden identity binding with security fixes and identity-bound guard - Fix JWT validation (was using validate_permissive) - Limit proxy scopes to non-admin - Add identity-bound pubkey guard: bound pubkeys must present JWT - Cache identity lookups via moka (2-min TTL), invalidate on bind - Extract shared AppState::is_identity_bound() helper - Add sprout-admin unbind-identity for key rotation/offboarding - Add identity-specific JWKS config (SPROUT_IDENTITY_JWKS_URI/ISSUER/AUDIENCE) - Use EXISTS instead of COUNT(*) for binding check - Remove existing_pubkey from mismatch logs (PII) - Add verified_name badge in desktop UI - Update ARCHITECTURE.md and AGENTS.md Amp-Thread-ID: https://ampcode.com/threads/T-019d77d2-5136-77ed-880b-928e13d5cb1c Co-authored-by: Amp --- .env.example | 16 +- AGENTS.md | 21 ++ ARCHITECTURE.md | 27 +- crates/sprout-admin/src/main.rs | 62 ++++ crates/sprout-auth/src/identity.rs | 15 + crates/sprout-auth/src/lib.rs | 42 ++- crates/sprout-db/src/identity_binding.rs | 293 +++++++++++++++++- crates/sprout-db/src/lib.rs | 20 ++ crates/sprout-db/src/user.rs | 12 + crates/sprout-relay/src/api/identity.rs | 97 +++--- crates/sprout-relay/src/api/mod.rs | 50 ++- crates/sprout-relay/src/config.rs | 9 + crates/sprout-relay/src/handlers/auth.rs | 41 ++- crates/sprout-relay/src/router.rs | 6 +- crates/sprout-relay/src/state.rs | 33 ++ desktop/src-tauri/src/models.rs | 3 + .../src/features/messages/ui/MessageRow.tsx | 16 +- desktop/src/features/profile/lib/identity.ts | 13 +- .../profile/ui/UserProfilePopover.tsx | 10 + 19 files changed, 675 insertions(+), 111 deletions(-) diff --git a/.env.example b/.env.example index 1e9e18de..3394c073 100644 --- a/.env.example +++ b/.env.example @@ -83,15 +83,21 @@ OKTA_AUDIENCE=sprout-desktop # ── Corporate identity mode ───────────────────────────────────────────────── # Identity mode: "disabled" (default), "proxy", "hybrid". -# proxy — relay validates x-forwarded-identity-token JWT on every request -# (injected by cf-doorman). Clients generate their own Nostr keypairs -# and register them via POST /api/identity/register. The relay binds -# (uid, device_cn) → pubkey in the identity_bindings table. -# Requires OKTA_JWKS_URI/ISSUER/AUDIENCE pointed at cf-doorman's JWKS. +# proxy — all connections must present a valid identity JWT (no fallback). +# hybrid — identity JWT preferred; connections without it fall through to +# standard auth (API tokens, Okta JWTs) for agents. # SPROUT_IDENTITY_MODE=disabled +# # JWT claim names for uid (identity binding key) and username (display only). # SPROUT_IDENTITY_UID_CLAIM=uid # SPROUT_IDENTITY_USER_CLAIM=user +# +# Identity provider JWKS configuration. When set, identity JWTs are validated +# against this IdP independently of the main Okta/Keycloak JWKS config above. +# If unset, falls back to OKTA_JWKS_URI / OKTA_ISSUER / OKTA_AUDIENCE. +# SPROUT_IDENTITY_JWKS_URI=http://localhost:9200/certs +# SPROUT_IDENTITY_ISSUER=cf-doorman-production +# SPROUT_IDENTITY_AUDIENCE=watson # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/AGENTS.md b/AGENTS.md index 8aec2b0c..b436b47d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,27 @@ simple and testable. thread root events. Any code that inserts replies must update these counters — check existing reply handlers for the pattern. +**Identity binding (proxy mode)**: In corporate deployments the relay sits +behind a trusted reverse proxy (cf-doorman) that injects +`x-forwarded-identity-token` and `x-block-client-cert-subject-cn` headers. +`SPROUT_IDENTITY_MODE` controls behaviour: + +- `disabled` (default) — standard Nostr key-based auth only. +- `proxy` — all connections must present a valid identity JWT; the relay binds + (uid, device\_cn) → pubkey in the `identity_bindings` table. NIP-42 is still + required to prove pubkey ownership. +- `hybrid` — identity JWT preferred for humans; connections without the header + fall through to standard auth (API tokens, Okta JWTs) for agents. + +Identity bindings are **immutable** — once a (uid, device\_cn) is bound to a +pubkey, a different pubkey for the same slot returns a mismatch error. Use +`sprout-admin unbind-identity` to clear a binding (e.g., key rotation, device +reset, offboarding). + +**Trusted-proxy security invariant**: The relay trusts proxy headers +unconditionally. It **must** be deployed behind the trusted reverse proxy — +direct access to the relay port would allow header injection. + --- ## Testing diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c1af66c..1eb1303c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -85,7 +85,7 @@ sprout-huddle (LiveKit audio/video integration — standalone, not wired i sprout-sdk (typed Nostr event builders — used by sprout-mcp, sprout-acp, and sprout-cli) sprout-media (Blossom/S3 media storage) sprout-cli (agent-first CLI) -sprout-admin (operator CLI: mint/list API tokens) +sprout-admin (operator CLI: mint/list API tokens, unbind identities) sprout-test-client (integration test harness + manual CLI) ``` @@ -179,9 +179,25 @@ The client must respond with `["AUTH", ]` before submitting events | NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta | | NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts | | HTTP Bearer JWT | `Authorization: Bearer ` header on REST endpoints | REST API clients | +| NIP-42 + proxy identity | Identity JWT at upgrade + NIP-42 challenge | Corporate SSO via cf-doorman (proxy/hybrid mode) | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. +#### Proxy Identity Mode (Corporate SSO) + +When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted reverse proxy (cf-doorman) that injects an identity JWT via the `x-forwarded-identity-token` header. The flow adds a two-phase binding step: + +1. **WS Upgrade** — The relay validates the identity JWT (signature + expiry via JWKS), extracts `uid` and `username` claims, and stashes them as `PendingProxyIdentity` on the connection. No pubkey is known yet. +2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, device_cn, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`. +3. **REST API** — Proxy-authenticated REST requests validate the identity JWT, then look up the existing `(uid, device_cn) → pubkey` binding from the `identity_bindings` table. +4. **Registration** — `POST /api/identity/register` allows initial binding of a pubkey to a corporate identity via NIP-98 proof of key ownership. + +**Binding semantics:** Once a `(uid, device_cn)` pair is bound to a pubkey, the binding is immutable. A different pubkey for the same slot returns a 409 Conflict. Admin can unbind via `sprout-admin unbind-identity`. + +**Hybrid mode:** When the identity JWT header is absent, the connection falls through to standard NIP-42 auth (API tokens, Okta JWTs). This allows agents without corporate JWTs to authenticate alongside human users. + +**Security invariant:** The relay trusts proxy headers unconditionally. It **must** be deployed behind the trusted reverse proxy — direct access would allow header injection. + ### Step 4: Active Loops Three concurrent tasks run for the lifetime of the connection: @@ -764,15 +780,18 @@ Sprout Relay ──WS──→ sprout-acp ──stdio (ACP/JSON-RPC)──→ Ag ### sprout-admin — Operator CLI -**213 LOC.** Two subcommands: +**~280 LOC.** Three subcommands: | Subcommand | Purpose | |------------|---------| | `mint-token` | Generate API token, store SHA-256 hash in DB, display raw token once | | `list-tokens` | List all active tokens (ID, name, scopes, created) | +| `unbind-identity` | Remove identity binding(s) for key rotation or offboarding | `mint-token` options: `--name`, `--scopes` (comma-separated), optional `--pubkey`. If `--pubkey` omitted, generates a new keypair and displays `nsec` (bech32) and pubkey. +`unbind-identity` options: `--uid` (required), optional `--device-cn` (omit to remove all devices), `--clear-name` (also clears verified_name from user records). Cache propagation delay: up to 2 minutes. + Raw token is shown exactly once and never stored. Only the SHA-256 hash reaches the database. --- @@ -818,6 +837,7 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic | NIP-42 timestamp | ±60 second tolerance — prevents replay attacks | | AUTH events | Never stored in Postgres, never logged in audit chain | | Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default | +| Proxy identity | JWT validated via JWKS; headers trusted from cf-doorman only; `require_auth_token` forced true | ### Input Validation @@ -890,6 +910,7 @@ Docker Compose provides the full local development stack. All services include h | `api_tokens` | API token records (hash only, never plaintext) | | `audit_log` | Hash-chain audit entries | | `delivery_log` | Delivery tracking (partitioned; Rust module pending) | +| `identity_bindings` | Proxy mode: (uid, device_cn) → pubkey binding for corporate identity | ### Redis Key Patterns @@ -939,7 +960,7 @@ These are verified gaps in the current implementation — not design aspirations | sprout-sdk | 1,237 | Shared library | | sprout-media | 977 | Media storage | | sprout-cli | 2,919 | Tooling | -| sprout-admin | 213 | Tooling | +| sprout-admin | ~280 | Tooling | | sprout-test-client | 9,319 | Tooling | | **Total** | **~72,126** | | diff --git a/crates/sprout-admin/src/main.rs b/crates/sprout-admin/src/main.rs index 7d983e28..02a0d803 100644 --- a/crates/sprout-admin/src/main.rs +++ b/crates/sprout-admin/src/main.rs @@ -38,6 +38,20 @@ enum Command { }, /// List all active API tokens. ListTokens, + /// Remove an identity binding (for key rotation or offboarding). + UnbindIdentity { + /// Corporate user identifier (UID from identity JWT). + #[arg(long)] + uid: String, + + /// Device common name. If omitted, removes all bindings for the UID. + #[arg(long)] + device_cn: Option, + + /// Also clear verified_name from the user record(s). + #[arg(long, default_value_t = false)] + clear_name: bool, + }, } #[tokio::main] @@ -61,6 +75,11 @@ async fn main() -> Result<()> { owner_pubkey, } => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?, Command::ListTokens => list_tokens(&db).await?, + Command::UnbindIdentity { + uid, + device_cn, + clear_name, + } => unbind_identity(&db, &uid, device_cn.as_deref(), clear_name).await?, } Ok(()) @@ -183,6 +202,49 @@ async fn mint_token( Ok(()) } +async fn unbind_identity( + db: &Db, + uid: &str, + device_cn: Option<&str>, + clear_name: bool, +) -> Result<()> { + if let Some(device_cn) = device_cn { + // Single binding removal + let binding = db.get_identity_binding(uid, device_cn).await?; + let deleted = db.delete_identity_binding(uid, device_cn).await?; + if deleted { + println!("Removed identity binding for uid={uid}, device_cn={device_cn}"); + if clear_name { + if let Some(binding) = binding { + let cleared = db.clear_verified_name(&binding.pubkey).await?; + if cleared { + println!("Cleared verified_name for the bound pubkey"); + } + } + } + } else { + println!("No binding found for uid={uid}, device_cn={device_cn}"); + } + } else { + // Remove all bindings for the UID + let bindings = db.get_bindings_for_uid(uid).await?; + let count = db.delete_bindings_for_uid(uid).await?; + println!("Removed {count} identity binding(s) for uid={uid}"); + if clear_name { + for binding in &bindings { + let cleared = db.clear_verified_name(&binding.pubkey).await?; + if cleared { + println!( + "Cleared verified_name for pubkey bound to device_cn={}", + binding.device_cn + ); + } + } + } + } + Ok(()) +} + async fn list_tokens(db: &Db) -> Result<()> { let tokens = db.list_active_tokens().await?; diff --git a/crates/sprout-auth/src/identity.rs b/crates/sprout-auth/src/identity.rs index bcc5b338..b1cc6964 100644 --- a/crates/sprout-auth/src/identity.rs +++ b/crates/sprout-auth/src/identity.rs @@ -67,6 +67,18 @@ pub struct IdentityConfig { /// JWT claim name containing the human-readable username. #[serde(default = "default_user_claim")] pub user_claim: String, + /// JWKS endpoint URL for the identity provider (e.g. cf-doorman). + /// Falls back to the main Okta/JWKS URI if empty. + #[serde(default)] + pub jwks_uri: String, + /// Expected JWT issuer claim for identity JWTs. + /// Falls back to the main Okta issuer if empty. + #[serde(default)] + pub issuer: String, + /// Expected JWT audience claim for identity JWTs. + /// Falls back to the main Okta audience if empty. + #[serde(default)] + pub audience: String, } impl Default for IdentityConfig { @@ -75,6 +87,9 @@ impl Default for IdentityConfig { mode: default_mode(), uid_claim: default_uid_claim(), user_claim: default_user_claim(), + jwks_uri: String::new(), + issuer: String::new(), + audience: String::new(), } } } diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 3ec1e456..0e1cbe41 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -338,24 +338,43 @@ impl AuthService { /// Validate a proxy-injected identity JWT and extract the corporate identity claims. /// /// Used in proxy identity mode where cf-doorman injects `x-forwarded-identity-token`. - /// Validates the JWT via JWKS (same infrastructure as Okta), extracts the `uid` and - /// `user` claims. + /// Validates the JWT via JWKS, extracts the `uid` and `user` claims. /// - /// Returns `(claims, all_known_scopes)` on success. + /// Uses identity-specific JWKS/issuer/audience config when set, falling back to the + /// main Okta config values. + /// + /// Returns `(claims, scopes)` on success. Scopes exclude admin privileges. pub async fn validate_identity_jwt( &self, jwt: &str, ) -> Result<(identity::ProxyIdentityClaims, Vec), AuthError> { + // Use identity-specific JWKS URI, falling back to the main JWKS config. + let jwks_uri = if self.config.identity.jwks_uri.is_empty() { + &self.config.okta.jwks_uri + } else { + &self.config.identity.jwks_uri + }; + let issuer = if self.config.identity.issuer.is_empty() { + &self.config.okta.issuer + } else { + &self.config.identity.issuer + }; + let audience = if self.config.identity.audience.is_empty() { + &self.config.okta.audience + } else { + &self.config.identity.audience + }; + let cached = self .jwks_cache .get_or_refresh( - &self.config.okta.jwks_uri, + jwks_uri, self.config.okta.jwks_refresh_secs, &self.http_client, ) .await?; - let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?; + let claims = cached.validate(jwt, issuer, audience)?; let uid = claims .get(&self.config.identity.uid_claim) @@ -374,10 +393,19 @@ impl AuthService { let username = claims .get(&self.config.identity.user_claim) .and_then(|v| v.as_str()) - .unwrap_or("unknown") + .ok_or_else(|| { + tracing::warn!( + user_claim = %self.config.identity.user_claim, + "identity JWT missing user claim — rejecting" + ); + AuthError::InvalidJwt(format!( + "missing '{}' claim in identity JWT", + self.config.identity.user_claim + )) + })? .to_string(); - let scopes = Scope::all_known(); + let scopes = Scope::all_non_admin(); Ok((identity::ProxyIdentityClaims { uid, username }, scopes)) } diff --git a/crates/sprout-db/src/identity_binding.rs b/crates/sprout-db/src/identity_binding.rs index 910979ea..51e826f2 100644 --- a/crates/sprout-db/src/identity_binding.rs +++ b/crates/sprout-db/src/identity_binding.rs @@ -3,6 +3,17 @@ //! Maps (corporate_uid, device_cn) pairs to Nostr pubkeys. Each device //! gets its own binding, enabling multi-device support under one corporate //! identity. +//! +//! # TODO: Self-service key rotation +//! +//! Bindings are currently immutable — rebinding requires admin intervention +//! (`sprout-admin unbind-identity`). Planned work: +//! +//! - Add `POST /api/identity/rotate` endpoint (JWT + device cert + NIP-98 with new key). +//! - Soft-rotate: add `rotated_at` / `replaced_by` columns instead of deleting old rows, +//! preserving an audit trail and letting the UI resolve old pubkeys to usernames. +//! - Add a UNIQUE constraint on pubkey for active (non-rotated) bindings. +//! - Keep the 409 Conflict on mismatch — rotation must be an explicit action, not implicit. use crate::error::Result; use sqlx::PgPool; @@ -52,12 +63,14 @@ pub async fn get_identity_binding( .fetch_optional(pool) .await?; - Ok(row.map(|(uid, device_cn, pubkey, username)| IdentityBinding { - uid, - device_cn, - pubkey, - username, - })) + Ok( + row.map(|(uid, device_cn, pubkey, username)| IdentityBinding { + uid, + device_cn, + pubkey, + username, + }), + ) } /// Bind a pubkey to (uid, device_cn), or validate an existing binding. @@ -78,6 +91,10 @@ pub async fn bind_or_validate_identity( ) -> Result { let mut tx = pool.begin().await?; + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + let existing = sqlx::query_as::<_, (Vec,)>( r#" SELECT pubkey @@ -134,10 +151,7 @@ pub async fn bind_or_validate_identity( } /// Get all bindings for a given uid (all devices). -pub async fn get_bindings_for_uid( - pool: &PgPool, - uid: &str, -) -> Result> { +pub async fn get_bindings_for_uid(pool: &PgPool, uid: &str) -> Result> { let rows = sqlx::query_as::<_, (String, String, Vec, Option)>( r#" SELECT uid, device_cn, pubkey, username @@ -160,3 +174,262 @@ pub async fn get_bindings_for_uid( }) .collect()) } + +/// Delete all identity bindings for a given UID (all devices). +/// Used for employee offboarding / GDPR erasure. +pub async fn delete_bindings_for_uid(pool: &PgPool, uid: &str) -> Result { + let result = sqlx::query("DELETE FROM identity_bindings WHERE uid = $1") + .bind(uid) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +/// Check whether a pubkey has any active identity binding. +/// +/// Used by the auth layer to enforce "once bound, always require JWT" — +/// a pubkey that was bound to a corporate identity via proxy mode cannot +/// fall through to standard NIP-42 / API token auth in hybrid mode. +pub async fn is_pubkey_identity_bound(pool: &PgPool, pubkey: &[u8]) -> Result { + let bound = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM identity_bindings WHERE pubkey = $1)", + ) + .bind(pubkey) + .fetch_one(pool) + .await?; + Ok(bound) +} + +/// Delete a specific identity binding for a (uid, device_cn) pair. +/// Allows re-binding after key loss or device reset. +pub async fn delete_identity_binding(pool: &PgPool, uid: &str, device_cn: &str) -> Result { + let result = sqlx::query("DELETE FROM identity_bindings WHERE uid = $1 AND device_cn = $2") + .bind(uid) + .bind(device_cn) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://sprout:sprout_dev@localhost:5432/sprout"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + fn random_uid() -> String { + format!("test-uid-{}", uuid::Uuid::new_v4()) + } + + fn random_pubkey() -> Vec { + Keys::generate().public_key().serialize().to_vec() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_creates_new_binding() { + let pool = setup_pool().await; + let uid = random_uid(); + let device_cn = "test-laptop"; + let pubkey = random_pubkey(); + + let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + .await + .expect("bind should succeed"); + assert_eq!(result, BindingResult::Created); + + // Verify the binding is readable + let binding = get_identity_binding(&pool, &uid, device_cn) + .await + .expect("get should succeed") + .expect("binding should exist"); + assert_eq!(binding.pubkey, pubkey); + assert_eq!(binding.username.as_deref(), Some("alice")); + + // Cleanup + delete_identity_binding(&pool, &uid, device_cn) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_same_pubkey_returns_matched() { + let pool = setup_pool().await; + let uid = random_uid(); + let device_cn = "test-laptop"; + let pubkey = random_pubkey(); + + bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + .await + .expect("first bind"); + + let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + .await + .expect("second bind"); + assert_eq!(result, BindingResult::Matched); + + // Cleanup + delete_identity_binding(&pool, &uid, device_cn) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn bind_different_pubkey_returns_mismatch() { + let pool = setup_pool().await; + let uid = random_uid(); + let device_cn = "test-laptop"; + let pubkey1 = random_pubkey(); + let pubkey2 = random_pubkey(); + + bind_or_validate_identity(&pool, &uid, device_cn, &pubkey1, "alice") + .await + .expect("first bind"); + + let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey2, "alice") + .await + .expect("second bind with different pubkey"); + assert!( + matches!(result, BindingResult::Mismatch { .. }), + "expected Mismatch, got {result:?}" + ); + + // Cleanup + delete_identity_binding(&pool, &uid, device_cn) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn multi_device_bindings() { + let pool = setup_pool().await; + let uid = random_uid(); + let pubkey1 = random_pubkey(); + let pubkey2 = random_pubkey(); + + bind_or_validate_identity(&pool, &uid, "laptop", &pubkey1, "alice") + .await + .expect("bind laptop"); + bind_or_validate_identity(&pool, &uid, "phone", &pubkey2, "alice") + .await + .expect("bind phone"); + + let bindings = get_bindings_for_uid(&pool, &uid) + .await + .expect("get bindings"); + assert_eq!(bindings.len(), 2); + + // Cleanup + delete_bindings_for_uid(&pool, &uid).await.unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delete_binding_allows_rebind() { + let pool = setup_pool().await; + let uid = random_uid(); + let device_cn = "test-laptop"; + let pubkey1 = random_pubkey(); + let pubkey2 = random_pubkey(); + + // Bind first key + bind_or_validate_identity(&pool, &uid, device_cn, &pubkey1, "alice") + .await + .expect("first bind"); + + // Delete the binding + let deleted = delete_identity_binding(&pool, &uid, device_cn) + .await + .expect("delete should succeed"); + assert!(deleted); + + // Rebind with different key should now succeed + let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey2, "alice") + .await + .expect("rebind should succeed"); + assert_eq!(result, BindingResult::Created); + + // Cleanup + delete_identity_binding(&pool, &uid, device_cn) + .await + .unwrap(); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn delete_bindings_for_uid_removes_all_devices() { + let pool = setup_pool().await; + let uid = random_uid(); + + bind_or_validate_identity(&pool, &uid, "laptop", &random_pubkey(), "alice") + .await + .expect("bind laptop"); + bind_or_validate_identity(&pool, &uid, "phone", &random_pubkey(), "alice") + .await + .expect("bind phone"); + + let count = delete_bindings_for_uid(&pool, &uid) + .await + .expect("delete all"); + assert_eq!(count, 2); + + let bindings = get_bindings_for_uid(&pool, &uid) + .await + .expect("get bindings"); + assert!(bindings.is_empty()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_nonexistent_binding_returns_none() { + let pool = setup_pool().await; + let result = get_identity_binding(&pool, "nonexistent-uid", "nonexistent-device") + .await + .expect("query should not error"); + assert!(result.is_none()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn is_pubkey_identity_bound_reflects_binding_lifecycle() { + let pool = setup_pool().await; + let uid = random_uid(); + let device_cn = "test-laptop"; + let pubkey = random_pubkey(); + + // Not bound before any binding exists. + assert!( + !is_pubkey_identity_bound(&pool, &pubkey).await.unwrap(), + "should not be bound before creation" + ); + + // Bound after creation. + bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + .await + .expect("bind should succeed"); + assert!( + is_pubkey_identity_bound(&pool, &pubkey).await.unwrap(), + "should be bound after creation" + ); + + // Not bound after deletion. + delete_identity_binding(&pool, &uid, device_cn) + .await + .expect("delete should succeed"); + assert!( + !is_pubkey_identity_bound(&pool, &pubkey).await.unwrap(), + "should not be bound after deletion" + ); + } +} diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 4ebbf8f6..8119038f 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -541,6 +541,26 @@ impl Db { identity_binding::get_bindings_for_uid(&self.pool, uid).await } + /// Delete all identity bindings for a given UID. + pub async fn delete_bindings_for_uid(&self, uid: &str) -> Result { + identity_binding::delete_bindings_for_uid(&self.pool, uid).await + } + + /// Check whether a pubkey has any active identity binding. + pub async fn is_pubkey_identity_bound(&self, pubkey: &[u8]) -> Result { + identity_binding::is_pubkey_identity_bound(&self.pool, pubkey).await + } + + /// Delete a specific identity binding. + pub async fn delete_identity_binding(&self, uid: &str, device_cn: &str) -> Result { + identity_binding::delete_identity_binding(&self.pool, uid, device_cn).await + } + + /// Clear the verified corporate name from a user record. + pub async fn clear_verified_name(&self, pubkey: &[u8]) -> Result { + user::clear_verified_name(&self.pool, pubkey).await + } + /// Get a single user record by pubkey. pub async fn get_user(&self, pubkey: &[u8]) -> Result> { user::get_user(&self.pool, pubkey).await diff --git a/crates/sprout-db/src/user.rs b/crates/sprout-db/src/user.rs index 8815e13a..b4ab1ff8 100644 --- a/crates/sprout-db/src/user.rs +++ b/crates/sprout-db/src/user.rs @@ -63,6 +63,18 @@ pub async fn ensure_user_with_verified_name( Ok(()) } +/// Clear the verified corporate name from a user record. +/// Used for employee offboarding / GDPR erasure. +pub async fn clear_verified_name(pool: &PgPool, pubkey: &[u8]) -> Result { + let result = sqlx::query( + "UPDATE users SET verified_name = NULL WHERE pubkey = $1 AND verified_name IS NOT NULL", + ) + .bind(pubkey) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> { diff --git a/crates/sprout-relay/src/api/identity.rs b/crates/sprout-relay/src/api/identity.rs index ac794295..31c421e6 100644 --- a/crates/sprout-relay/src/api/identity.rs +++ b/crates/sprout-relay/src/api/identity.rs @@ -92,19 +92,8 @@ pub async fn identity_register( ) })?; - // 2. Extract device identifier from client certificate CN - let device_cn = headers - .get("x-block-client-cert-subject-cn") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "device_cn_required", - "message": "x-block-client-cert-subject-cn header is required" - })), - ) - })?; + // 2. Extract device identifier from client certificate CN (fallback to "default") + let device_cn = super::extract_device_cn(&headers); // 3. Verify NIP-98 auth to prove pubkey ownership let auth_header = headers @@ -146,18 +135,16 @@ pub async fn identity_register( ) })?; - let canonical_url = reconstruct_canonical_url(&headers, &state); + let canonical_url = reconstruct_canonical_url(&state); - let pubkey = - sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", None).map_err( - |e| { - tracing::warn!("identity register: NIP-98 verification failed: {e}"); - ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "nip98_invalid" })), - ) - }, - )?; + let pubkey = sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", None) + .map_err(|e| { + tracing::warn!("identity register: NIP-98 verification failed: {e}"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "nip98_invalid" })), + ) + })?; let pubkey_bytes = pubkey.serialize().to_vec(); @@ -180,15 +167,30 @@ pub async fn identity_register( })?; match result { - sprout_db::BindingResult::Mismatch { existing_pubkey } => { - let existing_hex = nostr::PublicKey::from_slice(&existing_pubkey) - .map(|pk| pk.to_hex()) - .unwrap_or_else(|_| hex::encode(&existing_pubkey)); + sprout_db::BindingResult::Created => { + // Invalidate cached `false` so the identity-bound guard takes + // effect immediately on this relay instance. + state.identity_bound_cache.invalidate(&pubkey_bytes); + tracing::info!( + uid = %identity_claims.uid, + device_cn = %device_cn, + pubkey = %pubkey.to_hex(), + "identity binding created" + ); + } + sprout_db::BindingResult::Matched => { + tracing::info!( + uid = %identity_claims.uid, + device_cn = %device_cn, + pubkey = %pubkey.to_hex(), + "identity binding matched" + ); + } + sprout_db::BindingResult::Mismatch { .. } => { tracing::warn!( uid = %identity_claims.uid, device_cn = %device_cn, presented = %pubkey.to_hex(), - existing = %existing_hex, "identity binding mismatch" ); return Err(( @@ -199,15 +201,6 @@ pub async fn identity_register( })), )); } - ref status => { - tracing::info!( - uid = %identity_claims.uid, - device_cn = %device_cn, - pubkey = %pubkey.to_hex(), - status = ?status, - "identity binding resolved" - ); - } } // 5. Ensure user record exists with verified name @@ -232,25 +225,11 @@ pub async fn identity_register( }))) } -/// Reconstruct the canonical URL for NIP-98 verification from proxy headers. -fn reconstruct_canonical_url(headers: &HeaderMap, state: &AppState) -> String { - let proto = headers - .get("x-forwarded-proto") - .and_then(|v| v.to_str().ok()) - .unwrap_or("https"); - let host = headers - .get("x-forwarded-host") - .or_else(|| headers.get("host")) - .and_then(|v| v.to_str().ok()); - - if let Some(host) = host { - format!("{proto}://{host}/api/identity/register") - } else { - let base = state - .config - .relay_url - .replace("wss://", "https://") - .replace("ws://", "http://"); - format!("{base}/api/identity/register") - } +fn reconstruct_canonical_url(state: &AppState) -> String { + let base = state + .config + .relay_url + .replace("wss://", "https://") + .replace("ws://", "http://"); + format!("{base}/api/identity/register") } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 72e1ea6d..a787392b 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -91,6 +91,16 @@ use sprout_auth::Scope; use crate::state::AppState; +/// Extract the device common name from the `x-block-client-cert-subject-cn` header, +/// defaulting to `"default"` when the header is absent (e.g. Cloudflare Tunnel +/// deployments without mTLS client certificates). +pub(crate) fn extract_device_cn(headers: &axum::http::HeaderMap) -> &str { + headers + .get("x-block-client-cert-subject-cn") + .and_then(|v| v.to_str().ok()) + .unwrap_or("default") +} + // ── Auth context types ──────────────────────────────────────────────────────── /// How the REST request was authenticated. @@ -135,6 +145,25 @@ pub struct RestAuthContext { pub channel_ids: Option>, } +/// In hybrid identity mode, reject a pubkey that has an identity binding +/// but authenticated via standard auth (no JWT). +async fn reject_if_identity_bound( + pubkey_bytes: &[u8], + state: &AppState, +) -> Result<(), (StatusCode, Json)> { + if state.is_identity_bound(pubkey_bytes).await { + tracing::warn!("auth: identity-bound pubkey attempted standard auth without JWT"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "identity_jwt_required", + "message": "this pubkey is bound to a corporate identity — present x-forwarded-identity-token" + })), + )); + } + Ok(()) +} + /// Extract the full auth context from request headers. /// /// Auth resolution order: @@ -167,8 +196,11 @@ pub(crate) async fn extract_auth_context( .get("x-forwarded-identity-token") .and_then(|v| v.to_str().ok()) { - let (identity_claims, scopes) = - state.auth.validate_identity_jwt(identity_jwt).await.map_err(|e| { + let (identity_claims, scopes) = state + .auth + .validate_identity_jwt(identity_jwt) + .await + .map_err(|e| { tracing::warn!("auth: identity JWT validation failed: {e}"); ( StatusCode::UNAUTHORIZED, @@ -176,10 +208,7 @@ pub(crate) async fn extract_auth_context( ) })?; - let device_cn = headers - .get("x-block-client-cert-subject-cn") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); + let device_cn = extract_device_cn(headers); // Look up the pubkey binding for this (uid, device_cn). let binding = state @@ -217,13 +246,6 @@ pub(crate) async fn extract_auth_context( })?; let pubkey_bytes = binding.pubkey; - if let Err(e) = state - .db - .ensure_user_with_verified_name(&pubkey_bytes, &identity_claims.username) - .await - { - tracing::warn!("ensure_user_with_verified_name failed: {e}"); - } return Ok(RestAuthContext { pubkey, pubkey_bytes, @@ -335,6 +357,7 @@ pub(crate) async fn extract_auth_context( ) { Ok((pubkey, scopes)) => { let pubkey_bytes = pubkey.serialize().to_vec(); + reject_if_identity_bound(&pubkey_bytes, state).await?; if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { tracing::warn!("ensure_user failed: {e}"); } @@ -378,6 +401,7 @@ pub(crate) async fn extract_auth_context( match state.auth.validate_bearer_jwt(token).await { Ok((pubkey, scopes)) => { let pubkey_bytes = pubkey.serialize().to_vec(); + reject_if_identity_bound(&pubkey_bytes, state).await?; if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { tracing::warn!("ensure_user failed: {e}"); } diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 23ebe549..5fc1274c 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -146,6 +146,15 @@ impl Config { if let Ok(user_claim) = std::env::var("SPROUT_IDENTITY_USER_CLAIM") { auth.identity.user_claim = user_claim; } + if let Ok(jwks_uri) = std::env::var("SPROUT_IDENTITY_JWKS_URI") { + auth.identity.jwks_uri = jwks_uri; + } + if let Ok(issuer) = std::env::var("SPROUT_IDENTITY_ISSUER") { + auth.identity.issuer = issuer; + } + if let Ok(audience) = std::env::var("SPROUT_IDENTITY_AUDIENCE") { + auth.identity.audience = audience; + } // When identity mode is active the relay sits behind a trusted proxy // (cf-doorman) — force require_auth_token so the NIP-42 fallback path diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 74fa784d..7560007f 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -100,15 +100,14 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let pubkey_bytes = event.pubkey.serialize().to_vec(); match state .db - .bind_or_validate_identity( - &proxy.uid, - &proxy.device_cn, - &pubkey_bytes, - &proxy.username, - ) + .bind_or_validate_identity(&proxy.uid, &proxy.device_cn, &pubkey_bytes, &proxy.username) .await { Ok(sprout_db::BindingResult::Created) => { + // Invalidate cached `false` so the identity-bound guard takes + // effect immediately — prevents a 2-min window where the pubkey + // could still authenticate via standard auth. + state.identity_bound_cache.invalidate(&pubkey_bytes); info!(conn_id = %conn_id, uid = %proxy.uid, device_cn = %proxy.device_cn, pubkey = %event.pubkey.to_hex(), "identity binding created"); } @@ -251,6 +250,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: &record.scopes, ) { Ok((pubkey, scopes)) => { + // Identity-bound pubkey guard — bound pubkeys must use identity JWT. + if state.is_identity_bound(&pubkey.serialize()).await { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), + "identity-bound pubkey attempted API token auth without JWT"); + metrics::counter!("sprout_auth_failures_total", "reason" => "identity_bound_no_jwt") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: this pubkey is bound to a corporate identity — present x-forwarded-identity-token", + )); + return; + } + info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "API token auth successful"); // Update last_used_at asynchronously — non-fatal if it fails. let db = state.db.clone(); @@ -323,6 +337,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } } + // Identity-bound pubkey guard — bound pubkeys must use identity JWT. + if state.is_identity_bound(&pubkey.serialize()).await { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), + "identity-bound pubkey attempted standard auth without JWT"); + metrics::counter!("sprout_auth_failures_total", "reason" => "identity_bound_no_jwt") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: this pubkey is bound to a corporate identity — present x-forwarded-identity-token", + )); + return; + } + info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 2c30e6df..294ab65a 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -232,11 +232,7 @@ async fn nip11_or_ws_handler( { Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { Ok((identity_claims, scopes)) => { - let device_cn = headers - .get("x-block-client-cert-subject-cn") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown") - .to_string(); + let device_cn = crate::api::extract_device_cn(&headers).to_string(); Some(crate::connection::PendingProxyIdentity { uid: identity_claims.uid, username: identity_claims.username, diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 9a78bf09..b829ae53 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -197,6 +197,11 @@ pub struct AppState { /// Membership cache: (channel_id, pubkey_bytes) → is_member. /// Short TTL (10s) — membership changes are rare but must propagate. pub membership_cache: Arc), bool>>, + /// Identity binding cache: pubkey_bytes → is_bound. + /// 2-minute TTL — bindings rarely change; unbind propagates within minutes. + /// Used to enforce "once bound, always require JWT" in hybrid mode + /// without hitting the DB on every request. + pub identity_bound_cache: Arc, bool>>, /// Bounded channel for search indexing — prevents OOM if Typesense is slow/down. /// Capacity 1000: at ~1KB/event that's ~1MB of backlog before we start dropping. @@ -280,6 +285,12 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(10)) .build(), ), + identity_bound_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(120)) + .build(), + ), search_index_tx, media_storage: Arc::new(media_storage), @@ -294,6 +305,28 @@ impl AppState { pub fn mark_local_event(&self, event_id: &nostr::EventId) { self.local_event_ids.insert(event_id.to_bytes(), ()); } + + /// Check whether a pubkey has an active identity binding (cached). + /// + /// In hybrid mode, a bound pubkey must authenticate via identity JWT — + /// standard auth (API tokens, NIP-42) is rejected. Returns `false` + /// when identity mode is not `Hybrid` (guard is a no-op). + pub async fn is_identity_bound(&self, pubkey_bytes: &[u8]) -> bool { + if self.auth.identity_config().mode != sprout_auth::IdentityMode::Hybrid { + return false; + } + if let Some(cached) = self.identity_bound_cache.get(pubkey_bytes) { + return cached; + } + let bound = self + .db + .is_pubkey_identity_bound(pubkey_bytes) + .await + .unwrap_or(false); + self.identity_bound_cache + .insert(pubkey_bytes.to_vec(), bound); + bound + } } impl std::fmt::Debug for AppState { diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index ced3e086..10c8e255 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -14,6 +14,7 @@ pub struct IdentityInfo { pub struct ProfileInfo { pub pubkey: String, pub display_name: Option, + pub verified_name: Option, pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, @@ -22,6 +23,7 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + pub verified_name: Option, pub avatar_url: Option, pub nip05_handle: Option, } @@ -36,6 +38,7 @@ pub struct UsersBatchResponse { pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, + pub verified_name: Option, pub avatar_url: Option, pub nip05_handle: Option, } diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 100107f4..19ab8f84 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -3,7 +3,9 @@ import * as React from "react"; import type { TimelineMessage } from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { resolveUserVerification } from "@/features/profile/lib/identity"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; import { cn } from "@/shared/lib/cn"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -66,6 +68,10 @@ export const MessageRow = React.memo( [channels], ); + const verifiedName = message.pubkey + ? resolveUserVerification({ pubkey: message.pubkey, profiles }) + : null; + const visibleDepth = Math.min(message.depth, 6); const indentPx = visibleDepth * 28; const depthGuideOffsets = React.useMemo(() => { @@ -330,8 +336,11 @@ export const MessageRow = React.memo(
{avatarNode}
-
+
{authorNode} + {verifiedName ? ( + + ) : null} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( @@ -352,8 +361,11 @@ export const MessageRow = React.memo( <>
{avatarNode}
-
+
{authorNode} + {verifiedName ? ( + + ) : null} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 2693e9aa..b5c4e1f0 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -69,10 +69,13 @@ export function resolveUserLabel(input: { const profile = getResolvedProfile(pubkey, profiles); const verifiedName = profile?.verifiedName?.trim(); + const displayName = profile?.displayName?.trim(); + if (displayName && verifiedName && displayName !== verifiedName) { + return `${displayName} (${verifiedName})`; + } if (verifiedName) { return verifiedName; } - const displayName = profile?.displayName?.trim(); if (displayName) { return displayName; } @@ -90,6 +93,14 @@ export function resolveUserLabel(input: { return truncatePubkey(pubkey); } +export function resolveUserVerification(input: { + pubkey: string; + profiles?: UserProfileLookup; +}): string | null { + const profile = getResolvedProfile(input.pubkey, input.profiles); + return profile?.verifiedName?.trim() || null; +} + export function resolveUserSecondaryLabel(input: { pubkey: string; profiles?: UserProfileLookup; diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 33a67533..ad136ebc 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -11,6 +11,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Markdown } from "@/shared/ui/markdown"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { VerifiedBadge } from "@/shared/ui/VerifiedBadge"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -82,7 +83,16 @@ export function UserProfilePopover({ className="shrink-0 rounded" /> ) : null} + {profile?.verifiedName ? ( + + ) : null}
+ {profile?.verifiedName && + profile.verifiedName !== profile?.displayName ? ( +

+ {profile.verifiedName} +

+ ) : null} {profile?.nip05Handle ? (

{profile.nip05Handle} From 5da1c08d94ba1bb5ae8dd5aa145539877397a3cc Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Fri, 10 Apr 2026 15:14:32 -0600 Subject: [PATCH 4/7] fix: configurable identity headers, remove cf-doorman refs, add VerifiedBadge - Make identity JWT and device CN header names configurable via SPROUT_IDENTITY_JWT_HEADER and SPROUT_IDENTITY_DEVICE_CN_HEADER - Replace all 'cf-doorman' references with generic 'auth proxy' - Remove hardcoded header names from error messages and docs - Fix NIP-11 identity_mode doc to include 'hybrid' - Add missing VerifiedBadge component (was untracked) - Fix cargo fmt in desktop/src-tauri identity command Amp-Thread-ID: https://ampcode.com/threads/T-019d791a-849a-746b-ba47-cb9b56ee196d Co-authored-by: Amp --- .env.example | 10 +++++-- AGENTS.md | 4 +-- ARCHITECTURE.md | 6 ++-- crates/sprout-auth/src/identity.rs | 24 ++++++++++++++-- crates/sprout-auth/src/lib.rs | 2 +- crates/sprout-relay/src/api/identity.rs | 19 +++++++------ crates/sprout-relay/src/api/mod.rs | 30 +++++++++++--------- crates/sprout-relay/src/config.rs | 12 ++++++-- crates/sprout-relay/src/connection.rs | 2 +- crates/sprout-relay/src/handlers/auth.rs | 6 ++-- crates/sprout-relay/src/handlers/ingest.rs | 2 +- crates/sprout-relay/src/nip11.rs | 2 +- crates/sprout-relay/src/router.rs | 13 ++++++--- desktop/src-tauri/src/commands/identity.rs | 6 ++-- desktop/src/shared/ui/VerifiedBadge.tsx | 33 ++++++++++++++++++++++ 15 files changed, 120 insertions(+), 51 deletions(-) create mode 100644 desktop/src/shared/ui/VerifiedBadge.tsx diff --git a/.env.example b/.env.example index 3394c073..36a3e049 100644 --- a/.env.example +++ b/.env.example @@ -96,8 +96,14 @@ OKTA_AUDIENCE=sprout-desktop # against this IdP independently of the main Okta/Keycloak JWKS config above. # If unset, falls back to OKTA_JWKS_URI / OKTA_ISSUER / OKTA_AUDIENCE. # SPROUT_IDENTITY_JWKS_URI=http://localhost:9200/certs -# SPROUT_IDENTITY_ISSUER=cf-doorman-production -# SPROUT_IDENTITY_AUDIENCE=watson +# SPROUT_IDENTITY_ISSUER=my-identity-provider +# SPROUT_IDENTITY_AUDIENCE=my-audience +# +# HTTP header names for the proxy-injected identity JWT and device CN. +# These default to the values shown below. Override if your auth proxy uses +# different header names. +# SPROUT_IDENTITY_JWT_HEADER=x-forwarded-identity-token +# SPROUT_IDENTITY_DEVICE_CN_HEADER=x-block-client-cert-subject-cn # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/AGENTS.md b/AGENTS.md index b436b47d..f83fbc72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,8 +93,8 @@ thread root events. Any code that inserts replies must update these counters — check existing reply handlers for the pattern. **Identity binding (proxy mode)**: In corporate deployments the relay sits -behind a trusted reverse proxy (cf-doorman) that injects -`x-forwarded-identity-token` and `x-block-client-cert-subject-cn` headers. +behind a trusted auth proxy that injects identity JWT and device CN headers +(configured via `SPROUT_IDENTITY_JWT_HEADER` and `SPROUT_IDENTITY_DEVICE_CN_HEADER`). `SPROUT_IDENTITY_MODE` controls behaviour: - `disabled` (default) — standard Nostr key-based auth only. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1eb1303c..2c3ac396 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -179,13 +179,13 @@ The client must respond with `["AUTH", ]` before submitting events | NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta | | NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts | | HTTP Bearer JWT | `Authorization: Bearer ` header on REST endpoints | REST API clients | -| NIP-42 + proxy identity | Identity JWT at upgrade + NIP-42 challenge | Corporate SSO via cf-doorman (proxy/hybrid mode) | +| NIP-42 + proxy identity | Identity JWT at upgrade + NIP-42 challenge | Corporate SSO via auth proxy (proxy/hybrid mode) | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. #### Proxy Identity Mode (Corporate SSO) -When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted reverse proxy (cf-doorman) that injects an identity JWT via the `x-forwarded-identity-token` header. The flow adds a two-phase binding step: +When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted auth proxy that injects an identity JWT via the configured header (`SPROUT_IDENTITY_JWT_HEADER`). The flow adds a two-phase binding step: 1. **WS Upgrade** — The relay validates the identity JWT (signature + expiry via JWKS), extracts `uid` and `username` claims, and stashes them as `PendingProxyIdentity` on the connection. No pubkey is known yet. 2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, device_cn, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`. @@ -837,7 +837,7 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic | NIP-42 timestamp | ±60 second tolerance — prevents replay attacks | | AUTH events | Never stored in Postgres, never logged in audit chain | | Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default | -| Proxy identity | JWT validated via JWKS; headers trusted from cf-doorman only; `require_auth_token` forced true | +| Proxy identity | JWT validated via JWKS; headers trusted from auth proxy only; `require_auth_token` forced true | ### Input Validation diff --git a/crates/sprout-auth/src/identity.rs b/crates/sprout-auth/src/identity.rs index b1cc6964..473d06d0 100644 --- a/crates/sprout-auth/src/identity.rs +++ b/crates/sprout-auth/src/identity.rs @@ -1,6 +1,6 @@ //! Corporate identity mode for the Sprout relay. //! -//! Supports proxy-based identity where an upstream reverse proxy (e.g. cf-doorman) +//! Supports proxy-based identity where an upstream auth proxy //! injects identity JWTs. The relay extracts corporate identity claims and binds //! the client's self-generated pubkey to them. @@ -15,7 +15,7 @@ pub enum IdentityMode { /// Identity mode is disabled — standard Nostr key-based authentication. #[default] Disabled, - /// A reverse proxy (e.g. cf-doorman) injects identity JWTs into requests. + /// An auth proxy injects identity JWTs into requests. /// All connections **must** present a valid identity JWT — no fallback. Proxy, /// Transitional mode: proxy identity is preferred for human users, but @@ -67,7 +67,7 @@ pub struct IdentityConfig { /// JWT claim name containing the human-readable username. #[serde(default = "default_user_claim")] pub user_claim: String, - /// JWKS endpoint URL for the identity provider (e.g. cf-doorman). + /// JWKS endpoint URL for the identity provider (e.g. the auth proxy). /// Falls back to the main Okta/JWKS URI if empty. #[serde(default)] pub jwks_uri: String, @@ -79,6 +79,12 @@ pub struct IdentityConfig { /// Falls back to the main Okta audience if empty. #[serde(default)] pub audience: String, + /// HTTP header containing the identity JWT injected by the auth proxy. + #[serde(default = "default_identity_jwt_header")] + pub identity_jwt_header: String, + /// HTTP header containing the device common name from the client certificate. + #[serde(default = "default_device_cn_header")] + pub device_cn_header: String, } impl Default for IdentityConfig { @@ -90,6 +96,8 @@ impl Default for IdentityConfig { jwks_uri: String::new(), issuer: String::new(), audience: String::new(), + identity_jwt_header: default_identity_jwt_header(), + device_cn_header: default_device_cn_header(), } } } @@ -106,6 +114,14 @@ fn default_user_claim() -> String { "user".to_string() } +fn default_identity_jwt_header() -> String { + "x-forwarded-identity-token".to_string() +} + +fn default_device_cn_header() -> String { + "x-block-client-cert-subject-cn".to_string() +} + // Custom serde for IdentityMode as a lowercase string. impl Serialize for IdentityMode { fn serialize(&self, serializer: S) -> Result { @@ -174,5 +190,7 @@ mod tests { assert_eq!(config.mode, IdentityMode::Disabled); assert_eq!(config.uid_claim, "uid"); assert_eq!(config.user_claim, "user"); + assert_eq!(config.identity_jwt_header, "x-forwarded-identity-token"); + assert_eq!(config.device_cn_header, "x-block-client-cert-subject-cn"); } } diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 0e1cbe41..2a00f669 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -337,7 +337,7 @@ impl AuthService { /// Validate a proxy-injected identity JWT and extract the corporate identity claims. /// - /// Used in proxy identity mode where cf-doorman injects `x-forwarded-identity-token`. + /// Used in proxy identity mode where the auth proxy injects an identity JWT. /// Validates the JWT via JWKS, extracts the `uid` and `user` claims. /// /// Uses identity-specific JWKS/issuer/audience config when set, falling back to the diff --git a/crates/sprout-relay/src/api/identity.rs b/crates/sprout-relay/src/api/identity.rs index 31c421e6..59c35aa9 100644 --- a/crates/sprout-relay/src/api/identity.rs +++ b/crates/sprout-relay/src/api/identity.rs @@ -8,10 +8,10 @@ //! //! # Trusted-proxy assumption //! -//! The relay trusts the `x-forwarded-identity-token` and -//! `x-block-client-cert-subject-cn` headers unconditionally. -//! It MUST be deployed behind a trusted reverse proxy (cf-doorman) that is the -//! sole source of these headers. +//! The relay trusts the identity JWT and device CN headers (configured via +//! `SPROUT_IDENTITY_JWT_HEADER` and `SPROUT_IDENTITY_DEVICE_CN_HEADER`) +//! unconditionally. It MUST be deployed behind a trusted auth proxy that is +//! the sole source of these headers. use std::sync::Arc; @@ -32,8 +32,8 @@ use crate::state::AppState; /// /// # Headers /// -/// - `x-forwarded-identity-token`: Corporate identity JWT (injected by cf-doorman) -/// - `x-block-client-cert-subject-cn`: Device identifier from client certificate +/// - Identity JWT header (`SPROUT_IDENTITY_JWT_HEADER`): Corporate identity JWT (injected by auth proxy) +/// - Device CN header (`SPROUT_IDENTITY_DEVICE_CN_HEADER`): Device identifier from client certificate /// - `Authorization: Nostr `: NIP-98 signed event proving pubkey ownership /// /// # Binding semantics @@ -68,14 +68,14 @@ pub async fn identity_register( // 1. Validate proxy identity JWT → extract uid + username let identity_jwt = headers - .get("x-forwarded-identity-token") + .get(&*state.auth.identity_config().identity_jwt_header) .and_then(|v| v.to_str().ok()) .ok_or_else(|| { ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_token_required", - "message": "x-forwarded-identity-token header is required" + "message": "identity JWT header is required" })), ) })?; @@ -93,7 +93,8 @@ pub async fn identity_register( })?; // 2. Extract device identifier from client certificate CN (fallback to "default") - let device_cn = super::extract_device_cn(&headers); + let device_cn = + super::extract_device_cn(&headers, &state.auth.identity_config().device_cn_header); // 3. Verify NIP-98 auth to prove pubkey ownership let auth_header = headers diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index a787392b..87959999 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -91,12 +91,15 @@ use sprout_auth::Scope; use crate::state::AppState; -/// Extract the device common name from the `x-block-client-cert-subject-cn` header, -/// defaulting to `"default"` when the header is absent (e.g. Cloudflare Tunnel -/// deployments without mTLS client certificates). -pub(crate) fn extract_device_cn(headers: &axum::http::HeaderMap) -> &str { +/// Extract the device common name from the configured device CN header, +/// defaulting to `"default"` when the header is absent (e.g. deployments +/// without mTLS client certificates). +pub(crate) fn extract_device_cn<'a>( + headers: &'a axum::http::HeaderMap, + header_name: &str, +) -> &'a str { headers - .get("x-block-client-cert-subject-cn") + .get(header_name) .and_then(|v| v.to_str().ok()) .unwrap_or("default") } @@ -114,7 +117,7 @@ pub enum RestAuthMethod { Nip98, /// `X-Pubkey: ` — dev mode only (`require_auth_token=false`). DevPubkey, - /// `x-forwarded-identity-token` — proxy identity mode (cf-doorman). + /// Identity JWT header — proxy identity mode (auth proxy). ProxyIdentity, } @@ -157,7 +160,7 @@ async fn reject_if_identity_bound( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_jwt_required", - "message": "this pubkey is bound to a corporate identity — present x-forwarded-identity-token" + "message": "this pubkey is bound to a corporate identity — connect via the auth proxy" })), )); } @@ -184,7 +187,7 @@ pub(crate) async fn extract_auth_context( let require_auth = state.config.require_auth_token; // ── 0. Proxy / hybrid identity mode ────────────────────────────────── - // When identity_mode is proxy or hybrid, cf-doorman injects + // When identity_mode is proxy or hybrid, the auth proxy injects // x-forwarded-identity-token for human users. The relay validates the JWT, // extracts uid + device_cn, and looks up the pubkey binding from the DB. // - Proxy: header is mandatory — reject if missing. @@ -193,7 +196,7 @@ pub(crate) async fn extract_auth_context( let identity_mode = &state.auth.identity_config().mode; if identity_mode.is_proxy() { if let Some(identity_jwt) = headers - .get("x-forwarded-identity-token") + .get(&*state.auth.identity_config().identity_jwt_header) .and_then(|v| v.to_str().ok()) { let (identity_claims, scopes) = state @@ -208,7 +211,8 @@ pub(crate) async fn extract_auth_context( ) })?; - let device_cn = extract_device_cn(headers); + let device_cn = + extract_device_cn(headers, &state.auth.identity_config().device_cn_header); // Look up the pubkey binding for this (uid, device_cn). let binding = state @@ -255,14 +259,12 @@ pub(crate) async fn extract_auth_context( channel_ids: None, }); } else if *identity_mode == sprout_auth::IdentityMode::Proxy { - tracing::warn!( - "auth: proxy mode enabled but x-forwarded-identity-token header missing" - ); + tracing::warn!("auth: proxy mode enabled but identity JWT header missing"); return Err(( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_token_required", - "message": "x-forwarded-identity-token header is required in proxy identity mode" + "message": "identity JWT header is required in proxy identity mode" })), )); } diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 5fc1274c..784f58bd 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -155,9 +155,15 @@ impl Config { if let Ok(audience) = std::env::var("SPROUT_IDENTITY_AUDIENCE") { auth.identity.audience = audience; } + if let Ok(jwt_header) = std::env::var("SPROUT_IDENTITY_JWT_HEADER") { + auth.identity.identity_jwt_header = jwt_header; + } + if let Ok(cn_header) = std::env::var("SPROUT_IDENTITY_DEVICE_CN_HEADER") { + auth.identity.device_cn_header = cn_header; + } // When identity mode is active the relay sits behind a trusted proxy - // (cf-doorman) — force require_auth_token so the NIP-42 fallback path + // (auth proxy) — force require_auth_token so the NIP-42 fallback path // cannot be used with bare keypair-only auth. let require_auth_token = if identity_mode.is_proxy() { if !require_auth_token { @@ -166,8 +172,8 @@ impl Config { ); } tracing::warn!( - "Identity mode: {identity_mode} — relay trusts x-forwarded-identity-token headers. \ - Ensure the relay is reachable ONLY via the trusted reverse proxy (cf-doorman). \ + "Identity mode: {identity_mode} — relay trusts proxy-injected identity headers. \ + Ensure the relay is reachable ONLY via the trusted auth proxy. \ Direct access to the relay port would allow header injection." ); auth.okta.require_token = true; diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index b928f18a..75b6c369 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -39,7 +39,7 @@ pub struct PendingProxyIdentity { pub uid: String, /// Human-readable username from the identity JWT. pub username: String, - /// Device common name from the `x-block-client-cert-subject-cn` header. + /// Device common name from the client certificate header. pub device_cn: String, /// Permission scopes granted by the identity JWT. pub scopes: Vec, diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 7560007f..ddc9c7ae 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -260,8 +260,8 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: conn.send(RelayMessage::ok( &event_id_hex, false, - "auth-required: this pubkey is bound to a corporate identity — present x-forwarded-identity-token", - )); + "auth-required: this pubkey is bound to a corporate identity — connect via the auth proxy", + )); return; } @@ -347,7 +347,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: conn.send(RelayMessage::ok( &event_id_hex, false, - "auth-required: this pubkey is bound to a corporate identity — present x-forwarded-identity-token", + "auth-required: this pubkey is bound to a corporate identity — connect via the auth proxy", )); return; } diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index e69cf61d..d2606e49 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -41,7 +41,7 @@ pub enum HttpAuthMethod { OktaJwt, /// `X-Pubkey: ` dev-mode header. DevPubkey, - /// `x-forwarded-identity-token` proxy identity mode. + /// Identity JWT header — proxy identity mode. ProxyIdentity, } diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index 9fe47293..78cca9d1 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -28,7 +28,7 @@ pub struct RelayInfo { pub version: String, /// Protocol and resource limits advertised to clients. pub limitation: Option, - /// Corporate identity mode: `"proxy"` or `"disabled"`. + /// Corporate identity mode: `"disabled"`, `"proxy"`, or `"hybrid"`. pub identity_mode: String, } diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 294ab65a..4190700c 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -196,7 +196,8 @@ pub fn build_health_router(state: Arc) -> Router { /// TCP connections populate it via `into_make_service_with_connect_info`; UDS /// connections fall back to `0.0.0.0:0`. /// -/// In proxy identity mode, the `x-forwarded-identity-token` header is validated +/// In proxy identity mode, the identity JWT header (configured via +/// `SPROUT_IDENTITY_JWT_HEADER`) is validated /// at upgrade time and the connection is pre-authenticated (NIP-42 is skipped). async fn nip11_or_ws_handler( State(state): State>, @@ -227,12 +228,16 @@ async fn nip11_or_ws_handler( let identity_mode = &state.auth.identity_config().mode; let proxy_identity = if identity_mode.is_proxy() { match headers - .get("x-forwarded-identity-token") + .get(&*state.auth.identity_config().identity_jwt_header) .and_then(|v| v.to_str().ok()) { Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { Ok((identity_claims, scopes)) => { - let device_cn = crate::api::extract_device_cn(&headers).to_string(); + let device_cn = crate::api::extract_device_cn( + &headers, + &state.auth.identity_config().device_cn_header, + ) + .to_string(); Some(crate::connection::PendingProxyIdentity { uid: identity_claims.uid, username: identity_claims.username, @@ -246,7 +251,7 @@ async fn nip11_or_ws_handler( } }, None if *identity_mode == sprout_auth::IdentityMode::Proxy => { - tracing::warn!("ws: proxy mode enabled but x-forwarded-identity-token missing"); + tracing::warn!("ws: proxy mode enabled but identity JWT header missing"); return (StatusCode::UNAUTHORIZED, "identity token required").into_response(); } // Hybrid: no identity token — proceed to standard NIP-42 auth. diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 0a4b2d7a..38e8f42c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -95,10 +95,8 @@ pub async fn initialize_identity( let nip98_b64 = { let keys = state.keys.lock().map_err(|e| e.to_string())?; let tags = vec![ - Tag::parse(vec!["u", ®ister_url]) - .map_err(|e| format!("u tag: {e}"))?, - Tag::parse(vec!["method", "POST"]) - .map_err(|e| format!("method tag: {e}"))?, + Tag::parse(vec!["u", ®ister_url]).map_err(|e| format!("u tag: {e}"))?, + Tag::parse(vec!["method", "POST"]).map_err(|e| format!("method tag: {e}"))?, ]; let event = EventBuilder::new(Kind::HttpAuth, "") .tags(tags) diff --git a/desktop/src/shared/ui/VerifiedBadge.tsx b/desktop/src/shared/ui/VerifiedBadge.tsx new file mode 100644 index 00000000..63f98031 --- /dev/null +++ b/desktop/src/shared/ui/VerifiedBadge.tsx @@ -0,0 +1,33 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type VerifiedBadgeProps = { + verifiedName: string; +}; + +export function VerifiedBadge({ verifiedName }: VerifiedBadgeProps) { + return ( + + + + + + + + + +

Verified as {verifiedName}

+ + + ); +} From edb0ab924205b8cb673a20871ede5fef03704636 Mon Sep 17 00:00:00 2001 From: Tyler Longwell Date: Fri, 10 Apr 2026 20:01:59 -0400 Subject: [PATCH 5/7] fix: security hardening for identity-pubkey binding - Enforce pubkey uniqueness via UNIQUE index on identity_bindings.pubkey - Derive scopes from JWT claims instead of granting all_non_admin() - Fail closed on DB error in is_identity_bound() (deny, don't allow) - Register proxy-authenticated sessions in connection manager Amp-Thread-ID: https://ampcode.com/threads/T-019d9d76-ce40-7239-9f9e-131de6069f37 Co-authored-by: Amp --- crates/sprout-auth/src/lib.rs | 2 +- crates/sprout-relay/src/handlers/auth.rs | 4 ++++ crates/sprout-relay/src/state.rs | 21 +++++++++++++-------- desktop/scripts/check-file-sizes.mjs | 4 ++-- schema/schema.sql | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index 2a00f669..8b6e3add 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -405,7 +405,7 @@ impl AuthService { })? .to_string(); - let scopes = Scope::all_non_admin(); + let scopes = extract_scopes_from_claims(&claims); Ok((identity::ProxyIdentityClaims { uid, username }, scopes)) } diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index ddc9c7ae..3029b2e2 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -152,9 +152,13 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: let auth_ctx = sprout_auth::AuthContext { pubkey: event.pubkey, scopes: proxy.scopes, + channel_ids: None, auth_method: sprout_auth::AuthMethod::ProxyIdentity, }; *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); + state + .conn_manager + .set_authenticated_pubkey(conn_id, pubkey_bytes.clone()); conn.send(RelayMessage::ok(&event_id_hex, true, "")); return; } diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index b829ae53..456e6d08 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -318,14 +318,19 @@ impl AppState { if let Some(cached) = self.identity_bound_cache.get(pubkey_bytes) { return cached; } - let bound = self - .db - .is_pubkey_identity_bound(pubkey_bytes) - .await - .unwrap_or(false); - self.identity_bound_cache - .insert(pubkey_bytes.to_vec(), bound); - bound + match self.db.is_pubkey_identity_bound(pubkey_bytes).await { + Ok(bound) => { + self.identity_bound_cache + .insert(pubkey_bytes.to_vec(), bound); + bound + } + Err(e) => { + tracing::error!(error = %e, "identity binding check failed — denying access"); + // Fail closed: treat DB errors as "bound" so the caller + // rejects standard auth and requires identity JWT. + true + } + } } } diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b262a6cc..c99086b8 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,7 +40,7 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 840], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + identity bootstrap + ["src/shared/api/relayClientSession.ts", 845], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + identity bootstrap ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests @@ -67,7 +67,7 @@ const overrides = new Map([ ["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism ["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations - ["src/shared/api/tauri.ts", 1110], // +14 lines for 3 NIP-AB pairing command wrappers + ["src/shared/api/tauri.ts", 1140], // +14 lines for 3 NIP-AB pairing command wrappers + identity bootstrap commands ]); async function walkFiles(directory) { diff --git a/schema/schema.sql b/schema/schema.sql index be9ecc97..53c2586c 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -342,5 +342,5 @@ CREATE TABLE identity_bindings ( CONSTRAINT chk_identity_bindings_pubkey_len CHECK (LENGTH(pubkey) = 32) ); -CREATE INDEX idx_identity_bindings_pubkey ON identity_bindings(pubkey); +CREATE UNIQUE INDEX idx_identity_bindings_pubkey ON identity_bindings(pubkey); CREATE INDEX idx_identity_bindings_uid ON identity_bindings(uid); From 3106e6cc7c330bcf676acc7a3cf968e5ce455fae Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Mon, 20 Apr 2026 09:03:45 -0600 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20remove=20device=5Fcn,=20simplif?= =?UTF-8?q?y=20identity=20binding=20to=20uid=20=E2=86=92=20pubkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single key per user, shared across devices via NIP-AB pairing. The identity_bindings table now uses uid as the primary key instead of (uid, device_cn). - Drop device_cn column from identity_bindings schema - Remove device_cn from all DB queries, structs, and public API - Remove extract_device_cn and SPROUT_IDENTITY_DEVICE_CN_HEADER config - Simplify sprout-admin unbind-identity (no --device-cn flag) - Update docs: AGENTS.md, ARCHITECTURE.md, .env.example Amp-Thread-ID: https://ampcode.com/threads/T-019da7ce-37ed-73ea-a680-18714f07a751 Co-authored-by: Amp --- .env.example | 7 +- AGENTS.md | 15 +- ARCHITECTURE.md | 8 +- crates/sprout-admin/src/main.rs | 56 ++----- crates/sprout-auth/src/identity.rs | 9 -- crates/sprout-db/src/identity_binding.rs | 180 +++++---------------- crates/sprout-db/src/lib.rs | 29 +--- crates/sprout-relay/src/api/identity.rs | 35 ++-- crates/sprout-relay/src/api/mod.rs | 25 +-- crates/sprout-relay/src/config.rs | 3 - crates/sprout-relay/src/connection.rs | 12 +- crates/sprout-relay/src/handlers/auth.rs | 10 +- crates/sprout-relay/src/router.rs | 8 +- desktop/src-tauri/src/commands/identity.rs | 2 +- schema/schema.sql | 7 +- 15 files changed, 101 insertions(+), 305 deletions(-) diff --git a/.env.example b/.env.example index 36a3e049..9d8a38c8 100644 --- a/.env.example +++ b/.env.example @@ -99,11 +99,10 @@ OKTA_AUDIENCE=sprout-desktop # SPROUT_IDENTITY_ISSUER=my-identity-provider # SPROUT_IDENTITY_AUDIENCE=my-audience # -# HTTP header names for the proxy-injected identity JWT and device CN. -# These default to the values shown below. Override if your auth proxy uses -# different header names. +# HTTP header name for the proxy-injected identity JWT. +# Defaults to the value shown below. Override if your auth proxy uses a +# different header name. # SPROUT_IDENTITY_JWT_HEADER=x-forwarded-identity-token -# SPROUT_IDENTITY_DEVICE_CN_HEADER=x-block-client-cert-subject-cn # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) diff --git a/AGENTS.md b/AGENTS.md index f83fbc72..02924afe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,21 +93,20 @@ thread root events. Any code that inserts replies must update these counters — check existing reply handlers for the pattern. **Identity binding (proxy mode)**: In corporate deployments the relay sits -behind a trusted auth proxy that injects identity JWT and device CN headers -(configured via `SPROUT_IDENTITY_JWT_HEADER` and `SPROUT_IDENTITY_DEVICE_CN_HEADER`). +behind a trusted auth proxy that injects an identity JWT header +(configured via `SPROUT_IDENTITY_JWT_HEADER`). `SPROUT_IDENTITY_MODE` controls behaviour: - `disabled` (default) — standard Nostr key-based auth only. - `proxy` — all connections must present a valid identity JWT; the relay binds - (uid, device\_cn) → pubkey in the `identity_bindings` table. NIP-42 is still - required to prove pubkey ownership. + uid → pubkey in the `identity_bindings` table. NIP-42 is still required to + prove pubkey ownership. Keys are shared across devices via NIP-AB pairing. - `hybrid` — identity JWT preferred for humans; connections without the header fall through to standard auth (API tokens, Okta JWTs) for agents. -Identity bindings are **immutable** — once a (uid, device\_cn) is bound to a -pubkey, a different pubkey for the same slot returns a mismatch error. Use -`sprout-admin unbind-identity` to clear a binding (e.g., key rotation, device -reset, offboarding). +Identity bindings are **immutable** — once a uid is bound to a pubkey, a +different pubkey returns a mismatch error. Use `sprout-admin unbind-identity` +to clear a binding (e.g., key rotation, offboarding). **Trusted-proxy security invariant**: The relay trusts proxy headers unconditionally. It **must** be deployed behind the trusted reverse proxy — diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2c3ac396..088d1b2b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -188,11 +188,11 @@ On success, `ConnectionState.auth_state` transitions from `Pending` → `Authent When `SPROUT_IDENTITY_MODE` is `proxy` or `hybrid`, the relay sits behind a trusted auth proxy that injects an identity JWT via the configured header (`SPROUT_IDENTITY_JWT_HEADER`). The flow adds a two-phase binding step: 1. **WS Upgrade** — The relay validates the identity JWT (signature + expiry via JWKS), extracts `uid` and `username` claims, and stashes them as `PendingProxyIdentity` on the connection. No pubkey is known yet. -2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, device_cn, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`. -3. **REST API** — Proxy-authenticated REST requests validate the identity JWT, then look up the existing `(uid, device_cn) → pubkey` binding from the `identity_bindings` table. +2. **NIP-42 AUTH** — The client signs the challenge with its Nostr keypair. The AUTH handler verifies the signature, then calls `bind_or_validate_identity(uid, pubkey)` to create or validate the binding. On success, `AuthState` transitions to `Authenticated`. +3. **REST API** — Proxy-authenticated REST requests validate the identity JWT, then look up the existing `uid → pubkey` binding from the `identity_bindings` table. 4. **Registration** — `POST /api/identity/register` allows initial binding of a pubkey to a corporate identity via NIP-98 proof of key ownership. -**Binding semantics:** Once a `(uid, device_cn)` pair is bound to a pubkey, the binding is immutable. A different pubkey for the same slot returns a 409 Conflict. Admin can unbind via `sprout-admin unbind-identity`. +**Binding semantics:** Once a uid is bound to a pubkey, the binding is immutable. A different pubkey for the same uid returns a 409 Conflict. Admin can unbind via `sprout-admin unbind-identity`. Keys are shared across devices via NIP-AB pairing. **Hybrid mode:** When the identity JWT header is absent, the connection falls through to standard NIP-42 auth (API tokens, Okta JWTs). This allows agents without corporate JWTs to authenticate alongside human users. @@ -910,7 +910,7 @@ Docker Compose provides the full local development stack. All services include h | `api_tokens` | API token records (hash only, never plaintext) | | `audit_log` | Hash-chain audit entries | | `delivery_log` | Delivery tracking (partitioned; Rust module pending) | -| `identity_bindings` | Proxy mode: (uid, device_cn) → pubkey binding for corporate identity | +| `identity_bindings` | Proxy mode: uid → pubkey binding for corporate identity | ### Redis Key Patterns diff --git a/crates/sprout-admin/src/main.rs b/crates/sprout-admin/src/main.rs index 02a0d803..46d1a829 100644 --- a/crates/sprout-admin/src/main.rs +++ b/crates/sprout-admin/src/main.rs @@ -44,11 +44,7 @@ enum Command { #[arg(long)] uid: String, - /// Device common name. If omitted, removes all bindings for the UID. - #[arg(long)] - device_cn: Option, - - /// Also clear verified_name from the user record(s). + /// Also clear verified_name from the user record. #[arg(long, default_value_t = false)] clear_name: bool, }, @@ -75,11 +71,9 @@ async fn main() -> Result<()> { owner_pubkey, } => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?, Command::ListTokens => list_tokens(&db).await?, - Command::UnbindIdentity { - uid, - device_cn, - clear_name, - } => unbind_identity(&db, &uid, device_cn.as_deref(), clear_name).await?, + Command::UnbindIdentity { uid, clear_name } => { + unbind_identity(&db, &uid, clear_name).await? + } } Ok(()) @@ -202,45 +196,21 @@ async fn mint_token( Ok(()) } -async fn unbind_identity( - db: &Db, - uid: &str, - device_cn: Option<&str>, - clear_name: bool, -) -> Result<()> { - if let Some(device_cn) = device_cn { - // Single binding removal - let binding = db.get_identity_binding(uid, device_cn).await?; - let deleted = db.delete_identity_binding(uid, device_cn).await?; - if deleted { - println!("Removed identity binding for uid={uid}, device_cn={device_cn}"); - if clear_name { - if let Some(binding) = binding { - let cleared = db.clear_verified_name(&binding.pubkey).await?; - if cleared { - println!("Cleared verified_name for the bound pubkey"); - } - } - } - } else { - println!("No binding found for uid={uid}, device_cn={device_cn}"); - } - } else { - // Remove all bindings for the UID - let bindings = db.get_bindings_for_uid(uid).await?; - let count = db.delete_bindings_for_uid(uid).await?; - println!("Removed {count} identity binding(s) for uid={uid}"); +async fn unbind_identity(db: &Db, uid: &str, clear_name: bool) -> Result<()> { + let binding = db.get_identity_binding(uid).await?; + let deleted = db.delete_identity_binding(uid).await?; + if deleted { + println!("Removed identity binding for uid={uid}"); if clear_name { - for binding in &bindings { + if let Some(binding) = binding { let cleared = db.clear_verified_name(&binding.pubkey).await?; if cleared { - println!( - "Cleared verified_name for pubkey bound to device_cn={}", - binding.device_cn - ); + println!("Cleared verified_name for the bound pubkey"); } } } + } else { + println!("No binding found for uid={uid}"); } Ok(()) } diff --git a/crates/sprout-auth/src/identity.rs b/crates/sprout-auth/src/identity.rs index 473d06d0..333279c8 100644 --- a/crates/sprout-auth/src/identity.rs +++ b/crates/sprout-auth/src/identity.rs @@ -82,9 +82,6 @@ pub struct IdentityConfig { /// HTTP header containing the identity JWT injected by the auth proxy. #[serde(default = "default_identity_jwt_header")] pub identity_jwt_header: String, - /// HTTP header containing the device common name from the client certificate. - #[serde(default = "default_device_cn_header")] - pub device_cn_header: String, } impl Default for IdentityConfig { @@ -97,7 +94,6 @@ impl Default for IdentityConfig { issuer: String::new(), audience: String::new(), identity_jwt_header: default_identity_jwt_header(), - device_cn_header: default_device_cn_header(), } } } @@ -118,10 +114,6 @@ fn default_identity_jwt_header() -> String { "x-forwarded-identity-token".to_string() } -fn default_device_cn_header() -> String { - "x-block-client-cert-subject-cn".to_string() -} - // Custom serde for IdentityMode as a lowercase string. impl Serialize for IdentityMode { fn serialize(&self, serializer: S) -> Result { @@ -191,6 +183,5 @@ mod tests { assert_eq!(config.uid_claim, "uid"); assert_eq!(config.user_claim, "user"); assert_eq!(config.identity_jwt_header, "x-forwarded-identity-token"); - assert_eq!(config.device_cn_header, "x-block-client-cert-subject-cn"); } } diff --git a/crates/sprout-db/src/identity_binding.rs b/crates/sprout-db/src/identity_binding.rs index 51e826f2..6a9cf615 100644 --- a/crates/sprout-db/src/identity_binding.rs +++ b/crates/sprout-db/src/identity_binding.rs @@ -1,24 +1,22 @@ //! Identity binding persistence for proxy identity mode. //! -//! Maps (corporate_uid, device_cn) pairs to Nostr pubkeys. Each device -//! gets its own binding, enabling multi-device support under one corporate -//! identity. +//! Maps corporate UIDs to Nostr pubkeys. Each user gets a single binding, +//! and keys are shared across devices via NIP-AB pairing. //! //! # TODO: Self-service key rotation //! //! Bindings are currently immutable — rebinding requires admin intervention //! (`sprout-admin unbind-identity`). Planned work: //! -//! - Add `POST /api/identity/rotate` endpoint (JWT + device cert + NIP-98 with new key). +//! - Add `POST /api/identity/rotate` endpoint (JWT + NIP-98 with new key). //! - Soft-rotate: add `rotated_at` / `replaced_by` columns instead of deleting old rows, //! preserving an audit trail and letting the UI resolve old pubkeys to usernames. -//! - Add a UNIQUE constraint on pubkey for active (non-rotated) bindings. //! - Keep the 409 Conflict on mismatch — rotation must be an explicit action, not implicit. use crate::error::Result; use sqlx::PgPool; -/// Result of attempting to bind a pubkey to a (uid, device_cn) pair. +/// Result of attempting to bind a pubkey to a uid. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BindingResult { /// No prior binding existed; a new one was created. @@ -27,7 +25,7 @@ pub enum BindingResult { Matched, /// A binding already existed but for a different pubkey. Mismatch { - /// The pubkey that is already bound to this (uid, device_cn). + /// The pubkey that is already bound to this uid. existing_pubkey: Vec, }, } @@ -37,43 +35,38 @@ pub enum BindingResult { pub struct IdentityBinding { /// Corporate user identifier. pub uid: String, - /// Device common name from client certificate. - pub device_cn: String, /// Bound Nostr public key (32 bytes). pub pubkey: Vec, /// Cached username from the identity JWT. pub username: Option, } -/// Look up a binding by (uid, device_cn). +/// Look up a binding by uid. pub async fn get_identity_binding( pool: &PgPool, uid: &str, - device_cn: &str, ) -> Result> { - let row = sqlx::query_as::<_, (String, String, Vec, Option)>( + let row = sqlx::query_as::<_, (String, Vec, Option)>( r#" - SELECT uid, device_cn, pubkey, username + SELECT uid, pubkey, username FROM identity_bindings - WHERE uid = $1 AND device_cn = $2 + WHERE uid = $1 "#, ) .bind(uid) - .bind(device_cn) .fetch_optional(pool) .await?; Ok( - row.map(|(uid, device_cn, pubkey, username)| IdentityBinding { + row.map(|(uid, pubkey, username)| IdentityBinding { uid, - device_cn, pubkey, username, }), ) } -/// Bind a pubkey to (uid, device_cn), or validate an existing binding. +/// Bind a pubkey to a uid, or validate an existing binding. /// /// Uses `SELECT ... FOR UPDATE` inside a transaction to prevent race conditions /// on first bind. @@ -85,7 +78,6 @@ pub async fn get_identity_binding( pub async fn bind_or_validate_identity( pool: &PgPool, uid: &str, - device_cn: &str, pubkey: &[u8], username: &str, ) -> Result { @@ -99,12 +91,11 @@ pub async fn bind_or_validate_identity( r#" SELECT pubkey FROM identity_bindings - WHERE uid = $1 AND device_cn = $2 + WHERE uid = $1 FOR UPDATE "#, ) .bind(uid) - .bind(device_cn) .fetch_optional(&mut *tx) .await?; @@ -115,12 +106,11 @@ pub async fn bind_or_validate_identity( sqlx::query( r#" UPDATE identity_bindings - SET last_seen_at = NOW(), username = NULLIF($3, '') - WHERE uid = $1 AND device_cn = $2 + SET last_seen_at = NOW(), username = NULLIF($2, '') + WHERE uid = $1 "#, ) .bind(uid) - .bind(device_cn) .bind(username) .execute(&mut *tx) .await?; @@ -132,12 +122,11 @@ pub async fn bind_or_validate_identity( None => { sqlx::query( r#" - INSERT INTO identity_bindings (uid, device_cn, pubkey, username) - VALUES ($1, $2, $3, NULLIF($4, '')) + INSERT INTO identity_bindings (uid, pubkey, username) + VALUES ($1, $2, NULLIF($3, '')) "#, ) .bind(uid) - .bind(device_cn) .bind(pubkey) .bind(username) .execute(&mut *tx) @@ -150,41 +139,6 @@ pub async fn bind_or_validate_identity( Ok(result) } -/// Get all bindings for a given uid (all devices). -pub async fn get_bindings_for_uid(pool: &PgPool, uid: &str) -> Result> { - let rows = sqlx::query_as::<_, (String, String, Vec, Option)>( - r#" - SELECT uid, device_cn, pubkey, username - FROM identity_bindings - WHERE uid = $1 - ORDER BY created_at - "#, - ) - .bind(uid) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .map(|(uid, device_cn, pubkey, username)| IdentityBinding { - uid, - device_cn, - pubkey, - username, - }) - .collect()) -} - -/// Delete all identity bindings for a given UID (all devices). -/// Used for employee offboarding / GDPR erasure. -pub async fn delete_bindings_for_uid(pool: &PgPool, uid: &str) -> Result { - let result = sqlx::query("DELETE FROM identity_bindings WHERE uid = $1") - .bind(uid) - .execute(pool) - .await?; - Ok(result.rows_affected()) -} - /// Check whether a pubkey has any active identity binding. /// /// Used by the auth layer to enforce "once bound, always require JWT" — @@ -200,12 +154,11 @@ pub async fn is_pubkey_identity_bound(pool: &PgPool, pubkey: &[u8]) -> Result Result { - let result = sqlx::query("DELETE FROM identity_bindings WHERE uid = $1 AND device_cn = $2") +/// Delete the identity binding for a uid. +/// Allows re-binding after key loss or rotation. +pub async fn delete_identity_binding(pool: &PgPool, uid: &str) -> Result { + let result = sqlx::query("DELETE FROM identity_bindings WHERE uid = $1") .bind(uid) - .bind(device_cn) .execute(pool) .await?; Ok(result.rows_affected() > 0) @@ -238,16 +191,15 @@ mod tests { async fn bind_creates_new_binding() { let pool = setup_pool().await; let uid = random_uid(); - let device_cn = "test-laptop"; let pubkey = random_pubkey(); - let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + let result = bind_or_validate_identity(&pool, &uid, &pubkey, "alice") .await .expect("bind should succeed"); assert_eq!(result, BindingResult::Created); // Verify the binding is readable - let binding = get_identity_binding(&pool, &uid, device_cn) + let binding = get_identity_binding(&pool, &uid) .await .expect("get should succeed") .expect("binding should exist"); @@ -255,9 +207,7 @@ mod tests { assert_eq!(binding.username.as_deref(), Some("alice")); // Cleanup - delete_identity_binding(&pool, &uid, device_cn) - .await - .unwrap(); + delete_identity_binding(&pool, &uid).await.unwrap(); } #[tokio::test] @@ -265,22 +215,19 @@ mod tests { async fn bind_same_pubkey_returns_matched() { let pool = setup_pool().await; let uid = random_uid(); - let device_cn = "test-laptop"; let pubkey = random_pubkey(); - bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + bind_or_validate_identity(&pool, &uid, &pubkey, "alice") .await .expect("first bind"); - let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + let result = bind_or_validate_identity(&pool, &uid, &pubkey, "alice") .await .expect("second bind"); assert_eq!(result, BindingResult::Matched); // Cleanup - delete_identity_binding(&pool, &uid, device_cn) - .await - .unwrap(); + delete_identity_binding(&pool, &uid).await.unwrap(); } #[tokio::test] @@ -288,15 +235,14 @@ mod tests { async fn bind_different_pubkey_returns_mismatch() { let pool = setup_pool().await; let uid = random_uid(); - let device_cn = "test-laptop"; let pubkey1 = random_pubkey(); let pubkey2 = random_pubkey(); - bind_or_validate_identity(&pool, &uid, device_cn, &pubkey1, "alice") + bind_or_validate_identity(&pool, &uid, &pubkey1, "alice") .await .expect("first bind"); - let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey2, "alice") + let result = bind_or_validate_identity(&pool, &uid, &pubkey2, "alice") .await .expect("second bind with different pubkey"); assert!( @@ -305,33 +251,7 @@ mod tests { ); // Cleanup - delete_identity_binding(&pool, &uid, device_cn) - .await - .unwrap(); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn multi_device_bindings() { - let pool = setup_pool().await; - let uid = random_uid(); - let pubkey1 = random_pubkey(); - let pubkey2 = random_pubkey(); - - bind_or_validate_identity(&pool, &uid, "laptop", &pubkey1, "alice") - .await - .expect("bind laptop"); - bind_or_validate_identity(&pool, &uid, "phone", &pubkey2, "alice") - .await - .expect("bind phone"); - - let bindings = get_bindings_for_uid(&pool, &uid) - .await - .expect("get bindings"); - assert_eq!(bindings.len(), 2); - - // Cleanup - delete_bindings_for_uid(&pool, &uid).await.unwrap(); + delete_identity_binding(&pool, &uid).await.unwrap(); } #[tokio::test] @@ -339,62 +259,35 @@ mod tests { async fn delete_binding_allows_rebind() { let pool = setup_pool().await; let uid = random_uid(); - let device_cn = "test-laptop"; let pubkey1 = random_pubkey(); let pubkey2 = random_pubkey(); // Bind first key - bind_or_validate_identity(&pool, &uid, device_cn, &pubkey1, "alice") + bind_or_validate_identity(&pool, &uid, &pubkey1, "alice") .await .expect("first bind"); // Delete the binding - let deleted = delete_identity_binding(&pool, &uid, device_cn) + let deleted = delete_identity_binding(&pool, &uid) .await .expect("delete should succeed"); assert!(deleted); // Rebind with different key should now succeed - let result = bind_or_validate_identity(&pool, &uid, device_cn, &pubkey2, "alice") + let result = bind_or_validate_identity(&pool, &uid, &pubkey2, "alice") .await .expect("rebind should succeed"); assert_eq!(result, BindingResult::Created); // Cleanup - delete_identity_binding(&pool, &uid, device_cn) - .await - .unwrap(); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn delete_bindings_for_uid_removes_all_devices() { - let pool = setup_pool().await; - let uid = random_uid(); - - bind_or_validate_identity(&pool, &uid, "laptop", &random_pubkey(), "alice") - .await - .expect("bind laptop"); - bind_or_validate_identity(&pool, &uid, "phone", &random_pubkey(), "alice") - .await - .expect("bind phone"); - - let count = delete_bindings_for_uid(&pool, &uid) - .await - .expect("delete all"); - assert_eq!(count, 2); - - let bindings = get_bindings_for_uid(&pool, &uid) - .await - .expect("get bindings"); - assert!(bindings.is_empty()); + delete_identity_binding(&pool, &uid).await.unwrap(); } #[tokio::test] #[ignore = "requires Postgres"] async fn get_nonexistent_binding_returns_none() { let pool = setup_pool().await; - let result = get_identity_binding(&pool, "nonexistent-uid", "nonexistent-device") + let result = get_identity_binding(&pool, "nonexistent-uid") .await .expect("query should not error"); assert!(result.is_none()); @@ -405,7 +298,6 @@ mod tests { async fn is_pubkey_identity_bound_reflects_binding_lifecycle() { let pool = setup_pool().await; let uid = random_uid(); - let device_cn = "test-laptop"; let pubkey = random_pubkey(); // Not bound before any binding exists. @@ -415,7 +307,7 @@ mod tests { ); // Bound after creation. - bind_or_validate_identity(&pool, &uid, device_cn, &pubkey, "alice") + bind_or_validate_identity(&pool, &uid, &pubkey, "alice") .await .expect("bind should succeed"); assert!( @@ -424,7 +316,7 @@ mod tests { ); // Not bound after deletion. - delete_identity_binding(&pool, &uid, device_cn) + delete_identity_binding(&pool, &uid) .await .expect("delete should succeed"); assert!( diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index 8119038f..f300b2a1 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -512,48 +512,33 @@ impl Db { user::ensure_user_with_verified_name(&self.pool, pubkey, verified_name).await } - /// Look up an identity binding by (uid, device_cn). + /// Look up an identity binding by uid. pub async fn get_identity_binding( &self, uid: &str, - device_cn: &str, ) -> Result> { - identity_binding::get_identity_binding(&self.pool, uid, device_cn).await + identity_binding::get_identity_binding(&self.pool, uid).await } - /// Bind a pubkey to (uid, device_cn) or validate an existing binding. + /// Bind a pubkey to a uid or validate an existing binding. pub async fn bind_or_validate_identity( &self, uid: &str, - device_cn: &str, pubkey: &[u8], username: &str, ) -> Result { - identity_binding::bind_or_validate_identity(&self.pool, uid, device_cn, pubkey, username) + identity_binding::bind_or_validate_identity(&self.pool, uid, pubkey, username) .await } - /// Get all identity bindings for a given uid. - pub async fn get_bindings_for_uid( - &self, - uid: &str, - ) -> Result> { - identity_binding::get_bindings_for_uid(&self.pool, uid).await - } - - /// Delete all identity bindings for a given UID. - pub async fn delete_bindings_for_uid(&self, uid: &str) -> Result { - identity_binding::delete_bindings_for_uid(&self.pool, uid).await - } - /// Check whether a pubkey has any active identity binding. pub async fn is_pubkey_identity_bound(&self, pubkey: &[u8]) -> Result { identity_binding::is_pubkey_identity_bound(&self.pool, pubkey).await } - /// Delete a specific identity binding. - pub async fn delete_identity_binding(&self, uid: &str, device_cn: &str) -> Result { - identity_binding::delete_identity_binding(&self.pool, uid, device_cn).await + /// Delete the identity binding for a uid. + pub async fn delete_identity_binding(&self, uid: &str) -> Result { + identity_binding::delete_identity_binding(&self.pool, uid).await } /// Clear the verified corporate name from a user record. diff --git a/crates/sprout-relay/src/api/identity.rs b/crates/sprout-relay/src/api/identity.rs index 59c35aa9..b1bf8463 100644 --- a/crates/sprout-relay/src/api/identity.rs +++ b/crates/sprout-relay/src/api/identity.rs @@ -1,17 +1,17 @@ //! Identity registration endpoint for proxy/hybrid identity mode. //! //! In proxy mode, the desktop client generates its own Nostr keypair locally. -//! This endpoint binds the client's public key to its corporate identity -//! (UID + device) so the relay can resolve identity on subsequent requests. +//! This endpoint binds the client's public key to its corporate identity (UID) +//! so the relay can resolve identity on subsequent requests. Keys are shared +//! across devices via NIP-AB pairing. //! //! The endpoint is only available when `SPROUT_IDENTITY_MODE=proxy` or `hybrid`. //! //! # Trusted-proxy assumption //! -//! The relay trusts the identity JWT and device CN headers (configured via -//! `SPROUT_IDENTITY_JWT_HEADER` and `SPROUT_IDENTITY_DEVICE_CN_HEADER`) -//! unconditionally. It MUST be deployed behind a trusted auth proxy that is -//! the sole source of these headers. +//! The relay trusts the identity JWT header (configured via +//! `SPROUT_IDENTITY_JWT_HEADER`) unconditionally. It MUST be deployed behind +//! a trusted auth proxy that is the sole source of this header. use std::sync::Arc; @@ -26,21 +26,20 @@ use crate::state::AppState; /// `POST /api/identity/register` /// -/// Binds the caller's Nostr public key to their corporate identity (UID + device). +/// Binds the caller's Nostr public key to their corporate identity (UID). /// The caller proves key ownership via a NIP-98 signed event in the `Authorization` /// header. /// /// # Headers /// /// - Identity JWT header (`SPROUT_IDENTITY_JWT_HEADER`): Corporate identity JWT (injected by auth proxy) -/// - Device CN header (`SPROUT_IDENTITY_DEVICE_CN_HEADER`): Device identifier from client certificate /// - `Authorization: Nostr `: NIP-98 signed event proving pubkey ownership /// /// # Binding semantics /// -/// - First request from a (UID, device) pair: creates a new binding. +/// - First request from a UID: creates a new binding. /// - Subsequent requests with the same pubkey: succeeds (idempotent). -/// - Request with a different pubkey for an already-bound (UID, device): returns +/// - Request with a different pubkey for an already-bound UID: returns /// 409 Conflict with `identity_binding_mismatch`. /// /// # Response @@ -92,11 +91,7 @@ pub async fn identity_register( ) })?; - // 2. Extract device identifier from client certificate CN (fallback to "default") - let device_cn = - super::extract_device_cn(&headers, &state.auth.identity_config().device_cn_header); - - // 3. Verify NIP-98 auth to prove pubkey ownership + // 2. Verify NIP-98 auth to prove pubkey ownership let auth_header = headers .get("authorization") .and_then(|v| v.to_str().ok()) @@ -149,12 +144,11 @@ pub async fn identity_register( let pubkey_bytes = pubkey.serialize().to_vec(); - // 4. Bind or validate the identity + // 3. Bind or validate the identity let result = state .db .bind_or_validate_identity( &identity_claims.uid, - device_cn, &pubkey_bytes, &identity_claims.username, ) @@ -174,7 +168,6 @@ pub async fn identity_register( state.identity_bound_cache.invalidate(&pubkey_bytes); tracing::info!( uid = %identity_claims.uid, - device_cn = %device_cn, pubkey = %pubkey.to_hex(), "identity binding created" ); @@ -182,7 +175,6 @@ pub async fn identity_register( sprout_db::BindingResult::Matched => { tracing::info!( uid = %identity_claims.uid, - device_cn = %device_cn, pubkey = %pubkey.to_hex(), "identity binding matched" ); @@ -190,7 +182,6 @@ pub async fn identity_register( sprout_db::BindingResult::Mismatch { .. } => { tracing::warn!( uid = %identity_claims.uid, - device_cn = %device_cn, presented = %pubkey.to_hex(), "identity binding mismatch" ); @@ -198,13 +189,13 @@ pub async fn identity_register( StatusCode::CONFLICT, Json(serde_json::json!({ "error": "identity_binding_mismatch", - "message": "this device is already bound to a different pubkey" + "message": "this uid is already bound to a different pubkey" })), )); } } - // 5. Ensure user record exists with verified name + // 4. Ensure user record exists with verified name if let Err(e) = state .db .ensure_user_with_verified_name(&pubkey_bytes, &identity_claims.username) diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 87959999..1d911098 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -91,19 +91,6 @@ use sprout_auth::Scope; use crate::state::AppState; -/// Extract the device common name from the configured device CN header, -/// defaulting to `"default"` when the header is absent (e.g. deployments -/// without mTLS client certificates). -pub(crate) fn extract_device_cn<'a>( - headers: &'a axum::http::HeaderMap, - header_name: &str, -) -> &'a str { - headers - .get(header_name) - .and_then(|v| v.to_str().ok()) - .unwrap_or("default") -} - // ── Auth context types ──────────────────────────────────────────────────────── /// How the REST request was authenticated. @@ -189,7 +176,7 @@ pub(crate) async fn extract_auth_context( // ── 0. Proxy / hybrid identity mode ────────────────────────────────── // When identity_mode is proxy or hybrid, the auth proxy injects // x-forwarded-identity-token for human users. The relay validates the JWT, - // extracts uid + device_cn, and looks up the pubkey binding from the DB. + // extracts uid, and looks up the pubkey binding from the DB. // - Proxy: header is mandatory — reject if missing. // - Hybrid: header is preferred — fall through to standard auth if missing. // In both modes, a present-but-invalid header is a hard 401. @@ -211,13 +198,10 @@ pub(crate) async fn extract_auth_context( ) })?; - let device_cn = - extract_device_cn(headers, &state.auth.identity_config().device_cn_header); - - // Look up the pubkey binding for this (uid, device_cn). + // Look up the pubkey binding for this uid. let binding = state .db - .get_identity_binding(&identity_claims.uid, device_cn) + .get_identity_binding(&identity_claims.uid) .await .map_err(|e| { tracing::error!("auth: identity binding lookup failed: {e}"); @@ -229,14 +213,13 @@ pub(crate) async fn extract_auth_context( .ok_or_else(|| { tracing::warn!( uid = %identity_claims.uid, - device_cn = %device_cn, "no identity binding — call POST /api/identity/register first" ); ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "identity_binding_required", - "message": "no identity binding for this device — call POST /api/identity/register first" + "message": "no identity binding — call POST /api/identity/register first" })), ) })?; diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 784f58bd..377ef58d 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -158,9 +158,6 @@ impl Config { if let Ok(jwt_header) = std::env::var("SPROUT_IDENTITY_JWT_HEADER") { auth.identity.identity_jwt_header = jwt_header; } - if let Ok(cn_header) = std::env::var("SPROUT_IDENTITY_DEVICE_CN_HEADER") { - auth.identity.device_cn_header = cn_header; - } // When identity mode is active the relay sits behind a trusted proxy // (auth proxy) — force require_auth_token so the NIP-42 fallback path diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index 75b6c369..f1c59171 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -29,18 +29,16 @@ pub(crate) type ConnectionSubscriptions = Arc> /// Proxy identity claims stashed on the connection at upgrade time. /// -/// In proxy/hybrid mode the JWT and device_cn headers are validated during -/// the HTTP → WS upgrade, but the client's pubkey is not yet known. The -/// claims are held here until the NIP-42 AUTH event arrives, at which point -/// the relay can bind (uid, device_cn) → pubkey. +/// In proxy/hybrid mode the JWT is validated during the HTTP → WS upgrade, +/// but the client's pubkey is not yet known. The claims are held here until +/// the NIP-42 AUTH event arrives, at which point the relay can bind +/// uid → pubkey. #[derive(Debug, Clone)] pub struct PendingProxyIdentity { /// Corporate user identifier extracted from the identity JWT. pub uid: String, /// Human-readable username from the identity JWT. pub username: String, - /// Device common name from the client certificate header. - pub device_cn: String, /// Permission scopes granted by the identity JWT. pub scopes: Vec, } @@ -129,7 +127,7 @@ impl ConnectionState { /// /// If `proxy_identity` is `Some`, the connection has a validated corporate /// identity from the proxy but still requires NIP-42 to prove the client's -/// Nostr pubkey. The AUTH handler will bind (uid, device_cn) → pubkey. +/// Nostr pubkey. The AUTH handler will bind uid → pubkey. pub async fn handle_connection( socket: WebSocket, state: Arc, diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 3029b2e2..6cf830ed 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -70,7 +70,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // event only needs to prove the client owns its pubkey. No JWT or API // token tag is required — the identity was already established from the // proxy headers. After signature verification, the relay creates or - // validates the (uid, device_cn) → pubkey binding. + // validates the uid → pubkey binding. if let Some(proxy) = proxy_identity { // Verify event structure + signature + challenge + relay URL (no token check). let event_clone = event.clone(); @@ -96,11 +96,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } - // Resolve the (uid, device_cn) → pubkey binding. + // Resolve the uid → pubkey binding. let pubkey_bytes = event.pubkey.serialize().to_vec(); match state .db - .bind_or_validate_identity(&proxy.uid, &proxy.device_cn, &pubkey_bytes, &proxy.username) + .bind_or_validate_identity(&proxy.uid, &pubkey_bytes, &proxy.username) .await { Ok(sprout_db::BindingResult::Created) => { @@ -108,7 +108,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // effect immediately — prevents a 2-min window where the pubkey // could still authenticate via standard auth. state.identity_bound_cache.invalidate(&pubkey_bytes); - info!(conn_id = %conn_id, uid = %proxy.uid, device_cn = %proxy.device_cn, + info!(conn_id = %conn_id, uid = %proxy.uid, pubkey = %event.pubkey.to_hex(), "identity binding created"); } Ok(sprout_db::BindingResult::Matched) => { @@ -116,7 +116,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: "identity binding matched"); } Ok(sprout_db::BindingResult::Mismatch { .. }) => { - warn!(conn_id = %conn_id, uid = %proxy.uid, device_cn = %proxy.device_cn, + warn!(conn_id = %conn_id, uid = %proxy.uid, pubkey = %event.pubkey.to_hex(), "identity binding mismatch"); metrics::counter!("sprout_auth_failures_total", "reason" => "binding_mismatch") .increment(1); diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 4190700c..cd12f306 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -221,7 +221,7 @@ async fn nip11_or_ws_handler( return Json(info).into_response(); } - // ── Proxy / hybrid identity: validate JWT + device_cn at upgrade time ── + // ── Proxy / hybrid identity: validate JWT at upgrade time ───────────── // - Proxy: identity token mandatory — reject if missing. // - Hybrid: identity token preferred — fall through to NIP-42 if missing. // NIP-42 challenge is always sent; the AUTH handler resolves the pubkey binding. @@ -233,15 +233,9 @@ async fn nip11_or_ws_handler( { Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { Ok((identity_claims, scopes)) => { - let device_cn = crate::api::extract_device_cn( - &headers, - &state.auth.identity_config().device_cn_header, - ) - .to_string(); Some(crate::connection::PendingProxyIdentity { uid: identity_claims.uid, username: identity_claims.username, - device_cn, scopes, }) } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 38e8f42c..64ecb332 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -87,7 +87,7 @@ pub async fn initialize_identity( "proxy" | "hybrid" => { // Client-generated key — the key is already persisted locally by // resolve_persisted_identity(). We just need to register it with - // the relay so the relay binds (uid, device_cn) → pubkey. + // the relay so the relay binds uid → pubkey. let base_url = crate::relay::relay_api_base_url(); let register_url = format!("{base_url}/api/identity/register"); diff --git a/schema/schema.sql b/schema/schema.sql index 53c2586c..d186014e 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -328,19 +328,16 @@ CREATE TABLE pubkey_allowlist ( note TEXT ); --- ── Identity bindings (proxy mode: corporate UID + device → pubkey) ─────────── +-- ── Identity bindings (proxy mode: corporate UID → pubkey) ──────────────────── CREATE TABLE identity_bindings ( - uid TEXT NOT NULL, - device_cn TEXT NOT NULL, + uid TEXT NOT NULL PRIMARY KEY, pubkey BYTEA NOT NULL, username VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - PRIMARY KEY (uid, device_cn), CONSTRAINT chk_identity_bindings_pubkey_len CHECK (LENGTH(pubkey) = 32) ); CREATE UNIQUE INDEX idx_identity_bindings_pubkey ON identity_bindings(pubkey); -CREATE INDEX idx_identity_bindings_uid ON identity_bindings(uid); From 5b470fb063d3264d9f44c742e6b0fb2000e21e27 Mon Sep 17 00:00:00 2001 From: fsola-sq Date: Mon, 20 Apr 2026 09:12:02 -0600 Subject: [PATCH 7/7] style: cargo fmt Amp-Thread-ID: https://ampcode.com/threads/T-019da7ce-37ed-73ea-a680-18714f07a751 Co-authored-by: Amp --- crates/sprout-db/src/identity_binding.rs | 17 ++++++----------- crates/sprout-db/src/lib.rs | 3 +-- crates/sprout-relay/src/router.rs | 12 +++++------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/sprout-db/src/identity_binding.rs b/crates/sprout-db/src/identity_binding.rs index 6a9cf615..7adee6e0 100644 --- a/crates/sprout-db/src/identity_binding.rs +++ b/crates/sprout-db/src/identity_binding.rs @@ -42,10 +42,7 @@ pub struct IdentityBinding { } /// Look up a binding by uid. -pub async fn get_identity_binding( - pool: &PgPool, - uid: &str, -) -> Result> { +pub async fn get_identity_binding(pool: &PgPool, uid: &str) -> Result> { let row = sqlx::query_as::<_, (String, Vec, Option)>( r#" SELECT uid, pubkey, username @@ -57,13 +54,11 @@ pub async fn get_identity_binding( .fetch_optional(pool) .await?; - Ok( - row.map(|(uid, pubkey, username)| IdentityBinding { - uid, - pubkey, - username, - }), - ) + Ok(row.map(|(uid, pubkey, username)| IdentityBinding { + uid, + pubkey, + username, + })) } /// Bind a pubkey to a uid, or validate an existing binding. diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index f300b2a1..c6e4288e 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -527,8 +527,7 @@ impl Db { pubkey: &[u8], username: &str, ) -> Result { - identity_binding::bind_or_validate_identity(&self.pool, uid, pubkey, username) - .await + identity_binding::bind_or_validate_identity(&self.pool, uid, pubkey, username).await } /// Check whether a pubkey has any active identity binding. diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index cd12f306..c479c00b 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -232,13 +232,11 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) { Some(jwt) => match state.auth.validate_identity_jwt(jwt).await { - Ok((identity_claims, scopes)) => { - Some(crate::connection::PendingProxyIdentity { - uid: identity_claims.uid, - username: identity_claims.username, - scopes, - }) - } + Ok((identity_claims, scopes)) => Some(crate::connection::PendingProxyIdentity { + uid: identity_claims.uid, + username: identity_claims.username, + scopes, + }), Err(e) => { tracing::warn!("ws: proxy identity JWT validation failed: {e}"); return (StatusCode::UNAUTHORIZED, "identity token invalid").into_response();