diff --git a/.github/workflows/_ci-clients.yml b/.github/workflows/_ci-clients.yml index af5861e5f13..cdcac676ba5 100644 --- a/.github/workflows/_ci-clients.yml +++ b/.github/workflows/_ci-clients.yml @@ -48,6 +48,8 @@ jobs: run: pnpm install --frozen-lockfile - name: Web lint and format run: just web-check + - name: Web unit tests + run: just web-test - name: Web build run: just web-build - name: Save pnpm store cache diff --git a/Cargo.lock b/Cargo.lock index e57698003fc..7ba30299df0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1456,14 +1456,19 @@ dependencies = [ name = "buzz-ws-client" version = "0.1.0" dependencies = [ + "base64 0.22.1", "futures-util", "nostr 0.44.7", + "reqwest 0.13.4", + "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tracing", "url", + "zeroize", ] [[package]] diff --git a/Justfile b/Justfile index 00c4fecc2f9..d9ffd1db937 100644 --- a/Justfile +++ b/Justfile @@ -350,7 +350,7 @@ desktop-e2e-pre-push: _ensure-migrations cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main # Run all checks suitable for CI / pre-push (no infra needed) -ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test +ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-test web-build mobile-test # ─── Test ───────────────────────────────────────────────────────────────────── @@ -799,6 +799,10 @@ web-fix: web-typecheck: cd {{web_dir}} && pnpm typecheck +# Run browser signing and protocol unit tests +web-test: + cd {{web_dir}} && pnpm test + # Build web frontend assets web-build: cd {{web_dir}} && pnpm build diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 5cec925677f..e4f99a4ec46 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -15,3 +15,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } url = { workspace = true } tracing = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +zeroize = { workspace = true } +base64 = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index bec5b56bb43..cd782e29e4b 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -92,6 +92,43 @@ impl NostrWsConnection { Ok(()) } + /// Authenticate through corporate custody, then receive events directly from the relay. + /// + /// Credentials are needed only for this challenge. No incoming message is sent to the signer. + pub async fn authenticate_enterprise( + &mut self, + signer: &crate::enterprise::EnterpriseSigner, + credentials: &crate::enterprise::EnterpriseCredentials, + ) -> Result<(), WsClientError> { + if self.relay_url != signer.identity().relay_ws_url { + return Err(WsClientError::AuthFailed( + "Enterprise relay scope mismatch".to_owned(), + )); + } + let challenge = self + .wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS)) + .await?; + let template = crate::enterprise::EnterpriseTemplate { + kind: 22242, + created_at: nostr::Timestamp::now().as_secs(), + tags: vec![ + vec!["relay".to_owned(), self.relay_url.clone()], + vec!["challenge".to_owned(), challenge], + ], + content: String::new(), + }; + let event = signer.sign(&template, credentials).await?; + let id = event.id.to_hex(); + self.send_raw(&json!(["AUTH", event])).await?; + let ok = self + .wait_for_ok(&id, Duration::from_secs(AUTH_OK_TIMEOUT_SECS)) + .await?; + if !ok.accepted { + return Err(WsClientError::AuthFailed(ok.message)); + } + Ok(()) + } + /// Sends a signed event to the relay and waits for the OK response. pub async fn send_event(&mut self, event: Event) -> Result { let event_id = event.id.to_hex(); @@ -120,7 +157,8 @@ impl NostrWsConnection { /// Sends a raw JSON value as a WebSocket text frame. pub async fn send_raw(&mut self, value: &Value) -> Result<(), WsClientError> { let text = serde_json::to_string(value)?; - debug!("→ relay: {text}"); + // AUTH frames and event tags may contain bearer proofs; never log raw frames. + debug!(bytes = text.len(), "sending relay frame"); self.ws.send(Message::Text(text.into())).await?; Ok(()) } diff --git a/crates/buzz-ws-client/src/enterprise.rs b/crates/buzz-ws-client/src/enterprise.rs new file mode 100644 index 00000000000..4c42d2c79d2 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise.rs @@ -0,0 +1,240 @@ +//! HTTPS corporate signer transport. No local key fallback and no secret-key export. +use std::time::Duration; + +use nostr::Event; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::WsClientError; + +/// Identity and community pinned by the corporate login lifecycle. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EnterpriseSession { + /// Custodied Nostr public key, never selected by a signing request. + pub pubkey: String, + /// Community WebSocket origin. + pub relay_ws_url: String, + /// Community HTTPS origin. + pub relay_http_url: String, +} + +/// Exact retryable NIP-01 template; signed fields are never sent to the service. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EnterpriseTemplate { + /// Nostr event kind. + pub kind: u16, + /// Preserve across retries rather than reconstructing from the current time. + pub created_at: u64, + /// Ordered Nostr tags. + pub tags: Vec>, + /// Event content. + pub content: String, +} + +/// Ephemeral explicit credentials. Deliberately has no Debug/Serialize implementation. +pub struct EnterpriseCredentials { + corporate: Zeroizing, +} + +impl EnterpriseCredentials { + /// Own credentials for a request; the login owner is responsible for refresh and secure persistence. + pub fn new(corporate: String) -> Self { + Self { + corporate: Zeroizing::new(corporate), + } + } + + fn headers(&self) -> Result { + let mut headers = HeaderMap::new(); + let value = self.corporate.as_str(); + if value.is_empty() || value.len() > 16 * 1024 { + return Err(failure()); + } + let mut header = + HeaderValue::from_str(&format!("Bearer {value}")).map_err(|_| failure())?; + header.set_sensitive(true); + headers.insert("authorization", header); + Ok(headers) + } +} + +/// Shared native HTTPS transport for desktop/CLI integrations. It never owns or generates a Nostr key. +pub struct EnterpriseSigner { + client: reqwest::Client, + base: String, + expected: EnterpriseSession, +} + +impl EnterpriseSigner { + /// Construct from trusted managed configuration and a login-pinned identity. + pub fn new(base: &str, expected: EnterpriseSession) -> Result { + let base_url = url::Url::parse(base).map_err(|_| failure())?; + if !https(&base_url) { + return Err(failure()); + } + let relay = url::Url::parse(&expected.relay_http_url).map_err(|_| failure())?; + if !https(&relay) + || relay.path() != "/" + || expected.relay_ws_url + != format!( + "wss://{}", + relay[url::Position::BeforeHost..url::Position::AfterPort].to_owned() + ) + { + return Err(failure()); + } + if expected.pubkey.len() != 64 + || !expected + .pubkey + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(failure()); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|_| failure())?; + Ok(Self { + client, + base: base_url.as_str().trim_end_matches('/').to_owned(), + expected, + }) + } + + /// Discover the server-selected identity from a trusted signer and pin it for this login. + pub async fn login( + base: &str, + credentials: &EnterpriseCredentials, + ) -> Result { + // The provisional value is never exposed and cannot sign: discovery only calls /session. + let mut signer = Self::new( + base, + EnterpriseSession { + pubkey: "0".repeat(64), + relay_ws_url: "wss://invalid.example".into(), + relay_http_url: "https://invalid.example".into(), + }, + )?; + let session = signer + .post("session", serde_json::json!({}), credentials) + .await?; + signer = Self::new(base, session)?; + Ok(signer) + } + + /// Return the pinned identity; fresh corporate authorization is still checked on every signing request. + pub fn identity(&self) -> &EnterpriseSession { + &self.expected + } + + async fn post( + &self, + path: &str, + body: serde_json::Value, + credentials: &EnterpriseCredentials, + ) -> Result { + let mut response = self + .client + .post(format!("{}/v1/buzz/enterprise-signer/{path}", self.base)) + .headers(credentials.headers()?) + .header("Cache-Control", "no-store") + .json(&body) + .send() + .await + .map_err(|_| failure())?; + if !response.status().is_success() { + return Err(failure()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| failure())? { + if bytes.len() + chunk.len() > 256 * 1024 { + return Err(failure()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| failure()) + } + + /// Confirm provisioning and reject account/community changes during credential refresh. + pub async fn session(&self, credentials: &EnterpriseCredentials) -> Result<(), WsClientError> { + let actual: EnterpriseSession = self + .post("session", serde_json::json!({}), credentials) + .await?; + if actual != self.expected { + return Err(failure()); + } + Ok(()) + } + + /// Sign and verify an exact template; persist the result before publishing and replay it after ambiguous ACKs. + pub async fn sign( + &self, + template: &EnterpriseTemplate, + credentials: &EnterpriseCredentials, + ) -> Result { + let purpose = match template.kind { + 22242 => "nip42-auth", + 27235 => "http-auth", + 24242 + if template + .tags + .iter() + .any(|t| t.len() == 2 && t[0] == "t" && t[1] == "upload") => + { + "media-upload" + } + 24242 => "media-read", + _ => "publish", + }; + #[derive(Deserialize)] + struct Reply { + event: Event, + } + let reply: Reply = self + .post( + "events/sign", + serde_json::json!({"event": template, "purpose": purpose}), + credentials, + ) + .await?; + let event = reply.event; + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + if event.pubkey.to_hex() != self.expected.pubkey + || event.kind.as_u16() != template.kind + || event.created_at.as_secs() != template.created_at + || event.content != template.content + || tags != template.tags + || event.verify().is_err() + { + return Err(failure()); + } + Ok(event) + } +} + +fn https(url: &url::Url) -> bool { + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} +fn failure() -> WsClientError { + WsClientError::AuthFailed( + "Enterprise signing failed; corporate login or authorization is required".to_owned(), + ) +} + +#[cfg(test)] +#[path = "enterprise_tests.rs"] +mod tests; diff --git a/crates/buzz-ws-client/src/enterprise_oauth.rs b/crates/buzz-ws-client/src/enterprise_oauth.rs new file mode 100644 index 00000000000..96595556c21 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise_oauth.rs @@ -0,0 +1,274 @@ +//! Public-client authorization-code/PKCE transport. The application owns browser callbacks and secure storage. +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use zeroize::Zeroizing; + +/// Non-secret, release-selected corporate login configuration. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EnterpriseLoginConfig { + /// Trusted signer base URL, including deployment prefix. + pub signer_url: String, + /// Auth0 HTTPS issuer origin. + pub issuer: String, + /// Registered native/public client ID (never a client secret). + pub client_id: String, + /// Dedicated signer API audience. + pub audience: String, + /// Exact Auth0 organization. + pub organization: String, + /// Exact corporate federation connection. + pub connection: String, +} + +impl EnterpriseLoginConfig { + /// Validate the release-owned destinations before opening a browser or transmitting credentials. + pub fn validate(&self) -> Result<(), String> { + for (value, origin) in [(&self.issuer, true), (&self.signer_url, false)] { + let url = url::Url::parse(value).map_err(|_| "Invalid enterprise URL")?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || (origin && url.path() != "/") + { + return Err("Enterprise endpoints must be trusted HTTPS URLs".into()); + } + } + if [ + &self.client_id, + &self.audience, + &self.organization, + &self.connection, + ] + .iter() + .any(|s| s.is_empty() || s.len() > 1024) + { + return Err("Incomplete enterprise login configuration".into()); + } + Ok(()) + } +} + +/// One PKCE attempt. No Debug/Serialize: the verifier is a temporary credential. +pub struct EnterpriseLoginAttempt { + verifier: Zeroizing, + state: String, + redirect_uri: String, +} +impl EnterpriseLoginAttempt { + /// Create from OS-random bytes supplied by the native platform (32 bytes each). + pub fn new(verifier_entropy: [u8; 32], state_entropy: [u8; 32], redirect_uri: String) -> Self { + Self { + verifier: Zeroizing::new(URL_SAFE_NO_PAD.encode(verifier_entropy)), + state: URL_SAFE_NO_PAD.encode(state_entropy), + redirect_uri, + } + } + /// Build authorization URL with PKCE S256 and exact organization/connection. + pub fn authorization_url(&self, config: &EnterpriseLoginConfig) -> Result { + config.validate()?; + let mut url = url::Url::parse(&format!( + "{}/authorize", + config.issuer.trim_end_matches('/') + )) + .map_err(|_| "Invalid issuer")?; + url.query_pairs_mut().extend_pairs([ + ("response_type", "code"), + ("client_id", config.client_id.as_str()), + ("redirect_uri", self.redirect_uri.as_str()), + ("audience", config.audience.as_str()), + ("organization", config.organization.as_str()), + ("connection", config.connection.as_str()), + ("scope", "openid profile email offline_access"), + ("state", self.state.as_str()), + ("code_challenge_method", "S256"), + ( + "code_challenge", + URL_SAFE_NO_PAD + .encode(Sha256::digest(self.verifier.as_bytes())) + .as_str(), + ), + ]); + Ok(url) + } + /// Verify callback destination and state before consuming the authorization code. + pub fn callback_code(&self, callback: &url::Url) -> Result { + let mut destination = callback.clone(); + destination.set_query(None); + destination.set_fragment(None); + if destination.as_str() != self.redirect_uri || callback.fragment().is_some() { + return Err("Invalid login callback destination".into()); + } + let pairs: Vec<_> = callback.query_pairs().collect(); + let states: Vec<_> = pairs.iter().filter(|(k, _)| k == "state").collect(); + let codes: Vec<_> = pairs.iter().filter(|(k, _)| k == "code").collect(); + if states.len() != 1 + || states[0].1 != self.state + || codes.len() != 1 + || codes[0].1.is_empty() + || codes[0].1.len() > 4096 + || pairs.iter().any(|(k, _)| k == "error") + { + return Err("Corporate login failed or callback state mismatch".into()); + } + Ok(codes[0].1.to_string()) + } + /// Consume this attempt so the same verifier is not accidentally reused. + pub async fn exchange( + self, + config: &EnterpriseLoginConfig, + callback: &url::Url, + ) -> Result { + let code = self.callback_code(callback)?; + token_request(config, serde_json::json!({"grant_type":"authorization_code", "client_id":config.client_id, "code":code, "redirect_uri":self.redirect_uri, "code_verifier":self.verifier.as_str()})).await + } +} + +/// Token response. No Debug; Serialize is only for the application's encrypted credential store. +#[derive(Serialize, Deserialize)] +pub struct EnterpriseOAuthTokens { + /// Short-lived dedicated API access token. + pub access_token: String, + /// Rotating refresh credential; never log or place in a URL. + pub refresh_token: Option, + /// Access token lifetime in seconds. + pub expires_in: u64, + /// OAuth token type, required to be Bearer. + pub token_type: String, +} +impl Drop for EnterpriseOAuthTokens { + fn drop(&mut self) { + use zeroize::Zeroize; + self.access_token.zeroize(); + if let Some(token) = self.refresh_token.as_mut() { + token.zeroize(); + } + } +} +/// Refresh once. On an ambiguous failure the owner must require login rather than retry a consumed rotating token. +pub async fn refresh( + config: &EnterpriseLoginConfig, + refresh_token: &str, +) -> Result { + token_request(config, serde_json::json!({"grant_type":"refresh_token", "client_id":config.client_id, "refresh_token":refresh_token})).await +} +async fn token_request( + config: &EnterpriseLoginConfig, + body: serde_json::Value, +) -> Result { + config.validate()?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(15)) + .build() + .map_err(|_| "Cannot initialize corporate login")?; + let mut response = client + .post(format!( + "{}/oauth/token", + config.issuer.trim_end_matches('/') + )) + .header("Cache-Control", "no-store") + .json(&body) + .send() + .await + .map_err(|_| "Corporate token exchange failed; sign in again")?; + if !response.status().is_success() { + return Err("Corporate token exchange rejected; sign in again".into()); + } + let mut bytes = Zeroizing::new(Vec::new()); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Corporate token response failed")? + { + if bytes.len() + chunk.len() > 64 * 1024 { + return Err("Corporate token response too large".into()); + } + bytes.extend_from_slice(&chunk); + } + let tokens: EnterpriseOAuthTokens = + serde_json::from_slice(&bytes).map_err(|_| "Invalid corporate token response")?; + if tokens.access_token.is_empty() + || tokens.access_token.len() > 16 * 1024 + || tokens.expires_in == 0 + || tokens.expires_in > 300 + || !tokens.token_type.eq_ignore_ascii_case("bearer") + || tokens + .refresh_token + .as_ref() + .is_some_and(|s| s.is_empty() || s.len() > 16 * 1024) + { + return Err("Invalid corporate token lifetime or type".into()); + } + Ok(tokens) +} + +#[cfg(test)] +mod tests { + use super::*; + fn config() -> EnterpriseLoginConfig { + EnterpriseLoginConfig { + signer_url: "https://signer.example/api".into(), + issuer: "https://login.example/".into(), + client_id: "native".into(), + audience: "signer".into(), + organization: "org".into(), + connection: "okta".into(), + } + } + #[test] + fn pkce_login_pins_authority_and_checks_callback_state() { + let attempt = EnterpriseLoginAttempt::new( + [1; 32], + [2; 32], + "http://127.0.0.1:1234/enterprise-callback".into(), + ); + let auth = attempt.authorization_url(&config()).unwrap(); + let params: std::collections::HashMap<_, _> = auth.query_pairs().collect(); + assert_eq!(auth.origin().ascii_serialization(), "https://login.example"); + assert_eq!(params["code_challenge_method"], "S256"); + assert_eq!(params["organization"], "org"); + assert_eq!(params["connection"], "okta"); + assert_eq!( + params["code_challenge"], + URL_SAFE_NO_PAD.encode(Sha256::digest(URL_SAFE_NO_PAD.encode([1; 32]))) + ); + let callback = url::Url::parse(&format!( + "http://127.0.0.1:1234/enterprise-callback?code=authorized&state={}", + params["state"] + )) + .unwrap(); + assert_eq!(attempt.callback_code(&callback).unwrap(), "authorized"); + for value in [ + callback.to_string() + "&state=duplicate", + callback.to_string() + "&code=duplicate", + callback.to_string() + "#fragment", + callback.to_string().replace("1234", "4321"), + callback + .to_string() + .replace(params["state"].as_ref(), "wrong"), + ] { + assert!(attempt + .callback_code(&url::Url::parse(&value).unwrap()) + .is_err()); + } + } + #[test] + fn config_refuses_insecure_or_credential_bearing_endpoints() { + for value in [ + "http://login.example", + "https://user:pass@login.example", + "https://login.example?token=x", + "https://login.example/path", + ] { + let mut config = config(); + config.issuer = value.into(); + assert!(config.validate().is_err()); + } + } +} diff --git a/crates/buzz-ws-client/src/enterprise_tests.rs b/crates/buzz-ws-client/src/enterprise_tests.rs new file mode 100644 index 00000000000..71ec74f0de4 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn identity(keys: &Keys) -> EnterpriseSession { + EnterpriseSession { + pubkey: keys.public_key().to_hex(), + relay_ws_url: "wss://buzz.example".to_owned(), + relay_http_url: "https://buzz.example".to_owned(), + } +} + +#[test] +fn configuration_and_credentials_fail_closed() { + let keys = Keys::generate(); + for url in [ + "http://signer.example", + "https://user:pass@signer.example", + "https://signer.example/?token=x", + "https://signer.example/#x", + ] { + assert!(EnterpriseSigner::new(url, identity(&keys)).is_err()); + } + let mut wrong = identity(&keys); + wrong.relay_ws_url = "wss://other.example".to_owned(); + assert!(EnterpriseSigner::new("https://signer.example", wrong).is_err()); + assert!(EnterpriseCredentials::new("".to_owned()).headers().is_err()); + assert!(EnterpriseCredentials::new("bad\r\nheader".to_owned()) + .headers() + .is_err()); + let headers = EnterpriseCredentials::new("token".to_owned()) + .headers() + .unwrap(); + assert!(headers["authorization"].is_sensitive()); + assert!(headers["authorization"].is_sensitive()); +} + +async fn server_reply( + body: String, + status: &str, + expected: EnterpriseSession, +) -> (EnterpriseSigner, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_owned(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut bytes = [0; 4096]; + let count = stream.read(&mut bytes).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&bytes[..count]); + assert!(request.len() < 256 * 1024); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse::() + .unwrap(); + if request.len() >= end + 4 + length { + break; + } + } + } + stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + // Test-only loopback transport; production construction always enforces HTTPS. + let signer = EnterpriseSigner { + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + .unwrap(), + base: format!("http://{address}"), + expected, + }; + (signer, task) +} + +fn template() -> EnterpriseTemplate { + EnterpriseTemplate { + kind: 9, + created_at: 1000, + tags: vec![vec!["h".to_owned(), "channel".to_owned()]], + content: "hello".to_owned(), + } +} +fn signed(keys: &Keys, content: &str) -> Event { + EventBuilder::new(Kind::from(9), content) + .custom_created_at(Timestamp::from(1000)) + .tags([Tag::parse(["h", "channel"]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +async fn verifies_real_signatures_template_and_account_and_sends_explicit_credentials() { + let keys = Keys::generate(); + let event = signed(&keys, "hello"); + let (signer, request) = server_reply( + serde_json::json!({"event": event}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + let credentials = EnterpriseCredentials::new("test-token-do-not-use".to_owned()); + assert_eq!( + signer.sign(&template(), &credentials).await.unwrap().id, + event.id + ); + let request = request.await.unwrap(); + assert!(!request.contains("x-bb-session-credential:")); + let authorization = request + .lines() + .find_map(|line| line.strip_prefix("authorization: ")) + .unwrap(); + let (scheme, token) = authorization.split_once(' ').unwrap(); + assert_eq!(scheme, "Bearer"); + assert_eq!(token, "test-token-do-not-use"); + assert!(!request.to_lowercase().contains("cookie:")); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["purpose"], "publish"); + assert!(body["event"].get("pubkey").is_none()); + for invalid in [signed(&keys, "changed"), signed(&Keys::generate(), "hello")] { + let (signer, request) = server_reply( + serde_json::json!({"event": invalid}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + assert!(signer.sign(&template(), &credentials).await.is_err()); + request.await.unwrap(); + } + let mut forged = serde_json::to_value(event).unwrap(); + forged["sig"] = serde_json::json!("0".repeat(128)); + let (signer, request) = server_reply( + serde_json::json!({"event": forged}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + assert!(signer.sign(&template(), &credentials).await.is_err()); + request.await.unwrap(); +} + +#[tokio::test] +async fn rejects_denial_large_response_and_session_switch() { + let keys = Keys::generate(); + let credentials = EnterpriseCredentials::new("test-token-do-not-use".to_owned()); + for (status, body) in [ + ("403 Forbidden", "{}".to_owned()), + ("200 OK", "x".repeat(256 * 1024 + 1)), + ( + "200 OK", + serde_json::to_string(&identity(&Keys::generate())).unwrap(), + ), + ] { + let (signer, request) = server_reply(body, status, identity(&keys)).await; + assert!(signer.session(&credentials).await.is_err()); + request.await.unwrap(); + } +} diff --git a/crates/buzz-ws-client/src/lib.rs b/crates/buzz-ws-client/src/lib.rs index 02c7d4b1b20..409a6ecd8c9 100644 --- a/crates/buzz-ws-client/src/lib.rs +++ b/crates/buzz-ws-client/src/lib.rs @@ -1,6 +1,8 @@ #![deny(unsafe_code)] pub mod connection; +pub mod enterprise; +pub mod enterprise_oauth; pub mod error; pub mod message; diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176d..532eae0a78e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/enterprise-login.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af27ee2438..b92430fa615 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1260,14 +1260,19 @@ dependencies = [ name = "buzz-ws-client" version = "0.1.0" dependencies = [ + "base64 0.22.1", "futures-util", "nostr 0.44.7", + "reqwest 0.13.4", + "serde", "serde_json", + "sha2 0.11.0", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tracing", "url", + "zeroize", ] [[package]] diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..eb412ce906e 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -8,6 +8,33 @@ include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; fn main() { + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_ENTERPRISE"); + if let Ok(config) = std::env::var("BUZZ_BUILD_ENTERPRISE") { + let parsed: serde_json::Value = + serde_json::from_str(&config).expect("BUZZ_BUILD_ENTERPRISE must be JSON"); + assert!( + std::env::var_os("CARGO_FEATURE_SYSTEM_KEYRING").is_some(), + "Enterprise builds require system-keyring" + ); + for field in [ + "signerUrl", + "issuer", + "clientId", + "audience", + "organization", + "connection", + ] { + assert!( + parsed[field].as_str().is_some_and(|s| !s.is_empty()), + "Missing enterprise build field: {field}" + ); + } + println!( + "cargo:rustc-env=BUZZ_DESKTOP_BUILD_ENTERPRISE={}", + serde_json::to_string(&parsed).expect("serialize enterprise config") + ); + } + println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_PUBLIC_KEY"); diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index f1136e88923..439a901bfe3 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -17,6 +17,7 @@ use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; pub struct AppState { + pub(crate) enterprise: crate::enterprise_identity::EnterpriseIdentity, pub keys: Mutex, /// Durable backend holding `keys`. Updated after the key write and before /// recovery flags are cleared so `get_identity` reports a consistent state. @@ -147,6 +148,9 @@ pub struct AppState { /// fall through to persisted resolution. A malformed value is logged and /// treated as absent rather than left on an ephemeral identity. fn identity_from_env() -> Option { + if crate::enterprise_identity::enabled() { + return None; + } match std::env::var("BUZZ_PRIVATE_KEY") { Ok(nsec) => match Keys::parse(nsec.trim()) { Ok(keys) => Some(keys), @@ -199,9 +203,11 @@ pub fn build_app_state() -> AppState { }; AppState { + enterprise: crate::enterprise_identity::EnterpriseIdentity::default(), keys: Mutex::new(keys), identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(300)) .pool_max_idle_per_host(2) @@ -264,6 +270,9 @@ mod accessors; /// but inaccessible this boot). Both states boot with an ephemeral key; the /// frontend shows different recovery screens for each. pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(), String> { + if crate::enterprise_identity::enabled() { + return Ok(()); + } // Only skip file-based resolution if the env var was present AND parsed // successfully. A malformed env var should fall through to the persisted // key rather than leaving the app on an ephemeral identity. diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs index 72744e1605e..4421e3f619a 100644 --- a/desktop/src-tauri/src/app_state_accessors.rs +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -49,7 +49,28 @@ impl AppState { /// unavailable this boot). All signing and publish commands must call /// this instead of locking `state.keys` directly, so that recovery mode /// blocks publishing under an invalid or inaccessible identity. + pub(crate) fn signing_identity( + &self, + ) -> Result { + if crate::enterprise_identity::enabled() { + self.enterprise.identity() + } else { + self.signing_keys().map(Into::into) + } + } + + pub(crate) fn public_key(&self) -> Result { + self.signing_identity().map(|s| s.public_key()) + } + pub fn signing_keys(&self) -> Result { + if crate::enterprise_identity::enabled() { + return Err( + "This feature requires a local private key and is unavailable in enterprise mode" + .into(), + ); + } + if self .identity_lost .load(std::sync::atomic::Ordering::Acquire) diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index 1b246b3fa23..3057ea57ef3 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -63,8 +63,7 @@ pub fn spawn_warm_init(app: tauri::AppHandle) { } fn identity_pubkey(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - Ok(keys.public_key().to_hex()) + Ok(state.public_key()?.to_hex()) } fn now_secs() -> i64 { @@ -179,10 +178,10 @@ pub(crate) async fn archive_candidates( let bucket_results = query_buckets(plan.buckets, state).await; // ── Phase 3: persist (blocking SQLite) ────────────────────────────────── - let owner_keys = { - let keys_guard = state.keys.lock().map_err(|e| e.to_string())?; - keys_guard.clone() - // guard drops here, before awaiting the blocking commit task. + let owner_keys = if crate::enterprise_identity::enabled() { + None + } else { + Some(state.signing_keys()?) }; let commit_identity_pk = identity_pk.clone(); let commit_relay_url = relay_url.clone(); @@ -195,7 +194,7 @@ pub(crate) async fn archive_candidates( plan.pre_dropped, &commit_identity_pk, &commit_relay_url, - &owner_keys, + owner_keys.as_ref(), now, conn, ) diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index c589b5bd522..5274d9c60ee 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -108,7 +108,7 @@ fn run_batch_sync_with_keys( plan.pre_dropped, identity_pk, relay_url, - owner_keys, + Some(owner_keys), 0, conn, ) @@ -772,7 +772,7 @@ mod real_relay { plan.pre_dropped, &identity_pk, &relay_url, - &owner_keys, + Some(&owner_keys), 0, &conn, ) diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index f2fb3e6b895..8c20bb8d09f 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -263,7 +263,7 @@ pub(super) fn commit_archive( pre_dropped: u32, identity_pk: &str, relay_url: &str, - owner_keys: &nostr::Keys, + owner_keys: Option<&nostr::Keys>, now: i64, conn: &Connection, ) -> Result { @@ -319,6 +319,10 @@ pub(super) fn commit_archive( // ciphertext or partial output. let stored_json = if p.event.kind.as_u16() as u64 == super::KIND_AGENT_TURN_METRIC as u64 { + let Some(owner_keys) = owner_keys else { + dropped += 1; + continue; + }; match buzz_core_pkg::agent_turn_metric::decrypt_agent_turn_metric( owner_keys, &p.event, ) { @@ -459,11 +463,13 @@ pub(super) fn commit_archive( // Write a status row regardless of outcome so backfill never // re-processes this frame (INSERT OR IGNORE on PK is a no-op if // the row is already present from a prior run). - let channel_id_for_index: Option = - buzz_core_pkg::observer::decrypt_observer_payload::( - owner_keys, &p.event, - ) - .ok() + let channel_id_for_index: Option = owner_keys + .and_then(|keys| { + buzz_core_pkg::observer::decrypt_observer_payload::( + keys, &p.event, + ) + .ok() + }) .and_then(|v| v.get("channelId")?.as_str().map(|s| s.to_owned())); store::upsert_observer_channel_index( &tx, diff --git a/desktop/src-tauri/src/archive/sync.rs b/desktop/src-tauri/src/archive/sync.rs index 3730774e952..54433dd1c04 100644 --- a/desktop/src-tauri/src/archive/sync.rs +++ b/desktop/src-tauri/src/archive/sync.rs @@ -561,7 +561,7 @@ pub async fn start_archive_sync( epoch: u64, lease: u64, ) -> Result<(), String> { - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; let relay_url = crate::relay::relay_ws_url_with_override(&state); let scope = (keys.public_key().to_hex(), relay_url.clone()); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 00a968f20f0..4e348847707 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -23,8 +23,7 @@ use crate::{ /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. pub(super) fn workspace_owner_hex(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - Ok(keys.public_key().to_hex()) + Ok(state.public_key()?.to_hex()) } #[path = "agents_pending.rs"] @@ -344,6 +343,10 @@ pub async fn create_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local managed agents are unavailable in enterprise mode".into()); + } + let name = input.name.trim().to_string(); let requested_persona_id = input .persona_id @@ -831,6 +834,10 @@ pub async fn start_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local managed agents are unavailable in enterprise mode".into()); + } + // Snapshot the workspace owner pubkey for the legacy auth_tag fallback. // Read outside the records lock to keep lock ordering simple. let owner_hex = workspace_owner_hex(&state)?; diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index fd74b3d8933..9165280e1a0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -247,7 +247,7 @@ fn has_all_starter_channels(channels: &[ChannelInfo]) -> bool { async fn ensure_starter_channel_memberships( state: &AppState, - keys: &nostr::Keys, + keys: &crate::enterprise_identity::SigningIdentity, channels: &mut [ChannelInfo], ) -> Result<(), String> { for spec in STARTER_CHANNELS { @@ -329,7 +329,7 @@ pub async fn create_channel( // whoever `state.keys` holds once the network round-trip completes. An // in-process identity swap while the request is in flight must not be // able to retarget the mark onto the new identity. - let creator_keys = state.signing_keys()?; + let creator_keys = state.signing_identity()?; let creator_pubkey = creator_keys.public_key().to_hex(); submit_event_with_keys(builder, &state, &creator_keys, None).await?; @@ -367,7 +367,7 @@ pub async fn ensure_starter_channels( let mut existing_channels = fetch_channels(&state, DirectoryScope::IncludeOpenDirectory).await?; let relay_scope = relay_api_base_url_with_override(&state); - let creator_keys = state.signing_keys()?; + let creator_keys = state.signing_identity()?; let creator_pubkey = creator_keys.public_key().to_hex(); let mut starter_ids = Vec::with_capacity(STARTER_CHANNELS.len()); let mut created_ids = std::collections::HashSet::new(); @@ -545,7 +545,7 @@ pub async fn add_channel_members( let uuid = parse_channel_uuid(&channel_id)?; let relay_base = relay_api_base_url_with_override(&state); assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?; - let signing_keys = state.signing_keys()?; + let signing_keys = state.signing_identity()?; assert_expected_signer( expected_signer_pubkey.as_deref(), &signing_keys.public_key().to_hex(), diff --git a/desktop/src-tauri/src/commands/channels/fetch.rs b/desktop/src-tauri/src/commands/channels/fetch.rs index 993d40807e2..f08dbedbbd0 100644 --- a/desktop/src-tauri/src/commands/channels/fetch.rs +++ b/desktop/src-tauri/src/commands/channels/fetch.rs @@ -198,10 +198,7 @@ pub(super) async fn fetch_channels( #[cfg(debug_assertions)] let _profile_start = std::time::Instant::now(); - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; // Channels this identity created whose kind:39002 membership hasn't yet // propagated. Under member-only scope they are the only non-member diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index 252068c94d7..dd8f829c81e 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -51,7 +51,7 @@ pub(crate) async fn open_dm_with_scope( // NIP-98 auth of every request in this command. let api_base_url = crate::relay::relay_api_base_url_with_override(state); assert_expected_relay_scope(expected_relay_url, &api_base_url)?; - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; assert_expected_signer(expected_signer_pubkey, &keys.public_key().to_hex())?; // Submit a kind:41010 dm-open event; the relay replies with the channel id diff --git a/desktop/src-tauri/src/commands/engrams.rs b/desktop/src-tauri/src/commands/engrams.rs index 74de1294925..6b939b5891d 100644 --- a/desktop/src-tauri/src/commands/engrams.rs +++ b/desktop/src-tauri/src/commands/engrams.rs @@ -137,10 +137,7 @@ pub async fn get_agent_memory( let agent = PublicKey::from_hex(&agent_pubkey) .map_err(|e| format!("agent pubkey must be 64-hex: {e}"))?; - let viewer_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let viewer_pubkey = { state.public_key()?.to_hex() }; let managed = load_managed_agents(&app)?; let is_managed = managed.iter().any(|m| m.pubkey == agent_pubkey); @@ -163,7 +160,7 @@ pub async fn get_agent_memory( // Owner = viewer. Clone the secret key out of the lock immediately so // we don't hold the mutex across the relay round trip. let (owner_pubkey, owner_seckey) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let keys = state.signing_keys()?; (keys.public_key(), keys.secret_key().clone()) }; diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 8852fcb7e01..16bb0bfe32b 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -53,8 +53,15 @@ mod truncated_display_name_tests { #[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 = if crate::enterprise_identity::enabled() { + state.public_key()? + } else { + state + .keys + .lock() + .map_err(|error| error.to_string())? + .public_key() + }; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; let lost = state @@ -70,7 +77,11 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, - storage: state.identity_storage().as_str().to_string(), + storage: if crate::enterprise_identity::enabled() { + "enterprise".into() + } else { + state.identity_storage().as_str().to_string() + }, lost, locked, reset_failed, @@ -139,27 +150,17 @@ pub async fn sign_event( tags: Vec>, state: State<'_, AppState>, ) -> Result { - let keys = state.signing_keys()?; - - tauri::async_runtime::spawn_blocking(move || { - let nostr_tags = tags - .into_iter() - .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) - .collect::, _>>()?; - - let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); - if let Some(created_at) = created_at { - builder = builder.custom_created_at(Timestamp::from(created_at)); - } - - let event = builder - .sign_with_keys(&keys) - .map_err(|error| format!("sign failed: {error}"))?; - - Ok(event.as_json()) - }) - .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + let identity = state.signing_identity()?; + let nostr_tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| e.to_string())?; + let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + Ok(identity.sign(builder).await?.as_json()) } #[tauri::command] @@ -366,6 +367,10 @@ pub async fn import_identity( password: Option, app_handle: tauri::AppHandle, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local identity changes are unavailable in enterprise mode".into()); + } + tokio::task::spawn_blocking(move || { // NIP-49 backups require a passphrase and decrypt entirely in Rust. // Raw nsec/hex input follows the existing parser path unchanged. @@ -437,6 +442,10 @@ pub(crate) fn commit_imported_identity( keys: nostr::Keys, persist: impl FnOnce(&nostr::Keys) -> Result, ) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { + if crate::enterprise_identity::enabled() { + return Err("Local-key import is unavailable in enterprise mode".into()); + } + // Capture the previous pubkey up front for post-commit cleanup. let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); @@ -494,6 +503,10 @@ pub(crate) fn commit_imported_identity( pub async fn persist_current_identity( app_handle: tauri::AppHandle, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local identity changes are unavailable in enterprise mode".into()); + } + tokio::task::spawn_blocking(move || { let state = app_handle.state::(); diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index bf66b761d32..abf1705a9d0 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -137,10 +137,7 @@ pub async fn resolve_oa_owner( return Ok(None); }; - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; Ok(Some(OwnerOfAgent { is_me: my_pubkey.eq_ignore_ascii_case(&owner_hex), @@ -294,10 +291,7 @@ async fn maybe_owner_auth_tag( state: &AppState, target_pubkey: &str, ) -> Result, String> { - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; // Self path: never attach auth (spec §Self Requests: if actor==target and // an `auth` tag is also present, relay MUST treat it as self). diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8cf8cc41747..c128748068f 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -342,7 +342,16 @@ pub(crate) fn sign_blossom_get_auth_header( /// Safety contract: callers must only attach the returned header to URLs /// constructed from (or validated against) the app's own relay base URL — /// never to third-party origins, where the bearer token would leak. -pub(crate) fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option { +pub(crate) async fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option { + if crate::enterprise_identity::enabled() { + return state + .signing_identity() + .ok()? + .media_read(base_url) + .await + .ok(); + } + let keys = match state.signing_keys() { Ok(k) => k, Err(e) => { @@ -423,7 +432,25 @@ async fn do_upload( 300 }; let base_url = relay_api_base_url_with_override(state); - let auth_event = { + let auth_event = if crate::enterprise_identity::enabled() { + let now = Timestamp::now().as_secs(); + let server = extract_server_authority(&base_url).ok_or("Invalid relay authority")?; + let tags = vec![ + vec!["t".to_owned(), "upload".into()], + vec!["x".into(), sha256.clone()], + vec!["server".into(), server], + vec!["expiration".into(), (now + 300).to_string()], + ]; + let tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| e.to_string())?; + state + .signing_identity()? + .sign(EventBuilder::new(Kind::from(24242), "Upload buzz-media").tags(tags)) + .await? + } else { let keys = state.signing_keys()?; sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)? }; diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index e841d4b2f54..a48eb4fa432 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -269,7 +269,7 @@ pub(super) async fn fetch_blob_bytes_with_cap( // `validate_download_url`, satisfying the mint_media_get_auth safety // contract (the token never leaves the relay origin). let relay_base = relay_api_base_url_with_override(state); - if let Some(auth) = mint_media_get_auth(state, &relay_base) { + if let Some(auth) = mint_media_get_auth(state, &relay_base).await { req = req.header("authorization", auth); } diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1e221b6bd18..2cfb24b5bbc 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -66,10 +66,7 @@ pub async fn get_feed( .map(|t| t.split(',').any(|s| s.trim() == "needs_action")) .unwrap_or(true); - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; // Mentions: messages that reference me via #p. let mut mention_filter = serde_json::json!({ @@ -442,7 +439,7 @@ pub async fn send_channel_message( // exact snapshot signs the event and its NIP-98 auth below. let relay_base = crate::relay::relay_api_base_url_with_override(&state); assert_expected_relay_scope(expected_relay_url.as_deref(), &relay_base)?; - let signing_keys = state.signing_keys()?; + let signing_keys = state.signing_identity()?; assert_expected_signer( expected_signer_pubkey.as_deref(), &signing_keys.public_key().to_hex(), @@ -666,7 +663,7 @@ fn managed_agent_submission_auth_tag( return Ok(Some(auth_tag)); } - let owner_keys = state.keys.lock().map_err(|error| error.to_string())?; + let owner_keys = state.signing_keys()?; legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey) } @@ -839,10 +836,7 @@ pub async fn remove_reaction( state: State<'_, AppState>, ) -> Result<(), String> { // Find our own kind:7 reaction event referencing the target. - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; let target = event_id.trim(); let trimmed_emoji = emoji.trim(); diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs index 8ec82beebb7..2fedfdfb6d0 100644 --- a/desktop/src-tauri/src/commands/messages/thread_ref.rs +++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs @@ -1,4 +1,5 @@ -use nostr::{EventId, Keys}; +use crate::enterprise_identity::SigningIdentity; +use nostr::EventId; use crate::{ app_state::AppState, @@ -30,7 +31,7 @@ pub(super) async fn thread_ref( root_event_id: Option<&str>, state: &AppState, api_base_url: &str, - signing_keys: Option<&Keys>, + signing_keys: Option<&SigningIdentity>, ) -> Result { match root_event_id { Some(root_event_id) => provided_thread_ref(root_event_id, parent_event_id), @@ -50,7 +51,7 @@ pub(super) async fn resolve_thread_ref( parent_event_id: &str, state: &AppState, api_base_url: &str, - keys: Option<&nostr::Keys>, + keys: Option<&SigningIdentity>, ) -> Result { let parent_eid = EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 517e333b293..5f11429e452 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -672,9 +672,11 @@ pub async fn mint_agent_card( // so the token never leaves the relay (same contract as // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); - let auth = is_same_origin(url, &relay_base) - .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) - .flatten(); + let auth = if is_same_origin(url, &relay_base) { + crate::commands::media::mint_media_get_auth(&state, &relay_base).await + } else { + None + }; fetch_avatar(url, auth.as_deref()).await? } _ => { diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index da93af673de..ad7cce13fcd 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -44,6 +44,10 @@ pub async fn update_profile( nip05_handle: Option, state: State<'_, AppState>, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Your organization manages your profile".into()); + } + // Read-merge-write: kind 0 is a full profile snapshot. let my_pubkey = current_pubkey_hex(&state)?; let prior_events = query_relay( @@ -393,8 +397,7 @@ pub async fn get_presence( } fn current_pubkey_hex(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - Ok(keys.public_key().to_hex()) + Ok(state.public_key()?.to_hex()) } fn current_pubkey_hex_unwrap(state: &AppState) -> String { diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index 9ccf8baac0d..990a5fe5b0b 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -64,10 +64,7 @@ pub async fn list_relay_members(state: State<'_, AppState>) -> Result, ) -> Result { - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; + let my_pubkey = { state.public_key()?.to_hex() }; let events = query_relay( &state, diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index c4e5d38c8ba..88e95d3dc05 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -387,8 +387,7 @@ fn trigger_wire_from_message( } fn current_pubkey_hex(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - Ok(keys.public_key().to_hex()) + Ok(state.public_key()?.to_hex()) } fn now_secs() -> i64 { diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 67f2f36c208..c4af8d7866c 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -107,11 +107,11 @@ pub struct ActiveWorkspaceInfo { /// Returns the current active workspace info (relay URL + pubkey). #[tauri::command] pub fn get_active_workspace(state: State<'_, AppState>) -> Result { - let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey = state.public_key()?; let relay_url = relay::relay_ws_url_with_override(&state); Ok(ActiveWorkspaceInfo { relay_url, - pubkey: keys.public_key().to_hex(), + pubkey: pubkey.to_hex(), }) } @@ -158,6 +158,21 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let state = app.state::(); + if crate::enterprise_identity::enabled() { + let identity = state.signing_identity()?; + let crate::enterprise_identity::SigningIdentity::Corporate(_) = identity else { + return Err("Corporate login required".into()); + }; + let current = crate::relay::relay_ws_url_with_override(&state); + if nsec.is_some() || relay_url.trim_end_matches('/') != current.trim_end_matches('/') { + return Err( + "This enterprise build uses its corporate community and cannot import local keys" + .into(), + ); + } + return Ok(()); + } + // Take the generation only after entering the serialized transaction. An // apply that is already running remains authoritative until it releases // the lock; the next apply then advances the generation. This keeps every diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index fe053602f9e..6d2eca4fa9b 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -153,8 +153,8 @@ async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { } /// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`). -#[test] -fn boundary_huddle_stt_blocks_ncryptsec() { +#[tokio::test] +async fn boundary_huddle_stt_blocks_ncryptsec() { let keys = nostr::Keys::generate(); let channel = uuid::Uuid::new_v4(); let builder = crate::events::build_message( @@ -170,7 +170,9 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &crate::relay::relay_api_base_url(), ) .unwrap(); - let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); + let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys) + .await + .unwrap_err(); assert_guard_error(&err); // Clean transcripts pass through the same seam. @@ -187,7 +189,11 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &crate::relay::relay_api_base_url(), ) .unwrap(); - assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); + assert!( + crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys) + .await + .is_ok() + ); } /// Boundary 8: native websocket send loop — the single choke point for all diff --git a/desktop/src-tauri/src/enterprise_identity.rs b/desktop/src-tauri/src/enterprise_identity.rs new file mode 100644 index 00000000000..cb948ab638c --- /dev/null +++ b/desktop/src-tauri/src/enterprise_identity.rs @@ -0,0 +1,547 @@ +//! Build-selected corporate identity. Local keys are never a fallback in enterprise builds. +use base64::{engine::general_purpose::STANDARD, Engine}; +use buzz_ws_client_pkg::enterprise::{ + EnterpriseCredentials, EnterpriseSession, EnterpriseSigner, EnterpriseTemplate, +}; +use buzz_ws_client_pkg::enterprise_oauth::{ + EnterpriseLoginAttempt, EnterpriseLoginConfig, EnterpriseOAuthTokens, +}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, PublicKey}; +use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{Emitter, Manager}; +use tauri_plugin_opener::OpenerExt; +use tokio::sync::Mutex as AsyncMutex; + +pub(crate) fn build_config() -> Result, String> { + option_env!("BUZZ_DESKTOP_BUILD_ENTERPRISE") + .map(|raw| { + let config: EnterpriseLoginConfig = + serde_json::from_str(raw).map_err(|_| "Invalid enterprise build configuration")?; + config.validate()?; + Ok(config) + }) + .transpose() +} +pub(crate) fn enabled() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_ENTERPRISE").is_some() +} + +#[derive(Default)] +pub(crate) struct EnterpriseIdentity { + current: Mutex>>, + login: AsyncMutex<()>, +} +pub(crate) struct CorporateSession { + public_key: PublicKey, + media: AsyncMutex>, + config: EnterpriseLoginConfig, + signer: EnterpriseSigner, + tokens: AsyncMutex, + active: std::sync::atomic::AtomicBool, +} +#[derive(Serialize, Deserialize)] +struct StoredTokens { + config: EnterpriseLoginConfig, + tokens: EnterpriseOAuthTokens, + expires_at: u64, + identity: EnterpriseSession, +} +fn now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|_| "Invalid system clock".into()) +} +fn secret_store() -> &'static crate::secret_store::SecretStore { + static STORE: std::sync::OnceLock = + std::sync::OnceLock::new(); + STORE.get_or_init(|| { + crate::secret_store::SecretStore::keyring(format!( + "{}-enterprise", + crate::build_identity::keyring_service() + )) + }) +} + +/// A snapshot binds async work to the same identity even when login changes. +#[derive(Clone)] +pub(crate) enum SigningIdentity { + Local(Keys), + Corporate(Arc), +} +impl From for SigningIdentity { + fn from(keys: Keys) -> Self { + Self::Local(keys) + } +} +impl From<&Keys> for SigningIdentity { + fn from(keys: &Keys) -> Self { + Self::Local(keys.clone()) + } +} +impl From<&SigningIdentity> for SigningIdentity { + fn from(identity: &SigningIdentity) -> Self { + identity.clone() + } +} +impl SigningIdentity { + pub(crate) fn public_key(&self) -> PublicKey { + match self { + Self::Local(keys) => keys.public_key(), + Self::Corporate(session) => session.signer_public_key(), + } + } + pub(crate) async fn sign(&self, builder: EventBuilder) -> Result { + match self { + Self::Local(keys) => builder.sign_with_keys(keys).map_err(|e| e.to_string()), + Self::Corporate(session) => { + let unsigned = builder.build(self.public_key()); + let template = EnterpriseTemplate { + kind: unsigned.kind.as_u16(), + created_at: unsigned.created_at.as_secs(), + tags: unsigned + .tags + .iter() + .map(|t| t.as_slice().to_vec()) + .collect(), + content: unsigned.content, + }; + session.sign(&template).await + } + } + } + pub(crate) async fn nip98( + &self, + method: &reqwest::Method, + url: &str, + body: &[u8], + ) -> Result { + use sha2::{Digest, Sha256}; + if let Self::Corporate(session) = self { + let target = url::Url::parse(url).map_err(|_| "Invalid request URL")?; + let relay = url::Url::parse(&session.signer.identity().relay_http_url) + .map_err(|_| "Invalid relay URL")?; + if target.origin() != relay.origin() + || !target.username().is_empty() + || target.password().is_some() + { + return Err("Corporate request outside configured community".into()); + } + } + let tags = vec![ + vec!["u".to_owned(), url.to_owned()], + vec!["method".into(), method.to_string()], + vec!["payload".into(), hex::encode(Sha256::digest(body))], + vec!["nonce".into(), uuid::Uuid::new_v4().to_string()], + ]; + let tags = tags + .into_iter() + .map(nostr::Tag::parse) + .collect::, _>>() + .map_err(|e| e.to_string())?; + let event = self + .sign(EventBuilder::new(nostr::Kind::HttpAuth, "").tags(tags)) + .await?; + Ok(format!("Nostr {}", STANDARD.encode(event.as_json()))) + } + pub(crate) async fn media_read(&self, base: &str) -> Result { + let Self::Corporate(session) = self else { + return Err("Expected corporate signer".into()); + }; + session.check_active()?; + if base.trim_end_matches('/') + != session + .signer + .identity() + .relay_http_url + .trim_end_matches('/') + { + return Err("Enterprise media scope mismatch".into()); + } + let mut cached = session.media.lock().await; + if let Some((header, expires)) = cached.as_ref() { + if *expires > now()? + 15 { + return Ok(header.clone()); + } + } + let expires = now()? + 120; + let server = url::Url::parse(base).map_err(|_| "Invalid media origin")?; + let authority = server[url::Position::BeforeHost..url::Position::AfterPort].to_owned(); + let event = session + .sign(&EnterpriseTemplate { + kind: 24242, + created_at: now()?, + content: "Get buzz-media".into(), + tags: vec![ + vec!["t".into(), "get".into()], + vec!["server".into(), authority], + vec!["expiration".into(), expires.to_string()], + ], + }) + .await?; + session.check_active()?; + let header = format!("Nostr {}", STANDARD.encode(event.as_json())); + *cached = Some((header.clone(), expires)); + Ok(header) + } + + pub(crate) async fn connect( + &self, + relay: &str, + auth: Option<&nostr::Tag>, + ) -> Result { + match self { + Self::Local(keys) => { + buzz_ws_client_pkg::NostrWsConnection::connect_authenticated(relay, keys, auth) + .await + .map_err(|e| e.to_string()) + } + Self::Corporate(session) => { + if auth.is_some() { + return Err("Agent delegation is unavailable in enterprise mode".into()); + } + let credentials = session.credentials().await?; + let mut connection = buzz_ws_client_pkg::NostrWsConnection::connect(relay) + .await + .map_err(|e| e.to_string())?; + connection + .authenticate_enterprise(&session.signer, &credentials) + .await + .map_err(|e| e.to_string())?; + session.check_active()?; + Ok(connection) + } + } + } +} +impl CorporateSession { + fn signer_public_key(&self) -> PublicKey { + // Validated at construction; retain an infallible public-key accessor without exposing key material. + self.pubkey() + } + fn pubkey(&self) -> PublicKey { + self.public_key + } + fn check_active(&self) -> Result<(), String> { + if self.active.load(std::sync::atomic::Ordering::Acquire) { + Ok(()) + } else { + Err("Corporate identity changed; sign in again".into()) + } + } + async fn credentials(&self) -> Result { + self.check_active()?; + let mut stored = self.tokens.lock().await; + self.check_active()?; + if stored.expires_at <= now()? + 30 { + let refresh = stored + .tokens + .refresh_token + .as_ref() + .ok_or("Corporate session expired; sign in again")?; + // A rotating token may be consumed even when the response is lost. Remove the old durable + // credential before exchange so a restart cannot replay it; failure requires browser login. + secret_store().delete("session")?; + let result = buzz_ws_client_pkg::enterprise_oauth::refresh(&self.config, refresh).await; + let fresh = match result { + Ok(tokens) => tokens, + Err(error) => { + self.active + .store(false, std::sync::atomic::Ordering::Release); + return Err(error); + } + }; + let credentials = EnterpriseCredentials::new(fresh.access_token.clone()); + if self.signer.session(&credentials).await.is_err() { + self.active + .store(false, std::sync::atomic::Ordering::Release); + return Err("Corporate account changed or access denied; sign in again".into()); + } + let next = StoredTokens { + config: self.config.clone(), + expires_at: now()? + fresh.expires_in, + tokens: fresh, + identity: stored.identity.clone(), + }; + // Rotation is one atomic credential-store write; no success if rotated credentials cannot be saved. + let encoded = zeroize::Zeroizing::new( + serde_json::to_string(&next).map_err(|_| "Cannot encode corporate credentials")?, + ); + if let Err(error) = secret_store().store("session", &encoded) { + self.active + .store(false, std::sync::atomic::Ordering::Release); + return Err(error); + } + *stored = next; + } + self.check_active()?; + Ok(EnterpriseCredentials::new( + stored.tokens.access_token.clone(), + )) + } + async fn sign(&self, template: &EnterpriseTemplate) -> Result { + let credentials = self.credentials().await?; + let event = self + .signer + .sign(template, &credentials) + .await + .map_err(|e| e.to_string())?; + self.check_active()?; + Ok(event) + } +} +impl EnterpriseIdentity { + pub(crate) fn identity(&self) -> Result { + self.current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .as_ref() + .cloned() + .map(SigningIdentity::Corporate) + .ok_or_else(|| "Corporate login required".into()) + } + async fn install( + &self, + config: EnterpriseLoginConfig, + stored: StoredTokens, + ) -> Result { + if serde_json::to_value(&stored.config).map_err(|_| "Invalid stored configuration")? + != serde_json::to_value(&config).map_err(|_| "Invalid build configuration")? + { + return Err("Enterprise build changed; sign in again".into()); + } + let public_key = PublicKey::from_hex(&stored.identity.pubkey) + .map_err(|_| "Invalid corporate identity")?; + let signer = EnterpriseSigner::new(&config.signer_url, stored.identity.clone()) + .map_err(|e| e.to_string())?; + let session = Arc::new(CorporateSession { + config, + signer, + public_key, + media: AsyncMutex::new(None), + tokens: AsyncMutex::new(stored), + active: std::sync::atomic::AtomicBool::new(true), + }); + session.credentials().await?; + let identity = session.signer.identity().clone(); + let mut current = self + .current + .lock() + .map_err(|_| "Corporate identity lock failed")?; + if let Some(old) = current.replace(session) { + old.active + .store(false, std::sync::atomic::Ordering::Release); + } + Ok(identity) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EnterpriseStatus { + enabled: bool, + identity: Option, +} +#[tauri::command] +pub(crate) async fn enterprise_status( + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let Some(config) = build_config()? else { + return Ok(EnterpriseStatus { + enabled: false, + identity: None, + }); + }; + let _lock = state.enterprise.login.lock().await; + if let Ok(SigningIdentity::Corporate(session)) = state.enterprise.identity() { + if session.credentials().await.is_err() { + return Ok(EnterpriseStatus { + enabled: true, + identity: None, + }); + } + return Ok(EnterpriseStatus { + enabled: true, + identity: Some(session.signer.identity().clone()), + }); + } + let Some(encoded) = secret_store().load("session")? else { + return Ok(EnterpriseStatus { + enabled: true, + identity: None, + }); + }; + let encoded = zeroize::Zeroizing::new(encoded); + let stored = serde_json::from_str(&encoded).map_err(|_| "Cannot restore corporate login")?; + let identity = state.enterprise.install(config, stored).await?; + *state + .relay_url_override + .lock() + .map_err(|_| "Relay lock failed")? = Some(identity.relay_ws_url.clone()); + Ok(EnterpriseStatus { + enabled: true, + identity: Some(identity), + }) +} +#[tauri::command] +pub(crate) async fn enterprise_login( + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let config = build_config()?.ok_or("Not an enterprise build")?; + app.state::() + .clear_identity() + .await; + let _lock = state.enterprise.login.lock().await; + let previous = state + .enterprise + .current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .clone(); + if let Some(previous) = previous { + let _tokens = previous.tokens.lock().await; + previous + .active + .store(false, std::sync::atomic::Ordering::Release); + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|_| "Cannot open login callback")?; + let port = listener + .local_addr() + .map_err(|_| "Cannot resolve login callback")? + .port(); + let redirect = format!("http://127.0.0.1:{port}/enterprise-callback"); + let mut verifier = [0; 32]; + let mut nonce = [0; 32]; + getrandom::getrandom(&mut verifier).map_err(|_| "Secure random unavailable")?; + getrandom::getrandom(&mut nonce).map_err(|_| "Secure random unavailable")?; + let attempt = EnterpriseLoginAttempt::new(verifier, nonce, redirect); + app.opener() + .open_url(attempt.authorization_url(&config)?.as_str(), None::<&str>) + .map_err(|_| "Cannot open corporate login")?; + let callback = tokio::time::timeout(Duration::from_secs(300), async { + for _ in 0..32 { + let (mut stream, _) = listener.accept().await.map_err(|_| "Login callback failed")?; + let request = tokio::time::timeout(Duration::from_secs(3), async { + let mut bytes = Vec::new(); + let mut chunk = [0; 1024]; + while !bytes.windows(4).any(|w| w == b"\r\n\r\n") { + let count = stream.read(&mut chunk).await.map_err(|_| "Callback read failed")?; + if count == 0 || bytes.len() + count > 8192 { return Err("Invalid callback size"); } + bytes.extend_from_slice(&chunk[..count]); + } + String::from_utf8(bytes).map_err(|_| "Invalid callback encoding") + }).await; + let Ok(Ok(request)) = request else { continue; }; + let Some(target) = request.lines().next().and_then(|l| l.strip_prefix("GET ")).and_then(|l| l.strip_suffix(" HTTP/1.1")) else { continue; }; + if !target.starts_with('/') || target.starts_with("//") { continue; } + let Ok(url) = url::Url::parse(&format!("http://127.0.0.1:{port}{target}")) else { continue; }; + let valid = attempt.callback_code(&url).is_ok(); + let response = if valid { "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nReturn to Buzz to finish signing in." } else { "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\nInvalid login callback." }; + let _ = tokio::time::timeout(Duration::from_secs(3), stream.write_all(response.as_bytes())).await; + if valid { return Ok::<_, String>(url); } + } + Err("Too many invalid login callbacks".into()) + }).await.map_err(|_| "Corporate login timed out")??; + let tokens = attempt.exchange(&config, &callback).await?; + let credentials = EnterpriseCredentials::new(tokens.access_token.clone()); + let signer = EnterpriseSigner::login(&config.signer_url, &credentials) + .await + .map_err(|e| e.to_string())?; + let stored = StoredTokens { + config: config.clone(), + expires_at: now()? + tokens.expires_in, + tokens, + identity: signer.identity().clone(), + }; + let encoded = zeroize::Zeroizing::new( + serde_json::to_string(&stored).map_err(|_| "Cannot encode credentials")?, + ); + secret_store().store("session", &encoded)?; + let identity = state.enterprise.install(config, stored).await?; + *state + .relay_url_override + .lock() + .map_err(|_| "Relay lock failed")? = Some(identity.relay_ws_url.clone()); + let _ = app.emit("enterprise-identity-changed", ()); + Ok(identity) +} + +#[tauri::command] +pub(crate) async fn enterprise_logout( + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result<(), String> { + if !enabled() { + return Err("Not an enterprise build".into()); + } + let _login = state.enterprise.login.lock().await; + let session = state + .enterprise + .current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .take(); + if let Some(session) = session { + let _tokens = session.tokens.lock().await; + session + .active + .store(false, std::sync::atomic::Ordering::Release); + } + app.state::() + .clear_identity() + .await; + secret_store().delete("session") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn absent_session_never_provides_a_local_fallback() { + let identity = EnterpriseIdentity::default(); + assert!(identity.identity().is_err()); + } + #[tokio::test] + async fn local_signing_snapshot_preserves_captured_identity_and_template() { + let keys = Keys::generate(); + let signer = SigningIdentity::from(keys.clone()); + let event = signer + .sign( + EventBuilder::new(nostr::Kind::from(9), "message") + .custom_created_at(nostr::Timestamp::from(1000)), + ) + .await + .unwrap(); + assert_eq!(event.pubkey, keys.public_key()); + assert_eq!(event.created_at.as_secs(), 1000); + assert!(event.verify().is_ok()); + } + #[test] + #[ignore = "run with BUZZ_BUILD_ENTERPRISE set to validate the compiled enterprise guard"] + fn compiled_enterprise_build_rejects_local_key_access_and_import() { + assert!(enabled()); + assert!(build_config().unwrap().is_some()); + let state = crate::app_state::build_app_state(); + assert!(state.signing_keys().is_err()); + assert!(state.signing_identity().is_err()); + let mut called = false; + let result = crate::commands::commit_imported_identity( + &state, + std::path::Path::new("/unused"), + Keys::generate(), + |_| { + called = true; + Ok(crate::identity_storage::IdentityStorage::Ephemeral) + }, + ); + assert!(result.is_err()); + assert!(!called); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 47d4aeb43d1..cb99c6e8747 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -607,13 +607,11 @@ fn should_reselect_constructed_voice(constructed_voice: &str, latest_voice: &str /// Factored out of the transcription loop so egress boundary 5 (huddle STT) /// has a directly testable seam: the NIP-49 egress guard runs here, before /// any bytes can reach the network. -pub(crate) fn sign_and_guard_stt_body( +pub(crate) async fn sign_and_guard_stt_body( builder: nostr::EventBuilder, - keys: &nostr::Keys, + identity: impl Into, ) -> Result, String> { - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("sign event: {e}"))?; + let event = identity.into().sign(builder).await?; let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "huddle STT publish")?; Ok(body_bytes) @@ -638,8 +636,8 @@ pub(crate) fn spawn_transcription_task( let spawned_gen = session_generation.load(Ordering::Acquire); let http_client = state.http_client.clone(); - let keys = match state.keys.lock() { - Ok(k) => k.clone(), + let keys = match state.signing_identity() { + Ok(k) => k, Err(_) => return, }; let relay_base_url = crate::relay::relay_api_base_url_with_override(state); @@ -688,7 +686,7 @@ pub(crate) fn spawn_transcription_task( // the kind event and build NIP-98 auth after the wait so both // timestamps are fresh — single clean order: wait → sign → auth → send. crate::relay_admission::wait_for_rate_limit().await; - let body_bytes = match sign_and_guard_stt_body(builder, &keys) { + let body_bytes = match sign_and_guard_stt_body(builder, &keys).await { Ok(b) => b, Err(e) => { eprintln!("buzz-desktop: STT publish: {e}"); @@ -696,12 +694,7 @@ pub(crate) fn spawn_transcription_task( } }; let url = format!("{relay_base_url}/events"); - let auth_header = match crate::relay::build_nip98_auth_header_for_keys( - &keys, - &reqwest::Method::POST, - &url, - &body_bytes, - ) { + let auth_header = match keys.nip98(&reqwest::Method::POST, &url, &body_bytes).await { Ok(h) => h, Err(e) => { eprintln!("buzz-desktop: STT NIP-98 auth: {e}"); diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 190397aa054..1585c71af1a 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -73,7 +73,7 @@ async fn connect_authenticated_audio_socket( channel_id: &str, parent_channel_id: Option<&str>, relay_url: &str, - keys: &nostr::Keys, + keys: &crate::enterprise_identity::SigningIdentity, auth_tag_json: Option<&str>, ) -> Result<(WsSink, WsReceiver, u8, Vec<(u8, String, u8)>), String> { use nostr::JsonUtil; @@ -107,7 +107,25 @@ async fn connect_authenticated_audio_socket( .await .map_err(|_| "timeout waiting for challenge from relay".to_string())??; - let event = build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)?; + let event = match keys { + crate::enterprise_identity::SigningIdentity::Local(keys) => { + build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)? + } + _ => { + if auth_tag_json.is_some() { + return Err("Enterprise agent delegation unavailable".into()); + } + let tags = [ + nostr::Tag::parse(["relay", relay_url]), + nostr::Tag::parse(["challenge", challenge.as_str()]), + ] + .into_iter() + .collect::, _>>() + .map_err(|e| e.to_string())?; + keys.sign(nostr::EventBuilder::new(nostr::Kind::Custom(22242), "").tags(tags)) + .await? + } + }; let event_json: serde_json::Value = serde_json::from_str(&event.as_json()) .map_err(|e| format!("failed to serialize auth event: {e}"))?; let auth_msg = serde_json::json!({ @@ -184,7 +202,7 @@ pub(crate) async fn connect_audio_relay( state: &AppState, ) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { let relay_url = crate::relay::relay_ws_url_with_override(state); - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_identity()?; // TTS interrupt flags — recv task cancels TTS when remote humans speak. let ( @@ -326,7 +344,7 @@ pub(crate) async fn connect_tts_audio_publisher( channel_id, parent_channel_id, &relay_url, - keys, + &crate::enterprise_identity::SigningIdentity::from(keys), auth_tag_json, ) .await?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b038577..5d112d20c1f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod channel_head_cache; mod commands; mod deep_link; mod egress_guard; +mod enterprise_identity; mod event_sync; mod events; mod huddle; @@ -447,7 +448,7 @@ pub fn run() { // has no relay override to the localhost fallback. Preserve the // boot-time repos and identity recovery safety gates by only marking // restoration pending when both allow it. - if restore_agents && !recovery_mode { + if restore_agents && !recovery_mode && !enterprise_identity::enabled() { state .managed_agent_restore_pending .store(true, Ordering::Release); @@ -537,6 +538,9 @@ pub fn run() { clear_pending_navigation_deep_links, take_pending_entity_deep_link, acknowledge_pending_entity_deep_link, + enterprise_identity::enterprise_status, + enterprise_identity::enterprise_login, + enterprise_identity::enterprise_logout, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..cef99f6f698 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -248,6 +248,10 @@ fn start_pair( expected_updated_at: Option<&str>, app: AppHandle, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local managed agents are unavailable in enterprise mode".into()); + } + let state = app.state::(); let _transition = state .managed_agent_runtime_transition diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 21692ce5237..503f561afc4 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -59,7 +59,7 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> // `upstream_url` is always `{relay base}{path}`, so the token can't reach // a third-party origin (mint_media_get_auth safety contract). - if let Some(auth) = mint_media_get_auth(&app_state, &base_url) { + if let Some(auth) = mint_media_get_auth(&app_state, &base_url).await { upstream = upstream.header("authorization", auth); } @@ -187,7 +187,7 @@ pub async fn handle_buzz_media( // `upstream_url` is always `{relay base}{path}`, so the token can't reach // a third-party origin (mint_media_get_auth safety contract). - if let Some(auth) = mint_media_get_auth(&state, &base) { + if let Some(auth) = mint_media_get_auth(&state, &base).await { upstream = upstream.header("authorization", auth); } diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs index 19740dd0197..4069d2ab332 100644 --- a/desktop/src-tauri/src/native_relay_client.rs +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -24,8 +24,11 @@ use std::{ time::Duration, }; +use crate::enterprise_identity::SigningIdentity; use buzz_ws_client_pkg::{NostrWsConnection, RelayMessage}; -use nostr::{Event, Keys}; +use nostr::Event; +#[cfg(test)] +use nostr::Keys; use tokio::{ sync::{mpsc, oneshot, Mutex}, time::Instant, @@ -132,11 +135,22 @@ impl Drop for SessionLease { } impl NativeRelayClient { + pub(crate) async fn clear_identity(&self) { + if let Some(current) = self.current.lock().await.take() { + current.session.shutdown(); + } + } + /// Installs the session for `scope`, shutting down whatever scope held the /// slot. Destructive on entry, so every caller must already hold proof it /// is the current owner — today that is /// [`crate::archive::sync::ArchiveOwnership`]. - async fn ensure_session(&self, relay_url: String, keys: Keys) -> Arc { + async fn ensure_session( + &self, + relay_url: String, + keys: impl Into, + ) -> Arc { + let keys = keys.into(); let scope = (relay_url.clone(), keys.public_key().to_hex()); let mut current = self.current.lock().await; if let Some(managed) = current.as_ref().filter(|managed| managed.scope == scope) { @@ -176,7 +190,12 @@ impl NativeRelayClient { /// Filling an empty slot is deliberate: at startup the catalog fetch /// commonly precedes archive sync, and installing here means the archive /// start that follows reuses this socket instead of opening a second one. - pub(crate) async fn session(&self, relay_url: String, keys: Keys) -> SessionLease { + pub(crate) async fn session( + &self, + relay_url: String, + keys: impl Into, + ) -> SessionLease { + let keys = keys.into(); let scope = (relay_url.clone(), keys.public_key().to_hex()); let mut current = self.current.lock().await; if let Some(managed) = current.as_ref() { @@ -216,7 +235,7 @@ impl NativeRelayClient { pub(crate) async fn archive_session( &self, relay_url: String, - keys: Keys, + keys: SigningIdentity, _ownership: &crate::archive::sync::ArchiveOwnership<'_>, ) -> (Arc, mpsc::Receiver) { let session = self.ensure_session(relay_url, keys).await; @@ -386,15 +405,19 @@ impl RelaySession { #[cfg(test)] pub(crate) async fn start( relay_url: String, - keys: Keys, + keys: impl Into, auth_tag: Option, ) -> (Arc, mpsc::Receiver) { - let session = start_managed(relay_url, keys, auth_tag); + let session = start_managed(relay_url, keys.into(), auth_tag); let events = session.attach_archive().await; (session, events) } -fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> Arc { +fn start_managed( + relay_url: String, + keys: SigningIdentity, + auth_tag: Option, +) -> Arc { let (wake, wake_rx) = mpsc::channel(1); let session = Arc::new(RelaySession { state: Arc::new(Mutex::new(SessionState::default())), @@ -417,7 +440,7 @@ fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> async fn run_session( relay_url: String, - keys: Keys, + keys: SigningIdentity, auth_tag: Option, session: Arc, mut wake_rx: mpsc::Receiver<()>, @@ -428,7 +451,7 @@ async fn run_session( return; } - match NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()).await { + match keys.connect(&relay_url, auth_tag.as_ref()).await { Ok(conn) => { // A connection that authenticated is healthy regardless of how // long it then lived, so backoff resets here rather than on diff --git a/desktop/src-tauri/src/persona_catalog.rs b/desktop/src-tauri/src/persona_catalog.rs index c7976b06e35..08280ed447a 100644 --- a/desktop/src-tauri/src/persona_catalog.rs +++ b/desktop/src-tauri/src/persona_catalog.rs @@ -72,7 +72,7 @@ pub(crate) async fn fetch_persona_catalog( state: State<'_, AppState>, relay_client: State<'_, NativeRelayClient>, ) -> Result, String> { - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; let owner = keys.public_key().to_hex(); let relay_url = crate::relay::relay_ws_url_with_override(&state); let session = relay_client.session(relay_url.clone(), keys).await; @@ -106,7 +106,7 @@ pub(crate) async fn fetch_persona_catalog( } } - let current_keys = state.signing_keys()?; + let current_keys = state.signing_identity()?; if current_keys.public_key().to_hex() != owner || crate::relay::relay_ws_url_with_override(&state) != relay_url { diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 2484fc7d14c..fa611f7da88 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -117,14 +117,13 @@ pub fn relay_api_base_url() -> String { // ── NIP-98 HTTP auth ──────────────────────────────────────────────────────── -pub fn build_nip98_auth_header( +pub async fn build_nip98_auth_header( method: &Method, url: &str, body: &[u8], state: &AppState, ) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - build_nip98_auth_header_for_keys(&keys, method, url, body) + state.signing_identity()?.nip98(method, url, body).await } pub fn build_nip98_auth_header_for_keys( @@ -374,7 +373,7 @@ pub async fn query_relay_at( let url = format!("{}/query", api_base_url); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; - let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; + let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state).await?; send_query_request( &state.http_client, &url, @@ -390,14 +389,14 @@ pub async fn query_relay_at_with_keys( state: &AppState, api_base_url: &str, filters: &[serde_json::Value], - keys: &Keys, + keys: impl Into, auth_tag: Option<&str>, ) -> Result, String> { crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/query", api_base_url); let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; - let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + let auth = keys.into().nip98(&Method::POST, &url, &body_bytes).await?; send_query_request( &state.http_client, &url, @@ -634,22 +633,22 @@ pub use submit::{ pub async fn submit_event_with_keys( builder: nostr::EventBuilder, state: &AppState, - keys: &Keys, + keys: impl Into, auth_tag: Option<&str>, ) -> Result { - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; - submit_signed_event_with_keys(&event, state, keys, auth_tag).await + let keys = keys.into(); + let event = keys.sign(builder).await?; + submit_signed_event_with_keys(&event, state, &keys, auth_tag).await } /// POST an already-signed event using the same explicit identity for NIP-98. pub async fn submit_signed_event_with_keys( event: &nostr::Event, state: &AppState, - keys: &Keys, + keys: impl Into, auth_tag: Option<&str>, ) -> Result { + let keys = keys.into(); if event.pubkey != keys.public_key() { return Err("signed event does not match the publishing identity".to_string()); } @@ -657,7 +656,7 @@ pub async fn submit_signed_event_with_keys( let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; - let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + let auth_header = keys.nip98(&Method::POST, &url, &body_bytes).await?; let mut request = state .http_client diff --git a/desktop/src-tauri/src/relay/get.rs b/desktop/src-tauri/src/relay/get.rs index 7d0855f463f..befd1fd9a05 100644 --- a/desktop/src-tauri/src/relay/get.rs +++ b/desktop/src-tauri/src/relay/get.rs @@ -22,7 +22,7 @@ pub async fn get_relay_json( relay_api_base_url_with_override(state), path_with_query ); - let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; + let auth = build_nip98_auth_header(&Method::GET, &url, &[], state).await?; let response = state .http_client .get(&url) diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index b6a5703fd96..7237169c77d 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -17,8 +17,9 @@ pub async fn submit_signed_event_at_with_keys( event: &nostr::Event, state: &AppState, api_base_url: &str, - keys: &nostr::Keys, + keys: impl Into, ) -> Result { + let keys = keys.into(); if event.pubkey != keys.public_key() { return Err("signed event does not match the publishing identity".to_string()); } @@ -26,7 +27,7 @@ pub async fn submit_signed_event_at_with_keys( let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; - let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; + let auth_header = keys.nip98(&Method::POST, &url, &body_bytes).await?; let response = state .http_client @@ -59,12 +60,11 @@ pub async fn submit_event_at_with_keys( builder: nostr::EventBuilder, state: &AppState, api_base_url: &str, - keys: &nostr::Keys, + keys: impl Into, ) -> Result { - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; - submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await + let keys = keys.into(); + let event = keys.sign(builder).await?; + submit_signed_event_at_with_keys(&event, state, api_base_url, &keys).await } /// Build and submit an event to the currently active workspace relay. @@ -73,7 +73,7 @@ pub async fn submit_event( state: &AppState, ) -> Result { let api_base_url = relay_api_base_url_with_override(state); - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; submit_event_at_with_keys(builder, state, &api_base_url, &keys).await } @@ -98,13 +98,12 @@ pub async fn submit_event_at_created_at( builder: nostr::EventBuilder, state: &AppState, api_base_url: &str, - keys: &nostr::Keys, + keys: impl Into, ) -> Result<(SubmitEventResponse, i64), String> { - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; + let keys = keys.into(); + let event = keys.sign(builder).await?; let created_at = event.created_at.as_secs() as i64; - let result = submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await?; + let result = submit_signed_event_at_with_keys(&event, state, api_base_url, &keys).await?; Ok((result, created_at)) } diff --git a/desktop/src-tauri/src/team_catalog.rs b/desktop/src-tauri/src/team_catalog.rs index 82b7685a143..0406f90f329 100644 --- a/desktop/src-tauri/src/team_catalog.rs +++ b/desktop/src-tauri/src/team_catalog.rs @@ -64,7 +64,7 @@ pub(crate) async fn fetch_team_catalog( state: State<'_, AppState>, relay_client: State<'_, NativeRelayClient>, ) -> Result, String> { - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; let owner = keys.public_key().to_hex(); let relay_url = crate::relay::relay_ws_url_with_override(&state); let session = relay_client.session(relay_url.clone(), keys).await; @@ -90,7 +90,7 @@ pub(crate) async fn fetch_team_catalog( }) .await?; - let current_keys = state.signing_keys()?; + let current_keys = state.signing_identity()?; if current_keys.public_key().to_hex() != owner || crate::relay::relay_ws_url_with_override(&state) != relay_url { diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index 96a740638b9..7fd255dcff8 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -139,7 +139,7 @@ pub(crate) async fn unread_catch_up( relay_client: State<'_, NativeRelayClient>, app: AppHandle, ) -> Result { - let keys = state.signing_keys()?; + let keys = state.signing_identity()?; let owner = keys.public_key().to_hex(); if !owner.eq_ignore_ascii_case(&request.self_pubkey) { return Err("unread catch-up identity does not match active scope".to_string()); @@ -217,7 +217,7 @@ pub(crate) async fn unread_catch_up( fetched.sort_by_key(|item| item.order); - let current_keys = state.signing_keys()?; + let current_keys = state.signing_identity()?; if current_keys.public_key().to_hex() != owner || crate::relay::relay_ws_url_with_override(&state) != relay_url { diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 7f1f73b1752..50be16e6d46 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -389,3 +389,12 @@ matches the code is worse than no rule; a new pattern that isn't written down here will be broken by the next agent that never learns it existed. Reviewers: treat a config-behavior diff without a matching AGENTS.md diff (or an explicit "no rules changed" note) as incomplete. + +## Enterprise identity builds + +`Identity.storage === "enterprise"` identifies a release-selected corporate signer. +The first enterprise build does not export or delegate its private key: local +managed-agent creation/start and runtime-start commands fail before side effects, +launch restore is disabled, and Agents shows that limitation instead of local +management controls. Independently operated relay agents remain usable through +normal community permissions; do not apply an owner-only clamp to mentions. diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50d8..ea93bfda79d 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -123,9 +123,16 @@ export function AgentsScreen() { >
- }> - - + {identityQuery.data?.storage === "enterprise" ? ( +

+ Local managed agents are unavailable in this enterprise build. You + can still talk to authorized agents in your community. +

+ ) : ( + }> + + + )} {profilePanelTarget ? ( { mgr.destroy(); }); + +test("enterprise local-only read state never queries or publishes encrypted blobs", async () => { + globalThis.window.localStorage = makeLocalStorage(); + let calls = 0; + const relay = { + fetchEvents: async () => { + calls++; + return []; + }, + publishEvent: async () => { + calls++; + }, + subscribeLive: () => { + calls++; + return () => {}; + }, + }; + const manager = new ReadStateManager("a".repeat(64), relay, false); + await manager.initialize(); + manager.markContextRead("channel-local", 123); + assert.equal(manager.getEffectiveTimestamp("channel-local"), 123); + manager.destroy(); + assert.equal(calls, 0); +}); diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 87fc1ceaac8..9d95a37420c 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -269,6 +269,7 @@ export function trimContextsToBudget( } export class ReadStateManager { + private remoteEnabled: boolean; private pubkey: string; private relayClient: RelayClient; private clientId: string; @@ -290,7 +291,8 @@ export class ReadStateManager { /** Event ids we published ourselves; used to skip decrypting their echoes. */ private recentlyPublishedIds = new Set(); - constructor(pubkey: string, relayClient: RelayClient) { + constructor(pubkey: string, relayClient: RelayClient, remoteEnabled = true) { + this.remoteEnabled = remoteEnabled; this.pubkey = pubkey; this.relayClient = relayClient; this.clientId = getOrCreatePersisted(clientIdKey(pubkey), () => @@ -311,6 +313,11 @@ export class ReadStateManager { ); this.hydrateFromLocalStorage(); + if (!this.remoteEnabled) { + this.initialized = true; + this.notifyListeners(); + return; + } await this.fetchAndMerge(); if (this.destroyed) return; @@ -632,6 +639,7 @@ export class ReadStateManager { } private schedulePublish(): void { + if (!this.remoteEnabled) return; if (this.destroyed) return; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts index 8326a2bd0ac..ae70b2a79be 100644 --- a/desktop/src/features/channels/readState/useReadState.ts +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { ReadStateManager, type ContextParentResolver, @@ -18,6 +19,7 @@ export function useReadState( pubkey: string | undefined, relayClient: RelayClient | undefined, ) { + const corporate = useIdentityQuery().data?.storage === "enterprise"; const [readStateVersion, forceUpdate] = React.useReducer( (x: number) => x + 1, 0, @@ -33,7 +35,7 @@ export function useReadState( if (!pubkey || !relayClient) return; let isCancelled = false; - const manager = new ReadStateManager(pubkey, relayClient); + const manager = new ReadStateManager(pubkey, relayClient, !corporate); managerRef.current = manager; const unsubscribe = manager.subscribe(() => { @@ -52,7 +54,7 @@ export function useReadState( manager.destroy(); managerRef.current = null; }; - }, [pubkey, relayClient]); + }, [pubkey, relayClient, corporate]); const getEffectiveTimestamp = React.useCallback( (contextId: string): number | null => { diff --git a/desktop/src/features/communities/EnterpriseLoginGate.tsx b/desktop/src/features/communities/EnterpriseLoginGate.tsx new file mode 100644 index 00000000000..bf7fb4d05ca --- /dev/null +++ b/desktop/src/features/communities/EnterpriseLoginGate.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState, useRef, type ReactNode } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { useCommunities } from "./useCommunities"; +import { markCommunityOnboardingComplete } from "@/features/onboarding/communityOnboarding"; + +type Identity = { pubkey: string; relayWsUrl: string; relayHttpUrl: string }; +type Status = { enabled: boolean; identity: Identity | null }; + +/** Release-selected login gate; never turns a failed corporate login into local-key onboarding. */ +export function EnterpriseLoginGate({ children }: { children: ReactNode }) { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const { addCommunity, switchCommunity } = useCommunities(); + + function accept(identity: Identity) { + const id = addCommunity({ + id: `enterprise-${identity.pubkey}`, + name: "Work", + relayUrl: identity.relayWsUrl, + pubkey: identity.pubkey, + addedAt: new Date().toISOString(), + }); + switchCommunity(id); + markCommunityOnboardingComplete(identity.pubkey, identity.relayWsUrl); + localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${identity.pubkey}`, + "true", + ); + setStatus({ enabled: true, identity }); + } + + const acceptRef = useRef(accept); + acceptRef.current = accept; + + useEffect(() => { + let active = true; + invoke("enterprise_status") + .then((next) => { + if (!active) return; + if (next.identity) acceptRef.current(next.identity); + else setStatus(next); + }) + .catch(() => { + if (active) { + setStatus({ enabled: true, identity: null }); + setError("Your work session could not be restored. Sign in again."); + } + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!status?.enabled || !status.identity) return; + let active = true; + const timer = window.setInterval(() => { + void invoke("enterprise_status") + .then((next) => { + if (active && !next.identity) { + setStatus(next); + setError("Your work session expired. Sign in again."); + } + }) + .catch(() => { + if (active) { + setStatus({ enabled: true, identity: null }); + setError( + "Your work session could not be refreshed. Sign in again.", + ); + } + }); + }, 60_000); + return () => { + active = false; + window.clearInterval(timer); + }; + }, [status?.enabled, status?.identity]); + + if (status?.enabled === false) return children; + if (status?.identity) + return ( + <> + {children} + + + ); + + return ( +
+

Sign in to Buzz for work

+

+ Your organization manages your identity and community. +

+ {error ? ( +

+ {error} +

+ ) : null} + +
+ ); +} diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx index 8eb0256a557..d9083a78216 100644 --- a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -11,6 +11,7 @@ import { initialBackupTestProgress, } from "@/features/settings/ui/BackupTestFlow"; import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { getNsec } from "@/shared/api/tauriIdentity"; import { Button } from "@/shared/ui/button"; import { @@ -58,6 +59,18 @@ function BackupAvailabilityFill({ * while expanded; encrypted backup state lives in the app-level provider. */ export function PrivateKeyBackupRow() { + const identity = useIdentityQuery(); + if (identity.data?.storage === "enterprise") + return ( +

+ Your organization holds your signing key. Private-key export, backup, + and pairing are unavailable. +

+ ); + return ; +} + +function LocalPrivateKeyBackupRow() { const [isOpen, setIsOpen] = React.useState(false); const [nsec, setNsec] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 0866ee051ed..a340de0cc77 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -10,6 +10,7 @@ import "@fontsource/jetbrains-mono/700.css"; import "@/shared/styles/globals.css"; import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage"; +import { EnterpriseLoginGate } from "@/features/communities/EnterpriseLoginGate"; import { CommunitiesProvider } from "@/features/communities/useCommunities"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding"; @@ -95,7 +96,9 @@ function renderApp() { - + + + diff --git a/desktop/src/shared/api/identityTypes.ts b/desktop/src/shared/api/identityTypes.ts index 9ae41d33453..03be6a576df 100644 --- a/desktop/src/shared/api/identityTypes.ts +++ b/desktop/src/shared/api/identityTypes.ts @@ -2,7 +2,8 @@ export type IdentityStorage = | "system-keyring" | "local-file" | "environment" - | "ephemeral"; + | "ephemeral" + | "enterprise"; export type Identity = { pubkey: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9eda4f08fd4..5a19d87455e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1237,6 +1237,11 @@ async function writeClipboardFlavors({ declare global { interface Window { + __BUZZ_E2E_ENTERPRISE__?: { + enabled: boolean; + loginFails?: boolean; + loggedIn?: boolean; + }; __BUZZ_E2E__?: E2eConfig; /** Last payload written through the native clipboard command. */ __BUZZ_E2E_LAST_CLIPBOARD__?: { html: string | null; text: string }; @@ -13408,6 +13413,38 @@ export function maybeInstallE2eTauriMocks() { return getRelayWsUrl(activeConfig); case "get_default_relay_url": return getRelayWsUrl(activeConfig); + case "enterprise_status": { + const enterprise = window.__BUZZ_E2E_ENTERPRISE__; + return { + enabled: enterprise?.enabled ?? false, + identity: enterprise?.loggedIn + ? { + pubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + relayWsUrl: getRelayWsUrl(activeConfig), + relayHttpUrl: getRelayWsUrl(activeConfig) + .replace("wss:", "https:") + .replace("ws:", "http:"), + } + : null, + }; + } + case "enterprise_logout": + if (window.__BUZZ_E2E_ENTERPRISE__) + window.__BUZZ_E2E_ENTERPRISE__.loggedIn = false; + return null; + case "enterprise_login": { + const enterprise = window.__BUZZ_E2E_ENTERPRISE__; + if (!enterprise?.enabled || enterprise.loginFails) + throw new Error("Corporate login denied"); + enterprise.loggedIn = true; + return { + pubkey: identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + relayWsUrl: getRelayWsUrl(activeConfig), + relayHttpUrl: getRelayWsUrl(activeConfig) + .replace("wss:", "https:") + .replace("ws:", "http:"), + }; + } case "auto_connect_default_relay_enabled": return activeConfig?.autoConnectDefaultRelay ?? false; case "get_legacy_workspace_storage": diff --git a/desktop/tests/e2e/enterprise-login.spec.ts b/desktop/tests/e2e/enterprise-login.spec.ts new file mode 100644 index 00000000000..ef79f4d5762 --- /dev/null +++ b/desktop/tests/e2e/enterprise-login.spec.ts @@ -0,0 +1,53 @@ +import { mkdir } from "node:fs/promises"; +import { waitForAnimations } from "../helpers/animations"; +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +test("corporate denial stays at login and never enters local-key onboarding", async ({ + page, +}) => { + await page.addInitScript(() => { + window.__BUZZ_E2E_ENTERPRISE__ = { enabled: true, loginFails: true }; + }); + await installMockBridge(page); + await page.goto("/"); + const login = page.getByRole("button", { + name: "Sign in with corporate account", + }); + await expect(login).toBeVisible(); + await mkdir("test-results/enterprise-login", { recursive: true }); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/enterprise-login/01-corporate-login.png", + }); + await login.click(); + await expect(page.getByRole("alert")).toHaveText( + "Sign-in did not complete. Try again.", + ); + await expect(login).toBeEnabled(); + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/enterprise-login/02-denied-login.png", + }); + const calls = await page.evaluate(() => window.__BUZZ_E2E_COMMANDS__ ?? []); + expect(calls).not.toContain("get_nsec"); + expect(calls).not.toContain("persist_current_identity"); + expect(calls).not.toContain("sign_event"); +}); + +test("successful corporate login installs community and bypasses key onboarding", async ({ + page, +}) => { + await page.addInitScript(() => { + window.__BUZZ_E2E_ENTERPRISE__ = { enabled: true }; + }); + await installMockBridge(page); + await page.goto("/"); + await page + .getByRole("button", { name: "Sign in with corporate account" }) + .click(); + await expect( + page.getByRole("heading", { name: "Sign in to Buzz for work" }), + ).not.toBeVisible(); + await expect(page.getByTestId("open-settings")).toBeVisible(); +}); diff --git a/docs/enterprise-remote-signer-api.md b/docs/enterprise-remote-signer-api.md new file mode 100644 index 00000000000..a27317a7eb1 --- /dev/null +++ b/docs/enterprise-remote-signer-api.md @@ -0,0 +1,164 @@ +# Enterprise HTTPS signer — integration and release contract + +**Status: draft native integration.** Desktop and mobile now have release-selected +corporate login, refresh and remote signing. The real Auth0/Okta applications, +Kgoose deployment and end-to-end acceptance in that environment are separate +rollout work. Do not infer production readiness from mock/transport tests. + +Enterprise custody intentionally differs from `VISION_SOVEREIGN.md`: the +organization controls its employee identity. OSS builds retain local/NIP-07 keys. +Nostr events, external relay hosting and community/channel permissions remain. + +## Release selection — no user configuration + +Desktop packaging sets `BUZZ_BUILD_ENTERPRISE` to non-secret JSON before compiling +Tauri. Mobile passes the same JSON using `--dart-define=BUZZ_BUILD_ENTERPRISE=...` +(or a define file). Absence selects existing OSS self-custody; malformed enterprise +configuration fails closed, never falls back to local credentials. + +```json +{ + "signerUrl": "https://signer.example/api", + "issuer": "https://corporate-login.example/", + "clientId": "registered-native-public-client", + "audience": "buzz-enterprise-signer", + "organization": "org_corporate", + "connection": "okta-employees" +} +``` + +Mobile additionally requires `"redirectUri":"buzz://enterprise-login"`. Desktop +uses `http://127.0.0.1:/enterprise-callback`. Register the native +client's appropriate redirect policy in Auth0; confirm support for desktop's +loopback ephemeral port. Never bake a client secret, access token or refresh token. +The internal release pipeline lives outside this repository; its real values and +packaging changes must be supplied there. The OSS release process is unchanged. + +## Login and token lifecycle + +Both native clients open the system browser for authorization-code/PKCE S256. +Random state and callback validation bind the browser response to the attempt. +The client exchanges the code with Auth0, then calls the trusted signer's session +endpoint to discover and pin its identity/community. Employees do not choose a +signer, import an nsec, or configure a relay in enterprise mode. + +Tokens stay in native secure storage (desktop OS keyring, mobile secure storage), +not renderer/localStorage. Refresh is single-flight. Rotating refresh credentials +are removed from durable storage before exchange so a lost response cannot cause +a restart to replay a consumed credential. Failure requires browser login; a +successful replacement is saved before further signing. This intentionally favors +safe reauthentication over uninterrupted access after a crash during rotation. +Stored credentials are bound to the release-selected login configuration. + +Desktop reports expiry via its login gate; mobile invalidates authentication when +refresh fails. Account/community changes are rejected on refresh. In-flight signing +is bound to the captured identity; logging out or replacing it cannot silently +retarget an event. Reads after successful NIP-42 auth go directly to Buzz. + +## Two signer endpoints + +All requests use HTTPS POST and JSON, no redirects or caches, and exactly one +`Authorization: Bearer ` header. No second +bbidentity app-session credential is required. Cookie-only and ordinary app-token +requests cannot authorize signing. The backend resolves the existing stable +bbidentity account from the verified Auth0 organization and subject. + +The token contract checks issuer, RS256 signature, audience, subject, organization, +enterprise connection, `buzz:sign`, and signed corporate entitlement. Authorization +expires at most five minutes after the issuer's corporate check, not five minutes +after an arbitrary refresh. Auth0-side entitlement verification on login **and +refresh** remains an external prerequisite; a stale app session is not employment +proof. See the companion Kgoose design for the precise namespaced claims. + +### POST `/v1/buzz/enterprise-signer/session` + +Request `{}`; response: + +```json +{ + "pubkey": "64 lowercase hex characters", + "relayWsUrl": "wss://community.example", + "relayHttpUrl": "https://community.example" +} +``` + +Creates or reads the atomic encrypted account key. First membership admission and +corporate-derived kind:0 profile are journaled as signed events before publication. +Retries retain event IDs after ambiguous acknowledgements. Completed accounts +check current membership rather than silently re-adding a removed employee. +The enterprise relay must be membership-gated. + +### POST `/v1/buzz/enterprise-signer/events/sign` + +```json +{ + "purpose": "nip42-auth", + "event": { + "kind": 22242, + "created_at": 1789167600, + "tags": [["relay", "wss://community.example"], ["challenge", "relay-challenge"]], + "content": "" + } +} +``` + +Returns `{ "event": }`. No identity selector, +`pubkey`, `id`, `sig` or unknown template field is accepted. Clients verify the +signature, public key and exact template. The service rechecks corporate authority +and membership before signing. + +Purposes: `nip42-auth`, `http-auth`, `media-read`, `media-upload`, `publish`. +NIP-98 proofs bind exact origin/method/body hash. Media proofs bind server authority, +short expiry and upload hash; file bytes travel directly to Buzz. Generic profile, +delegation, pairing, agent custody and relay-admin signing remain denied. + +Bearer timestamps are within -60/+30 seconds; durable publish templates may be +older. Content and tag strings are each capped at 64 KiB, 256 tags, 16 strings/tag. +Application request limit is 128 KiB; signer response limit is 256 KiB. Deployment +must impose a pre-buffer ingress limit, redact bearer headers/bodies, and configure +fleet quotas. Backend admission adds per-pod concurrency/account bounds. + +## Native signing coverage and deliberate exclusions + +Desktop's `SigningIdentity` snapshots route generic event IPC, message/channel/DM +submission, query HTTP auth, native relay sessions, media and human huddle auth/STT +through either local keys or the corporate signer. `signing_keys()` explicitly +rejects enterprise mode. The renderer never receives corporate tokens. + +Mobile's `signClientEvent` routes normal submissions, NIP-42, HTTP query auth, +media uploads, typing/status and human huddle auth through the corporate signer. +Media image/file/audio/video callers await host-scoped read credentials. Read +proofs cache for two minutes with an expiry margin; file bytes bypass Kgoose. + +First-build exclusions are explicit, not alternate local identities: +- Key export/import/backup/pairing and local managed-agent creation/start. +- Directory-controlled profile edits. +- Secret-dependent observer decryption, git private-key helper operations, mesh + identity and encrypted preference synchronization. +- Cross-device encrypted read-state sync; local read markers continue to work. +- Existing private-key-based mobile push lease/NSE paths are not enabled for the + keyless corporate community. Native corporate push support is a later feature. + +Authorized independently operated relay agents remain usable in conversations. +The signing abstraction is not a promise that every existing secret-dependent +feature has a remote equivalent. + +## Minimal sends, no new durable outbox + +Build a template, sign, publish and await the relay ACK using existing flows. +Preserve the template/signed event for retries inside the operation; never change +`created_at` to recreate a potentially delivered event. A crash or lost ACK can +still leave delivery uncertain. No new durable client outbox is implemented in +this milestone; the backend provisioning journal is separate. + +## Remaining rollout/security gates + +- Deploy Kgoose/schema/admin/encryption config; configure Auth0 native apps, + audience/claims/refresh and Okta federation; set internal release build values. +- Exercise real login, refresh, second device, chat/media/huddles, denial and OSS + regression in deployed native apps. Current evidence is unit/transport/mocked UI. +- Complete offboarding-driven signer denial and durable membership removal with + pending-provisioning generation fencing. Active WebSocket termination remains + on the separate branch. A short lease is not instantaneous revocation. +- Profile refresh/migration, custody retention/deletion/audit/restore, header + redaction, ingress size limits and fleet-wide quotas/security approval. diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 9f3724fd2d1..74732d420ab 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -28,6 +28,8 @@ import 'features/profile/profile_edit_page.dart'; import 'features/profile/profile_text_editor.dart'; import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; +import 'shared/auth/enterprise_identity.dart'; +import 'shared/auth/enterprise_login_page.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; import 'shared/push/push_subscription_provider.dart'; @@ -382,7 +384,9 @@ class App extends HookConsumerWidget { ), home: authState.when( loading: () => const _SplashScreen(), - error: (_, _) => const PairingPage(), + error: (_, _) => enterpriseEnabled + ? const EnterpriseLoginPage() + : const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => DeepLinkDispatcher( child: HomePage( @@ -390,10 +394,13 @@ class App extends HookConsumerWidget { hasUnreadInbox: hasUnreadInbox, ), ), - _ => const DeepLinkDispatcher( - dispatchMessageLinks: false, - child: PairingPage(), - ), + _ => + enterpriseEnabled + ? const EnterpriseLoginPage() + : const DeepLinkDispatcher( + dispatchMessageLinks: false, + child: PairingPage(), + ), }, ), ); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d07cf2425b2..821c4841c08 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:math' show cos, max, min, pi; import 'dart:ui' show ImageFilter, lerpDouble; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 5b0abb5766e..5e4fd9ab932 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -425,7 +425,7 @@ void _openMobileHuddle({ }) { final config = ref.read(relayConfigProvider); final nsec = config.nsec; - if (nsec == null || nsec.isEmpty) { + if (!enterpriseEnabled && (nsec == null || nsec.isEmpty)) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('A paired identity is required.')), ); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 71ba39d14ec..872dea51450 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:collection'; import 'dart:io'; @@ -15,8 +16,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:nostr/nostr.dart' as nostr; - import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_bindings.dart'; import '../../shared/huddle/huddle_session.dart'; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index fa20df6d168..a515b0bcdca 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -376,14 +376,11 @@ void _sendTypingIndicator( required String channelId, String? threadHeadId, String? rootId, -}) { +}) async { try { final config = ref.read(relayConfigProvider); final nsec = config.nsec; - if (nsec == null || nsec.isEmpty) return; - - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) return; + if (!enterpriseEnabled && (nsec == null || nsec.isEmpty)) return; final tags = >[ ['h', channelId], @@ -394,12 +391,11 @@ void _sendTypingIndicator( ['e', threadHeadId, '', 'reply'], ]; - final event = nostr.Event.from( + final event = await signClientEvent( kind: EventKind.typingIndicator, content: '', tags: tags, - secretKey: privkeyHex, - verify: false, + nsec: nsec, ); // Send directly over WebSocket — fire-and-forget, matching desktop. diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart index 2986af96360..6044983cd48 100644 --- a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -130,7 +130,7 @@ Future _presentIosEmojiPicker({ case 'mediaHeaders': final url = call.arguments; return url is String - ? mediaAuth.headersFor(url) + ? await mediaAuth.headersForAsync(url) : const {}; case 'selected': final emoji = call.arguments; diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index 1c3b50c44b3..b78ac451f41 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:io'; diff --git a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart index 7267795734a..d55c6ad934e 100644 --- a/mobile/lib/features/channels/media_viewer_page/video_viewer.dart +++ b/mobile/lib/features/channels/media_viewer_page/video_viewer.dart @@ -52,12 +52,12 @@ class MediaVideoViewerPage extends HookConsumerWidget { // keep Android on its streaming path. iOS uses the authenticated local // copy below because AVPlayer can drop those headers after the first // request. - if (Platform.isAndroid) { + if (Platform.isAndroid && !enterpriseEnabled) { VideoPlayerController? streamingController; try { streamingController = VideoPlayerController.networkUrl( uri, - httpHeaders: auth.headersFor(videoUrl), + httpHeaders: await auth.headersForAsync(videoUrl), ); await streamingController.initialize(); await streamingController.play(); @@ -80,11 +80,14 @@ class MediaVideoViewerPage extends HookConsumerWidget { final client = ref.read(mediaHttpClientProvider); final requestAbort = Completer(); downloadRequestAbort.value = requestAbort; - final request = http.AbortableStreamedRequest( - 'GET', - uri, - abortTrigger: requestAbort.future, - )..headers.addAll(auth.headersFor(videoUrl)); + final request = + http.AbortableStreamedRequest( + 'GET', + uri, + abortTrigger: requestAbort.future, + ) + ..followRedirects = false + ..headers.addAll(await auth.headersForAsync(videoUrl)); late final http.StreamedResponse response; try { response = await client.send(request); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 53dd4c30f65..9248cc23d50 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:http/http.dart' as http; import 'dart:io'; import 'dart:math' as math; import 'dart:ui'; @@ -336,12 +337,14 @@ class _DownloadedImage { } Future<_DownloadedImage> _downloadImage(WidgetRef ref, String imageUrl) async { - final response = await ref - .read(mediaHttpClientProvider) - .get( - Uri.parse(imageUrl), - headers: ref.read(mediaGetAuthServiceProvider).headersFor(imageUrl), - ); + final request = http.Request('GET', Uri.parse(imageUrl)) + ..followRedirects = false; + request.headers.addAll( + await ref.read(mediaGetAuthServiceProvider).headersForAsync(imageUrl), + ); + final response = await http.Response.fromStream( + await ref.read(mediaHttpClientProvider).send(request), + ); if (response.statusCode < 200 || response.statusCode >= 300) { throw HttpException( 'Image download failed (${response.statusCode})', diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index f2cccbaa237..7a5782fe499 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -505,7 +505,7 @@ class MessageContent extends HookConsumerWidget { try { await ref.read(openDownloadedFileProvider)( url, - auth.headersFor(url), + await auth.headersForAsync(url), text, ); } catch (_) { diff --git a/mobile/lib/features/channels/message_content/video_preview.dart b/mobile/lib/features/channels/message_content/video_preview.dart index 61d92cb2be6..3099e3b69cd 100644 --- a/mobile/lib/features/channels/message_content/video_preview.dart +++ b/mobile/lib/features/channels/message_content/video_preview.dart @@ -30,7 +30,8 @@ final videoPreviewFrameLoaderProvider = Provider(( ref, ) { final auth = ref.watch(mediaGetAuthServiceProvider); - return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url)); + return (url) async => + _loadVideoPreviewFrame(url, headers: await auth.headersForAsync(url)); }); class _MessageVideoPreview extends HookConsumerWidget { diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index 4cb856f3811..38eb92ed336 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'package:flutter/widgets.dart'; @@ -491,12 +492,12 @@ final class MobileHuddleController extends Notifier { }) { final config = ref.read(relayConfigProvider); final nsec = config.nsec; - if (nsec == null || nsec.isEmpty) { + if (!enterpriseEnabled && (nsec == null || nsec.isEmpty)) { throw StateError('A paired identity is required.'); } return HuddleConnectionParameters( relayWebSocketUrl: config.wsUrl, - nsec: nsec, + nsec: nsec ?? '', parentChannelId: parentChannelId, ephemeralChannelId: ephemeralChannelId, ); diff --git a/mobile/lib/features/channels/voice_note_attachment.dart b/mobile/lib/features/channels/voice_note_attachment.dart index 9b1950d922e..009024776b6 100644 --- a/mobile/lib/features/channels/voice_note_attachment.dart +++ b/mobile/lib/features/channels/voice_note_attachment.dart @@ -53,7 +53,7 @@ class VoiceNoteAttachment extends HookConsumerWidget { player.loadRemote( source, headers: () => - ref.read(mediaGetAuthServiceProvider).headersFor(source), + ref.read(mediaGetAuthServiceProvider).headersForAsync(source), fallbackDuration: duration, ), ); diff --git a/mobile/lib/features/channels/voice_note_recording.dart b/mobile/lib/features/channels/voice_note_recording.dart index c34565c3415..c110cd8db7b 100644 --- a/mobile/lib/features/channels/voice_note_recording.dart +++ b/mobile/lib/features/channels/voice_note_recording.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:io'; import 'dart:math' as math; @@ -332,7 +333,7 @@ abstract class VoiceNotePlayerController extends ChangeNotifier { Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }); @@ -468,7 +469,8 @@ class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { _client = client, _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory, _requiresAuthenticatedLocalFile = - requiresAuthenticatedLocalFile ?? Platform.isIOS, + requiresAuthenticatedLocalFile ?? + (Platform.isIOS || enterpriseEnabled), _downloadTimeout = downloadTimeout, _maxDownloadBytes = maxDownloadBytes, _player = player ?? _DeviceVoiceNoteAudioPlayerBackend() { @@ -524,7 +526,7 @@ class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { VoiceNotePlaybackState _state = const VoiceNotePlaybackState(); ({ String url, - Map Function() headers, + FutureOr> Function() headers, Duration fallbackDuration, })? _pendingRemote; @@ -560,7 +562,7 @@ class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) { _replaceSource(); @@ -604,11 +606,14 @@ class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { if (!requestAbort.isCompleted) requestAbort.complete(); return null; } - final request = http.AbortableStreamedRequest( - 'GET', - uri, - abortTrigger: requestAbort.future, - )..headers.addAll(remote.headers()); + final request = + http.AbortableStreamedRequest( + 'GET', + uri, + abortTrigger: requestAbort.future, + ) + ..followRedirects = false + ..headers.addAll(await remote.headers()); final response = await _client .send(request) .timeout( @@ -838,7 +843,8 @@ class DeviceVoiceNotePlayerController extends VoiceNotePlayerController { } } else { await _load( - () => _player.setUrl(remote.url, headers: remote.headers()), + () async => + _player.setUrl(remote.url, headers: await remote.headers()), fallbackDuration: remote.fallbackDuration, sourceGeneration: sourceGeneration, playbackOperationGeneration: playbackOperationGeneration, diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index 1ed3c9218d2..2bd5783e883 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -1,7 +1,7 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; import '../../shared/relay/relay.dart'; import 'user_status.dart'; @@ -32,16 +32,8 @@ class UserStatusNotifier extends AsyncNotifier { Future _fetch() async { final config = ref.read(relayConfigProvider); final nsec = config.nsec; - if (nsec == null || nsec.isEmpty) return null; - - String pubkey; - try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - final keyPair = nostr.Keys(privkeyHex); - pubkey = keyPair.public.toLowerCase(); - } catch (_) { - return null; - } + final pubkey = pubkeyFromNsec(nsec); + if (pubkey == null) return null; final sessionState = ref.read(relaySessionProvider); if (sessionState.status != SessionStatus.connected) return null; @@ -81,7 +73,7 @@ class UserStatusNotifier extends AsyncNotifier { final trimmed = text.trim(); final config = ref.read(relayConfigProvider); final nsec = config.nsec; - if (nsec == null || nsec.isEmpty) return; + if (!enterpriseEnabled && (nsec == null || nsec.isEmpty)) return; final tags = >[ ['d', 'general'], @@ -93,13 +85,11 @@ class UserStatusNotifier extends AsyncNotifier { tags.add(['expiration', '${expiresAt.millisecondsSinceEpoch ~/ 1000}']); } - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - final event = nostr.Event.from( + final event = await signClientEvent( kind: EventKind.userStatus, content: trimmed, tags: tags, - secretKey: privkeyHex, - verify: false, + nsec: nsec, ); final session = ref.read(relaySessionProvider.notifier); @@ -120,8 +110,8 @@ class UserStatusNotifier extends AsyncNotifier { _scheduleExpiration(newStatus); // Also update the shared cache so other UI reads stay consistent. - final keyPair = nostr.Keys(privkeyHex); - final pubkey = keyPair.public.toLowerCase(); + final pubkey = pubkeyFromNsec(nsec); + if (pubkey == null) return; ref.read(userStatusCacheProvider.notifier).updateStatus(pubkey, newStatus); } @@ -160,16 +150,8 @@ class UserStatusNotifier extends AsyncNotifier { } } - String? _currentPubkey() { - final nsec = ref.read(relayConfigProvider).nsec; - if (nsec == null || nsec.isEmpty) return null; - try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - return nostr.Keys(privkeyHex).public.toLowerCase(); - } catch (_) { - return null; - } - } + String? _currentPubkey() => + pubkeyFromNsec(ref.read(relayConfigProvider).nsec); } final userStatusProvider = diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 3253e2212e3..83ba3adb4e1 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/enterprise_identity.dart'; import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -75,6 +76,15 @@ class SettingsPage extends HookConsumerWidget { ); Future showEditProfileSheet() async { + if (enterpriseEnabled) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Your organization manages your profile.'), + ), + ); + return; + } + final action = await showBuzzModalBottomSheet<_ProfileEditAction>( context: context, title: 'Edit profile', diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 7e14fb559c5..299fb9d59ff 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -16,6 +16,14 @@ class _ConnectionSection extends ConsumerWidget { label: 'Connection', verticalPadding: Grid.twelve, children: [ + if (enterpriseEnabled) + const Padding( + padding: EdgeInsets.all(Grid.xs), + child: Text( + 'Your organization holds your signing key. Private-key export, backup, and pairing are unavailable.', + ), + ), + if (nsec != null && nsec.isNotEmpty && community != null) ...[ _IdentityRow(nsec: nsec), AppListRow( diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index 9dc17e1e741..e5e7faa0615 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'enterprise_identity.dart'; import '../community/community.dart'; import '../community/community_provider.dart'; @@ -18,6 +19,30 @@ class AuthState { class AuthNotifier extends AsyncNotifier { @override Future build() async { + if (enterpriseEnabled) { + final revision = EnterpriseIdentity.instance.revision; + void changed() { + ref.invalidateSelf(); + } + + revision.addListener(changed); + ref.onDispose(() => revision.removeListener(changed)); + await EnterpriseIdentity.instance.restore(); + final identity = EnterpriseIdentity.instance; + if (!identity.authenticated) { + return const AuthState(status: AuthStatus.unauthenticated); + } + final community = Community( + id: 'enterprise-${identity.pubkey}', + name: 'Work', + relayUrl: identity.relayUrl!, + pubkey: identity.pubkey, + addedAt: DateTime.now(), + ); + await syncCommunitySnapshot(ref, [community]); + return AuthState(status: AuthStatus.authenticated, community: community); + } + // Read from storage directly — NOT from community providers. // Watching community providers here would create a circular dependency // because authenticateWithCommunity() writes to those providers. @@ -57,6 +82,9 @@ class AuthNotifier extends AsyncNotifier { /// Writes to storage directly to avoid circular dependency with community /// providers. Future authenticateWithCommunity(Community community) { + if (enterpriseEnabled) { + throw StateError('This build requires corporate login'); + } return ref.read(communityTransitionProvider).runExclusive(() async { await ref.read(communityTransitionProvider).run(); final storage = ref.read(communityStorageProvider); @@ -75,6 +103,14 @@ class AuthNotifier extends AsyncNotifier { } Future signOut() { + if (enterpriseEnabled) { + return () async { + await EnterpriseIdentity.instance.logout(); + await syncCommunitySnapshot(ref, []); + state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + }(); + } + return () async { final storage = ref.read(communityStorageProvider); await ref diff --git a/mobile/lib/shared/auth/enterprise_identity.dart b/mobile/lib/shared/auth/enterprise_identity.dart new file mode 100644 index 00000000000..c692773a76e --- /dev/null +++ b/mobile/lib/shared/auth/enterprise_identity.dart @@ -0,0 +1,423 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Non-secret release configuration; absence preserves OSS self-custody. +const enterpriseBuildConfig = String.fromEnvironment('BUZZ_BUILD_ENTERPRISE'); +bool get enterpriseEnabled => enterpriseBuildConfig.isNotEmpty; + +/// One app-owned identity, scoped to the fixed enterprise community. Never contains a Nostr private key. +class EnterpriseIdentity { + EnterpriseIdentity({ + http.Client? client, + FlutterSecureStorage? storage, + String config = enterpriseBuildConfig, + }) : _configuration = config, + _client = client ?? http.Client(), + _storage = storage ?? const FlutterSecureStorage(); + static final instance = EnterpriseIdentity(); + final String _configuration; + final http.Client _client; + final FlutterSecureStorage _storage; + final revision = ValueNotifier(0); + Map? _tokens; + Map? _identity; + Future? _refreshing; + Future? _restoring; + bool _loggingIn = false; + int _generation = 0; + bool get authenticated => _tokens != null && _identity != null; + String? get pubkey => _identity?['pubkey'] as String?; + String? get relayUrl => _identity?['relayHttpUrl'] as String?; + Map get _config { + final config = jsonDecode(_configuration) as Map; + for (final key in ['signerUrl', 'issuer']) { + final url = Uri.parse(config[key] as String); + if (url.scheme != 'https' || + url.host.isEmpty || + url.userInfo.isNotEmpty || + url.hasQuery || + url.hasFragment || + (key == 'issuer' && url.path != '' && url.path != '/')) { + throw StateError('Invalid enterprise build endpoint'); + } + } + for (final key in [ + 'clientId', + 'audience', + 'organization', + 'connection', + 'redirectUri', + ]) { + if (config[key] is! String || (config[key] as String).isEmpty) { + throw StateError('Incomplete enterprise build configuration'); + } + } + final redirect = Uri.parse(config['redirectUri'] as String); + if (redirect.scheme != 'buzz' || + redirect.host != 'enterprise-login' || + redirect.hasQuery || + redirect.hasFragment) { + throw StateError('Invalid enterprise callback'); + } + return config; + } + + String _endpoint(String key, String path) => + '${(_config[key] as String).replaceFirst(RegExp(r'/+$'), '')}/$path'; + + Future> _post( + String url, + Map body, { + String? token, + }) async { + final abort = Completer(); + final request = http.AbortableRequest( + 'POST', + Uri.parse(url), + abortTrigger: abort.future, + )..followRedirects = false; + request.headers.addAll({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + if (token != null) 'Authorization': 'Bearer $token', + }); + request.body = jsonEncode(body); + return (() async { + final response = await _client.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + await response.stream.listen((_) {}).cancel(); + throw StateError( + 'Corporate request rejected (${response.statusCode}); sign in again', + ); + } + final bytes = BytesBuilder(copy: false); + await for (final chunk in response.stream) { + if (bytes.length + chunk.length > 256 * 1024) { + throw StateError('Corporate response too large'); + } + bytes.add(chunk); + } + return jsonDecode(utf8.decode(bytes.takeBytes())) as Map; + })().timeout(const Duration(seconds: 15)).whenComplete(() { + if (!abort.isCompleted) abort.complete(); + }); + } + + void _validateTokens(Map tokens) { + if (tokens['access_token'] is! String || + (tokens['access_token'] as String).isEmpty || + (tokens['access_token'] as String).length > 16384 || + tokens['token_type']?.toString().toLowerCase() != 'bearer' || + tokens['expires_in'] is! int || + tokens['expires_in'] < 1 || + tokens['expires_in'] > 300) { + throw StateError('Invalid corporate credentials'); + } + final refresh = tokens['refresh_token']; + if (refresh != null && + (refresh is! String || refresh.isEmpty || refresh.length > 16384)) { + throw StateError('Invalid corporate refresh credential'); + } + tokens['expiresAt'] = + DateTime.now().millisecondsSinceEpoch ~/ 1000 + + (tokens['expires_in'] as int); + } + + Map _validateIdentity(Map identity) { + if (identity['pubkey'] is! String || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(identity['pubkey'] as String)) { + throw StateError('Invalid corporate identity'); + } + final relay = Uri.parse(identity['relayHttpUrl'] as String); + if (relay.scheme != 'https' || + relay.host.isEmpty || + relay.userInfo.isNotEmpty || + relay.hasQuery || + relay.hasFragment || + (relay.path != '' && relay.path != '/') || + identity['relayWsUrl'] != 'wss://${relay.authority}') { + throw StateError('Invalid corporate community'); + } + return identity; + } + + Future restore() => + _restoring ??= _restore().whenComplete(() => _restoring = null); + + Future _restore() async { + if (_configuration.isEmpty || _identity != null) return; + final generation = _generation; + final encoded = await _storage.read(key: 'buzz.enterprise.session'); + if (encoded == null || generation != _generation) return; + final saved = jsonDecode(encoded) as Map; + // Credentials never cross build-selected issuer/client/signer boundaries. + if (saved['config'] != _configuration) { + throw StateError('Corporate build changed; sign in again'); + } + _identity = _validateIdentity( + Map.from(saved['identity'] as Map), + ); + _tokens = Map.from(saved['tokens'] as Map); + if (_tokens?['access_token'] is! String || _tokens?['expiresAt'] is! int) { + _tokens = null; + _identity = null; + throw StateError('Invalid saved corporate credentials'); + } + try { + await accessToken(); + } catch (_) { + _identity = null; + _tokens = null; + rethrow; + } + } + + Future _persist( + Map tokens, + Map identity, + ) => _storage.write( + key: 'buzz.enterprise.session', + value: jsonEncode({ + 'config': _configuration, + 'tokens': tokens, + 'identity': identity, + }), + ); + + Future accessToken() async { + final tokens = _tokens; + if (tokens == null) throw StateError('Corporate login required'); + if ((tokens['expiresAt'] as int) <= + DateTime.now().millisecondsSinceEpoch ~/ 1000 + 30) { + _refreshing ??= _refresh().whenComplete(() => _refreshing = null); + await _refreshing; + } + return _tokens?['access_token'] as String? ?? + (throw StateError('Corporate login required')); + } + + Future _refresh() async { + final generation = _generation; + try { + final refresh = _tokens?['refresh_token'] as String?; + if (refresh == null) { + throw StateError('Corporate session expired; sign in again'); + } + await _storage.delete(key: 'buzz.enterprise.session'); + final next = await _post(_endpoint('issuer', 'oauth/token'), { + 'grant_type': 'refresh_token', + 'client_id': _config['clientId'], + 'refresh_token': refresh, + }); + _validateTokens(next); + final identity = _validateIdentity( + await _post( + _endpoint('signerUrl', 'v1/buzz/enterprise-signer/session'), + {}, + token: next['access_token'] as String, + ), + ); + if (generation != _generation || + identity['pubkey'] != pubkey || + identity['relayHttpUrl'] != relayUrl) { + throw StateError('Corporate account changed'); + } + await _persist(next, identity); + if (generation != _generation) { + throw StateError('Corporate account changed'); + } + _tokens = next; + } catch (_) { + if (generation == _generation) { + _tokens = null; + revision.value++; + await _storage.delete(key: 'buzz.enterprise.session'); + } + rethrow; // Never retry a potentially consumed rotating refresh token automatically. + } + } + + Future login() async { + if (_loggingIn) throw StateError('Corporate login already in progress'); + _loggingIn = true; + // Drain any rotating refresh before a new login can replace the same secure-storage record. + try { + await _refreshing; + } catch (_) { + /* A new browser login is the recovery path. */ + } + final generation = ++_generation; + StreamSubscription? subscription; + try { + final config = _config; + final random = Random.secure(); + String randomToken() => base64Url + .encode(List.generate(32, (_) => random.nextInt(256))) + .replaceAll('=', ''); + final verifier = randomToken(), state = randomToken(); + final challenge = base64Url + .encode( + SHA256Digest().process(Uint8List.fromList(ascii.encode(verifier))), + ) + .replaceAll('=', ''); + final redirect = Uri.parse(config['redirectUri'] as String); + final callback = Completer(); + subscription = AppLinks().uriLinkStream.listen((uri) { + if (uri.scheme != redirect.scheme || + uri.host != redirect.host || + uri.path != redirect.path || + uri.hasFragment) { + return; + } + if (uri.queryParametersAll['state']?.length != 1 || + uri.queryParameters['state'] != state || + uri.queryParametersAll['code']?.length != 1 || + uri.queryParameters.containsKey('error')) { + return; + } + if (!callback.isCompleted) callback.complete(uri); + }); + final authorize = Uri.parse(_endpoint('issuer', 'authorize')).replace( + queryParameters: { + 'response_type': 'code', + 'client_id': config['clientId'], + 'redirect_uri': redirect.toString(), + 'audience': config['audience'], + 'organization': config['organization'], + 'connection': config['connection'], + 'scope': 'openid profile email offline_access', + 'state': state, + 'code_challenge_method': 'S256', + 'code_challenge': challenge, + }, + ); + if (!await launchUrl(authorize, mode: LaunchMode.externalApplication)) { + throw StateError('Cannot open corporate login'); + } + final uri = await callback.future.timeout(const Duration(minutes: 5)); + final tokens = await _post(_endpoint('issuer', 'oauth/token'), { + 'grant_type': 'authorization_code', + 'client_id': config['clientId'], + 'redirect_uri': redirect.toString(), + 'code': uri.queryParameters['code'], + 'code_verifier': verifier, + }); + _validateTokens(tokens); + final identity = _validateIdentity( + await _post( + _endpoint('signerUrl', 'v1/buzz/enterprise-signer/session'), + {}, + token: tokens['access_token'] as String, + ), + ); + if (generation != _generation) { + throw StateError('Corporate login changed'); + } + await _persist(tokens, identity); + if (generation != _generation) { + await _storage.delete(key: 'buzz.enterprise.session'); + throw StateError('Corporate login changed'); + } + _tokens = tokens; + _identity = identity; + revision.value++; + } finally { + await subscription?.cancel(); + _loggingIn = false; + } + } + + Future logout() async { + _generation++; + try { + await _refreshing; + } catch (_) { + /* Still clear credentials below. */ + } + _tokens = null; + _identity = null; + revision.value++; + await _storage.delete(key: 'buzz.enterprise.session'); + } + + Future sign({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }) async { + final generation = _generation; + final expected = pubkey; + if (expected == null) throw StateError('Corporate login required'); + final template = { + 'kind': kind, + 'content': content, + 'tags': tags.map((t) => List.of(t)).toList(), + 'created_at': createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000, + }; + final purpose = kind == 22242 + ? 'nip42-auth' + : kind == 27235 + ? 'http-auth' + : kind == 24242 + ? (tags.any((t) => t.length > 1 && t[0] == 't' && t[1] == 'upload') + ? 'media-upload' + : 'media-read') + : 'publish'; + final response = await _post( + _endpoint('signerUrl', 'v1/buzz/enterprise-signer/events/sign'), + {'purpose': purpose, 'event': template}, + token: await accessToken(), + ); + final event = nostr.Event.fromJson( + jsonEncode(response['event']), + ); // Constructor verifies id and Schnorr signature. + if (generation != _generation || + event.pubkey != expected || + event.kind != kind || + event.content != content || + event.createdAt != template['created_at'] || + jsonEncode(event.tags) != jsonEncode(template['tags'])) { + throw StateError('Corporate signature or identity mismatch'); + } + return event; + } +} + +/// Central signing seam. In enterprise builds nsec is ignored, never used as fallback. +Future signClientEvent({ + required String? nsec, + required int kind, + required String content, + required List> tags, + int? createdAt, +}) async { + if (enterpriseEnabled) { + return EnterpriseIdentity.instance.sign( + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ); + } + if (nsec == null || nsec.isEmpty) throw StateError('No signing identity'); + final key = nostr.Nip19.decode(payload: nsec).data; + return nostr.Event.from( + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + secretKey: key, + verify: false, + ); +} diff --git a/mobile/lib/shared/auth/enterprise_login_page.dart b/mobile/lib/shared/auth/enterprise_login_page.dart new file mode 100644 index 00000000000..8e1daa761e5 --- /dev/null +++ b/mobile/lib/shared/auth/enterprise_login_page.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../theme/grid.dart'; +import 'auth_provider.dart'; +import 'enterprise_identity.dart'; + +/// Corporate builds never offer local-key pairing as a fallback. +class EnterpriseLoginPage extends HookConsumerWidget { + const EnterpriseLoginPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final busy = useState(false); + final error = useState(null); + return Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Sign in to Buzz for work'), + const SizedBox(height: Grid.xs), + const Text( + 'Your organization manages your identity and community.', + ), + if (error.value != null) + Semantics(liveRegion: true, child: Text(error.value!)), + const SizedBox(height: Grid.sm), + FilledButton( + onPressed: busy.value + ? null + : () async { + busy.value = true; + error.value = null; + try { + await EnterpriseIdentity.instance.login(); + ref.invalidate(authProvider); + } catch (_) { + if (context.mounted) { + error.value = + 'Sign-in did not complete. Try again.'; + } + } finally { + if (context.mounted) busy.value = false; + } + }, + child: Text( + busy.value + ? 'Waiting for corporate sign-in…' + : 'Sign in with corporate account', + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 7788082745e..b620e3215f7 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,3 +1,4 @@ +import '../auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:developer' as developer; import 'dart:math'; @@ -235,6 +236,27 @@ class CommunityListNotifier extends AsyncNotifier> { @override Future> build() async { + if (enterpriseEnabled) { + final identity = EnterpriseIdentity.instance; + void changed() { + ref.invalidateSelf(); + } + + identity.revision.addListener(changed); + ref.onDispose(() => identity.revision.removeListener(changed)); + await identity.restore(); + if (!identity.authenticated) return []; + return [ + Community( + id: 'enterprise-${identity.pubkey}', + name: 'Work', + relayUrl: identity.relayUrl!, + pubkey: identity.pubkey, + addedAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ]; + } + final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); await syncCommunitySnapshot(ref, communities); @@ -244,6 +266,9 @@ class CommunityListNotifier extends AsyncNotifier> { /// Add a community. If one with the same relay URL already exists, update /// its credentials instead. Returns the effective community ID. Future addCommunity(Community community) async { + if (enterpriseEnabled) { + throw StateError('Corporate community is release-managed'); + } final storage = ref.read(communityStorageProvider); final current = state.value ?? []; @@ -595,6 +620,7 @@ final communityListProvider = /// the community list. final activeCommunityProvider = FutureProvider((ref) async { final communities = await ref.watch(communityListProvider.future); + if (enterpriseEnabled) return communities.firstOrNull; final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); diff --git a/mobile/lib/shared/huddle/huddle_auth.dart b/mobile/lib/shared/huddle/huddle_auth.dart index f6829c7a121..9224d8cbd48 100644 --- a/mobile/lib/shared/huddle/huddle_auth.dart +++ b/mobile/lib/shared/huddle/huddle_auth.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; +import '../auth/enterprise_identity.dart'; import 'huddle_wire.dart'; /// Immutable connection inputs for one Huddle audio WebSocket. @@ -37,7 +38,7 @@ final class HuddleConnectionParameters { } _validateUuid(parentChannelId, 'parentChannelId'); _validateUuid(ephemeralChannelId, 'ephemeralChannelId'); - if (nsec.trim().isEmpty) { + if (!enterpriseEnabled && nsec.trim().isEmpty) { throw ArgumentError.value(nsec, 'nsec', 'must not be empty'); } } @@ -56,6 +57,36 @@ final class HuddleConnectionParameters { /// Fixed NIP-42 + Huddle auth envelope used after the audio relay challenge. abstract final class HuddleAuthV2 { + static Future> buildMessageAsync({ + required HuddleConnectionParameters parameters, + required String challenge, + int? createdAt, + }) async { + if (!enterpriseEnabled) { + return buildMessage( + parameters: parameters, + challenge: challenge, + createdAt: createdAt, + ); + } + final event = await signClientEvent( + nsec: null, + kind: 22242, + content: '', + createdAt: createdAt, + tags: [ + ['relay', parameters.relayWebSocketUrl], + ['challenge', challenge], + ], + ); + return { + 'type': 'auth', + 'event': event.toMap(), + 'parent_channel_id': parameters.parentChannelId, + 'protocol_version': HuddleWireV2.protocolVersion, + }; + } + static Map buildMessage({ required HuddleConnectionParameters parameters, required String challenge, diff --git a/mobile/lib/shared/huddle/huddle_transport.dart b/mobile/lib/shared/huddle/huddle_transport.dart index 87ed660d048..127735241be 100644 --- a/mobile/lib/shared/huddle/huddle_transport.dart +++ b/mobile/lib/shared/huddle/huddle_transport.dart @@ -391,7 +391,7 @@ final class HuddleTransport implements HuddleTransportClient { } } - void _handleChallenge(Map message, int generation) { + void _handleChallenge(Map message, int generation) async { if (_state.phase != HuddleTransportPhase.awaitingChallenge) { _handleProtocolProblem('Unexpected Huddle auth challenge.', generation); return; @@ -409,10 +409,11 @@ final class HuddleTransport implements HuddleTransportClient { } try { - final auth = HuddleAuthV2.buildMessage( + final auth = await HuddleAuthV2.buildMessageAsync( parameters: parameters, challenge: challenge, ); + if (generation != _generation) return; _channel?.sink.add(jsonEncode(auth)); _emitState( HuddleTransportState( diff --git a/mobile/lib/shared/read_state/read_state_manager.dart b/mobile/lib/shared/read_state/read_state_manager.dart index f8dcb878db6..f1eecd11fcd 100644 --- a/mobile/lib/shared/read_state/read_state_manager.dart +++ b/mobile/lib/shared/read_state/read_state_manager.dart @@ -13,7 +13,9 @@ import 'read_state_storage.dart'; import 'read_state_time.dart'; class ReadStateCrypto { - final Uint8List conversationKey; + final Uint8List? conversationKey; + + const ReadStateCrypto.localOnly() : conversationKey = null; const ReadStateCrypto._(this.conversationKey); @@ -33,10 +35,21 @@ class ReadStateCrypto { } } - String encrypt(String plaintext) => nip44Encrypt(conversationKey, plaintext); - - String decrypt(String ciphertext) => - nip44Decrypt(conversationKey, ciphertext); + String encrypt(String plaintext) => nip44Encrypt( + conversationKey ?? + (throw StateError( + 'Read-state sync is unavailable for corporate identities', + )), + plaintext, + ); + + String decrypt(String ciphertext) => nip44Decrypt( + conversationKey ?? + (throw StateError( + 'Read-state sync is unavailable for corporate identities', + )), + ciphertext, + ); } enum _ApplyRemoteContextResult { unchanged, advanced } diff --git a/mobile/lib/shared/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart index 22375db959e..b7b79ff9660 100644 --- a/mobile/lib/shared/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -1,3 +1,4 @@ +import '../auth/enterprise_identity.dart'; import 'dart:async'; import 'package:flutter/widgets.dart'; @@ -79,7 +80,7 @@ class ReadStateNotifier extends Notifier { final activeCommunity = ref.watch(activeCommunityProvider).value; final nsec = relayConfig.nsec?.trim(); - if (nsec == null || nsec.isEmpty) { + if (!enterpriseEnabled && (nsec == null || nsec.isEmpty)) { return const ReadStateState.inert(); } @@ -94,7 +95,9 @@ class ReadStateNotifier extends Notifier { return const ReadStateState.inert(); } - final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey); + final crypto = enterpriseEnabled + ? const ReadStateCrypto.localOnly() + : ReadStateCrypto.tryCreate(nsec: nsec!, pubkey: pubkey); if (crypto == null) { return const ReadStateState.inert(); } @@ -107,7 +110,7 @@ class ReadStateNotifier extends Notifier { crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), signedEventRelay: signedRelay, - remoteEnabled: true, + remoteEnabled: !enterpriseEnabled, onChanged: () => _emitManagerState(manager), ); _manager = manager; diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d6..db2ed87bf23 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../auth/enterprise_identity.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -47,7 +48,56 @@ class MediaGetAuthService { return _isRelayMediaUrl(uri, relayUri); } + Future>? _pending; + final String? _corporatePubkey = enterpriseEnabled + ? EnterpriseIdentity.instance.pubkey + : null; + + Future> headersForAsync(String url) async { + if (!enterpriseEnabled) return headersFor(url); + if (!isRelayMediaUrl(url)) return const {}; + if (!EnterpriseIdentity.instance.authenticated || + _corporatePubkey != EnterpriseIdentity.instance.pubkey) { + throw StateError('Corporate media identity changed'); + } + if (_cachedHeaders != null && + _refreshAt != null && + _now().isBefore(_refreshAt!)) { + return _cachedHeaders!; + } + return _pending ??= _refreshCorporate().whenComplete(() => _pending = null); + } + + Future> _refreshCorporate() async { + final expires = _now().millisecondsSinceEpoch ~/ 1000 + 120; + final event = await signClientEvent( + nsec: null, + kind: 24242, + content: 'Get buzz-media', + tags: [ + ['t', 'get'], + ['server', Uri.parse(_baseUrl).authority], + ['expiration', '$expires'], + ], + ); + if (!EnterpriseIdentity.instance.authenticated || + _corporatePubkey != EnterpriseIdentity.instance.pubkey) { + throw StateError('Corporate media identity changed'); + } + if (_now().millisecondsSinceEpoch ~/ 1000 >= expires - 15) { + throw StateError('Media credential expired while signing'); + } + _cachedHeaders = Map.unmodifiable({ + 'Authorization': 'Nostr ${base64.encode(utf8.encode(event.toJson()))}', + }); + _refreshAt = DateTime.fromMillisecondsSinceEpoch((expires - 15) * 1000); + return _cachedHeaders!; + } + Map headersFor(String url) { + if (enterpriseEnabled) { + throw StateError('Corporate media requires asynchronous credentials'); + } final nsec = _nsec; if (nsec == null || nsec.isEmpty) return const {}; if (!isRelayMediaUrl(url)) return const {}; diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 0ab5b5aa204..4ced2112a93 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -97,7 +97,9 @@ class MediaImageProvider extends ImageProvider { final uri = Uri.parse(url); final http.Response response; try { - response = await client.get(uri, headers: auth.headersFor(url)); + final request = http.Request('GET', uri)..followRedirects = false; + request.headers.addAll(await auth.headersForAsync(url)); + response = await http.Response.fromStream(await client.send(request)); } catch (_) { _cooldownUntil[url] = debugNow().add(_defaultCooldown); rethrow; diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 4f79080efd8..45b8efadbf2 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,3 +1,4 @@ +import '../auth/enterprise_identity.dart'; import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -637,9 +638,10 @@ class MediaUploadService { Uri.parse(_baseUrl).resolve(path), abortTrigger: cancellationToken?.whenCancelled, ); + request.followRedirects = false; request.contentLength = bytes.length; request.headers.addAll( - _buildUploadHeaders(mimeType: mimeType, sha256: sha256), + await _buildUploadHeaders(mimeType: mimeType, sha256: sha256), ); final writeRequest = request.sink .addStream(_uploadByteStream(bytes, onProgress)) @@ -656,19 +658,38 @@ class MediaUploadService { } } - Map _buildUploadHeaders({ + Future> _buildUploadHeaders({ required String mimeType, required String sha256, - }) { + }) async { final headers = { - 'Authorization': _buildUploadAuthHeader(sha256), + 'Authorization': await _buildUploadAuthHeader(sha256), 'Content-Type': mimeType, 'X-SHA-256': sha256, }; return headers; } - String _buildUploadAuthHeader(String sha256) { + Future _buildUploadAuthHeader(String sha256) async { + if (enterpriseEnabled) { + if (Uri.parse(_baseUrl).origin != + Uri.parse(EnterpriseIdentity.instance.relayUrl!).origin) { + throw StateError('Corporate media scope mismatch'); + } + final event = await signClientEvent( + nsec: null, + kind: 24242, + content: 'Upload buzz-media', + tags: [ + ['t', 'upload'], + ['x', sha256], + ['server', Uri.parse(_baseUrl).authority], + ['expiration', '${_now().millisecondsSinceEpoch ~/ 1000 + 300}'], + ], + ); + return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; + } + final authEvent = _buildUploadAuthEvent(sha256); final authJson = authEvent.toJson(); final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', ''); diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 00fcf65b716..233aaf7f60e 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -3,6 +3,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../community/community_provider.dart'; import 'relay_client.dart'; +import '../auth/enterprise_identity.dart'; /// Relay connection configuration. /// @@ -80,6 +81,14 @@ class Env { class RelayConfigNotifier extends Notifier { @override RelayConfig build() { + if (enterpriseEnabled) { + ref.watch(activeCommunityProvider); + return RelayConfig( + baseUrl: + EnterpriseIdentity.instance.relayUrl ?? 'https://invalid.example', + ); + } + // Watch the active community so that when it changes (community switch), // the config rebuilds, triggering the full provider cascade. final activeAsync = ref.watch(activeCommunityProvider); @@ -93,6 +102,9 @@ class RelayConfigNotifier extends Notifier { } void update({required String baseUrl, String? nsec}) { + if (enterpriseEnabled) { + throw StateError('Corporate community is release-managed'); + } state = RelayConfig(baseUrl: baseUrl, nsec: nsec); } } @@ -103,6 +115,7 @@ final relayConfigProvider = NotifierProvider( /// Derive the hex pubkey from a bech32 nsec, or null on any failure. String? pubkeyFromNsec(String? nsec) { + if (enterpriseEnabled) return EnterpriseIdentity.instance.pubkey; if (nsec == null || nsec.isEmpty) return null; try { final privkeyHex = nostr.Nip19.decode(payload: nsec).data; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 6a787cce129..b7eab42acaf 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; +import '../auth/enterprise_identity.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_closed_policy.dart'; @@ -141,7 +142,7 @@ class RelaySessionNotifier extends Notifier { // Auto-connect when authenticated and we have a signing key (NIP-42 AUTH). final isAuthenticated = authState.value?.status == AuthStatus.authenticated; - if (isAuthenticated && config.nsec != null) { + if (isAuthenticated && (config.nsec != null || enterpriseEnabled)) { // Schedule connection after build completes. Future.microtask(() => _connect(config)); } @@ -164,12 +165,19 @@ class RelaySessionNotifier extends Notifier { final response = await _httpQueryClient.post( Uri.parse(url), headers: { - 'Authorization': buildNip98AuthHeader( - method: 'POST', - url: url, - bodyBytes: bodyBytes, - nsec: config.nsec, - ), + 'Authorization': enterpriseEnabled + ? await buildClientNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: bodyBytes, + nsec: null, + ) + : buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: bodyBytes, + nsec: config.nsec, + ), 'Content-Type': 'application/json', }, body: bodyBytes, diff --git a/mobile/lib/shared/relay/relay_session_auth.dart b/mobile/lib/shared/relay/relay_session_auth.dart index 9c6c79ae02d..5f2e8e7e230 100644 --- a/mobile/lib/shared/relay/relay_session_auth.dart +++ b/mobile/lib/shared/relay/relay_session_auth.dart @@ -31,3 +31,35 @@ String buildNip98AuthHeader({ ); return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; } + +Future buildClientNip98AuthHeader({ + required String method, + required String url, + required List bodyBytes, + required String? nsec, +}) async { + if (!enterpriseEnabled) { + return buildNip98AuthHeader( + method: method, + url: url, + bodyBytes: bodyBytes, + nsec: nsec, + ); + } + final hash = SHA256Digest() + .process(Uint8List.fromList(bodyBytes)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final event = await signClientEvent( + nsec: null, + kind: 27235, + content: '', + tags: [ + ['u', url], + ['method', method.toUpperCase()], + ['payload', hash], + ['nonce', const Uuid().v4()], + ], + ); + return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; +} diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 5b23279e814..c700787f492 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -2,11 +2,11 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:nostr/nostr.dart' as nostr; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; +import '../auth/enterprise_identity.dart'; /// Low-level websocket connection with NIP-42 authentication. /// @@ -192,37 +192,25 @@ class RelaySocket { } /// Handle the relay's AUTH challenge: sign a kind:22242 event and respond. - void _handleAuthChallenge(List data) { + void _handleAuthChallenge(List data) async { if (data.length < 2) return; final challenge = data[1] as String; - if (_nsec == null) { - _failAuth(Exception('No nsec available for NIP-42 auth')); - return; - } - + final channel = _channel; try { - // Decode bech32 nsec to hex private key. - final privkeyHex = nostr.Nip19.decode(payload: _nsec).data; - if (privkeyHex.isEmpty) { - _failAuth(Exception('Invalid nsec')); - return; - } - - // Build the auth tags. - final tags = >[ - ['relay', _wsUrl], - ['challenge', challenge], - ]; - - // Create and sign the kind:22242 AUTH event. - final event = nostr.Event.from( + final event = await signClientEvent( + nsec: _nsec, kind: EventKind.auth, content: '', - tags: tags, - secretKey: privkeyHex, + tags: [ + ['relay', _wsUrl], + ['challenge', challenge], + ], ); - + if (!identical(channel, _channel) || + _state != SocketState.authenticating) { + return; + } _pendingAuthEventId = event.id; send(['AUTH', event.toMap()]); } catch (e) { diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index b0106639eb7..b3cbb95e27c 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:nostr/nostr.dart' as nostr; +import '../auth/enterprise_identity.dart'; import 'nostr_models.dart'; import 'relay_session.dart'; import 'relay_socket.dart'; @@ -10,15 +11,20 @@ import 'relay_socket.dart'; class SignedEventRelay { final RelaySessionNotifier _session; final String? _nsec; + final String? _corporatePubkey; SignedEventRelay({ required RelaySessionNotifier session, required String? nsec, - }) : _session = session, + }) : _corporatePubkey = enterpriseEnabled + ? EnterpriseIdentity.instance.pubkey + : null, + _session = session, _nsec = nsec; /// The hex pubkey derived from the signing key, or null if no key. String? get pubkey { + if (enterpriseEnabled) return _corporatePubkey; final nsec = _nsec; if (nsec == null || nsec.isEmpty) return null; final privkeyHex = nostr.Nip19.decode(payload: nsec).data; @@ -36,25 +42,23 @@ class SignedEventRelay { int? createdAt, void Function(NostrEvent event)? onSigned, }) async { - final nsec = _nsec; - if (nsec == null || nsec.isEmpty) { - throw Exception('Cannot submit event: no signing key available'); - } - - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) { - throw Exception('Invalid nsec'); + if (enterpriseEnabled && + (_corporatePubkey == null || + _corporatePubkey != EnterpriseIdentity.instance.pubkey)) { + throw StateError('Corporate identity changed before signing'); } - - final event = nostr.Event.from( + final event = await signClientEvent( + nsec: _nsec, kind: kind, content: content, tags: tags, - secretKey: privkeyHex, createdAt: createdAt, - verify: false, ); + if (enterpriseEnabled && + _corporatePubkey != EnterpriseIdentity.instance.pubkey) { + throw StateError('Corporate identity changed before publication'); + } final nostrEvent = NostrEvent.fromJson(event.toMap()); onSigned?.call(nostrEvent); return _session.publish(nostrEvent); @@ -74,15 +78,19 @@ Future submitSignedEventOnce({ int? createdAt, Duration timeout = const Duration(seconds: 12), }) async { - final privateKey = nostr.Nip19.decode(payload: nsec).data; - if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); - final signed = nostr.Event.from( + if (enterpriseEnabled && + wsUrl != + Uri.parse( + EnterpriseIdentity.instance.relayUrl!, + ).replace(scheme: 'wss').toString()) { + throw StateError('Corporate community mismatch'); + } + final signed = await signClientEvent( + nsec: nsec, kind: kind, content: content, tags: tags, - secretKey: privateKey, createdAt: createdAt, - verify: false, ); final event = NostrEvent.fromJson(signed.toMap()); final result = Completer(); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 34a91e7ea3b..2ec76ffdcca 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -512,7 +512,7 @@ class _FakeVoiceNotePlayer extends VoiceNotePlayerController { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) => loadLocal(url, fallbackDuration: fallbackDuration); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 8fe47e88030..3018dbbfeed 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -78,7 +78,7 @@ class _FakeVoiceNotePlayer extends VoiceNotePlayerController { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) => loadLocal(url, fallbackDuration: fallbackDuration); @@ -112,7 +112,7 @@ class _LoadingVoiceNotePlayer extends _FakeVoiceNotePlayer { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) async {} } @@ -140,7 +140,7 @@ class _BufferingVoiceNotePlayer extends _FakeVoiceNotePlayer { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) async {} @@ -211,7 +211,7 @@ class _RetryableVoiceNotePlayer extends _FakeVoiceNotePlayer { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) async {} diff --git a/mobile/test/features/channels/voice_note_recording_test.dart b/mobile/test/features/channels/voice_note_recording_test.dart index 6669cc620cc..477e8f06a0e 100644 --- a/mobile/test/features/channels/voice_note_recording_test.dart +++ b/mobile/test/features/channels/voice_note_recording_test.dart @@ -197,7 +197,7 @@ class _CoordinatedPlayer extends VoiceNotePlayerController { @override Future loadRemote( String url, { - required Map Function() headers, + required FutureOr> Function() headers, required Duration fallbackDuration, }) async {} diff --git a/mobile/test/shared/auth/enterprise_identity_test.dart b/mobile/test/shared/auth/enterprise_identity_test.dart new file mode 100644 index 00000000000..c883b6e968f --- /dev/null +++ b/mobile/test/shared/auth/enterprise_identity_test.dart @@ -0,0 +1,201 @@ +import 'dart:convert'; +import 'package:buzz/shared/auth/enterprise_identity.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final config = jsonEncode({ + 'signerUrl': 'https://signer.example/api', + 'issuer': 'https://login.example/', + 'clientId': 'native', + 'audience': 'signer', + 'organization': 'org', + 'connection': 'okta', + 'redirectUri': 'buzz://enterprise-login', + }); + final keys = nostr.Keys.generate(); + final identity = { + 'pubkey': keys.public, + 'relayHttpUrl': 'https://buzz.example', + 'relayWsUrl': 'wss://buzz.example', + }; + Map tokens(int expiresAt) => { + 'access_token': 'access', + 'refresh_token': 'refresh', + 'token_type': 'Bearer', + 'expires_in': 120, + 'expiresAt': expiresAt, + }; + void seed({required int expiresAt}) { + FlutterSecureStorage.setMockInitialValues({ + 'buzz.enterprise.session': jsonEncode({ + 'config': config, + 'identity': identity, + 'tokens': tokens(expiresAt), + }), + }); + } + + test( + 'restored corporate identity sends only bearer and verifies exact signed event', + () async { + seed(expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120); + final requests = []; + final service = EnterpriseIdentity( + config: config, + client: MockClient((request) async { + requests.add(request); + expect(request.followRedirects, false); + expect(request.headers['Authorization'], 'Bearer access'); + expect(request.headers.containsKey('X-BB-Session-Credential'), false); + final body = jsonDecode(request.body) as Map; + final template = body['event'] as Map; + expect(template.containsKey('pubkey'), false); + final signed = nostr.Event.from( + kind: template['kind'] as int, + content: template['content'] as String, + tags: (template['tags'] as List) + .map((t) => List.from(t as List)) + .toList(), + createdAt: template['created_at'] as int, + secretKey: nostr.Nip19.decode(payload: keys.nsec).data, + verify: false, + ); + return http.Response(jsonEncode({'event': signed.toMap()}), 200); + }), + ); + await service.restore(); + final event = await service.sign( + kind: 9, + content: 'hello', + tags: [ + ['h', 'channel'], + ], + createdAt: 1000, + ); + expect(event.pubkey, keys.public); + expect(event.createdAt, 1000); + expect( + requests.single.url.toString(), + 'https://signer.example/api/v1/buzz/enterprise-signer/events/sign', + ); + }, + ); + test('forged signatures or changed templates fail closed', () async { + for (final forged in [true, false]) { + seed(expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120); + final service = EnterpriseIdentity( + config: config, + client: MockClient((request) async { + final signed = nostr.Event.from( + kind: 9, + content: forged ? 'hello' : 'changed', + tags: [ + ['h', 'channel'], + ], + createdAt: 1000, + secretKey: nostr.Nip19.decode(payload: keys.nsec).data, + verify: false, + ).toMap(); + if (forged) signed['sig'] = '0' * 128; + return http.Response(jsonEncode({'event': signed}), 200); + }), + ); + await service.restore(); + await expectLater( + service.sign( + kind: 9, + content: 'hello', + tags: [ + ['h', 'channel'], + ], + createdAt: 1000, + ), + throwsA(anything), + ); + } + }); + test( + 'refresh is single-flight and rotated credential is persisted before use', + () async { + seed(expiresAt: 0); + var refreshes = 0; + final service = EnterpriseIdentity( + config: config, + client: MockClient((request) async { + if (request.url.path == '/oauth/token') { + refreshes++; + expect(jsonDecode(request.body)['refresh_token'], 'refresh'); + return http.Response( + jsonEncode({ + 'access_token': 'new-access', + 'refresh_token': 'rotated', + 'token_type': 'Bearer', + 'expires_in': 120, + }), + 200, + ); + } + expect(request.headers['Authorization'], 'Bearer new-access'); + return http.Response(jsonEncode(identity), 200); + }), + ); + await service.restore(); + expect( + await Future.wait([service.accessToken(), service.accessToken()]), + ['new-access', 'new-access'], + ); + expect(refreshes, 1); + final saved = jsonDecode( + (await const FlutterSecureStorage().read( + key: 'buzz.enterprise.session', + ))!, + ); + expect(saved['tokens']['refresh_token'], 'rotated'); + }, + ); + test( + 'refresh cannot switch account and denial never uses local keys', + () async { + seed(expiresAt: 0); + final service = EnterpriseIdentity( + config: config, + client: MockClient((request) async { + if (request.url.path == '/oauth/token') { + return http.Response( + jsonEncode({ + 'access_token': 'new-access', + 'refresh_token': 'rotated', + 'token_type': 'Bearer', + 'expires_in': 120, + }), + 200, + ); + } + return http.Response( + jsonEncode({...identity, 'pubkey': nostr.Keys.generate().public}), + 200, + ); + }), + ); + await expectLater(service.restore(), throwsStateError); + expect(service.authenticated, false); + await expectLater(service.accessToken(), throwsStateError); + }, + ); + test( + 'credentials cannot be restored by a different release-selected issuer', + () async { + seed(expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 + 120); + final service = EnterpriseIdentity( + config: config.replaceAll('login.example', 'other.example'), + client: MockClient((_) async => throw StateError('must not transmit')), + ); + await expectLater(service.restore(), throwsStateError); + }, + ); +} diff --git a/web/package.json b/web/package.json index d932a612c8c..be19c5f1be3 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test \"src/**/*.test.mjs\"", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index 0033e6cad5c..e0b5e231fb4 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -6,7 +6,7 @@ import { detectBuzzDownloadPlatform, resolveBuzzDownloadUrlForPlatform, } from "@/shared/lib/buzz-download"; -import { hasNip07Provider } from "@/shared/lib/nostr-signer"; +import { hasDurableSigner } from "@/shared/lib/nostr-signer"; import { relayWsUrl } from "@/shared/lib/relay-url"; import { Button } from "@/shared/ui/button"; import * as React from "react"; @@ -132,7 +132,7 @@ export function InvitePage({ code }: { code: string }) { } }; - const browserSigningAvailable = hasNip07Provider(); + const browserSigningAvailable = hasDurableSigner(); const disabled = policy === undefined || opening || diff --git a/web/src/shared/lib/enterprise-media.test.mjs b/web/src/shared/lib/enterprise-media.test.mjs new file mode 100644 index 00000000000..9c299f5d192 --- /dev/null +++ b/web/src/shared/lib/enterprise-media.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { finalizeEvent, generateSecretKey } from "nostr-tools/pure"; +import { EnterpriseMediaCredentials } from "./enterprise-media.ts"; + +const key = generateSecretKey(); +function harness() { + let now = 1000; + const events = []; + const signer = { + signEvent: async (e) => { + events.push(e); + return finalizeEvent(e, key); + }, + }; + return { + cache: new EnterpriseMediaCredentials( + signer, + "https://buzz.example", + () => now, + ), + events, + advance: (seconds) => { + now += seconds; + }, + }; +} + +test("media read credentials are single-flight, host-scoped and expire", async () => { + const { cache, events, advance } = harness(); + const [first, second] = await Promise.all([ + cache.read("https://buzz.example/a"), + cache.read("https://buzz.example/b"), + ]); + assert.equal(first, second); + assert.equal(events.length, 1); + assert.equal(await cache.read("https://buzz.example/c"), first); + await assert.rejects(cache.read("https://evil.example/a"), /outside/); + await assert.rejects(cache.read("https://buzz.example:444/a"), /outside/); + await assert.rejects(cache.read("https://user@buzz.example/a"), /outside/); + advance(106); + assert.notEqual(await cache.read("https://buzz.example/a"), first); + assert.equal(events.length, 2); +}); + +test("clearing credentials fences in-flight reads and failures remain retryable", async () => { + let finish; + let fail = true; + const signer = { + signEvent: (e) => + fail + ? Promise.reject(Error("login required")) + : new Promise((resolve) => { + finish = () => resolve(finalizeEvent(e, key)); + }), + }; + const cache = new EnterpriseMediaCredentials( + signer, + "https://buzz.example", + () => 1000, + ); + await assert.rejects(cache.read("https://buzz.example/a"), /login/); + fail = false; + const pending = cache.read("https://buzz.example/a"); + cache.clear(); + finish(); + await assert.rejects(pending, /identity changed/); +}); + +test("upload sends a file-bound hash to signer, not bytes", async () => { + const { cache, events } = harness(); + const bytes = new TextEncoder().encode("abc").buffer; + const header = await cache.upload("https://buzz.example/upload", bytes); + assert.ok(header.startsWith("Nostr ")); + assert.deepEqual( + events[0].tags.find((t) => t[0] === "x"), + ["x", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"], + ); + assert.equal(events[0].content, "Upload buzz-media"); + assert.equal(events[0].tags.find((t) => t[0] === "expiration")[1], "1120"); +}); diff --git a/web/src/shared/lib/enterprise-media.ts b/web/src/shared/lib/enterprise-media.ts new file mode 100644 index 00000000000..4cd936b3e76 --- /dev/null +++ b/web/src/shared/lib/enterprise-media.ts @@ -0,0 +1,122 @@ +import type { EnterpriseSigner } from "./enterprise-signer"; +import type { SignedNostrEvent } from "./nostr-signer"; + +function authorization(event: SignedNostrEvent): string { + const bytes = new TextEncoder().encode(JSON.stringify(event)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `Nostr ${btoa(binary)}`; +} + +/** One cache per logged-in signer, never a process-wide or cross-community bearer cache. */ +export class EnterpriseMediaCredentials { + private cached: { header: string; expires: number } | null = null; + private pending: Promise | null = null; + private generation = 0; + private readonly origin: string; + private readonly signer: Pick; + private readonly now: () => number; + + constructor( + signer: Pick, + relayHttpUrl: string, + now: () => number = () => Math.floor(Date.now() / 1000), + ) { + this.signer = signer; + this.now = now; + const url = new URL(relayHttpUrl); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/" + ) + throw new Error("Media credentials require an HTTPS relay origin."); + this.origin = url.origin; + } + + /** Clear on logout, account/community changes and a relay authentication rejection. */ + clear(): void { + this.generation++; + this.cached = null; + this.pending = null; + } + + private target(url: string): URL { + const target = new URL(url); + if ( + target.origin !== this.origin || + target.username || + target.password || + target.hash + ) + throw new Error("Media target is outside the enterprise community."); + return target; + } + + /** Reuse only before expiry; callers must disable redirects when attaching this bearer header. */ + async read(url: string): Promise { + this.target(url); + if (this.cached && this.cached.expires > this.now() + 15) + return this.cached.header; + if (this.pending) return this.pending; + const generation = this.generation; + const expires = this.now() + 120; + const pending = this.signer + .signEvent({ + kind: 24242, + created_at: this.now(), + content: "Get buzz-media", + tags: [ + ["t", "get"], + ["server", new URL(this.origin).host], + ["expiration", String(expires)], + ], + }) + .then((event) => { + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + if (expires <= this.now() + 15) + throw new Error("Media credential expired while signing."); + const header = authorization(event); + this.cached = { header, expires }; + return header; + }) + .finally(() => { + if (this.pending === pending) this.pending = null; + }); + this.pending = pending; + return pending; + } + + /** Hash the exact upload bytes; only the hash goes to the signer, never the file. */ + async upload(url: string, bytes: ArrayBuffer): Promise { + this.target(url); + const generation = this.generation; + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hash = Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + const expires = this.now() + 120; + const event = await this.signer.signEvent({ + kind: 24242, + created_at: this.now(), + content: "Upload buzz-media", + tags: [ + ["t", "upload"], + ["server", new URL(this.origin).host], + ["expiration", String(expires)], + ["x", hash], + ], + }); + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + if (expires <= this.now()) + throw new Error("Media credential expired while signing."); + return authorization(event); + } +} diff --git a/web/src/shared/lib/enterprise-signer.test.mjs b/web/src/shared/lib/enterprise-signer.test.mjs new file mode 100644 index 00000000000..e04f8d199f4 --- /dev/null +++ b/web/src/shared/lib/enterprise-signer.test.mjs @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from "nostr-tools/pure"; +import { EnterpriseSigner } from "./enterprise-signer.ts"; +import { + configureEnterpriseSigner, + hasDurableSigner, + signNostrEvent, +} from "./nostr-signer.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = originalFetch; + configureEnterpriseSigner(null); +}); +const key = generateSecretKey(); +const template = { + kind: 9, + created_at: 1000, + tags: [["h", "channel"]], + content: "hello\u001b 世界", +}; +const session = { + pubkey: getPublicKey(key), + relayWsUrl: "wss://buzz.example", + relayHttpUrl: "https://buzz.example", +}; +function signer() { + return new EnterpriseSigner({ + baseUrl: "https://signer.example/api", + corporateAuthorization: async () => "test-corporate-token", + expectedSession: session, + }); +} +function serve(sign = (e) => finalizeEvent(e, key)) { + globalThis.fetch = async (url, init) => { + assert.equal(init.redirect, "error"); + assert.equal(init.cache, "no-store"); + assert.equal(init.credentials, "omit"); + assert.equal(init.headers.Authorization, "Bearer test-corporate-token"); + return Response.json( + String(url).endsWith("/session") + ? session + : { event: sign(JSON.parse(init.body).event) }, + ); + }; +} + +test("production signing seam uses enterprise identity and preserves retry ID", async () => { + serve(); + configureEnterpriseSigner(signer()); + assert.equal(hasDurableSigner(), true); + const first = await signNostrEvent(template, { requireNip07: true }); + const retry = await signNostrEvent(template, { requireNip07: true }); + assert.equal(first.pubkey, session.pubkey); + assert.equal(first.id, retry.id); +}); +test("failed corporate authorization never falls back to anonymous signing", async () => { + globalThis.fetch = async () => new Response(null, { status: 403 }); + configureEnterpriseSigner(signer()); + await assert.rejects(signNostrEvent(template), /403/); +}); +test("rejects forged or changed signed events", async () => { + serve((e) => ({ ...finalizeEvent(e, key), sig: "0".repeat(128) })); + await assert.rejects(signer().signEvent(template), /invalid signed event/); + serve((e) => finalizeEvent({ ...e, content: "substituted" }, key)); + await assert.rejects(signer().signEvent(template), /invalid signed event/); + serve((e) => finalizeEvent(e, generateSecretKey())); + await assert.rejects(signer().signEvent(template), /invalid signed event/); +}); +test("forbids HTTP, embedded credentials and identity selectors before transmitting", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + throw Error("unexpected"); + }; + for (const baseUrl of [ + "http://signer.example", + "https://user:pass@signer.example", + "https://signer.example?token=x", + ]) { + assert.throws( + () => + new EnterpriseSigner({ + baseUrl, + corporateAuthorization: async () => "corporate-token", + expectedSession: session, + }), + ); + } + await assert.rejects( + signer().signEvent({ ...template, pubkey: session.pubkey }), + /identity/, + ); + assert.equal(calls, 0); +}); +test("rejects oversized responses", async () => { + globalThis.fetch = async () => new Response("x".repeat(256 * 1024 + 1)); + await assert.rejects(signer().session(), /too large/); +}); +test("logout fences an in-flight signature", async () => { + let finish; + const pending = new Promise((resolve) => { + finish = resolve; + }); + configureEnterpriseSigner({ signEvent: () => pending }); + const result = signNostrEvent(template); + configureEnterpriseSigner(null); + finish(finalizeEvent(structuredClone(template), key)); + await assert.rejects(result, /identity changed/); +}); +test("missing corporate credential does not make a network request", async () => { + globalThis.fetch = async () => { + throw Error("should not fetch"); + }; + await assert.rejects( + new EnterpriseSigner({ + baseUrl: "https://signer.example", + corporateAuthorization: async () => "", + expectedSession: session, + }).session(), + /login/, + ); +}); +test("self custody remains available when enterprise mode is unset", async () => { + assert.equal(hasDurableSigner(), false); + assert.ok((await signNostrEvent(template)).sig); +}); + +test("token refresh cannot silently change pinned identity or community", async () => { + let signCalls = 0; + for (const changed of [ + { ...session, pubkey: getPublicKey(generateSecretKey()) }, + { + ...session, + relayWsUrl: "wss://other.example", + relayHttpUrl: "https://other.example", + }, + ]) { + globalThis.fetch = async (url) => { + if (!String(url).endsWith("/session")) signCalls++; + return Response.json(changed); + }; + await assert.rejects( + signer().signEvent(template), + /identity or community changed/, + ); + } + assert.equal(signCalls, 0); +}); + +test("missing corporate token cannot use an otherwise valid app session", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + throw Error("unexpected network request"); + }; + const client = new EnterpriseSigner({ + baseUrl: "https://signer.example", + corporateAuthorization: async () => "", + expectedSession: session, + }); + await assert.rejects(client.signEvent(template), /login/); + assert.equal(calls, 0); +}); diff --git a/web/src/shared/lib/enterprise-signer.ts b/web/src/shared/lib/enterprise-signer.ts new file mode 100644 index 00000000000..c2ee0bf32c0 --- /dev/null +++ b/web/src/shared/lib/enterprise-signer.ts @@ -0,0 +1,164 @@ +import { verifyEvent } from "nostr-tools/pure"; +import type { SignedNostrEvent, UnsignedNostrEvent } from "./nostr-signer"; + +export type EnterpriseSignerSession = { + pubkey: string; + relayWsUrl: string; + relayHttpUrl: string; +}; + +/** A bearer credential is obtained by the platform login flow, never persisted by this adapter. */ +export type EnterpriseSignerOptions = { + baseUrl: string; + /** Dedicated short-lived corporate API token, not the long-lived app session. */ + corporateAuthorization: () => Promise; + /** Pin the identity and community established by login; token refresh must not switch either. */ + expectedSession: EnterpriseSignerSession; +}; + +/** Explicit, HTTPS-only corporate signer. Errors never fall back to another identity. */ +export class EnterpriseSigner { + private readonly baseUrl: string; + private readonly corporateAuthorization: () => Promise; + private readonly expectedSession: EnterpriseSignerSession; + + constructor(options: EnterpriseSignerOptions) { + const url = new URL(options.baseUrl); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + "Enterprise signer requires an HTTPS URL without credentials, query, or fragment.", + ); + } + this.baseUrl = url.toString().replace(/\/+$/, ""); + this.corporateAuthorization = options.corporateAuthorization; + this.expectedSession = { ...options.expectedSession }; + } + + private async post(path: string, body: unknown): Promise { + const corporateAuthorization = await this.corporateAuthorization(); + if ( + !corporateAuthorization || + corporateAuthorization.length > 16 * 1024 || + /[\r\n]/.test(corporateAuthorization) + ) + throw new Error("Corporate login is required."); + const response = await fetch( + `${this.baseUrl}/v1/buzz/enterprise-signer/${path}`, + { + method: "POST", + credentials: "omit", + redirect: "error", + cache: "no-store", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${corporateAuthorization}`, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Enterprise signer request failed (${response.status}).`); + } + const reader = response.body?.getReader(); + if (!reader) throw new Error("Empty signer response."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 256 * 1024) throw new Error("Signer response is too large."); + chunks.push(value); + } + } finally { + await reader.cancel(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(bytes)); + } + + async session(): Promise { + const session = (await this.post("session", {})) as EnterpriseSignerSession; + if (!session || !/^[0-9a-f]{64}$/.test(session.pubkey)) + throw new Error("Invalid enterprise identity."); + const relay = new URL(session.relayHttpUrl); + if ( + relay.protocol !== "https:" || + relay.username || + relay.password || + relay.search || + relay.hash || + relay.pathname !== "/" || + session.relayWsUrl !== `wss://${relay.host}` + ) + throw new Error("Invalid enterprise relay scope."); + if ( + session.pubkey !== this.expectedSession.pubkey || + session.relayWsUrl !== this.expectedSession.relayWsUrl || + session.relayHttpUrl !== this.expectedSession.relayHttpUrl + ) + throw new Error( + "Enterprise identity or community changed; login is required.", + ); + return session; + } + + async signEvent(template: UnsignedNostrEvent): Promise { + if ( + Object.keys(template).sort().join(",") !== "content,created_at,kind,tags" + ) { + throw new Error("Signer templates cannot select an identity."); + } + // Own a deep snapshot before the first await; a caller must not change what it asked us to sign in flight. + const event: UnsignedNostrEvent = JSON.parse(JSON.stringify(template)); + const session = await this.session(); + const purpose = + event.kind === 22242 + ? "nip42-auth" + : event.kind === 27235 + ? "http-auth" + : event.kind === 24242 + ? event.tags.some((t) => t[0] === "t" && t[1] === "upload") + ? "media-upload" + : "media-read" + : "publish"; + if ( + purpose === "nip42-auth" && + event.tags + .filter((t) => t[0] === "relay") + .some((t) => t[1] !== session.relayWsUrl) + ) { + throw new Error("Event targets another enterprise relay."); + } + const response = (await this.post("events/sign", { event, purpose })) as { + event: SignedNostrEvent; + }; + const signed = response?.event; + if ( + !signed || + signed.pubkey !== session.pubkey || + signed.kind !== event.kind || + signed.created_at !== event.created_at || + signed.content !== event.content || + JSON.stringify(signed.tags) !== JSON.stringify(event.tags) || + !verifyEvent(signed) + ) { + throw new Error("Enterprise signer returned an invalid signed event."); + } + return signed; + } +} diff --git a/web/src/shared/lib/nostr-signer.ts b/web/src/shared/lib/nostr-signer.ts index a32d3a680b8..57aab30eac7 100644 --- a/web/src/shared/lib/nostr-signer.ts +++ b/web/src/shared/lib/nostr-signer.ts @@ -36,6 +36,23 @@ export class Nip07UnavailableError extends Error { } let ephemeralSecretKey: Uint8Array | null = null; +let enterpriseSigner: Pick | null = null; +let signerGeneration = 0; + +/** Install before connecting. Null explicitly selects self-custody, not enterprise logout. Stores no credentials. */ +export function configureEnterpriseSigner( + signer: Pick | null, +): void { + signerGeneration++; + enterpriseSigner = signer; + ephemeralSecretKey?.fill(0); + ephemeralSecretKey = null; +} + +/** Durable membership may use either corporate custody or a user-owned NIP-07 identity. */ +export function hasDurableSigner(): boolean { + return enterpriseSigner !== null || hasNip07Provider(); +} function getEphemeralSecretKey(): Uint8Array { if (!ephemeralSecretKey) { @@ -79,6 +96,15 @@ export async function signNostrEvent( }; const provider = typeof window === "undefined" ? undefined : window.nostr; + if (enterpriseSigner) { + const generation = signerGeneration; + const signed = await enterpriseSigner.signEvent(unsigned); + if (generation !== signerGeneration) { + throw new Error("Enterprise identity changed while signing."); + } + return signed; + } + if (provider) { const expectedPubkey = await provider.getPublicKey(); const signed = await provider.signEvent(unsigned);