From f86cae61025177e1f4c24605140bcab29c41c2cb Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 12 Sep 2026 00:01:29 -0700 Subject: [PATCH] feat(identity): add release-selected remote managed identity Capture remote signing authority separately from login lifecycle, preserve OSS local custody, and gate local-secret capabilities. Fail closed before corporate media transport and bound managed read proofs to backend policy. Signed-off-by: Bradley Axen --- Cargo.lock | 5 + crates/buzz-ws-client/Cargo.toml | 5 + .../buzz-ws-client/src/enterprise_callback.rs | 249 +++++++ crates/buzz-ws-client/src/enterprise_oauth.rs | 431 +++++++++++ crates/buzz-ws-client/src/event_signer.rs | 13 + crates/buzz-ws-client/src/lib.rs | 3 + crates/buzz-ws-client/src/remote_identity.rs | 376 ++++++++++ .../src/remote_identity_tests.rs | 418 +++++++++++ desktop/MANAGED_IDENTITY.md | 124 ++++ desktop/playwright.config.ts | 1 + .../build-protected-feature-artifacts.mjs | 10 +- desktop/src-tauri/Cargo.lock | 5 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/build.rs | 19 + desktop/src-tauri/src/app_state.rs | 22 +- desktop/src-tauri/src/app_state_accessors.rs | 32 +- desktop/src-tauri/src/app_state_tests.rs | 2 +- desktop/src-tauri/src/archive/mod.rs | 13 +- desktop/src-tauri/src/archive/mod_tests.rs | 17 +- desktop/src-tauri/src/archive/pipeline.rs | 18 +- .../agent_discovery/relay_directory.rs | 8 +- .../relay_directory/owned_tests.rs | 2 +- desktop/src-tauri/src/commands/agents.rs | 11 +- .../src-tauri/src/commands/channels/fetch.rs | 5 +- .../src/commands/channels/fetch_tests.rs | 4 +- .../src-tauri/src/commands/channels_tests.rs | 11 +- desktop/src-tauri/src/commands/engrams.rs | 7 +- desktop/src-tauri/src/commands/identity.rs | 33 +- .../src/commands/identity_archive.rs | 14 +- .../src/commands/identity_key_backup_tests.rs | 13 +- desktop/src-tauri/src/commands/media.rs | 45 +- .../src-tauri/src/commands/media_download.rs | 6 +- desktop/src-tauri/src/commands/messages.rs | 12 +- desktop/src-tauri/src/commands/mod.rs | 2 +- .../src-tauri/src/commands/personas/card.rs | 2 +- .../inbound/catalog_reconcile_tests.rs | 2 +- desktop/src-tauri/src/commands/profile.rs | 14 +- .../src-tauri/src/commands/relay_members.rs | 5 +- .../src/commands/teams/pending/tests/gate.rs | 2 +- .../src/commands/teams/sharing/tests.rs | 4 +- desktop/src-tauri/src/commands/workflows.rs | 3 +- desktop/src-tauri/src/commands/workspace.rs | 14 +- desktop/src-tauri/src/egress_guard_tests.rs | 1 + .../src-tauri/src/enterprise_build_config.rs | 44 ++ desktop/src-tauri/src/enterprise_identity.rs | 369 ++++++++++ .../src/enterprise_identity_tests.rs | 319 +++++++++ .../src-tauri/src/enterprise_media_cache.rs | 152 ++++ desktop/src-tauri/src/huddle/mod.rs | 12 +- desktop/src-tauri/src/huddle/pipeline.rs | 7 +- desktop/src-tauri/src/huddle/relay_api.rs | 17 +- desktop/src-tauri/src/lib.rs | 9 +- .../managed_agents/persona_events/tests.rs | 4 +- .../src-tauri/src/managed_agents/restore.rs | 7 +- .../src-tauri/src/managed_agents/runtime.rs | 4 + .../src/managed_agents/runtime_commands.rs | 6 +- .../src-tauri/src/managed_agents/storage.rs | 3 + desktop/src-tauri/src/media_proxy.rs | 42 +- desktop/src-tauri/src/native_relay_client.rs | 42 +- .../src/native_relay_client_tests.rs | 19 + desktop/src-tauri/src/relay.rs | 25 +- desktop/src-tauri/src/relay/submit.rs | 11 +- .../src/relay/submit_signer_tests.rs | 106 +++ .../src/features/agents/ui/AgentsScreen.tsx | 14 +- .../readState/readStateManager.test.mjs | 37 + .../channels/readState/readStateManager.ts | 8 + .../communities/EnterpriseLoginGate.tsx | 143 ++++ .../features/communities/communityMarkRead.ts | 2 + .../communities/useCommunityUnread.ts | 6 +- .../lib/projectSidebarMembershipSync.ts | 4 + .../lib/useProjectSidebarMembership.ts | 2 + desktop/src/features/reminders/hooks.ts | 3 +- .../features/reminders/lib/reminderService.ts | 6 + .../reminders/ui/RemindMeLaterProvider.tsx | 8 + .../features/reminders/ui/RemindersPanel.tsx | 10 + .../reminders/useReminderNotifications.ts | 3 +- .../settings/ui/PrivateKeyBackupRow.tsx | 14 + .../features/sidebar/lib/channelMutesSync.ts | 4 + .../sidebar/lib/channelSectionsSync.ts | 4 + .../features/sidebar/lib/channelSortSync.ts | 4 + .../features/sidebar/lib/channelStarsSync.ts | 4 + .../features/sidebar/lib/useChannelMutes.ts | 2 + .../sidebar/lib/useChannelSections.ts | 2 + .../sidebar/lib/useChannelSortPreference.ts | 2 + .../features/sidebar/lib/useChannelStars.ts | 2 + desktop/src/main.tsx | 5 +- .../shared/api/identityCapabilities.test.mjs | 110 +++ .../src/shared/api/identityCapabilities.ts | 21 + desktop/src/shared/api/identityTypes.ts | 3 +- .../shared/theme/CommunityThemeController.tsx | 3 + .../src/shared/theme/communityThemeSync.ts | 6 + desktop/src/testing/e2eBridge.ts | 49 +- desktop/tests/e2e/enterprise-login.spec.ts | 90 +++ desktop/vite.config.ts | 2 + mobile/MANAGED_IDENTITY.md | 105 +++ mobile/ios/Runner/NativeEmojiPickerView.swift | 13 +- mobile/lib/app.dart | 17 +- .../channels/channel_management_actions.dart | 1 + mobile/lib/features/channels/compose_bar.dart | 2 - .../channels/compose_bar/helpers.dart | 15 +- .../lib/features/channels/emoji_picker.dart | 8 +- .../media_viewer_page/video_viewer.dart | 147 +++- .../features/channels/message_actions.dart | 11 +- .../features/channels/message_content.dart | 9 +- .../message_content/video_preview.dart | 8 +- .../channels/mobile_huddle_controller.dart | 3 +- .../channels/send_message_provider.dart | 4 +- .../channels/voice_note_attachment.dart | 5 +- .../channels/voice_note_recording.dart | 7 +- mobile/lib/features/forum/forum_provider.dart | 9 +- .../invites/invite_create_provider.dart | 45 +- .../invites/invite_join_provider.dart | 7 + .../features/pairing/pairing_provider.dart | 5 + .../features/profile/profile_provider.dart | 8 +- .../profile/user_status_provider.dart | 42 +- mobile/lib/features/pulse/pulse_actions.dart | 12 +- .../lib/features/settings/settings_page.dart | 10 + .../settings_page/community_section.dart | 21 +- .../settings_page/connection_section.dart | 22 +- .../settings_page/notifications_section.dart | 2 +- mobile/lib/shared/auth/auth_provider.dart | 36 + .../lib/shared/auth/enterprise_identity.dart | 526 ++++++++++++++ .../shared/auth/enterprise_login_page.dart | 62 ++ .../shared/community/community_provider.dart | 32 + mobile/lib/shared/huddle/huddle_auth.dart | 14 +- mobile/lib/shared/relay/media_auth.dart | 108 ++- mobile/lib/shared/relay/media_image.dart | 3 +- mobile/lib/shared/relay/media_upload.dart | 51 +- .../relay/media_upload/platform_bindings.dart | 1 + .../shared/relay/relay_http_query_client.dart | 6 +- mobile/lib/shared/relay/relay_provider.dart | 34 +- mobile/lib/shared/relay/relay_session.dart | 72 +- .../lib/shared/relay/relay_session_auth.dart | 14 +- .../lib/shared/relay/relay_session_types.dart | 2 + mobile/lib/shared/relay/relay_socket.dart | 27 +- .../lib/shared/relay/signed_event_relay.dart | 22 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../channels/channel_detail_page_test.dart | 3 + .../channel_management_provider_test.dart | 1 + .../channels/message_content_test.dart | 8 +- .../channels/send_message_provider_test.dart | 1 + .../video_viewer_cancellation_test.dart | 246 +++++++ .../channels/voice_note_recording_test.dart | 2 + .../profile/profile_provider_test.dart | 3 + .../profile/user_status_provider_test.dart | 1 + .../auth/enterprise_build_config_test.dart | 33 + .../shared/auth/enterprise_identity_test.dart | 672 ++++++++++++++++++ .../test/shared/auth/event_signer_test.dart | 22 +- .../test/shared/relay/media_image_test.dart | 4 + .../test/shared/relay/media_upload_test.dart | 8 +- .../test/shared/relay/relay_session_test.dart | 45 +- .../shared/relay/signed_event_scope_test.dart | 476 +++++++++++++ .../theme/community_theme_provider_test.dart | 1 + 153 files changed, 6422 insertions(+), 410 deletions(-) create mode 100644 crates/buzz-ws-client/src/enterprise_callback.rs create mode 100644 crates/buzz-ws-client/src/enterprise_oauth.rs create mode 100644 crates/buzz-ws-client/src/remote_identity.rs create mode 100644 crates/buzz-ws-client/src/remote_identity_tests.rs create mode 100644 desktop/MANAGED_IDENTITY.md create mode 100644 desktop/src-tauri/src/enterprise_build_config.rs create mode 100644 desktop/src-tauri/src/enterprise_identity.rs create mode 100644 desktop/src-tauri/src/enterprise_identity_tests.rs create mode 100644 desktop/src-tauri/src/enterprise_media_cache.rs create mode 100644 desktop/src-tauri/src/relay/submit_signer_tests.rs create mode 100644 desktop/src/features/communities/EnterpriseLoginGate.tsx create mode 100644 desktop/src/shared/api/identityCapabilities.test.mjs create mode 100644 desktop/src/shared/api/identityCapabilities.ts create mode 100644 desktop/tests/e2e/enterprise-login.spec.ts create mode 100644 mobile/MANAGED_IDENTITY.md create mode 100644 mobile/lib/shared/auth/enterprise_identity.dart create mode 100644 mobile/lib/shared/auth/enterprise_login_page.dart create mode 100644 mobile/test/features/channels/video_viewer_cancellation_test.dart create mode 100644 mobile/test/shared/auth/enterprise_build_config_test.dart create mode 100644 mobile/test/shared/auth/enterprise_identity_test.dart create mode 100644 mobile/test/shared/relay/signed_event_scope_test.dart 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/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/enterprise_callback.rs b/crates/buzz-ws-client/src/enterprise_callback.rs new file mode 100644 index 00000000000..aa08fc06cd5 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise_callback.rs @@ -0,0 +1,249 @@ +//! Fixed-port native PKCE callback listener. Bind before launching the browser. +use std::{net::Ipv4Addr, time::Duration}; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + time::timeout, +}; + +use crate::enterprise_oauth::{EnterpriseLoginAttempt, EnterpriseLoginConfig}; + +/// One bounded callback receiver for the exact release-registered redirect URI. +/// Dropping it closes the port. It never selects an ephemeral/fallback port. +pub struct EnterpriseCallback { + listener: TcpListener, + redirect_uri: String, + authority: String, +} + +impl EnterpriseCallback { + /// Bind the configured port on 127.0.0.1, failing closed when it is occupied. + pub async fn bind(config: &EnterpriseLoginConfig) -> Result { + config.validate()?; + let port = config.loopback_port()?; + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)) + .await + .map_err(|_| "Configured corporate login callback port is unavailable")?; + Ok(Self { + listener, + redirect_uri: config.redirect_uri.clone(), + authority: format!("127.0.0.1:{port}"), + }) + } + + /// Receive a state-checked callback, with a five-minute overall deadline, + /// at most 32 connections, 8 KiB headers, and three-second per-peer IO limits. + /// No code/verifier/token is reflected in the browser response. + pub async fn receive(self, attempt: &EnterpriseLoginAttempt) -> Result { + timeout(Duration::from_secs(300), async { + for _ in 0..32 { + let (mut stream, _) = self.listener.accept().await.map_err(|_| "Login callback failed")?; + let request = timeout(Duration::from_secs(3), read_headers(&mut stream)).await; + let callback = match request { + Ok(Ok(request)) => self.parse_request(&request, attempt), + _ => Err("Invalid login callback".into()), + }; + let (status, body) = if callback.is_ok() { + ("200 OK", "Return to Buzz to finish signing in.") + } else { + ("400 Bad Request", "Invalid login callback.") + }; + let response = format!("HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{body}", body.len()); + // A disconnected browser must not discard an already valid callback. + let _ = timeout(Duration::from_secs(3), async { + stream.write_all(response.as_bytes()).await?; + stream.shutdown().await + }).await; + if let Ok(callback) = callback { return Ok(callback); } + } + Err("Too many invalid login callbacks".into()) + }).await.map_err(|_| "Corporate login timed out")? + } + + fn parse_request( + &self, + request: &str, + attempt: &EnterpriseLoginAttempt, + ) -> Result { + let mut lines = request.split("\r\n"); + let target = lines + .next() + .and_then(|line| line.strip_prefix("GET ")) + .and_then(|line| line.strip_suffix(" HTTP/1.1")) + .ok_or("Invalid login callback method")?; + // Validate BEFORE URL parsing can normalize dot segments or backslashes. + if !(target == "/enterprise-callback" || target.starts_with("/enterprise-callback?")) + || target.contains(['\\', '#']) + || !target.is_ascii() + { + return Err("Invalid login callback path".into()); + } + let hosts: Vec<_> = lines + .take_while(|line| !line.is_empty()) + .filter_map(|line| line.split_once(':')) + .filter(|(name, _)| name.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.trim()) + .collect(); + if hosts != [self.authority.as_str()] { + return Err("Invalid login callback authority".into()); + } + let query = target + .strip_prefix("/enterprise-callback") + .ok_or("Invalid callback")?; + let callback = url::Url::parse(&format!("{}{query}", self.redirect_uri)) + .map_err(|_| "Invalid callback URL")?; + attempt.callback_code(&callback)?; + Ok(callback) + } +} + +async fn read_headers(stream: &mut TcpStream) -> Result { + 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".into()); + } + bytes.extend_from_slice(&chunk[..count]); + } + String::from_utf8(bytes).map_err(|_| "Invalid callback encoding".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(port: u16) -> EnterpriseLoginConfig { + EnterpriseLoginConfig { + signer_url: "https://signer.example/cash-app/goose/".into(), + issuer: "https://login.example/".into(), + client_id: "native".into(), + audience: "signer".into(), + organization: "org".into(), + connection: "corporate".into(), + redirect_uri: format!("http://127.0.0.1:{port}/enterprise-callback"), + } + } + + #[tokio::test] + async fn occupied_configured_port_fails_without_fallback() { + let occupied = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + assert!( + EnterpriseCallback::bind(&config(occupied.local_addr().unwrap().port())) + .await + .is_err() + ); + } + + #[tokio::test] + async fn actual_listener_ignores_bad_callbacks_then_closes_successful_response() { + let provisional = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let port = provisional.local_addr().unwrap().port(); + drop(provisional); + let config = config(port); + let callback = EnterpriseCallback::bind(&config).await.unwrap(); + assert_eq!(callback.listener.local_addr().unwrap().port(), port); + let attempt = EnterpriseLoginAttempt::new([1; 32], [2; 32], config.redirect_uri.clone()); + let authorize = attempt.authorization_url(&config).unwrap(); + let state = authorize + .query_pairs() + .find(|(k, _)| k == "state") + .unwrap() + .1 + .into_owned(); + let task = tokio::spawn(async move { callback.receive(&attempt).await }); + for (path, host, expected) in [ + ( + "/favicon.ico".to_string(), + format!("127.0.0.1:{port}"), + "400", + ), + ( + format!("/enterprise-callback?state={state}&code=secret"), + "attacker.example".into(), + "400", + ), + ( + "/enterprise-callback?state=wrong&code=secret".into(), + format!("127.0.0.1:{port}"), + "400", + ), + ( + format!("/enterprise-callback?state={state}&code=secret"), + format!("127.0.0.1:{port}"), + "200", + ), + ] { + let mut peer = TcpStream::connect((Ipv4Addr::LOCALHOST, port)) + .await + .unwrap(); + peer.write_all(format!("GET {path} HTTP/1.1\r\nHost: {host}\r\n\r\n").as_bytes()) + .await + .unwrap(); + let mut response = String::new(); + timeout(Duration::from_secs(2), peer.read_to_string(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with(&format!("HTTP/1.1 {expected}"))); + assert!(!response.contains("secret")); + } + assert_eq!( + task.await + .unwrap() + .unwrap() + .query_pairs() + .find(|(k, _)| k == "code") + .unwrap() + .1, + "secret" + ); + assert!(TcpStream::connect((Ipv4Addr::LOCALHOST, port)) + .await + .is_err()); + } + + #[tokio::test] + async fn request_parser_rejects_authority_path_and_normalization_confusion() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let config = config(port); + let callback = EnterpriseCallback { + listener, + redirect_uri: config.redirect_uri.clone(), + authority: format!("127.0.0.1:{port}"), + }; + let attempt = EnterpriseLoginAttempt::new([1; 32], [2; 32], config.redirect_uri.clone()); + let auth = attempt.authorization_url(&config).unwrap(); + let state = auth + .query_pairs() + .find(|(k, _)| k == "state") + .unwrap() + .1 + .into_owned(); + let good = format!("GET /enterprise-callback?code=x&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"); + assert!(callback.parse_request(&good, &attempt).is_ok()); + for bad in [ + good.replace("GET ", "POST "), + good.replace("/enterprise-callback?", "/a/../enterprise-callback?"), + good.replace("/enterprise-callback?", "//enterprise-callback?"), + good.replace("/enterprise-callback?", "/%65nterprise-callback?"), + good.replace("/enterprise-callback?", "/enterprise-callback\\?"), + good.replace("Host:", "Not-Host:"), + good.replace("Host:", &format!("Host: 127.0.0.1:{port}\r\nHost:")), + good.replace("127.0.0.1:", "user@127.0.0.1:"), + good.replace(" HTTP/1.1", "#fragment HTTP/1.1"), + good.replace("code=x", "code=x&code=y"), + ] { + assert!( + callback.parse_request(&bad, &attempt).is_err(), + "accepted {bad}" + ); + } + } +} 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..4e71d54797e --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise_oauth.rs @@ -0,0 +1,431 @@ +//! 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, + /// Exact registered native loopback redirect, including the owner-selected port. + pub redirect_uri: String, +} + +impl EnterpriseLoginConfig { + /// Validate the release-owned destinations before opening a browser or transmitting credentials. + pub fn validate(&self) -> Result<(), String> { + let issuer = crate::remote_identity::trusted_https_url(&self.issuer)?; + let authority_and_path = self + .issuer + .strip_prefix("https://") + .ok_or("Invalid issuer")?; + let raw_path = authority_and_path + .find('/') + .map(|i| &authority_and_path[i..]) + .unwrap_or(""); + if issuer.path() != "/" || !matches!(raw_path, "" | "/") { + return Err("Issuer must be an HTTPS origin".into()); + } + crate::remote_identity::deployment_prefix(&self.signer_url)?; + self.loopback_port()?; + if [ + &self.client_id, + &self.audience, + &self.organization, + &self.connection, + ] + .iter() + .any(|s| s.is_empty() || s.len() > 2048 || s.bytes().any(|b| b <= 32 || b >= 127)) + { + return Err("Incomplete enterprise login configuration".into()); + } + if [&self.client_id, &self.organization, &self.connection] + .iter() + .any(|s| { + !s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) + }) + { + return Err("Invalid corporate login identifier".into()); + } + Ok(()) + } + + /// Return the exact configured port after validating the raw callback spelling. + /// No userinfo, query, fragment, alternate IP spelling or port normalization. + pub fn loopback_port(&self) -> Result { + let port = self + .redirect_uri + .strip_prefix("http://127.0.0.1:") + .and_then(|s| s.strip_suffix("/enterprise-callback")) + .ok_or("Invalid registered desktop callback")?; + let parsed: u16 = port + .parse() + .map_err(|_| "Invalid registered desktop port")?; + if parsed < 1024 || parsed.to_string() != port { + return Err("Invalid registered desktop port".into()); + } + Ok(parsed) + } +} + +/// 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()?; + if self.redirect_uri != config.redirect_uri { + return Err("Login attempt must use the configured redirectUri".into()); + } + 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()), + // This requests permission; Auth0 RBAC must still emit permissions:["buzz:sign"]. + ("scope", "openid profile email offline_access buzz:sign"), + ("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 { + self.authorization_url(config)?; // Also pins this attempt to the configured redirect. + 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 { + if refresh_token.is_empty() || refresh_token.len() > 16 * 1024 { + return Err("Invalid corporate refresh credential".into()); + } + 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 started = std::time::Instant::now(); + 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 mut tokens: EnterpriseOAuthTokens = + serde_json::from_slice(&bytes).map_err(|_| "Invalid corporate token response")?; + tokens.validate()?; + // Never extend an authorization lease by the time spent obtaining its reply. + let elapsed = started.elapsed(); + let elapsed_seconds = elapsed.as_secs() + u64::from(elapsed.subsec_nanos() > 0); + tokens.expires_in = tokens.expires_in.saturating_sub(elapsed_seconds); + tokens.validate()?; + Ok(tokens) +} + +impl EnterpriseOAuthTokens { + /// Validate fresh AND restored token metadata without extending its deadline. + pub fn validate(&self) -> Result<(), String> { + let tokens = self; + if tokens.access_token.is_empty() + || tokens.access_token.len() > 16 * 1024 + || tokens.access_token.bytes().any(|b| b.is_ascii_whitespace()) + || 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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn config() -> EnterpriseLoginConfig { + EnterpriseLoginConfig { + signer_url: "https://signer.example/cash-app/goose/".into(), + issuer: "https://login.example/".into(), + client_id: "native".into(), + audience: "signer".into(), + organization: "org".into(), + connection: "okta".into(), + redirect_uri: "http://127.0.0.1:1234/enterprise-callback".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["scope"], + "openid profile email offline_access buzz:sign" + ); + 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()); + } + } + #[test] + fn fixed_callback_config_rejects_every_authority_and_port_alias() { + for redirect in [ + "http://127.0.0.1:0/enterprise-callback", + "http://127.0.0.1:1023/enterprise-callback", + "http://127.0.0.1:65536/enterprise-callback", + "http://127.0.0.1:01234/enterprise-callback", + "http://127.0.0.1:+1234/enterprise-callback", + "http://localhost:1234/enterprise-callback", + "http://127.1:1234/enterprise-callback", + "http://[::1]:1234/enterprise-callback", + "https://127.0.0.1:1234/enterprise-callback", + "http://user@127.0.0.1:1234/enterprise-callback", + "http://127.0.0.1:1234/enterprise-callback?", + "http://127.0.0.1:1234/enterprise-callback#", + "http://127.0.0.1:1234/a/../enterprise-callback", + "http://127.0.0.1:1234/enterprise-callback/", + "buzz://enterprise-login", + ] { + let mut value = config(); + value.redirect_uri = redirect.into(); + assert!(value.validate().is_err(), "accepted {redirect}"); + } + for port in [1024, 65535] { + let mut value = config(); + value.redirect_uri = format!("http://127.0.0.1:{port}/enterprise-callback"); + assert_eq!(value.loopback_port().unwrap(), port); + assert!(value.validate().is_ok()); + } + let attempt = EnterpriseLoginAttempt::new( + [1; 32], + [2; 32], + "http://127.0.0.1:4321/enterprise-callback".into(), + ); + assert!(attempt.authorization_url(&config()).is_err()); + } + + #[test] + fn native_schema_matches_release_subset_and_rejects_secret_or_environment_fields() { + let value = serde_json::to_value(config()).unwrap(); + let mut fields: Vec<_> = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + fields.sort(); + assert_eq!( + fields, + [ + "audience", + "clientId", + "connection", + "issuer", + "organization", + "redirectUri", + "signerUrl" + ] + ); + for field in ["clientSecret", "environment"] { + let mut invalid = value.clone(); + invalid[field] = serde_json::json!("forbidden"); + assert!(serde_json::from_value::(invalid).is_err()); + } + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove("redirectUri"); + assert!(serde_json::from_value::(missing).is_err()); + } + + #[test] + fn token_validation_never_accepts_over_five_minute_authority() { + let token = || EnterpriseOAuthTokens { + access_token: "test-only-token".into(), + refresh_token: Some("test-only-refresh".into()), + expires_in: 300, + token_type: "Bearer".into(), + }; + assert!(token().validate().is_ok()); + for lifetime in [0, 301, u64::MAX] { + let mut invalid = token(); + invalid.expires_in = lifetime; + assert!(invalid.validate().is_err()); + } + for bearer in ["", "bad token", "bad\r\nheader"] { + let mut invalid = token(); + invalid.access_token = bearer.into(); + assert!(invalid.validate().is_err()); + } + let mut invalid = token(); + invalid.token_type = "Basic".into(); + assert!(invalid.validate().is_err()); + let mut invalid = token(); + invalid.refresh_token = Some("".into()); + assert!(invalid.validate().is_err()); + let mut valid = token(); + valid.expires_in = 1; + valid.refresh_token = None; + assert!(valid.validate().is_ok()); + } +} diff --git a/crates/buzz-ws-client/src/event_signer.rs b/crates/buzz-ws-client/src/event_signer.rs index bbf6473d01b..5817c460326 100644 --- a/crates/buzz-ws-client/src/event_signer.rs +++ b/crates/buzz-ws-client/src/event_signer.rs @@ -19,6 +19,19 @@ pub trait EventSigner: Send + Sync { ) -> Pin> + Send + '_>>; } +// Shared capability snapshots remain the same exact-event boundary. +impl EventSigner for std::sync::Arc { + fn public_key(&self) -> PublicKey { + (**self).public_key() + } + fn sign( + &self, + event: UnsignedEvent, + ) -> Pin> + Send + '_>> { + (**self).sign(event) + } +} + /// An in-process signer backed by local keys. Key management remains outside the signer. #[derive(Clone)] pub struct LocalEventSigner { diff --git a/crates/buzz-ws-client/src/lib.rs b/crates/buzz-ws-client/src/lib.rs index c3e6306b434..f9312335c8e 100644 --- a/crates/buzz-ws-client/src/lib.rs +++ b/crates/buzz-ws-client/src/lib.rs @@ -1,9 +1,12 @@ #![deny(unsafe_code)] pub mod connection; +pub mod enterprise_callback; +pub mod enterprise_oauth; pub mod error; pub mod event_signer; pub mod message; +pub mod remote_identity; pub use connection::{publish_event, NostrWsConnection}; pub use error::WsClientError; diff --git a/crates/buzz-ws-client/src/remote_identity.rs b/crates/buzz-ws-client/src/remote_identity.rs new file mode 100644 index 00000000000..beb82eed09a --- /dev/null +++ b/crates/buzz-ws-client/src/remote_identity.rs @@ -0,0 +1,376 @@ +//! Managed identity transport. Enrollment is separate from exact-event signing. +//! +//! The login owner supplies an authorization snapshot; this module never stores +//! refresh credentials, generates keys, constructs events, or publishes to a relay. +use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; + +use nostr::{Event, PublicKey, UnsignedEvent}; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::event_signer::EventSigner; + +/// Server-selected public identity and its community, pinned for a login lifetime. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManagedIdentity { + /// Custodied Nostr public key; never a client-selected request field. + pub pubkey: String, + /// Community WebSocket origin. + pub relay_ws_url: String, + /// Community HTTPS origin. + pub relay_http_url: String, +} + +impl ManagedIdentity { + /// Validate the identity before installing it in a login/session owner. + pub fn validate(&self) -> Result { + let relay = relay_https_origin(&self.relay_http_url)?; + if relay.path() != "/" + || self.relay_ws_url + != format!( + "wss://{}", + relay[url::Position::BeforeHost..url::Position::AfterPort].to_owned() + ) + || self.pubkey.len() != 64 + || !self + .pubkey + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(failure()); + } + let public_key = PublicKey::from_hex(&self.pubkey).map_err(|_| failure())?; + // from_hex only validates length/encoding; ensure must return a curve point. + public_key.xonly().map_err(|_| failure())?; + Ok(public_key) + } +} + +/// An explicit short-lived bearer. Intentionally neither Debug nor Serialize. +pub struct RemoteCredentials(Zeroizing); + +impl RemoteCredentials { + /// Own one access token. The login owner must enforce its expiration/refresh. + pub fn new(token: String) -> Self { + Self(Zeroizing::new(token)) + } + + fn headers(&self) -> Result { + let token = self.0.as_str(); + if token.is_empty() + || token.len() > 16 * 1024 + || token.bytes().any(|b| b.is_ascii_whitespace()) + { + return Err(failure()); + } + let mut bearer = + HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| failure())?; + bearer.set_sensitive(true); + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::AUTHORIZATION, bearer); + Ok(headers) + } +} + +/// Authorization owned by one login generation, not a process-global identity. +/// +/// Implementations serialize refresh and secure persistence, enforce <=300-second +/// token lifetimes, and invalidate old snapshots on logout/replacement. They must +/// never resolve another account or silently fall back to a local key. +pub trait RemoteAuthorization: Send + Sync { + /// Fail if this captured login generation has been invalidated. + fn check_active(&self) -> Result<(), String>; + + /// Obtain an unexpired bearer for this same captured login generation. + fn credentials( + &self, + ) -> Pin> + Send + '_>>; +} + +/// HTTPS ensure/sign transport configured with the deployment prefix, not a key. +#[derive(Clone)] +pub struct RemoteIdentityClient { + client: reqwest::Client, + base: url::Url, +} + +impl RemoteIdentityClient { + /// Construct from a trusted release-selected HTTPS deployment prefix. + /// There is no default host, account selector, route alias or local fallback. + pub fn new(base: &str) -> Result { + let base = deployment_prefix(base)?; + Ok(Self { + client: http_client()?, + base, + }) + } + + /// Create-or-return custody and initial admission/profile for the authenticated + /// account. This is an enrollment operation, NOT a read-only session probe. + pub async fn ensure(&self, credentials: &RemoteCredentials) -> Result { + let identity: ManagedIdentity = self + .post( + "v1/buzz/identity/ensure", + &serde_json::json!({}), + credentials, + ) + .await?; + identity.validate()?; + Ok(identity) + } + + async fn post( + &self, + path: &str, + body: &serde_json::Value, + credentials: &RemoteCredentials, + ) -> Result { + let body = serde_json::to_vec(body).map_err(|_| failure())?; + if body.len() > 128 * 1024 { + return Err(failure()); + } + let mut response = self + .client + .post(self.base.join(path).map_err(|_| failure())?) + .headers(credentials.headers()?) + .header("Cache-Control", "no-store") + .header("Content-Type", "application/json") + .body(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()) + } +} + +/// A signer snapshot implementing the neutral exact-event boundary. +/// +/// It holds the server-selected identity and the SAME authorization owner for its +/// entire lifetime. Event creation and publication (including retry journals) +/// remain with callers; sign never enrolls, publishes or retries a failed request. +#[derive(Clone)] +pub struct RemoteEventSigner { + client: RemoteIdentityClient, + public_key: PublicKey, + identity: ManagedIdentity, + authorization: Arc, +} + +impl RemoteEventSigner { + /// Bind a validated ensure response to a captured login authorization owner. + pub fn new( + client: RemoteIdentityClient, + identity: &ManagedIdentity, + authorization: Arc, + ) -> Result { + let public_key = identity.validate()?; + authorization.check_active()?; + Ok(Self { + client, + public_key, + identity: identity.clone(), + authorization, + }) + } + + // Proof destinations are application-built, but must still belong to this + // captured managed community. Never send an off-origin proof for signing. + fn validate_proof_scope(&self, kind: u16, tags: &[Vec]) -> Result<(), String> { + let value = |name: &str| -> Result<&str, String> { + let mut values = tags.iter().filter(|t| t.first().is_some_and(|s| s == name)); + let tag = values.next().ok_or_else(failure)?; + if tag.len() != 2 || values.next().is_some() { + return Err(failure()); + } + Ok(tag[1].as_str()) + }; + match kind { + 22242 + if value("relay")? != self.identity.relay_ws_url + || tags.iter().any(|t| t.first().is_some_and(|s| s == "auth")) => + { + return Err(failure()); + } + 27235 => { + let raw = value("u")?; + let target = url::Url::parse(raw).map_err(|_| failure())?; + let relay = + url::Url::parse(&self.identity.relay_http_url).map_err(|_| failure())?; + if target.scheme() != "https" + || target.origin() != relay.origin() + || !target.username().is_empty() + || target.password().is_some() + || target.fragment().is_some() + || raw.contains('\\') + { + return Err(failure()); + } + } + 24242 => { + let relay = + url::Url::parse(&self.identity.relay_http_url).map_err(|_| failure())?; + if value("server")? != &relay[url::Position::BeforeHost..url::Position::AfterPort] { + return Err(failure()); + } + } + _ => {} + } + Ok(()) + } +} + +impl EventSigner for RemoteEventSigner { + fn public_key(&self) -> PublicKey { + self.public_key + } + + fn sign( + &self, + unsigned: UnsignedEvent, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + self.authorization.check_active()?; + if unsigned.pubkey != self.public_key { + return Err(failure()); + } + let tags: Vec> = unsigned + .tags + .iter() + .map(|t| t.as_slice().to_vec()) + .collect(); + self.validate_proof_scope(unsigned.kind.as_u16(), &tags)?; + let purpose = match unsigned.kind.as_u16() { + 22242 => "nip42-auth", + 27235 => "http-auth", + 24242 + if tags + .iter() + .any(|t| t.len() == 2 && t[0] == "t" && t[1] == "upload") => + { + "media-upload" + } + 24242 => "media-read", + _ => "publish", + }; + let template = serde_json::json!({"kind": unsigned.kind.as_u16(), "created_at": unsigned.created_at.as_secs(), "tags": tags, "content": unsigned.content}); + let credentials = self.authorization.credentials().await?; + self.authorization.check_active()?; + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Reply { + event: Event, + } + let reply: Reply = self + .client + .post( + "v1/buzz/identity/sign", + &serde_json::json!({"purpose": purpose, "event": template}), + &credentials, + ) + .await?; + self.authorization.check_active()?; + let event = reply.event; + if event.pubkey != self.public_key + || event.created_at != unsigned.created_at + || event.kind != unsigned.kind + || event.tags != unsigned.tags + || event.content != unsigned.content + || event.verify().is_err() + { + return Err(failure()); + } + Ok(event) + }) + } +} + +fn http_client() -> Result { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|_| failure()) +} + +// Unlike release-owned signer/issuer URLs, the backend community contract +// (IdentitySigningPolicy) permits an HTTPS origin with a non-default port. +// Keep raw canonical spelling: no userinfo, query, fragment or normalized path. +fn relay_https_origin(raw: &str) -> Result { + let parsed = url::Url::parse(raw).map_err(|_| failure())?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + || parsed.port() == Some(0) + || raw.trim_end_matches('/') != parsed.origin().ascii_serialization() + || raw.ends_with("//") + { + return Err(failure()); + } + Ok(parsed) +} + +// Compare raw authority as well as parsed fields: URL parsers normalize away +// default ports, empty userinfo and backslashes. Release policy forbids all three. +pub(crate) fn trusted_https_url(raw: &str) -> Result { + let url = url::Url::parse(raw).map_err(|_| failure())?; + let authority = raw + .strip_prefix("https://") + .and_then(|s| s.split('/').next()) + .ok_or_else(failure)?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + || raw.contains(['\\', '?', '#']) + || raw.bytes().any(|b| b <= 32 || b >= 127) + || authority.to_ascii_lowercase() != url.host_str().unwrap_or_default() + { + return Err(failure()); + } + Ok(url) +} + +pub(crate) fn deployment_prefix(raw: &str) -> Result { + let url = trusted_https_url(raw)?; + let path = raw + .strip_prefix("https://") + .and_then(|s| s.find('/').map(|i| &s[i..])) + .ok_or_else(failure)?; + if path == "/" + || !path.ends_with('/') + || path.contains("//") + || path.contains('%') + || path.contains("/v1/") + || path.split('/').any(|p| p == "." || p == "..") + { + return Err(failure()); + } + Ok(url) +} + +fn failure() -> String { + "Managed identity request failed; corporate login or authorization is required".into() +} + +#[cfg(test)] +#[path = "remote_identity_tests.rs"] +mod tests; diff --git a/crates/buzz-ws-client/src/remote_identity_tests.rs b/crates/buzz-ws-client/src/remote_identity_tests.rs new file mode 100644 index 00000000000..3cd64af4002 --- /dev/null +++ b/crates/buzz-ws-client/src/remote_identity_tests.rs @@ -0,0 +1,418 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +fn identity(keys: &Keys) -> ManagedIdentity { + ManagedIdentity { + pubkey: keys.public_key().to_hex(), + relay_ws_url: "wss://buzz.example".into(), + relay_http_url: "https://buzz.example".into(), + } +} + +#[derive(Default)] +struct Authorization { + invalid: AtomicBool, + calls: AtomicUsize, + fail: bool, +} +impl RemoteAuthorization for Authorization { + fn check_active(&self) -> Result<(), String> { + if self.invalid.load(Ordering::Acquire) { + Err("login changed".into()) + } else { + Ok(()) + } + } + fn credentials( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async { + self.calls.fetch_add(1, Ordering::SeqCst); + tokio::task::yield_now().await; + self.check_active()?; + if self.fail { + Err("refresh failed".into()) + } else { + Ok(RemoteCredentials::new("test-only-bearer".into())) + } + }) + } +} + +// Inject only a loopback URL into the production HTTP transport. Its redirect, +// response bound, request construction and verification paths are unchanged. +async fn server_reply( + body: String, + status: &str, + extra_headers: &str, + invalidate: Option>, +) -> (RemoteIdentityClient, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_owned(); + let extra_headers = extra_headers.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; + } + } + } + if let Some(owner) = invalidate { + owner.invalid.store(true, Ordering::Release); + } + let response = format!("HTTP/1.1 {status}\r\n{extra_headers}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + // Rejection may close a large-response connection before the server finishes. + let _ = stream.write_all(response.as_bytes()).await; + String::from_utf8(request).unwrap() + }); + ( + RemoteIdentityClient { + client: http_client().unwrap(), + base: url::Url::parse(&format!("http://{address}/cash-app/goose/")).unwrap(), + }, + task, + ) +} + +fn unsigned(keys: &Keys) -> UnsignedEvent { + EventBuilder::new(Kind::from(9), "hello 🐝\nexact") + .custom_created_at(Timestamp::from(1000)) + .tags([ + Tag::parse(["h", "channel"]).unwrap(), + Tag::parse(["x", "a", "b"]).unwrap(), + ]) + .build(keys.public_key()) +} + +#[test] +fn release_destination_and_identity_validation_fails_closed() { + for base in [ + "http://signer.example/cash-app/goose/", + "https://user:pass@signer.example/api/", + "https://@signer.example/api/", + "https://signer.example:443/api/", + "https://signer.example:444/api/", + "https://signer.example/api/?", + "https://signer.example/api/#", + "https://signer.example/api/\\", + "https://signer.example/api", + "https://signer.example/", + "https://signer.example/api/../goose/", + "https://signer.example/api/%2e/goose/", + "https://signer.example/api//goose/", + "https://signer.example/api/v1/buzz/identity/sign/", + "https://signer.example/api/\n", + ] { + assert!(RemoteIdentityClient::new(base).is_err(), "accepted {base}"); + } + assert!(RemoteIdentityClient::new("https://signer.example/cash-app/goose/").is_ok()); + let keys = Keys::generate(); + let mut wrong = identity(&keys); + wrong.relay_ws_url = "wss://other.example".into(); + assert!(wrong.validate().is_err()); + for pubkey in ["0".repeat(64), "a".repeat(63), "A".repeat(64)] { + wrong = identity(&keys); + wrong.pubkey = pubkey; + assert!(wrong.validate().is_err()); + } + for token in ["", "bad\r\nheader", "bad token"] { + assert!(RemoteCredentials::new(token.into()).headers().is_err()); + } + assert!( + RemoteCredentials::new("token".into()).headers().unwrap()["authorization"].is_sensitive() + ); +} + +#[tokio::test] +async fn ensure_is_separate_enrollment_with_empty_body_and_no_identity_selectors() { + let expected = identity(&Keys::generate()); + let (client, request) = server_reply( + serde_json::to_string(&expected).unwrap(), + "200 OK", + "", + None, + ) + .await; + assert_eq!( + client + .ensure(&RemoteCredentials::new("test-only-bearer".into())) + .await + .unwrap(), + expected + ); + let request = request.await.unwrap(); + assert!(request.starts_with("POST /cash-app/goose/v1/buzz/identity/ensure HTTP/1.1")); + assert_eq!(request.split("\r\n\r\n").nth(1).unwrap(), "{}"); + assert!(request.contains("authorization: Bearer test-only-bearer")); + assert!(!request.to_lowercase().contains("cookie:")); +} + +#[tokio::test] +async fn sign_uses_neutral_exact_event_interface_without_ensure_or_publish() { + let keys = Keys::generate(); + let input = unsigned(&keys); + let expected = input.clone().sign_with_keys(&keys).unwrap(); + let (client, request) = server_reply( + serde_json::json!({"event": expected}).to_string(), + "200 OK", + "", + None, + ) + .await; + let owner = Arc::new(Authorization::default()); + let snapshot = RemoteEventSigner::new(client, &identity(&keys), owner.clone()).unwrap(); + let signer: &dyn EventSigner = &snapshot; + let result = signer.sign(input.clone()).await.unwrap(); + assert_eq!(result.id, expected.id); + result.verify().unwrap(); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + let request = request.await.unwrap(); + assert!(request.starts_with("POST /cash-app/goose/v1/buzz/identity/sign HTTP/1.1")); + assert!(request.contains("authorization: Bearer test-only-bearer")); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!( + body, + serde_json::json!({"purpose":"publish", "event": {"kind":9, "created_at":1000, "tags":[["h","channel"],["x","a","b"]], "content":"hello 🐝\nexact"}}) + ); + assert!(!request.contains("enterprise-signer")); +} + +#[tokio::test] +async fn rejects_valid_signatures_with_any_template_change_and_invalid_crypto() { + let keys = Keys::generate(); + let input = unsigned(&keys); + let mut variants = Vec::new(); + let mut changed = input.clone(); + changed.content.push('!'); + variants.push(changed); + let mut changed = input.clone(); + changed.kind = Kind::from(7); + variants.push(changed); + let mut changed = input.clone(); + changed.created_at = Timestamp::from(1001); + variants.push(changed); + let mut changed = input.clone(); + changed.tags = nostr::Tags::from_list(vec![Tag::parse(["h", "other"]).unwrap()]); + variants.push(changed); + let mut replies: Vec<_> = variants + .into_iter() + .map(|mut e| { + e.id = None; + serde_json::to_value(e.sign_with_keys(&keys).unwrap()).unwrap() + }) + .collect(); + let other = Keys::generate(); + replies.push(serde_json::to_value(unsigned(&other).sign_with_keys(&other).unwrap()).unwrap()); + let correct = input.clone().sign_with_keys(&keys).unwrap(); + let mut forged = serde_json::to_value(&correct).unwrap(); + forged["sig"] = serde_json::json!("0".repeat(128)); + replies.push(forged); + let mut forged = serde_json::to_value(&correct).unwrap(); + forged["id"] = serde_json::json!("0".repeat(64)); + replies.push(forged); + for event in replies { + let (client, request) = server_reply( + serde_json::json!({"event": event}).to_string(), + "200 OK", + "", + None, + ) + .await; + let signer = + RemoteEventSigner::new(client, &identity(&keys), Arc::new(Authorization::default())) + .unwrap(); + assert!(signer.sign(input.clone()).await.is_err()); + request.await.unwrap(); + } +} + +#[tokio::test] +async fn refuses_wrong_author_and_failed_credentials_before_network_without_fallback() { + let keys = Keys::generate(); + let owner = Arc::new(Authorization { + fail: true, + ..Default::default() + }); + let client = RemoteIdentityClient::new("https://signer.invalid/cash-app/goose/").unwrap(); + let signer = RemoteEventSigner::new(client, &identity(&keys), owner.clone()).unwrap(); + assert!(signer.sign(unsigned(&Keys::generate())).await.is_err()); + assert_eq!(owner.calls.load(Ordering::SeqCst), 0); + assert_eq!( + signer.sign(unsigned(&keys)).await.unwrap_err(), + "refresh failed" + ); + owner.invalid.store(true, Ordering::Release); + assert_eq!( + signer.sign(unsigned(&keys)).await.unwrap_err(), + "login changed" + ); + assert_eq!(owner.calls.load(Ordering::SeqCst), 1); + assert_eq!(signer.public_key(), keys.public_key()); +} + +#[tokio::test] +async fn invalidated_snapshot_discards_in_flight_signature() { + let keys = Keys::generate(); + let event = unsigned(&keys).sign_with_keys(&keys).unwrap(); + let owner = Arc::new(Authorization::default()); + let (client, request) = server_reply( + serde_json::json!({"event": event}).to_string(), + "200 OK", + "", + Some(owner.clone()), + ) + .await; + let signer = RemoteEventSigner::new(client, &identity(&keys), owner).unwrap(); + assert_eq!( + signer.sign(unsigned(&keys)).await.unwrap_err(), + "login changed" + ); + request.await.unwrap(); +} + +#[tokio::test] +async fn denial_malformed_and_oversize_response_propagate_without_local_fallback() { + let keys = Keys::generate(); + for (status, body) in [ + ("403 Forbidden", "{}".into()), + ("200 OK", "not json".into()), + ("200 OK", "x".repeat(256 * 1024 + 1)), + ] { + let (client, request) = server_reply(body, status, "", None).await; + let signer = + RemoteEventSigner::new(client, &identity(&keys), Arc::new(Authorization::default())) + .unwrap(); + assert!(signer.sign(unsigned(&keys)).await.is_err()); + request.await.unwrap(); + } +} + +#[tokio::test] +async fn actual_http_client_refuses_redirect_without_sending_bearer_to_target() { + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let headers = format!("Location: http://{}/leak\r\n", target.local_addr().unwrap()); + let (client, request) = + server_reply("{}".into(), "307 Temporary Redirect", &headers, None).await; + assert!(client + .ensure(&RemoteCredentials::new("test-only-bearer".into())) + .await + .is_err()); + request.await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), target.accept()) + .await + .is_err() + ); +} + +#[test] +fn managed_community_allows_backend_port_but_never_changes_release_destination_rules() { + let keys = Keys::generate(); + let mut managed = identity(&keys); + managed.relay_http_url = "https://buzz.example:8443".into(); + managed.relay_ws_url = "wss://buzz.example:8443".into(); + assert!(managed.validate().is_ok()); + assert!(RemoteIdentityClient::new("https://signer.example:8443/cash-app/goose/").is_err()); + for invalid in [ + "http://buzz.example:8443", + "https://user@buzz.example:8443", + "https://buzz.example:8443/?q", + "https://buzz.example:8443/#fragment", + "https://buzz.example:8443/a/..", + "https://buzz.example:8443//", + "https://buzz.example:0", + ] { + managed.relay_http_url = invalid.into(); + assert!(managed.validate().is_err(), "{invalid}"); + } +} + +#[test] +fn remote_proofs_are_exactly_scoped_before_credentials_or_network() { + let keys = Keys::generate(); + let signer = RemoteEventSigner::new( + RemoteIdentityClient::new("https://signer.example/cash-app/goose/").unwrap(), + &identity(&keys), + Arc::new(Authorization::default()), + ) + .unwrap(); + for target in [ + "http://buzz.example/events", + "https://other.example/events", + "https://buzz.example:8443/events", + "https://user@buzz.example/events", + "https://buzz.example/events#fragment", + ] { + assert!(signer + .validate_proof_scope(27235, &[vec!["u".into(), target.into()]]) + .is_err()); + } + assert!(signer + .validate_proof_scope( + 27235, + &[vec!["u".into(), "https://buzz.example/events".into()]] + ) + .is_ok()); + assert!(signer + .validate_proof_scope(24242, &[vec!["server".into(), "buzz.example".into()]]) + .is_ok()); + assert!(signer + .validate_proof_scope(24242, &[vec!["server".into(), "buzz.example:8443".into()]]) + .is_err()); + assert!(signer + .validate_proof_scope(22242, &[vec!["relay".into(), "wss://other.example".into()]]) + .is_err()); + assert!(signer + .validate_proof_scope(22242, &[vec!["relay".into(), "wss://buzz.example".into()]]) + .is_ok()); +} + +#[tokio::test] +async fn production_sign_rejects_wrong_proof_scope_before_authorization_or_network() { + let keys = Keys::generate(); + let auth = Arc::new(Authorization::default()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let client = RemoteIdentityClient { + client: http_client().unwrap(), + base: url::Url::parse(&format!("http://{}/api/", listener.local_addr().unwrap())).unwrap(), + }; + let signer = RemoteEventSigner::new(client, &identity(&keys), auth.clone()).unwrap(); + for (kind, tags) in [ + (27235, vec![vec!["u", "https://other.example/events"]]), + (24242, vec![vec!["server", "other.example"]]), + (22242, vec![vec!["relay", "wss://other.example"]]), + ] { + let input = EventBuilder::new(Kind::from(kind), "") + .tags(tags.into_iter().map(|tag| Tag::parse(tag).unwrap())) + .build(keys.public_key()); + assert!(signer.sign(input).await.is_err()); + } + assert_eq!(auth.calls.load(Ordering::SeqCst), 0); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); +} diff --git a/desktop/MANAGED_IDENTITY.md b/desktop/MANAGED_IDENTITY.md new file mode 100644 index 00000000000..0f812415090 --- /dev/null +++ b/desktop/MANAGED_IDENTITY.md @@ -0,0 +1,124 @@ +# Organization-managed desktop identity + +This is a release-selected build mode, not a runtime switch or an ordinary service +publish option. OSS/local-key builds retain their existing behavior. The managed +build cannot fall back to generating, importing, recovering or exporting a local +human signing key when corporate login or signing fails. + +## Build and service contract + +`BUZZ_BUILD_ENTERPRISE` is one JSON object with **exactly seven** fields: +`signerUrl`, `issuer`, `clientId`, `audience`, `organization`, `connection`, +`redirectUri`. The release environment selector is not a field in this object. +The native build consumer validates and canonically embeds it as +`BUZZ_DESKTOP_BUILD_ENTERPRISE`; corporate builds require `system-keyring`. +Do not put client secrets, tokens, or user-selected relay/identity values in it. +Signer and issuer URLs follow the shared release schema (HTTPS, no credentials, +queries or fragments, and no explicit port). Signer URL is a path prefix ending +in `/`, not an identity endpoint. Enrollment and signing are respectively: + +- `POST v1/buzz/identity/ensure`, body `{}`; +- `POST v1/buzz/identity/sign`, body containing the exact unsigned event. + +There are no legacy endpoint aliases. Enrollment pins the returned public key and +matching HTTPS/WSS relay origin. Backend-supported non-default relay ports are +allowed; this does not relax signer or issuer validation. Event verification +rejects any changed public key, kind, content, timestamp, tag, ID or signature. +The remote signer rejects off-origin HTTP, relay-auth and media proofs before +asking for credentials or contacting the service. + +Browser login uses state and PKCE. The callback binds **exactly** the configured +`http://127.0.0.1:/enterprise-callback`; a busy port is a sign-in error, +not permission to select a new port. The callback wait is bounded. The same +seven-field validator is consumed by build.rs and native contract tests. + +## Ownership and offboarding + +`EnterpriseIdentity` owns secure token storage, refresh and session cancellation. +The signing interface is only `public_key()` plus asynchronous exact-event +`sign()`; it does not own enrollment, event construction, publication, encryption, +secret export or login lifecycle. Refresh is singleflight and never re-enrolls. +Before rotating a refresh credential the owner durably writes a rotation +pending marker, then atomically stores the replacement. Interrupted/ambiguous +rotation or any storage/refresh failure requires a fresh sign-in; the old token +is not replayed. A failed durable cleanup is reported, not silently declared +successful. + +Sign-out/login replacement cancels the old owner, native relay sessions (including +pending auth/reconnect), and huddle/upload authorization lifetimes. Responses from +an old signer cannot establish the new identity. A cancelled in-memory owner is +not restored from old disk credentials. Logout removes the saved session; it does +not wipe downloaded files, existing local caches/preferences, or revoke already +issued proofs. Operator offboarding must also remove backend/relay membership and +corporate authorization. A request accepted before cancellation cannot be undone +by cancelling the client. + +Corporate media read proofs last at most 120 seconds; uploads at most 300 seconds. +One read-proof entry is owned by each login session and matched by identity and +exact relay origin (including port). Concurrent misses singleflight, cached proofs +are not used within ten seconds of expiry, and cancelled signing cannot populate +or serve the cache. New logins never inherit it. Corporate proof failure (including no session or logout) aborts both media +proxies and bounded downloads before any upstream transport; only OSS recovery +retains optional unsigned reads. The avatar card caller also propagates proof +failure. Authenticated media clients do not follow redirects. Membership checks remain server-side: cached proofs are +not an offboarding bypass. + +## Deliberate exclusions + +The service supplies event signatures, **not local private-key/NIP-44 operations**. +Accordingly: + +- No private-key reveal/export/import, NIP-49 key backup, or key-based pairing. +- No encrypted cross-device read-state, sidebar stars/mutes/sort/sections, + project-sidebar membership, or appearance sync. Existing device-local stores + and local read-marker persistence remain available; sync managers do not fetch, + subscribe, encrypt, publish, or retry in managed mode. No plaintext relay fallback. +- Encrypted reminders are explicitly unavailable (no background reminder poll). +- Local managed-agent creation, start, instance-key loading and runtime spawning + are denied natively. The Agents surface explains the exclusion. Existing remote + relay identities remain public identities, not a promise of local management. +- Other commands requiring a local human secret, including encrypted agent/team + archives and local git credential/agent delegation setup, fail explicitly. +- Corporate community selection is pinned to the enrolled relay; importing a local + identity or applying another community is rejected by the native workspace command. + +Settings disclose these differences. These are intentional scope limitations, +not equivalent replacements for the local-key features or the remote-agent vision. + +## Retry semantics and validation limits + +Relay submission authenticates the captured relay and signer, and success requires +an ACK whose ID exactly matches the submitted signed event. Already-signed event +APIs preserve those bytes for caller-managed retries; media's legacy route retry +reuses its signed proof. Not every interactive command has a durable signed-event +outbox. After an ambiguous network failure, a new user action may construct a new +event: do not promise automatic exactly-once delivery or restart-safe retries. +There is no broad durable-outbox implementation in this port. + +Synthetic tests cover the production scope guard before network/credentials, +exact-event verification, delayed signer relay capture/ACK matching, token-owner +faults, native cancellation, media cache fencing, encrypted-feature gating and +device-local reads. An opt-in compiled corporate-mode test constructs real AppState +and proves no local key/import persistence; use only synthetic build configuration. +Loopback OAuth tests exercise the actual fixed callback listener. These do not +prove live corporate SSO enrollment, signing-service authorization, deployment +schema integration, or operator offboarding. Live staging remains blocked while +infrastructure policy is deny-only. Build artifact/source markers alone are not +runtime proof. No production credentials are needed or used by these tests. + +## Known draft limitations and dependent work + +Directory/profile-editing operations remain exposed while the managed backend +excludes ordinary profile mutation: these fail rather than changing directory +names. The UI/backend mismatch is not solved by remote signing. Mobile additionally +still invokes ensure during refresh and has weaker direct parser/proof preflight +than Rust; see `mobile/MANAGED_IDENTITY.md`. + +This feature is stacked on extraction draft #7600. Service draft +`squareup/cash-server#124571` signs but never publishes ordinary events; release +draft `squareup/buzz-releases#94` provides the seven compiled fields (its +`environment` selector is release-only). Terraform draft #1385 is unbound and +deny-only; an approved corporate authority and authorization for the active +infrastructure repository remain prerequisites. No schema apply, enrollment, +deployment, live keychain/SSO, native GUI or signed installer was validated here. +Empty local sidecar placeholders are not packaging evidence. 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/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs index 3de4830ceeb..68677b25c6c 100644 --- a/desktop/scripts/build-protected-feature-artifacts.mjs +++ b/desktop/scripts/build-protected-feature-artifacts.mjs @@ -34,7 +34,15 @@ function buildVariant({ internal, output }) { const result = spawnSync( process.execPath, - [viteEntrypoint, "build", "--outDir", output, "--emptyOutDir"], + [ + viteEntrypoint, + "build", + "--configLoader", + "runner", + "--outDir", + output, + "--emptyOutDir", + ], { cwd: desktopRoot, env, 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/Cargo.toml b/desktop/src-tauri/Cargo.toml index a7ae5229d1a..a0d64d9a8d8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:me system-keyring = ["dep:keyring"] [build-dependencies] +buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" } base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..39378755ff2 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,5 @@ +#[path = "src/enterprise_build_config.rs"] +mod enterprise_build_config; // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); @@ -8,6 +10,23 @@ 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 Some(raw) = std::env::var_os("BUZZ_BUILD_ENTERPRISE") { + let canonical = raw + .to_str() + .ok_or_else(|| "Invalid corporate build configuration".to_string()) + .and_then(|raw| { + enterprise_build_config::compile_config( + raw, + std::env::var_os("CARGO_FEATURE_SYSTEM_KEYRING").is_some(), + ) + }) + .unwrap_or_else(|_| { + panic!("Invalid corporate build configuration or missing system-keyring") + }); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_ENTERPRISE={canonical}"); + } + 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..cb9ccacfefb 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -17,7 +17,8 @@ use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; pub struct AppState { - pub keys: Mutex, + 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. pub(crate) identity_storage: AtomicU8, @@ -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), @@ -193,20 +197,25 @@ pub fn build_app_state() -> AppState { "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - (keys, IdentityStorage::Environment) + (Some(keys), IdentityStorage::Environment) } - None => (Keys::generate(), IdentityStorage::Ephemeral), + None => ( + (!crate::enterprise_identity::enabled()).then(Keys::generate), + IdentityStorage::Ephemeral, + ), }; 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) .build() - .unwrap_or_else(|_| reqwest::Client::new()), + .unwrap_or_else(|error| panic!("Cannot build no-redirect HTTP client: {error}")), media_fetch_client: build_media_fetch_client().expect( "media_fetch_client must build with redirect::Policy::none(); a \ redirect-following fallback would forward the minted media auth \ @@ -264,6 +273,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. @@ -282,7 +294,7 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( // any thread that reads a flag as false with Acquire sees consistent data. { let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; - *active_keys = resolved.keys; + *active_keys = Some(resolved.keys); state.set_identity_storage(resolved.storage); } state.identity_lost.store( diff --git a/desktop/src-tauri/src/app_state_accessors.rs b/desktop/src-tauri/src/app_state_accessors.rs index 28751e346c2..7a75b9a419c 100644 --- a/desktop/src-tauri/src/app_state_accessors.rs +++ b/desktop/src-tauri/src/app_state_accessors.rs @@ -53,6 +53,12 @@ impl AppState { /// this instead of locking `state.keys` directly, so that recovery mode /// blocks publishing under an invalid or inaccessible identity. pub fn signing_keys(&self) -> Result { + if crate::enterprise_identity::enabled() { + return Err( + "This feature requires a local private key and is unavailable in corporate builds" + .into(), + ); + } if self .identity_lost .load(std::sync::atomic::Ordering::Acquire) @@ -67,16 +73,34 @@ impl AppState { self.keys .lock() .map_err(|e| e.to_string()) - .map(|k| k.clone()) + .and_then(|k| k.clone().ok_or_else(|| "Local identity unavailable".into())) } /// Capture the active event signer after the same recovery checks as local keys. /// Secret export, encryption, pairing and provisioning must use `signing_keys`. pub fn event_signer( &self, - ) -> Result { - self.signing_keys() - .map(buzz_ws_client_pkg::event_signer::LocalEventSigner::new) + ) -> Result, String> { + if crate::enterprise_identity::enabled() { + return self.enterprise.event_signer(); + } + Ok(std::sync::Arc::new( + buzz_ws_client_pkg::event_signer::LocalEventSigner::new(self.signing_keys()?), + )) + } + + /// Public identity, including recovery-mode local identity display. + pub(crate) fn public_key(&self) -> Result { + if crate::enterprise_identity::enabled() { + use buzz_ws_client_pkg::event_signer::EventSigner; + return Ok(self.enterprise.event_signer()?.public_key()); + } + self.keys + .lock() + .map_err(|e| e.to_string())? + .as_ref() + .map(Keys::public_key) + .ok_or_else(|| "Local identity unavailable".into()) } /// Emit the current huddle state to the frontend via Tauri event. diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index ceef4d3f93e..b1e97beba7c 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -935,7 +935,7 @@ fn signing_keys_returns_ok_when_normal() { "signing_keys() must return Ok when neither flag is set" ); // The returned keys must match the stored keys. - let expected = state.keys.lock().unwrap().clone(); + let expected = state.keys.lock().unwrap().as_ref().unwrap().clone(); assert_key_eq(&result.unwrap(), &expected); } 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..b43d0823821 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, ) @@ -668,7 +668,7 @@ mod real_relay { /// is exercised, including NIP-98 signing inside `query_relay`. fn make_test_app_state(keys: Keys, relay_url: &str) -> AppState { let state = build_app_state(); - *state.keys.lock().unwrap() = keys; + *state.keys.lock().unwrap() = Some(keys); *state.relay_url_override.lock().unwrap() = Some(relay_url.to_string()); state } @@ -749,7 +749,14 @@ mod real_relay { state: &AppState, db_path: &Path, ) -> ArchiveBatchResult { - let identity_pk = state.keys.lock().unwrap().public_key().to_hex(); + let identity_pk = state + .keys + .lock() + .unwrap() + .as_ref() + .unwrap() + .public_key() + .to_hex(); let relay_url = crate::relay::relay_ws_url_with_override(state); // Phase 1: plan (sync). Connection dropped before any .await. @@ -765,14 +772,14 @@ mod real_relay { // Phase 3: persist (sync). Fresh connection, same file. let conn = store::open_archive_db(db_path).expect("open archive db for commit"); - let owner_keys = state.keys.lock().unwrap().clone(); + let owner_keys = state.keys.lock().unwrap().as_ref().unwrap().clone(); commit_archive( bucket_results, plan.ephemeral, 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/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index 8f7493e1e8b..d9cc108c4d3 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -78,11 +78,7 @@ fn managed_policy_filters( } fn current_user_pubkey(state: &AppState) -> Result { - state - .keys - .lock() - .map(|keys| keys.public_key().to_hex()) - .map_err(|error| error.to_string()) + Ok(state.public_key()?.to_hex()) } pub(super) fn advance_relay_cursor(filter: &mut serde_json::Value, page: &[nostr::Event]) { @@ -457,7 +453,7 @@ mod real_relay_tests { fn state_for(keys: Keys) -> AppState { let state = build_app_state(); - *state.keys.lock().unwrap() = keys; + *state.keys.lock().unwrap() = Some(keys); *state.relay_url_override.lock().unwrap() = Some(relay_ws_url()); state } diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs index bb42b3e6d24..2cb3b379ab4 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory/owned_tests.rs @@ -105,7 +105,7 @@ async fn remote_owned_discovery_and_membership_do_not_require_local_records() { axum::serve(listener, router).await.unwrap(); }); let state = crate::app_state::build_app_state(); - *state.keys.lock().unwrap() = owner.clone(); + *state.keys.lock().unwrap() = Some(owner.clone()); *state.relay_url_override.lock().unwrap() = Some(format!("ws://{address}")); let discovered = list_relay_agents_for_state(&state).await.unwrap(); 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/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/channels/fetch_tests.rs b/desktop/src-tauri/src/commands/channels/fetch_tests.rs index 439dfbd6c56..5f5c4143dd1 100644 --- a/desktop/src-tauri/src/commands/channels/fetch_tests.rs +++ b/desktop/src-tauri/src/commands/channels/fetch_tests.rs @@ -108,7 +108,7 @@ impl Relay { fn state(&self, keys: &Keys) -> AppState { let state = crate::app_state::build_app_state(); - *state.keys.lock().unwrap() = keys.clone(); + *state.keys.lock().unwrap() = Some(keys.clone()); *state.relay_url_override.lock().unwrap() = Some(self.url.clone()); state } @@ -327,7 +327,7 @@ async fn each_fetch_observes_roster_changes_and_the_current_identity_and_relay() .await .unwrap() .is_empty()); - *state.keys.lock().unwrap() = next_keys.clone(); + *state.keys.lock().unwrap() = Some(next_keys.clone()); assert_eq!( fetch(&state, DirectoryScope::MemberOnly).await.unwrap()[0].member_pubkeys, vec![next.clone()] diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 91e636d5f20..161ff873fa5 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -265,14 +265,21 @@ fn pending_owner_mark_uses_signer_captured_before_identity_swap() { // Simulate an in-process identity swap landing during the (here, // implicit) submit await — e.g. `import_identity` replacing // `state.keys` while the create request is in flight. - *state.keys.lock().expect("lock keys") = Keys::generate(); + *state.keys.lock().expect("lock keys") = Some(Keys::generate()); // The mark must use the captured signer, not whatever `state.keys` // holds now. state.mark_pending_owned_channel(&creator_pubkey, "chan-1"); assert!(state.is_pending_owned_channel(&creator_pubkey, "chan-1")); - let post_swap_pubkey = state.keys.lock().expect("lock keys").public_key().to_hex(); + let post_swap_pubkey = state + .keys + .lock() + .expect("lock keys") + .as_ref() + .unwrap() + .public_key() + .to_hex(); assert!(!state.is_pending_owned_channel(&post_swap_pubkey, "chan-1")); } 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 d45b25942f9..f646cee610c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -54,8 +54,7 @@ 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 = state.public_key()?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; let lost = state @@ -71,7 +70,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, @@ -368,6 +371,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. @@ -439,8 +446,12 @@ 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(); + let previous_pubkey = state.public_key()?; let storage = persist(&keys)?; @@ -450,7 +461,7 @@ pub(crate) fn commit_imported_identity( let pubkey = keys.public_key(); { let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; - *active_keys = keys; + *active_keys = Some(keys); state.set_identity_storage(storage); } @@ -496,6 +507,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::(); @@ -513,7 +528,7 @@ pub async fn persist_current_identity( } // Clone current keys without holding the mutex across keyring I/O. - let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); + let keys = state.signing_keys()?; let data_dir = app_handle .path() @@ -645,11 +660,7 @@ pub async fn sign_nostr_identity_binding( &expires_at, )?; - let keys = state - .keys - .lock() - .map_err(|error| error.to_string())? - .clone(); + let keys = state.signing_keys()?; tauri::async_runtime::spawn_blocking(move || { let event = build_nostr_identity_binding_event( diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index bf66b761d32..aad7f380264 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). @@ -871,9 +865,9 @@ mod tests { let router = Router::new() .route( "/events", - post(move || async move { + post(move |Json(event): Json| async move { Json(serde_json::json!({ - "event_id": "e".repeat(64), + "event_id": event["id"], "accepted": accepted, "message": if accepted { "" } else { "rejected by relay" }, })) diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs index c36af66879a..5bb61e13fb0 100644 --- a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -14,7 +14,14 @@ fn verification_returns_only_public_identity_and_match_status() { let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); assert_eq!( result.pubkey, - state.keys.lock().unwrap().public_key().to_hex() + state + .keys + .lock() + .unwrap() + .as_ref() + .unwrap() + .public_key() + .to_hex() ); assert!(result.npub.starts_with("npub1")); assert!(result.matches_current_identity); @@ -112,7 +119,7 @@ fn recovery_mode_blocks_backup_creation() { #[test] fn concurrent_identity_swap_vs_backup_is_serialized() { let state = std::sync::Arc::new(build_app_state()); - let key_a = state.keys.lock().unwrap().clone(); + let key_a = state.keys.lock().unwrap().as_ref().unwrap().clone(); let key_b = Keys::generate(); let swapper = { @@ -122,7 +129,7 @@ fn concurrent_identity_swap_vs_backup_is_serialized() { // Mirrors import_identity's locking: mutation guard held // across the key swap. let _guard = state.identity_mutation.lock().unwrap(); - *state.keys.lock().unwrap() = key_b; + *state.keys.lock().unwrap() = Some(key_b); }) }; diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 13fae7d1a77..53b0d47b358 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -337,29 +337,31 @@ pub(crate) async fn sign_blossom_get_auth_header( )) } -/// Mint a `t=get` Authorization header value for a relay media fetch, or -/// `None` when signing is unavailable (identity in recovery mode). -/// -/// When signing is unavailable, callers send no header and the relay rejects -/// the read. This keeps recovery mode from accidentally treating a media URL -/// as a bearer capability. +/// Mint a relay media read proof. Corporate identity/proof failures propagate; +/// only OSS recovery or local signing failure permits an unsigned request. /// /// 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) async fn mint_media_get_auth(state: &AppState, base_url: &str) -> Option { +pub(crate) async fn mint_media_get_auth( + state: &AppState, + base_url: &str, +) -> Result, String> { + if crate::enterprise_identity::enabled() { + return state.enterprise.media_read_proof(base_url).await.map(Some); + } let keys = match state.event_signer() { Ok(k) => k, Err(e) => { eprintln!("buzz-desktop: media get auth unavailable (unsigned request): {e}"); - return None; + return Ok(None); } }; match sign_blossom_get_auth_header(&keys, base_url, MEDIA_GET_AUTH_EXPIRY_SECS).await { - Ok(header) => Some(header), + Ok(header) => Ok(Some(header)), Err(e) => { eprintln!("buzz-desktop: media get auth signing failed (unsigned request): {e}"); - None + Ok(None) } } } @@ -417,13 +419,34 @@ async fn do_upload( state: &AppState, progress: Option<(tauri::AppHandle, String)>, cancellation: Option<&CancellationToken>, +) -> Result { + let lifetime = if crate::enterprise_identity::enabled() { + state.enterprise.cancellation()? + } else { + CancellationToken::new() + }; + tokio::select! { + biased; + _ = lifetime.cancelled() => Err("Corporate login changed; upload cancelled".into()), + result = do_upload_scoped(body, mime, state, progress, cancellation) => result, + } +} + +async fn do_upload_scoped( + body: Vec, + mime: &str, + state: &AppState, + progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); // Video uploads get a 1-hour auth window to survive slow connections; // images use 5 minutes. Must match the server-side max_age_secs values // in process_upload (600s) and process_video_upload (3600s). - let expiry_secs = if mime.starts_with("video/") { + let expiry_secs = if crate::enterprise_identity::enabled() { + 300 + } else if mime.starts_with("video/") { 3600 } else { 300 diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index a48eb4fa432..8d497003090 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -253,9 +253,9 @@ fn redirect_refusal_error(status: reqwest::StatusCode) -> Option { } /// Core streaming fetcher with a caller-supplied byte cap. -pub(super) async fn fetch_blob_bytes_with_cap( +pub(crate) async fn fetch_blob_bytes_with_cap( url: &str, - state: &State<'_, AppState>, + state: &AppState, cap: u64, cancellation: Option<&CancellationToken>, ) -> Result, String> { @@ -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).await { + 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..dd0bc5043f8 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!({ @@ -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/mod.rs b/desktop/src-tauri/src/commands/mod.rs index c8184a01031..6b807c4792d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -30,7 +30,7 @@ mod link_preview; mod managed_agent_definition; pub(crate) mod media; mod media_animated; -mod media_download; +pub(crate) mod media_download; mod media_fetch_cancellation; mod media_filename; mod media_gif; diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 5f11429e452..f07cb3d63e5 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -673,7 +673,7 @@ pub async fn mint_agent_card( // `media_download.rs`). let relay_base = crate::relay::relay_api_base_url_with_override(&state); let auth = if is_same_origin(url, &relay_base) { - crate::commands::media::mint_media_get_auth(&state, &relay_base).await + crate::commands::media::mint_media_get_auth(&state, &relay_base).await? } else { None }; diff --git a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs index 5d192780ae4..e0122f1af9b 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/catalog_reconcile_tests.rs @@ -80,7 +80,7 @@ fn team() -> TeamRecord { /// and overrides both so this handle's retention scope lands inside the tempdir. fn mock_app(keys: &nostr::Keys) -> tauri::App { let state = build_app_state(); - *state.keys.lock().unwrap() = keys.clone(); + *state.keys.lock().unwrap() = Some(keys.clone()); *state.relay_url_override.lock().unwrap() = Some(RELAY.to_string()); tauri::test::mock_builder() diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index da93af673de..9d196282bb3 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -393,8 +393,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 { @@ -425,11 +424,18 @@ mod tests { let captured = capture_expected_signer(&state, &original_pubkey) .expect("matching identity should be captured"); - *state.keys.lock().expect("lock keys") = nostr::Keys::generate(); + *state.keys.lock().expect("lock keys") = Some(nostr::Keys::generate()); assert_eq!(captured.public_key().to_hex(), original_pubkey); assert_ne!( - state.keys.lock().expect("lock keys").public_key().to_hex(), + state + .keys + .lock() + .expect("lock keys") + .as_ref() + .unwrap() + .public_key() + .to_hex(), original_pubkey ); assert_eq!( 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/teams/pending/tests/gate.rs b/desktop/src-tauri/src/commands/teams/pending/tests/gate.rs index be70f61a833..a705a2fe372 100644 --- a/desktop/src-tauri/src/commands/teams/pending/tests/gate.rs +++ b/desktop/src-tauri/src/commands/teams/pending/tests/gate.rs @@ -149,7 +149,7 @@ fn seed_team_head(db_path: &Path, keys: &nostr::Keys, created_at: i64) { fn app_state_for(keys: nostr::Keys, relay_http: &str) -> crate::app_state::AppState { let state = build_app_state(); - *state.keys.lock().unwrap() = keys; + *state.keys.lock().unwrap() = Some(keys); *state.relay_url_override.lock().unwrap() = Some(relay_http.to_string()); state } diff --git a/desktop/src-tauri/src/commands/teams/sharing/tests.rs b/desktop/src-tauri/src/commands/teams/sharing/tests.rs index e2f149f78f4..4058b4fee8d 100644 --- a/desktop/src-tauri/src/commands/teams/sharing/tests.rs +++ b/desktop/src-tauri/src/commands/teams/sharing/tests.rs @@ -301,7 +301,7 @@ async fn delayed_share_after_delete_never_republishes_the_catalog_head() { // 3. Flush the tombstone to the relay (Carl's contract: the tombstone // lands BEFORE the delayed publish is released). let state = build_app_state(); - *state.keys.lock().unwrap() = keys.clone(); + *state.keys.lock().unwrap() = Some(keys.clone()); *state.relay_url_override.lock().unwrap() = Some(relay_url); flush_pending_events_at( &db_path, @@ -449,7 +449,7 @@ async fn concurrent_flushes_never_land_the_head_after_its_tombstone() { let _prepared = prepared(&db_path, relay_url.clone(), keys.clone(), true); let state = Arc::new(build_app_state()); - *state.keys.lock().unwrap() = keys.clone(); + *state.keys.lock().unwrap() = Some(keys.clone()); *state.relay_url_override.lock().unwrap() = Some(relay_url.clone()); // Flush H: publishes the pending head. Its POST blocks in the gated relay, 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..bb6003515d6 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,14 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let state = app.state::(); + if crate::enterprise_identity::enabled() { + let identity = state.enterprise.managed_identity()?; + if nsec.is_some() || relay_url != identity.relay_ws_url { + return Err("Corporate builds cannot change community or 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 @@ -221,7 +229,7 @@ pub async fn apply_workspace( if let Some(keys) = parsed_keys { let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; - *keys_guard = keys; + *keys_guard = Some(keys); } // Keep the backend-side reconcile guard aligned with the frontend diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 6d2eca4fa9b..fef7dc04199 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -277,6 +277,7 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) // Test-only fixtures — no production egress, no guard: ("src/relay_admission.rs", 1, 0), + ("src/relay/submit_signer_tests.rs", 1, 0), // synthetic ACK relay; guarded production funnel ("src/native_relay_client_transport_tests.rs", 1, 0), ("src/archive/mod_tests.rs", 1, 0), ("src/managed_agents/persona_events/tests.rs", 1, 0), diff --git a/desktop/src-tauri/src/enterprise_build_config.rs b/desktop/src-tauri/src/enterprise_build_config.rs new file mode 100644 index 00000000000..1442989c95a --- /dev/null +++ b/desktop/src-tauri/src/enterprise_build_config.rs @@ -0,0 +1,44 @@ +//! One build-consumer contract, called by build.rs and tested in the native suite. +pub(crate) fn compile_config(raw: &str, system_keyring: bool) -> Result { + if !system_keyring { + return Err("Corporate builds require system-keyring".into()); + } + let config: buzz_ws_client_pkg::enterprise_oauth::EnterpriseLoginConfig = + serde_json::from_str(raw).map_err(|_| "Invalid corporate build configuration")?; + config.validate()?; + // Sorted JSON matches the release verifier, unlike declaration-order serialization. + let value = serde_json::to_value(config).map_err(|_| "Invalid corporate config")?; + serde_json::to_string(&value).map_err(|_| "Invalid corporate config".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + const VALID: &str = r#"{"signerUrl":"https://signer.example/cash-app/goose/","issuer":"https://issuer.example/","clientId":"native-client","audience":"https://signer-api.example","organization":"org_corp","connection":"corporate","redirectUri":"http://127.0.0.1:45871/enterprise-callback"}"#; + #[test] + fn actual_build_consumer_requires_keyring_and_exact_native_schema() { + assert!(compile_config(VALID, false).is_err()); + let canonical = compile_config(VALID, true).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&canonical).unwrap(); + assert_eq!(parsed.as_object().unwrap().len(), 7); + assert_eq!( + parsed["redirectUri"], + "http://127.0.0.1:45871/enterprise-callback" + ); + assert!(canonical.starts_with("{\"audience\":")); + for invalid in [ + VALID.replace("45871", "0"), + VALID.replace("http://127.0.0.1", "http://localhost"), + VALID.replace("https://signer.example", "http://signer.example"), + VALID.replace("cash-app/goose/", "cash-app/goose/v1/buzz/identity/sign"), + VALID.replace("\"issuer\":", "\"environment\":\"staging\",\"issuer\":"), + VALID.replace( + "\"issuer\":", + "\"clientSecret\":\"not-allowed\",\"issuer\":", + ), + VALID.replace("\"issuer\":", "\"clientId\":\"duplicate\",\"issuer\":"), + ] { + assert!(compile_config(&invalid, true).is_err()); + } + } +} diff --git a/desktop/src-tauri/src/enterprise_identity.rs b/desktop/src-tauri/src/enterprise_identity.rs new file mode 100644 index 00000000000..3486ca061bb --- /dev/null +++ b/desktop/src-tauri/src/enterprise_identity.rs @@ -0,0 +1,369 @@ +//! Corporate login owns refresh credentials and session lifetime, never event construction. +use buzz_ws_client_pkg::enterprise_callback::EnterpriseCallback; +use buzz_ws_client_pkg::enterprise_oauth::{ + EnterpriseLoginAttempt, EnterpriseLoginConfig, EnterpriseOAuthTokens, +}; +use buzz_ws_client_pkg::event_signer::EventSigner; +use buzz_ws_client_pkg::remote_identity::{ + ManagedIdentity, RemoteAuthorization, RemoteCredentials, RemoteEventSigner, + RemoteIdentityClient, +}; +use serde::{Deserialize, Serialize}; +use std::{ + future::Future, + pin::Pin, + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tauri::{Emitter, Manager}; +use tauri_plugin_opener::OpenerExt; +use tokio::sync::Mutex as AsyncMutex; +use tokio_util::sync::CancellationToken; + +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 corporate 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<()>, +} +struct CorporateSession { + identity: ManagedIdentity, + config: EnterpriseLoginConfig, + client: RemoteIdentityClient, + tokens: AsyncMutex, + cancelled: CancellationToken, + io: Arc, + media_reads: crate::enterprise_media_cache::MediaReadProofCache, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredTokens { + config: EnterpriseLoginConfig, + tokens: EnterpriseOAuthTokens, + expires_at: u64, + identity: ManagedIdentity, + // A durable rotation tombstone prevents replay after an ambiguous exchange/crash. + rotation_pending: bool, +} +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() + )) + }) +} +fn persist(stored: &StoredTokens) -> Result<(), String> { + let encoded = zeroize::Zeroizing::new( + serde_json::to_string(stored).map_err(|_| "Cannot encode corporate credentials")?, + ); + secret_store().store("session", &encoded) +} +// Narrow IO seam around the original storage/refresh lifecycle, shared by the +// production owner and fault-injection tests. No identity lookup or event API. +trait CorporateSessionIo: Send + Sync { + fn persist(&self, stored: &StoredTokens) -> Result<(), String>; + fn refresh<'a>( + &'a self, + config: &'a EnterpriseLoginConfig, + refresh: &'a str, + ) -> Pin> + Send + 'a>>; +} +struct NativeSessionIo; +impl CorporateSessionIo for NativeSessionIo { + fn persist(&self, stored: &StoredTokens) -> Result<(), String> { + persist(stored) + } + fn refresh<'a>( + &'a self, + config: &'a EnterpriseLoginConfig, + refresh: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(buzz_ws_client_pkg::enterprise_oauth::refresh( + config, refresh, + )) + } +} +impl RemoteAuthorization for CorporateSession { + fn check_active(&self) -> Result<(), String> { + if self.cancelled.is_cancelled() { + Err("Corporate identity changed; sign in again".into()) + } else { + Ok(()) + } + } + fn credentials( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + self.check_active()?; + let mut stored = self.tokens.lock().await; + self.check_active()?; + let result = async { + if stored.rotation_pending { + return Err("Corporate rotation interrupted; sign in again".into()); + } + if stored.expires_at <= now()? + 30 { + stored.rotation_pending = true; + self.io.persist(&stored)?; + let refresh = stored + .tokens + .refresh_token + .as_ref() + .ok_or("Corporate session expired; sign in again")?; + let fresh = self.io.refresh(&self.config, refresh).await?; + self.check_active()?; + // Refresh is authorization-only. Do not re-enroll or silently switch identity. + // The sign response must match the pinned public key and exact event. + let next = StoredTokens { + config: self.config.clone(), + expires_at: now()? + fresh.expires_in, + tokens: fresh, + identity: self.identity.clone(), + rotation_pending: false, + }; + self.io.persist(&next)?; + *stored = next; + } + self.check_active()?; + Ok(RemoteCredentials::new(stored.tokens.access_token.clone())) + } + .await; + if result.is_err() { + self.cancelled.cancel(); + } + result + }) + } +} +impl EnterpriseIdentity { + fn session(&self) -> Result, String> { + let session = self + .current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .clone() + .ok_or("Corporate login required")?; + session.check_active()?; + Ok(session) + } + pub(crate) fn managed_identity(&self) -> Result { + Ok(self.session()?.identity.clone()) + } + pub(crate) fn event_signer(&self) -> Result, String> { + let session = self.session()?; + Ok(Arc::new(RemoteEventSigner::new( + session.client.clone(), + &session.identity, + session.clone(), + )?)) + } + pub(crate) async fn media_read_proof(&self, base_url: &str) -> Result { + let session = self.session()?; + if session.identity.relay_http_url.trim_end_matches('/') != base_url.trim_end_matches('/') { + return Err("Corporate media host does not match login".into()); + } + let signer = + RemoteEventSigner::new(session.client.clone(), &session.identity, session.clone())?; + session + .media_reads + .get( + &signer, + session.identity.relay_http_url.trim_end_matches('/'), + &session.cancelled, + ) + .await + } + pub(crate) fn cancellation(&self) -> Result { + Ok(self.session()?.cancelled.clone()) + } + async fn invalidate(&self) -> Result<(), String> { + let old = self + .current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .clone(); + if let Some(old) = old { + old.cancelled.cancel(); + old.media_reads.clear().await; + // Wait for an in-flight durable refresh before deleting/replacing its entry. + let _tokens = old.tokens.lock().await; + } + Ok(()) + } + async fn install( + &self, + config: EnterpriseLoginConfig, + stored: StoredTokens, + ) -> Result { + if serde_json::to_value(&stored.config).map_err(|_| "Invalid saved configuration")? + != serde_json::to_value(&config).map_err(|_| "Invalid build configuration")? + || stored.rotation_pending + || stored.expires_at > now()? + 300 + { + return Err("Corporate build or session changed; sign in again".into()); + } + stored.identity.validate()?; + let session = Arc::new(CorporateSession { + identity: stored.identity.clone(), + client: RemoteIdentityClient::new(&config.signer_url)?, + config, + tokens: AsyncMutex::new(stored), + cancelled: CancellationToken::new(), + io: Arc::new(NativeSessionIo), + media_reads: Default::default(), + }); + session.credentials().await?; + let identity = session.identity.clone(); + let mut current = self + .current + .lock() + .map_err(|_| "Corporate identity lock failed")?; + if let Some(old) = current.replace(session) { + old.cancelled.cancel(); + } + Ok(identity) + } +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct EnterpriseStatus { + enabled: bool, + identity: Option, +} +#[tauri::command] +pub(crate) async fn enterprise_status( + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let Some(config) = build_config()? else { + return Ok(EnterpriseStatus { + enabled: false, + identity: None, + }); + }; + let _login = state.enterprise.login.lock().await; + // Never restore a cancelled in-memory login from its old durable credentials. + let current = state + .enterprise + .current + .lock() + .map_err(|_| "Corporate identity lock failed")? + .clone(); + if let Some(session) = current { + let identity = if session.credentials().await.is_ok() { + Some(session.identity.clone()) + } else { + None + }; + return Ok(EnterpriseStatus { + enabled: true, + identity, + }); + } + 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?; + app.state::() + .bind_identity(state.enterprise.cancellation()?) + .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 { + let config = build_config()?.ok_or("Not a corporate build")?; + let _login = state.enterprise.login.lock().await; + state.enterprise.invalidate().await?; + app.state::() + .clear_identity() + .await; + secret_store().delete("session")?; + let callback = EnterpriseCallback::bind(&config).await?; + 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, config.redirect_uri.clone()); + app.opener() + .open_url(attempt.authorization_url(&config)?.as_str(), None::<&str>) + .map_err(|_| "Cannot open corporate login")?; + let callback = callback.receive(&attempt).await?; + let tokens = attempt.exchange(&config, &callback).await?; + let identity = RemoteIdentityClient::new(&config.signer_url)? + .ensure(&RemoteCredentials::new(tokens.access_token.clone())) + .await?; + let stored = StoredTokens { + config: config.clone(), + expires_at: now()? + tokens.expires_in, + tokens, + identity, + rotation_pending: false, + }; + persist(&stored)?; + let identity = state.enterprise.install(config, stored).await?; + app.state::() + .bind_identity(state.enterprise.cancellation()?) + .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 a corporate build".into()); + } + let _login = state.enterprise.login.lock().await; + state.enterprise.invalidate().await?; + app.state::() + .clear_identity() + .await; + secret_store().delete("session") +} + +#[cfg(test)] +#[path = "enterprise_identity_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/enterprise_identity_tests.rs b/desktop/src-tauri/src/enterprise_identity_tests.rs new file mode 100644 index 00000000000..a82f9b18aa7 --- /dev/null +++ b/desktop/src-tauri/src/enterprise_identity_tests.rs @@ -0,0 +1,319 @@ +use super::*; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn config() -> EnterpriseLoginConfig { + serde_json::from_value(serde_json::json!({ + "signerUrl":"https://signer.example/cash-app/goose/", "issuer":"https://issuer.example/", + "clientId":"native-client", "audience":"https://signer-api.example", "organization":"org_corp", + "connection":"corporate", "redirectUri":"http://127.0.0.1:45871/enterprise-callback" + })).unwrap() +} +fn tokens() -> EnterpriseOAuthTokens { + serde_json::from_value(serde_json::json!({"access_token":"synthetic-access", "refresh_token":"synthetic-rotation", "expires_in":300, "token_type":"Bearer"})).unwrap() +} +fn stored(expires_at: u64) -> StoredTokens { + StoredTokens { + config: config(), + tokens: tokens(), + expires_at, + identity: ManagedIdentity { + pubkey: nostr::Keys::generate().public_key().to_hex(), + relay_ws_url: "wss://buzz.example".into(), + relay_http_url: "https://buzz.example".into(), + }, + rotation_pending: false, + } +} +#[derive(Default)] +struct Io { + refreshes: AtomicUsize, + saves: Mutex>, + fail_save: usize, + fail_refresh: bool, + cancel_during_refresh: Option, +} +impl CorporateSessionIo for Io { + fn persist(&self, stored: &StoredTokens) -> Result<(), String> { + let mut saves = self.saves.lock().unwrap(); + if self.fail_save == saves.len() + 1 { + return Err("secure storage unavailable".into()); + } + saves.push(stored.rotation_pending); + Ok(()) + } + fn refresh<'a>( + &'a self, + _: &'a EnterpriseLoginConfig, + refresh: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + assert_eq!(refresh, "synthetic-rotation"); + assert_eq!(*self.saves.lock().unwrap(), vec![true]); + self.refreshes.fetch_add(1, Ordering::SeqCst); + tokio::task::yield_now().await; + if let Some(cancel) = &self.cancel_during_refresh { + cancel.cancel(); + } + if self.fail_refresh { + return Err("refresh denied".into()); + } + Ok(tokens()) + }) + } +} +fn session(io: Arc) -> Arc { + let stored = stored(0); + Arc::new(CorporateSession { + config: config(), + client: RemoteIdentityClient::new(&config().signer_url).unwrap(), + identity: stored.identity.clone(), + tokens: AsyncMutex::new(stored), + cancelled: CancellationToken::new(), + io, + media_reads: Default::default(), + }) +} +#[test] +fn absent_session_has_no_local_fallback() { + let owner = EnterpriseIdentity::default(); + assert!(owner.event_signer().is_err()); + assert!(owner.managed_identity().is_err()); +} +#[tokio::test] +async fn production_authorization_owner_singleflights_and_atomically_persists_rotation() { + let io = Arc::new(Io::default()); + let session = session(io.clone()); + let original = session.identity.clone(); + let (a, b, c) = tokio::join!( + session.credentials(), + session.credentials(), + session.credentials() + ); + assert!(a.is_ok() && b.is_ok() && c.is_ok()); + assert_eq!(io.refreshes.load(Ordering::SeqCst), 1); + assert_eq!(*io.saves.lock().unwrap(), vec![true, false]); + assert_eq!(session.identity, original); + assert!(!session.tokens.lock().await.rotation_pending); +} +#[tokio::test] +async fn every_storage_or_refresh_failure_cancels_owner_and_never_replays_token() { + for (fail_save, fail_refresh, refreshes, saved) in [ + (1, false, 0, vec![]), + (2, false, 1, vec![true]), + (0, true, 1, vec![true]), + ] { + let io = Arc::new(Io { + fail_save, + fail_refresh, + ..Io::default() + }); + let session = session(io.clone()); + assert!(session.credentials().await.is_err()); + assert!(session.cancelled.is_cancelled()); + assert!(session.credentials().await.is_err()); + assert_eq!(io.refreshes.load(Ordering::SeqCst), refreshes); + assert_eq!(*io.saves.lock().unwrap(), saved); + } +} +#[tokio::test] +async fn logout_during_refresh_discards_response_and_leaves_durable_rotation_tombstone() { + let cancel = CancellationToken::new(); + let io = Arc::new(Io { + cancel_during_refresh: Some(cancel.clone()), + ..Io::default() + }); + let mut session = session(io.clone()); + Arc::get_mut(&mut session).unwrap().cancelled = cancel; + assert!(session.credentials().await.is_err()); + assert_eq!(*io.saves.lock().unwrap(), vec![true]); + assert!(session.tokens.lock().await.rotation_pending); +} +#[tokio::test] +async fn invalidation_keeps_cancelled_owner_so_status_cannot_restore_old_disk_login() { + let owner = EnterpriseIdentity::default(); + let session = session(Arc::new(Io::default())); + *owner.current.lock().unwrap() = Some(session.clone()); + let signer = owner.event_signer().unwrap(); + owner.invalidate().await.unwrap(); + assert!(owner.current.lock().unwrap().is_some()); + assert!(owner.managed_identity().is_err()); + assert!(session.credentials().await.is_err()); + let unsigned = + nostr::EventBuilder::new(nostr::Kind::TextNote, "old operation").build(signer.public_key()); + assert!(signer.sign(unsigned).await.is_err()); +} +#[tokio::test] +async fn restore_refuses_interrupted_rotation_or_different_build_without_network() { + let owner = EnterpriseIdentity::default(); + let mut pending = stored(now().unwrap() + 120); + pending.rotation_pending = true; + assert!(owner.install(config(), pending).await.is_err()); + let mut changed = stored(now().unwrap() + 120); + changed.config.organization = "another-org".into(); + assert!(owner.install(config(), changed).await.is_err()); + assert!(owner.event_signer().is_err()); +} + +#[test] +#[ignore = "run with a synthetic BUZZ_BUILD_ENTERPRISE config to test the actual compiled mode"] +fn compiled_corporate_mode_has_no_key_and_rejects_import_before_persistence() { + assert!(enabled()); + assert!(build_config().unwrap().is_some()); + let state = crate::app_state::build_app_state(); + assert!(state.keys.lock().unwrap().is_none()); + assert!(state.signing_keys().is_err()); + assert!(state.event_signer().is_err()); + let mut called = false; + let result = crate::commands::commit_imported_identity( + &state, + std::path::Path::new("/unused"), + nostr::Keys::generate(), + |_| { + called = true; + Ok(crate::identity_storage::IdentityStorage::Ephemeral) + }, + ); + assert!(result.is_err()); + assert!(!called); +} + +// These invoke the three production transport consumers, not just proof minting. +#[tokio::test] +#[ignore = "requires synthetic compiled corporate configuration; never live credentials"] +async fn compiled_corporate_media_consumers_never_transport_on_denial_or_logout() { + assert!(enabled()); + let requests = Arc::new(AtomicUsize::new(0)); + let recorded = requests.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new().route( + "/media/blob", + axum::routing::get(move || { + let recorded = recorded.clone(); + async move { + recorded.fetch_add(1, Ordering::SeqCst); + "must not reach media" + } + }), + ), + ) + .await + .unwrap(); + }); + let mut outcomes = Vec::new(); + for reason in ["missing", "logout", "proof-denial"] { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + if reason != "missing" { + // A valid HTTPS identity; the stale local relay origin must fail the + // real owner's proof check, not become optional auth on a stale URL. + let current = session(Arc::new(Io::default())); + *state.enterprise.current.lock().unwrap() = Some(current); + if reason == "logout" { + state.enterprise.invalidate().await.unwrap(); + } + } + let response = crate::media_proxy::proxy_handler_with_state( + &state, + &state.http_client, + axum::http::Request::builder() + .uri("/media/blob") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await; + outcomes.push((reason, "streaming proxy", response.status().as_u16())); + let response = crate::media_proxy::handle_buzz_media_with_state( + &state, + &tauri::http::Request::builder() + .uri("buzz-media://localhost/media/blob") + .body(Vec::new()) + .unwrap(), + ) + .await; + outcomes.push((reason, "protocol proxy", response.status().as_u16())); + let result = crate::commands::media_download::fetch_blob_bytes_with_cap( + &format!("{base}/media/blob"), + &state, + 1024, + None, + ) + .await; + outcomes.push((reason, "download", if result.is_err() { 403 } else { 200 })); + } + server.abort(); + assert_eq!( + requests.load(Ordering::SeqCst), + 0, + "unsigned transport: {outcomes:?}" + ); + assert!( + outcomes.iter().all(|(_, _, status)| *status == 403), + "{outcomes:?}" + ); +} + +#[tokio::test] +async fn oss_media_consumers_preserve_optional_unsigned_auth() { + assert!(!enabled(), "run OSS suite without corporate build config"); + let requests = Arc::new(AtomicUsize::new(0)); + let recorded = requests.clone(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve( + listener, + axum::Router::new().route( + "/media/blob", + axum::routing::get(move |headers: axum::http::HeaderMap| { + let recorded = recorded.clone(); + async move { + assert!(!headers.contains_key("authorization")); + recorded.fetch_add(1, Ordering::SeqCst); + "blob" + } + }), + ), + ) + .await + .unwrap(); + }); + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = None; + *state.relay_url_override.lock().unwrap() = Some(base.clone()); + let streaming = crate::media_proxy::proxy_handler_with_state( + &state, + &state.http_client, + axum::http::Request::builder() + .uri("/media/blob") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(streaming.status(), 200); + let protocol = crate::media_proxy::handle_buzz_media_with_state( + &state, + &tauri::http::Request::builder() + .uri("buzz-media://localhost/media/blob") + .body(Vec::new()) + .unwrap(), + ) + .await; + assert_eq!(protocol.status(), 200); + assert_eq!( + crate::commands::media_download::fetch_blob_bytes_with_cap( + &format!("{base}/media/blob"), + &state, + 1024, + None, + ) + .await + .unwrap(), + b"blob" + ); + server.abort(); + assert_eq!(requests.load(Ordering::SeqCst), 3); +} diff --git a/desktop/src-tauri/src/enterprise_media_cache.rs b/desktop/src-tauri/src/enterprise_media_cache.rs new file mode 100644 index 00000000000..72eb33f1a17 --- /dev/null +++ b/desktop/src-tauri/src/enterprise_media_cache.rs @@ -0,0 +1,152 @@ +//! A single bounded read proof per corporate login. A new session gets a new cache. +use buzz_ws_client_pkg::event_signer::EventSigner; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +pub(crate) struct MediaReadProofCache { + entry: Mutex>, +} +struct Entry { + pubkey: nostr::PublicKey, + origin: String, + header: String, + expires_at: u64, +} +impl MediaReadProofCache { + pub(crate) async fn clear(&self) { + *self.entry.lock().await = None; + } + + pub(crate) async fn get( + &self, + signer: &dyn EventSigner, + origin: &str, + cancelled: &CancellationToken, + ) -> Result { + // Serializes avatar-grid bursts without unbounded in-flight work/cache entries. + tokio::select! { + biased; + _ = cancelled.cancelled() => Err("Corporate login changed; media proof cancelled".into()), + result = async { + let mut entry = self.entry.lock().await; + let now = nostr::Timestamp::now().as_secs(); + if let Some(cached) = entry.as_ref() { + if cached.pubkey == signer.public_key() && cached.origin == origin + && cached.expires_at > now + 10 { + return Ok(cached.header.clone()); + } + } + // Capture expiry before signing: signer latency must not extend cache validity. + *entry = None; + let header = crate::commands::media::sign_blossom_get_auth_header( + signer, origin, 120, + ).await?; + if cancelled.is_cancelled() { + return Err("Corporate login changed; media proof cancelled".into()); + } + if now + 120 <= nostr::Timestamp::now().as_secs() + 10 { + return Err("Corporate media proof expired while signing".into()); + } + *entry = Some(Entry { + pubkey: signer.public_key(), origin: origin.into(), + header: header.clone(), expires_at: now + 120, + }); + Ok(header) + } => result, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + struct Signer { + keys: nostr::Keys, + calls: AtomicUsize, + cancel: Option, + } + impl EventSigner for Signer { + fn public_key(&self) -> nostr::PublicKey { + self.keys.public_key() + } + fn sign( + &self, + event: nostr::UnsignedEvent, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::SeqCst); + tokio::task::yield_now().await; + if let Some(cancel) = &self.cancel { + cancel.cancel(); + } + event.sign_with_keys(&self.keys).map_err(|e| e.to_string()) + }) + } + } + fn signer() -> Signer { + Signer { + keys: nostr::Keys::generate(), + calls: AtomicUsize::new(0), + cancel: None, + } + } + #[tokio::test] + async fn production_cache_singleflights_and_fences_host_identity_expiry_and_new_session() { + let cache = MediaReadProofCache::default(); + let signer = signer(); + let cancel = CancellationToken::new(); + let (a, b, c) = tokio::join!( + cache.get(&signer, "https://a.example", &cancel), + cache.get(&signer, "https://a.example", &cancel), + cache.get(&signer, "https://a.example", &cancel) + ); + assert_eq!(a.unwrap(), b.unwrap()); + assert!(c.is_ok()); + assert_eq!(signer.calls.load(Ordering::SeqCst), 1); + cache + .get(&signer, "https://a.example:8443", &cancel) + .await + .unwrap(); + assert_eq!(signer.calls.load(Ordering::SeqCst), 2); + cache.entry.lock().await.as_mut().unwrap().expires_at = 0; + cache + .get(&signer, "https://a.example:8443", &cancel) + .await + .unwrap(); + assert_eq!(signer.calls.load(Ordering::SeqCst), 3); + let other = super::tests::signer(); + cache + .get(&other, "https://a.example:8443", &cancel) + .await + .unwrap(); + assert_eq!(other.calls.load(Ordering::SeqCst), 1); + cancel.cancel(); + assert!(cache + .get(&other, "https://a.example:8443", &cancel) + .await + .is_err()); + cache.clear().await; + assert!(cache.entry.lock().await.is_none()); + MediaReadProofCache::default() + .get(&other, "https://a.example:8443", &CancellationToken::new()) + .await + .unwrap(); + assert_eq!(other.calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] + async fn cancellation_during_sign_never_populates_cache() { + let cache = MediaReadProofCache::default(); + let cancel = CancellationToken::new(); + let mut signer = signer(); + signer.cancel = Some(cancel.clone()); + assert!(cache + .get(&signer, "https://a.example", &cancel) + .await + .is_err()); + assert!(cache.entry.lock().await.is_none()); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index e219b2f75fa..74c4beeff09 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -308,11 +308,7 @@ pub async fn start_huddle( *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = successful_agents.clone(); hs.maybe_auto_enable_transcription_for_agents(); - let own_pubkey = state - .keys - .lock() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); + let own_pubkey = state.public_key().map(|k| k.to_hex()).unwrap_or_default(); let mut participants = successful_agents.clone(); if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { participants.insert(0, own_pubkey); @@ -428,11 +424,7 @@ pub async fn join_huddle( }; // Seed participant list with own pubkey as a fallback until relay responds. - let own_pubkey = state - .keys - .lock() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); + let own_pubkey = state.public_key().map(|k| k.to_hex()).unwrap_or_default(); let committed = { let mut hs = state.huddle()?; diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index a8679c7136a..424a5b8a4db 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -641,8 +641,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.event_signer() { + Ok(k) => k, Err(_) => return, }; let relay_base_url = crate::relay::relay_api_base_url_with_override(state); @@ -714,6 +714,9 @@ pub(crate) fn spawn_transcription_task( } }; + if session_generation.load(Ordering::Acquire) != spawned_gen { + break; + } let response = { http_client .post(&url) diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index b55aaf16f5c..975d122ab97 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -187,7 +187,12 @@ 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.event_signer()?; + let cancel = if crate::enterprise_identity::enabled() { + state.enterprise.cancellation()?.child_token() + } else { + CancellationToken::new() + }; // TTS interrupt flags — recv task cancels TTS when remote humans speak. let ( @@ -211,11 +216,11 @@ pub(crate) async fn connect_audio_relay( let app_handle = state.app_handle.lock().ok().and_then(|g| g.clone()); - let (ws_tx, ws_rx, _peer_index, initial_peers) = - connect_authenticated_audio_socket(channel_id, parent_channel_id, &relay_url, &keys, None) - .await?; - - let cancel = CancellationToken::new(); + let (ws_tx, ws_rx, _peer_index, initial_peers) = tokio::select! { + biased; + _ = cancel.cancelled() => return Err("Corporate login changed".into()), + result = connect_authenticated_audio_socket(channel_id, parent_channel_id, &relay_url, &keys, None) => result?, + }; let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 99620bff895..c702b98bf6c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,10 @@ mod channel_head_cache; mod commands; mod deep_link; mod egress_guard; +#[cfg(test)] +mod enterprise_build_config; +mod enterprise_identity; +mod enterprise_media_cache; mod event_signing; mod event_sync; mod events; @@ -448,7 +452,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); @@ -538,6 +542,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/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index 5f09d6e4757..a76328a537c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -966,7 +966,7 @@ mod flush_barrier { .sign_with_keys(&keys) .unwrap(); let state = build_app_state(); - *state.keys.lock().unwrap() = keys; + *state.keys.lock().unwrap() = Some(keys); let fresh = resign_with_fresh_timestamp(&stale, &state).unwrap(); @@ -1025,7 +1025,7 @@ mod flush_barrier { } let state = build_app_state(); - *state.keys.lock().unwrap() = keys; + *state.keys.lock().unwrap() = Some(keys); *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index d29bec4a784..03ff64dc48c 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -239,12 +239,7 @@ pub async fn restore_managed_agents_on_launch( // Snapshot the workspace owner pubkey once for the legacy auth_tag fallback. // Read outside the per-agent spawn loop so all parallel spawns see the same // value and we don't lock `state.keys` repeatedly. - let owner_hex: Option = state - .keys - .lock() - .map_err(|e| e.to_string()) - .ok() - .map(|k| k.public_key().to_hex()); + let owner_hex: Option = state.public_key().ok().map(|k| k.to_hex()); #[cfg(feature = "mesh-llm")] let agents_to_start = { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 5b44f95de92..3c69c105eb6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -452,6 +452,10 @@ pub fn spawn_agent_child( owner_hex: Option<&str>, replay_floor_unix: Option, ) -> Result { + if crate::enterprise_identity::enabled() { + return Err("Local managed-agent processes are unavailable in corporate builds".into()); + } + if let Some(error) = spawn_key_refusal(record) { return Err(error); } diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index ba0f91c9f7a..64bbb03f50a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -283,11 +283,7 @@ fn start_pair( runtimes.remove(&key); terminate_untracked_pair_runtime(&app, &key)?; - let owner = state - .keys - .lock() - .ok() - .map(|keys| keys.public_key().to_hex()); + let owner = state.public_key().ok().map(|k| k.to_hex()); let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref(), None)?; let now = crate::util::now_iso(); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index f8a2c1039a8..8b7478bd9df 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -266,6 +266,9 @@ fn load_agent_store( pub fn load_managed_agents( app: &AppHandle, ) -> Result, String> { + if crate::enterprise_identity::enabled() { + return Err("Local managed-agent keys are unavailable in corporate builds".into()); + } let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); hydrate_keys(&mut records); diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 503f561afc4..48f25bb211a 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -27,6 +27,15 @@ struct ProxyState { } async fn proxy_handler(AxumState(state): AxumState, req: Request) -> Response { + let app_state = state.app_handle.state::(); + proxy_handler_with_state(&app_state, &state.client, req).await +} + +pub(crate) async fn proxy_handler_with_state( + app_state: &AppState, + client: &reqwest::Client, + req: Request, +) -> Response { // Allow requests with no Origin (e.g.