diff --git a/.env.example b/.env.example index 7a9c0970a..36a3e0492 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,30 @@ OKTA_AUDIENCE=sprout-desktop # OKTA_AUDIENCE=sprout-api # OKTA_PUBKEY_CLAIM=nostr_pubkey +# ── Corporate identity mode ───────────────────────────────────────────────── +# Identity mode: "disabled" (default), "proxy", "hybrid". +# 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=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 a5d65d9e1..de0517972 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 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. +- `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 9c1af66ca..2c3ac396e 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 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 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. +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 auth proxy 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 7d983e286..02a0d803d 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 new file mode 100644 index 000000000..473d06d01 --- /dev/null +++ b/crates/sprout-auth/src/identity.rs @@ -0,0 +1,196 @@ +//! Corporate identity mode for the Sprout relay. +//! +//! 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. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// 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, + /// 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 + /// 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, + /// 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, + /// 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, + /// 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 { + fn default() -> Self { + Self { + mode: default_mode(), + uid_claim: default_uid_claim(), + user_claim: default_user_claim(), + jwks_uri: String::new(), + issuer: String::new(), + audience: String::new(), + identity_jwt_header: default_identity_jwt_header(), + device_cn_header: default_device_cn_header(), + } + } +} + +fn default_mode() -> IdentityMode { + IdentityMode::Disabled +} + +fn default_uid_claim() -> String { + "uid".to_string() +} + +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 { + 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) + } +} + +/// Claims extracted from a validated proxy identity JWT. +/// +/// 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::*; + + #[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()); + } + + #[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_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 b8d59db07..08dc9695a 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, identity claims extraction). +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::{IdentityConfig, IdentityMode, ProxyIdentityClaims}; 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. @@ -88,6 +93,9 @@ pub struct AuthConfig { /// Per-user and per-IP rate limit thresholds. #[serde(default)] pub rate_limits: RateLimitConfig, + /// Corporate identity mode (proxy JWT, identity claims extraction). + #[serde(default)] + pub identity: IdentityConfig, } /// Primary authentication service. @@ -316,6 +324,86 @@ 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 extract the corporate identity claims. + /// + /// 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 + /// 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( + jwks_uri, + self.config.okta.jwks_refresh_secs, + &self.http_client, + ) + .await?; + + let claims = cached.validate(jwt, issuer, 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()) + .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 = extract_scopes_from_claims(&claims); + + Ok((identity::ProxyIdentityClaims { uid, username }, scopes)) + } } /// 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 d4c0b7c18..5d6ba3f39 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -662,6 +662,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`). @@ -799,7 +801,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 { @@ -813,6 +815,7 @@ pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec]) -> Result, + }, +} + +/// 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?; + + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .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()) +} + +/// 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 4063cf8a1..8119038fc 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; @@ -500,6 +503,64 @@ impl Db { user::ensure_user(&self.pool, pubkey).await } + /// Ensure a user record exists and sync the verified corporate name. + pub async fn ensure_user_with_verified_name( + &self, + pubkey: &[u8], + verified_name: &str, + ) -> Result<()> { + 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 + } + + /// 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 721e9c4e5..b4ab1ff85 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,53 @@ 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(()) +} + +/// 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<()> { @@ -58,10 +101,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 +115,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 +226,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 +267,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 +307,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 4f9ee1890..be0c5aa1f 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 000000000..59c35aa95 --- /dev/null +++ b/crates/sprout-relay/src/api/identity.rs @@ -0,0 +1,236 @@ +//! 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. +//! +//! 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. + +use std::sync::Arc; + +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + +use crate::state::AppState; + +/// `POST /api/identity/register` +/// +/// 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. +/// +/// # 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. +/// - 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…", +/// "username": "alice", +/// "binding_status": "created" +/// } +/// ``` +pub async fn identity_register( + State(state): State>, + headers: HeaderMap, +) -> 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 registration is only available in proxy identity mode" + })), + )); + } + + // 1. Validate proxy identity JWT → extract uid + username + let identity_jwt = headers + .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": "identity JWT header is required" + })), + ) + })?; + + let (identity_claims, _scopes) = state + .auth + .validate_identity_jwt(identity_jwt) + .await + .map_err(|e| { + tracing::warn!("identity register: JWT validation failed: {e}"); + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "identity_token_invalid" })), + ) + })?; + + // 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 + 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(&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::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(), + "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" + })), + )); + } + } + + // 5. Ensure user record exists with verified name + if let Err(e) = state + .db + .ensure_user_with_verified_name(&pubkey_bytes, &identity_claims.username) + .await + { + tracing::warn!("identity register: ensure_user_with_verified_name failed: {e}"); + } + + 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, + }))) +} + +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 8cddece09..8d0a54c15 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. @@ -89,6 +91,19 @@ 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. @@ -102,6 +117,8 @@ pub enum RestAuthMethod { Nip98, /// `X-Pubkey: ` — dev mode only (`require_auth_token=false`). DevPubkey, + /// Identity JWT header — proxy identity mode (auth proxy). + ProxyIdentity, } /// Full authentication context returned to REST handlers. @@ -131,6 +148,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 — connect via the auth proxy" + })), + )); + } + Ok(()) +} + /// Extract the full auth context from request headers. /// /// Auth resolution order: @@ -150,6 +186,92 @@ 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, 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. + // - 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(&*state.auth.identity_config().identity_jwt_header) + .and_then(|v| v.to_str().ok()) + { + 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, + Json(serde_json::json!({ "error": "identity_token_invalid" })), + ) + })?; + + 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 + .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; + + 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 identity JWT header missing"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "identity_token_required", + "message": "identity JWT 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 @@ -237,6 +359,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}"); } @@ -280,6 +403,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/api/users.rs b/crates/sprout-relay/src/api/users.rs index 1f9e033f8..623a0b05c 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 95a379ace..784f58bd7 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -11,6 +11,9 @@ 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), } /// Relay runtime configuration, loaded from environment variables. @@ -129,6 +132,56 @@ 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(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; + } + 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; + } + 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 + // cannot be used with bare keypair-only auth. + let require_auth_token = if identity_mode.is_proxy() { + 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 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; + 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 9aea36087..75b6c3695 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 client certificate 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), @@ -104,7 +126,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 `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, + proxy_identity: Option, +) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, Err(_) => { @@ -114,7 +145,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 +155,22 @@ 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())); + // 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 { 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,6 +181,7 @@ 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); + // 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())) diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 13482c372..22a74125e 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -12,10 +12,13 @@ use crate::state::AppState; /// 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( @@ -52,7 +55,104 @@ 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) => { + // 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"); + } + 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); + state + .conn_manager + .set_authenticated_pubkey(conn_id, pubkey_bytes.clone()); + conn.send(RelayMessage::ok(&event_id_hex, true, "")); + return; + } if let Some(ref token) = auth_token { if token.starts_with("sprout_") { @@ -110,6 +210,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 — connect via the auth proxy", + )); + 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(); @@ -181,6 +296,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 — connect via the auth proxy", + )); + 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/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index c06f03b37..98eaec141 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -39,6 +39,8 @@ pub enum HttpAuthMethod { OktaJwt, /// `X-Pubkey: ` dev-mode header. DevPubkey, + /// Identity JWT header — 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 c9cf2e62e..78cca9d16 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: `"disabled"`, `"proxy"`, or `"hybrid"`. + 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 e449b66d5..98ffc46d2 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -150,6 +150,11 @@ 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 registration (proxy mode — binds client pubkey to corporate identity). + .route( + "/api/identity/register", + post(api::identity::identity_register), + ) // Reject request bodies larger than 1 MB to prevent resource exhaustion. .layer(RequestBodyLimitLayer::new(1024 * 1024)) .with_state(state.clone()); @@ -184,6 +189,10 @@ 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 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>, headers: HeaderMap, @@ -206,9 +215,49 @@ async fn nip11_or_ws_handler( return Json(info).into_response(); } + // ── 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 proxy_identity = if identity_mode.is_proxy() { + match headers + .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, + &state.auth.identity_config().device_cn_header, + ) + .to_string(); + Some(crate::connection::PendingProxyIdentity { + uid: identity_claims.uid, + username: identity_claims.username, + device_cn, + scopes, + }) + } + 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 identity JWT header missing"); + return (StatusCode::UNAUTHORIZED, "identity token required").into_response(); + } + // Hybrid: no identity token — proceed to standard 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, proxy_identity)) .into_response(), Err(_) => { // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 6db073132..04e8617cd 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -196,6 +196,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. @@ -277,6 +282,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), @@ -290,6 +301,33 @@ 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; + } + 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 + } + } + } } impl std::fmt::Debug for AppState { diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 71f1a5098..0dbbf6b4d 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", 850], // channels + forums creation forms ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + ["src/shared/api/relayClientSession.ts", 795], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + identity bootstrap ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 560], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() ["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 @@ -54,7 +54,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", 535], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + ["src/shared/api/types.ts", 545], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + identity types ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 7af53fd05..9a12f65a3 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -10,6 +10,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>, } @@ -53,6 +57,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()), } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 1940ae1d0..57d66d4d6 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -1,4 +1,5 @@ -use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use nostr::{EventBuilder, JsonUtil, Kind, PublicKey, Tag, ToBech32}; use tauri::State; use crate::{ @@ -7,19 +8,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 +69,143 @@ pub fn sign_event( Ok(event.as_json()) } +#[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" => { + // 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 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(®ister_url) + .header("Authorization", format!("Nostr {nip98_b64}")) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .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 registration failed: {msg}")); + } + + #[derive(serde::Deserialize)] + struct RegisterResponse { + #[allow(dead_code)] + pubkey: String, + username: String, + #[allow(dead_code)] + binding_status: String, + } + + let body: RegisterResponse = response + .json() + .await + .map_err(|e| format!("failed to parse register response: {e}"))?; + + let pubkey_hex = state + .keys + .lock() + .map_err(|e| e.to_string())? + .public_key() + .to_hex(); + + // 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()); + + Ok(InitializedIdentity { + pubkey: pubkey_hex, + display_name: body.username, + identity_mode: Some(identity_mode), + ws_auth_mode: "nip42".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 create_auth_event( challenge: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 37d3987d9..b84849099 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -423,6 +423,7 @@ pub fn run() { discover_managed_agent_prereqs, sign_event, create_auth_event, + initialize_identity, get_channels, create_channel, open_dm, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index a07fc67b9..bf104653f 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/app/App.tsx b/desktop/src/app/App.tsx index df0072e60..a4a77ebc4 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 000000000..82e142c29 --- /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/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index b75e3cc1f..e7eaf2c7b 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 { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; @@ -71,9 +73,14 @@ 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 initials = message.author + .replace(/\s*\(.*\)/, "") .split(" ") .map((part) => part[0]) .join("") @@ -251,7 +258,7 @@ export const MessageRow = React.memo(
-
+
{message.pubkey ? (