diff --git a/Cargo.lock b/Cargo.lock index 09d8faa9887..d07bfd8f17c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -883,6 +883,7 @@ dependencies = [ "buzz-core", "buzz-persona", "buzz-sdk", + "buzz-ws-client", "chrono", "clap", "evalexpr", @@ -1044,6 +1045,7 @@ dependencies = [ "dirs", "hex", "infer", + "nix 0.31.3", "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", @@ -1158,6 +1160,7 @@ dependencies = [ "base64 0.22.1", "buzz-cli", "buzz-core", + "buzz-ws-client", "git-credential-nostr", "git-sign-nostr", "ignore", @@ -1507,9 +1510,15 @@ dependencies = [ name = "buzz-ws-client" version = "0.1.0" dependencies = [ + "axum", + "base64 0.22.1", "futures-util", + "hex", "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", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..8b0d94396bb 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -37,6 +37,7 @@ futures-util = { workspace = true } # HTTP (channel discovery REST API) reqwest = { workspace = true } +buzz-ws-client = { workspace = true } # Serialization serde = { workspace = true } diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index cd049830688..38ef5adb8e0 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -10,7 +10,9 @@ use futures_util::StreamExt; use tokio::io::AsyncWriteExt; -use tokio::process::{Child, ChildStdin, ChildStdout}; +#[cfg(not(test))] +use tokio::process::ChildStdout; +use tokio::process::{Child, ChildStdin}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; use crate::observer::{ObserverContext, ObserverHandle}; @@ -38,12 +40,21 @@ pub struct McpServer { } /// A single environment variable for an MCP server. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Clone, serde::Serialize)] pub struct EnvVar { pub name: String, pub value: String, } +impl std::fmt::Debug for EnvVar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EnvVar") + .field("name", &self.name) + .field("value", &"[REDACTED]") + .finish() + } +} + /// Stop reason returned by `session/prompt` when the agent finishes a turn. /// /// Maps to the `stopReason` field in the `SessionPromptResponse`. @@ -149,7 +160,10 @@ pub struct AcpClient { /// Framed reader over the agent's stdout pipe (line-oriented, bounded). /// Uses `LinesCodec::new_with_max_length` to enforce MAX_LINE_SIZE at the /// read level — prevents OOM from rogue agents writing infinite non-newline bytes. + #[cfg(not(test))] reader: FramedRead, + #[cfg(test)] + reader: FramedRead, LinesCodec>, /// Monotonically increasing JSON-RPC request id counter. /// Harness-generated IDs are always numeric. next_id: u64, @@ -560,7 +574,13 @@ impl AcpClient { Ok(Self { child, stdin, + #[cfg(not(test))] reader: FramedRead::new(stdout, LinesCodec::new_with_max_length(MAX_LINE_SIZE)), + #[cfg(test)] + reader: FramedRead::new( + Box::new(stdout), + LinesCodec::new_with_max_length(MAX_LINE_SIZE), + ), next_id: 0, pending_permission_id: None, permission_responded: false, @@ -1125,7 +1145,9 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + // session/new carries MCP credentials (including enterprise capabilities). + // Log only method/id; never serialize credential-bearing parameters. + tracing::debug!(target: "acp::wire", method, id, "→ request"); // Wrap write + read in a single timeout so a hung agent can't block forever. // We cannot use an async block that borrows `self` mutably across two awaits @@ -3206,31 +3228,54 @@ mod tests { ); } - #[tokio::test] - async fn idle_resets_on_stdout_activity() { - // Send valid JSON (session/update notifications) to reset the idle timer. - // Non-JSON lines no longer reset idle — only valid JSON notifications do. - let mut client = spawn_script( - r#"for i in $(seq 1 10); do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"thinking"}}}}'; sleep 0.05; done; sleep 10"#, - ) - .await; + // Drives the actual bounded reader/deadline implementation with in-memory + // bytes and virtual time. No dependency on OS scheduling a shell every 50ms. + async fn assert_activity_resets_idle(update: serde_json::Value, count: u32, idle_ms: u64) { + let mut client = spawn_script("read _done").await; + let (mut writer, reader) = tokio::io::duplex(4096); + client.reader = FramedRead::new( + Box::new(reader), + LinesCodec::new_with_max_length(MAX_LINE_SIZE), + ); + tokio::time::pause(); let max_dur = std::time::Duration::from_secs(10); - let hard_deadline = tokio::time::Instant::now() + max_dur; - let start = std::time::Instant::now(); - let result = client - .read_until_response_with_idle_timeout( - "test", - 999, - std::time::Duration::from_millis(200), - hard_deadline, - max_dur, - ) - .await; - let elapsed = start.elapsed(); - // 10 messages × 50ms = ~500ms of activity, then idle timeout fires after 200ms more - assert!(elapsed >= std::time::Duration::from_millis(400)); - assert!(elapsed < std::time::Duration::from_secs(3)); + let start = tokio::time::Instant::now(); + let read = client.read_until_response_with_idle_timeout( + "test", + 999, + std::time::Duration::from_millis(idle_ms), + start + max_dur, + max_dur, + ); + let send = async { + for _ in 0..count { + let message = serde_json::json!({"jsonrpc":"2.0", "method":"session/update", "params":{"update":update}}); + writer + .write_all(format!("{message}\n").as_bytes()) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + // Keep the pipe open: the production result must be idle, not EOF. + std::future::pending::<()>().await; + }; + tokio::pin!(send); + let result = tokio::select! { result = read => result, _ = &mut send => unreachable!() }; assert!(matches!(result, Err(AcpError::IdleTimeout(_)))); + let expected = std::time::Duration::from_millis(u64::from(count - 1) * 50 + idle_ms); + // Tokio's millisecond timer wheel rounds each scheduled wake. Keep the + // exact lower bound, allowing at most two ticks per scheduled timer. + assert!(start.elapsed() >= expected); + assert!( + start.elapsed() + <= expected + std::time::Duration::from_millis(u64::from(count + 1) * 2) + ); + tokio::time::resume(); + } + + #[tokio::test] + async fn idle_resets_on_stdout_activity() { + assert_activity_resets_idle(serde_json::json!({"sessionUpdate":"agent_thought_chunk", "content":{"text":"thinking"}}), 10, 200).await; } #[tokio::test] @@ -3405,33 +3450,8 @@ mod tests { #[tokio::test] async fn keepalive_resets_idle_past_deadline() { - // Keepalive session/update lines every 50ms against a 100ms idle deadline. - // The turn should survive well past the 100ms deadline (proves the fix). - let mut client = spawn_script( - r#"for i in $(seq 1 20); do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"keepalive"}}}'; sleep 0.05; done; sleep 10"#, - ) - .await; - let max_dur = std::time::Duration::from_secs(10); - let hard_deadline = tokio::time::Instant::now() + max_dur; - let start = std::time::Instant::now(); - let result = client - .read_until_response_with_idle_timeout( - "test", - 999, - std::time::Duration::from_millis(100), - hard_deadline, - max_dur, - ) + assert_activity_resets_idle(serde_json::json!({"sessionUpdate":"keepalive"}), 20, 100) .await; - let elapsed = start.elapsed(); - // 20 keepalives × 50ms = ~1000ms of activity, then idle fires after 100ms more. - // Must survive well past the 100ms deadline. - assert!( - elapsed >= std::time::Duration::from_millis(500), - "keepalive should reset idle past the deadline; elapsed only {elapsed:?}" - ); - assert!(elapsed < std::time::Duration::from_secs(5)); - assert!(matches!(result, Err(AcpError::IdleTimeout(_)))); } #[tokio::test] diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 3ff1dc39898..3f853ff8ab1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5898,6 +5898,14 @@ fn build_mcp_servers(config: &Config) -> Vec { .expect("secret key bech32 encoding should never fail"), }, ]; + for name in buzz_ws_client::identity_adapter::ENV_KEYS { + if let Ok(value) = std::env::var(name) { + env.push(EnvVar { + name: name.into(), + value, + }); + } + } // Forward BUZZ_AUTH_TAG (NIP-OA owner attestation credential) // so the MCP server can attach it to every signed event. if let Ok(auth_tag) = std::env::var("BUZZ_AUTH_TAG") { diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index a019e758341..0ec4fdaeae0 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -125,7 +125,9 @@ use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag}; use serde_json::{json, Value}; use tokio::sync::mpsc; use tokio::time::timeout; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +#[cfg(test)] +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -406,7 +408,7 @@ impl RestClient { ) -> Result where F: Fn() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let mut last_err = None; @@ -439,11 +441,11 @@ impl RestClient { resp.status() ))); } - Err(e) if e.is_timeout() || e.is_connect() => { + Err(RelayError::HttpTransport(e)) if e.is_timeout() || e.is_connect() => { tracing::warn!("{method} {path} network error: {e}"); last_err = Some(RelayError::Http(e.to_string())); } - Err(e) => return Err(RelayError::Http(e.to_string())), + Err(e) => return Err(e), } } @@ -460,12 +462,11 @@ impl RestClient { let url = format!("{}{}", self.base_url, path); let body_owned = body_bytes.to_vec(); let auth_tag_header = self.auth_tag_json.clone(); - self.request_with_retry("POST", path, || { - // NIP-98 is re-signed each attempt (fresh created_at). - // sign_nip98 is infallible in practice (key is always valid). - let auth = self - .nip98_header("POST", &url, Some(&body_owned)) - .unwrap_or_default(); + self.request_with_retry("POST", path, || async { + let identity = buzz_ws_client::identity_adapter::environment_header(&url, &self.keys) + .await + .map_err(|e| RelayError::Http(e.to_string()))?; + let auth = self.nip98_header("POST", &url, Some(&body_owned))?; let mut req = self .http .post(&url) @@ -474,7 +475,13 @@ impl RestClient { if let Some(ref tag) = auth_tag_header { req = req.header("x-auth-tag", tag); } - req.body(body_owned.clone()).send() + if let Some(header) = identity { + req = req.header(buzz_ws_client::federated_identity::IDENTITY_HEADER, header); + } + req.body(body_owned.clone()) + .send() + .await + .map_err(RelayError::HttpTransport) }) .await } @@ -597,6 +604,9 @@ pub struct BuzzEvent { /// Errors from relay operations. #[derive(Debug, thiserror::Error)] pub enum RelayError { + /// HTTP transport failure, retaining retry classification. + #[error("HTTP transport error: {0}")] + HttpTransport(reqwest::Error), #[error("WebSocket error: {0}")] WebSocket(Box), @@ -684,7 +694,8 @@ enum RelayCommand { SetStartupWatermark { ts: u64 }, } -type WsStream = WebSocketStream>; +type WsStream = + buzz_ws_client::identity_socket::IdentitySocket>; /// Harness-side relay client. /// @@ -799,6 +810,7 @@ impl HarnessRelay { observer_control_rx: Some(observer_control_rx), cmd_tx, http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(10)) .connect_timeout(std::time::Duration::from_secs(5)) .build() @@ -3838,6 +3850,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result bool { match err { + RelayError::HttpTransport(_) => false, RelayError::Http(_) | RelayError::Json(_) | RelayError::UnexpectedMessage(_) => true, RelayError::WebSocket(e) => is_terminal_ws_error(e.as_ref()), RelayError::AuthFailed(message) => is_terminal_auth_failure(message), @@ -4013,13 +4026,32 @@ async fn do_connect( .parse::() .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?; - let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str())) + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + let mut request = parsed + .as_str() + .into_client_request() + .map_err(|e| RelayError::WebSocket(Box::new(e)))?; + let identity = buzz_ws_client::identity_adapter::environment_admission(relay_url, keys) + .await + .map_err(|e| RelayError::Http(e.to_string()))?; + if let Some((header, _)) = identity.clone() { + request + .headers_mut() + .insert(buzz_ws_client::federated_identity::IDENTITY_HEADER, header); + } + let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) .await .map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure .map_err(|e| RelayError::WebSocket(Box::new(e)))?; debug!("connected to relay at {relay_url}"); - let mut ws = ws; + let mut ws = buzz_ws_client::identity_socket::IdentitySocket::admitted( + ws, + identity.as_ref().map(|(_, deadline)| *deadline), + relay_url.into(), + keys.clone(), + ) + .map_err(|e| RelayError::Http(e.to_string()))?; let mut buffer: VecDeque = VecDeque::new(); let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; @@ -4719,7 +4751,10 @@ mod tests { let (client, _) = connect_async(format!("ws://{address}")) .await .expect("connect test websocket"); - (client, server.await.expect("join test websocket server")) + ( + client.into(), + server.await.expect("join test websocket server"), + ) } pub(super) async fn next_test_frame( diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index a848557ae2f..8841587bd55 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -89,6 +89,9 @@ const PASSTHROUGH_ENV: &[&str] = &[ "BUZZ_PRIVATE_KEY", "BUZZ_RELAY_URL", "BUZZ_AUTH_TAG", + "BUZZ_NIP_FI_ENDPOINT", + "BUZZ_NIP_FI_CREDENTIAL", + "BUZZ_NIP_FI_ORIGINS", // Agent display name — dev-mcp uses it as the git author name. On the // Desktop path this arrives via the wire `mcpServers[].env` declaration // (which wins here anyway); the allowlist entry covers ACP clients that diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 59d1bb2cee6..a40053d970e 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -93,3 +93,6 @@ tempfile = "3" axum = { workspace = true } # `test-util` enables paused-time control for deterministic timeout tests tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 6ade19f1cad..95accaeba2e 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -327,7 +327,7 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let exp_str = (now + 600).to_string(); + let exp_str = (now + 60).to_string(); let domain = relay_server_tag(media_url) .ok_or_else(|| CliError::Usage(format!("invalid media URL: {media_url}")))?; let tags = vec![ @@ -350,18 +350,14 @@ fn sign_blossom_get(keys: &Keys, media_url: &str) -> Result { fn sign_blossom_upload( keys: &Keys, sha256: &str, - mime: &str, + _mime: &str, relay_url: &str, ) -> Result { use base64::engine::general_purpose::URL_SAFE_NO_PAD; use nostr::Timestamp; let now = Timestamp::now().as_secs(); - let expiry: u64 = if mime.starts_with("video/") { - 3600 - } else { - 600 - }; + let expiry: u64 = 60; let exp_str = (now + expiry).to_string(); let mut tags = vec![ @@ -545,6 +541,7 @@ impl BuzzClient { auth_tag_json: Option, ) -> Result { let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .timeout(env_duration_secs("BUZZ_TIMEOUT_SECS", 30)) .connect_timeout(env_duration_secs("BUZZ_CONNECT_TIMEOUT_SECS", 15)) .build() @@ -613,11 +610,17 @@ impl BuzzClient { } /// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation). - fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - match self.auth_tag_json { + async fn with_auth_tag( + &self, + req: reqwest::RequestBuilder, + ) -> Result { + let req = buzz_ws_client::identity_adapter::authorize(req, &self.keys) + .await + .map_err(|e| CliError::Auth(e.to_string()))?; + Ok(match self.auth_tag_json { Some(ref json) => req.header("x-auth-tag", json), None => req, - } + }) } /// Execute `op` up to `RETRY_MAX_ATTEMPTS` times, including body-transfer failures @@ -810,6 +813,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body), ) + .await? .send() .await?; self.handle_response(resp).await @@ -840,6 +844,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body), ) + .await? .send() .await?; self.handle_response(resp).await @@ -862,6 +867,7 @@ impl BuzzClient { let auth = sign_nip98(&self.keys, "GET", &url, None)?; let resp = self .with_auth_tag(self.http.get(&url).header("Authorization", auth)) + .await? .send() .await?; self.handle_response(resp).await @@ -898,6 +904,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body_bytes), ) + .await? .send() .await?; // 204 No Content: return empty string rather than failing on @@ -934,6 +941,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body), ) + .await? .send() .await .map_err(|e| { @@ -1003,6 +1011,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body.clone()), ) + .await? .send() .await .map_err(CliError::from); @@ -1155,6 +1164,7 @@ impl BuzzClient { .header("Content-Type", "application/json") .body(body), ) + .await? .send() .await?; self.handle_response(resp).await @@ -1278,6 +1288,7 @@ impl BuzzClient { .header("X-SHA-256", &sha256) .body(upload_body), ) + .await? .send() .await?; let status = resp.status(); @@ -1324,6 +1335,7 @@ impl BuzzClient { .header("X-SHA-256", &sha256) .body(upload_body), ) + .await? .send() .await?; if !resp.status().is_success() { @@ -1342,6 +1354,7 @@ impl BuzzClient { let url = media_url_from_input(&self.relay_url, input)?; // Use a dedicated client: 120 s timeout, no redirect forwarding. let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(120)) // Do not forward Authorization or x-auth-tag to redirect targets. .redirect(reqwest::redirect::Policy::none()) @@ -1354,6 +1367,7 @@ impl BuzzClient { let auth_header = sign_blossom_get(&self.keys, &url)?; let resp = self .with_auth_tag(client.get(&url).header("Authorization", auth_header)) + .await? .send() .await?; if !resp.status().is_success() { @@ -2605,8 +2619,8 @@ mod tests { assert_eq!(auth_tags[0].as_slice()[1], "c".repeat(64)); } - #[test] - fn with_auth_tag_sets_header_when_configured() { + #[tokio::test] + async fn with_auth_tag_sets_header_when_configured() { let keys = Keys::generate(); let (auth_tag, auth_json) = make_auth_tag(); let client = BuzzClient::new( @@ -2618,7 +2632,7 @@ mod tests { .unwrap(); let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); + let req = client.with_auth_tag(req).await.unwrap(); let built = req.build().unwrap(); let header = built .headers() @@ -2631,13 +2645,13 @@ mod tests { ); } - #[test] - fn with_auth_tag_omits_header_when_not_configured() { + #[tokio::test] + async fn with_auth_tag_omits_header_when_not_configured() { let keys = Keys::generate(); let client = BuzzClient::new("https://test.relay".into(), keys, None, None).unwrap(); let req = client.http.post("https://test.relay/events"); - let req = client.with_auth_tag(req); + let req = client.with_auth_tag(req).await.unwrap(); let built = req.build().unwrap(); assert!( built.headers().get("x-auth-tag").is_none(), diff --git a/crates/buzz-cli/src/identity_git.rs b/crates/buzz-cli/src/identity_git.rs new file mode 100644 index 00000000000..5cfd8f43fe1 --- /dev/null +++ b/crates/buzz-cli/src/identity_git.rs @@ -0,0 +1,69 @@ +//! Git launcher for enterprise agents. A fresh assertion for each invocation, +//! not the expiring token inherited when an agent process started. +use crate::error::CliError; + +pub(crate) async fn run( + args: Vec, + keys: &nostr::Keys, + relay: &str, +) -> Result<(), CliError> { + let binary = std::env::var_os("BUZZ_NIP_FI_GIT_BINARY").unwrap_or_else(|| "git".into()); + let mut command = std::process::Command::new(binary); + command.args(args); + let base = std::env::var("GIT_CONFIG_COUNT") + .ok() + .map(|s| s.parse::()) + .transpose() + .map_err(|_| CliError::Usage("invalid Git configuration count".into()))? + .unwrap_or(0); + buzz_ws_client::identity_git::configure(&mut command, keys, relay, base) + .await + .map_err(|e| CliError::Auth(e.to_string()))?; + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|_| CliError::Other("could not start git".into()))?; + match tokio::time::timeout(std::time::Duration::from_secs(300), child.wait()).await { + Ok(Ok(status)) if status.success() => Ok(()), + Ok(Ok(_)) => Err(CliError::Other("git command failed".into())), + Ok(Err(_)) => Err(CliError::Other("could not wait for git".into())), + Err(_) => { + if let Some(pid) = child.id() { + #[cfg(unix)] + nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid as i32), + nix::sys::signal::Signal::SIGKILL, + ) + .map_err(|_| { + CliError::Other("could not stop timed-out git process group".into()) + })?; + #[cfg(windows)] + { + let status = tokio::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .await + .map_err(|_| { + CliError::Other("could not stop timed-out git process tree".into()) + })?; + if !status.success() { + return Err(CliError::Other( + "could not stop timed-out git process tree".into(), + )); + } + } + } + child + .wait() + .await + .map_err(|_| CliError::Other("could not stop timed-out git".into()))?; + Err(CliError::Other("git command timed out".into())) + } + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index b38486417a8..565894eceae 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -3,6 +3,7 @@ mod client; mod commands; mod error; mod help_tree; +mod identity_git; mod links; mod validate; @@ -210,6 +211,11 @@ pub enum OutputFormat { #[derive(Subcommand)] enum Cmd { + /// Run Git with a fresh, origin-scoped enterprise assertion + Git { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Draft owner-reviewed agent creation and updates #[command(subcommand)] Agents(AgentsCmd), @@ -2170,6 +2176,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { let client = BuzzClient::new(relay_url, keys, auth_tag, auth_tag_json)?; match cli.command { + Cmd::Git { args } => identity_git::run(args, client.keys(), client.relay_url()).await, Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, @@ -2327,6 +2334,7 @@ mod tests { "emoji", "feed", "gifs", + "git", "issues", "media", "mem", diff --git a/crates/buzz-cli/tests/federated_identity.rs b/crates/buzz-cli/tests/federated_identity.rs new file mode 100644 index 00000000000..19f40b2d28e --- /dev/null +++ b/crates/buzz-cli/tests/federated_identity.rs @@ -0,0 +1,221 @@ +//! Process-boundary POC acceptance: real CLI -> assumed adapter -> protected HTTP. +//! No process-global environment mutation, no corporate service dependency. +use axum::{ + extract::State, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use nostr::Keys; +use sha2::{Digest, Sha256}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, +}; + +// Use the test executable as a process launcher for the real CLI entrypoint. +// Tauri stages sidecar stubs over target/debug/buzz when sharing a target dir; +// this hashed executable is not a sidecar destination and cannot be clobbered. +#[tokio::test] +async fn cli_process_entry() { + let Ok(args) = std::env::var("BUZZ_TEST_CLI_ARGS") else { + return; + }; + let args: Vec = serde_json::from_str(&args).unwrap(); + std::process::exit(buzz_cli::run_from_args(args).await); +} + +#[derive(Clone)] +struct Fixture { + key: String, + rejected: Arc, + queries: Arc, +} +fn proof(headers: &HeaderMap) -> nostr::Event { + let bytes = STANDARD + .decode( + headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(), + ) + .unwrap(); + let event: nostr::Event = serde_json::from_slice(&bytes).unwrap(); + event.verify().unwrap(); + event +} +async fn issue( + State(f): State, + headers: HeaderMap, + body: String, +) -> Result, axum::http::StatusCode> { + assert_eq!( + headers["x-bb-session-credential"], + "fixture-agent-capability" + ); + assert!(!headers.contains_key("nostr-federated-identity")); + let event = proof(&headers); + assert_eq!(event.pubkey.to_hex(), f.key); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["payload", &hex::encode(Sha256::digest(body.as_bytes()))])); + if f.rejected.load(Ordering::SeqCst) { + return Err(axum::http::StatusCode::UNAUTHORIZED); + } + let now = nostr::Timestamp::now().as_secs(); + let token = format!("{}.{}.fixture", URL_SAFE_NO_PAD.encode(br#"{"typ":"nip-fi+jwt","alg":"ES256"}"#), + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!({"iss":"fixture","sub":"agent","aud":"buzz","nostr_pubkey":f.key,"iat":now,"exp":now+120})).unwrap())); + Ok(Json( + serde_json::json!({"assertion":token,"nostr_pubkey":f.key,"expires_at":now+100}), + )) +} +async fn query( + State(f): State, + headers: HeaderMap, + body: String, +) -> Json { + let event = proof(&headers); + assert_eq!(event.pubkey.to_hex(), f.key); + assert_eq!( + headers.get_all("nostr-federated-identity").iter().count(), + 1 + ); + assert!(headers["nostr-federated-identity"] + .to_str() + .unwrap() + .starts_with("Bearer ")); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["payload", &hex::encode(Sha256::digest(body.as_bytes()))])); + assert!(!body.contains("fixture-agent-capability")); + assert!(!body.contains("Bearer ")); + f.queries.fetch_add(1, Ordering::SeqCst); + Json(serde_json::json!([])) +} +async fn socket( + State(f): State, + headers: HeaderMap, + ws: axum::extract::WebSocketUpgrade, +) -> impl axum::response::IntoResponse { + assert_eq!( + headers.get_all("nostr-federated-identity").iter().count(), + 1 + ); + ws.on_upgrade(move |mut socket| async move { + socket + .send(axum::extract::ws::Message::Text( + r#"["AUTH","fixture-challenge"]"#.into(), + )) + .await + .unwrap(); + for expected in ["AUTH", "EVENT"] { + let message = socket.recv().await.unwrap().unwrap(); + let text = message.to_text().unwrap(); + assert!(!text.contains("Bearer ")); + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(value[0], expected); + let event: nostr::Event = serde_json::from_value(value[1].clone()).unwrap(); + event.verify().unwrap(); + assert_eq!(event.pubkey.to_hex(), f.key); + let reply = serde_json::json!(["OK", event.id.to_hex(), true, ""]); + socket + .send(axum::extract::ws::Message::Text(reply.to_string().into())) + .await + .unwrap(); + } + }) +} +#[tokio::test] +async fn cli_acquires_its_own_assertion_and_stops_before_query_after_revocation() { + let keys = Keys::generate(); + let fixture = Fixture { + key: keys.public_key().to_hex(), + rejected: Arc::default(), + queries: Arc::default(), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route("/", get(socket)) + .route("/assertions", post(issue)) + .route("/query", post(query)) + .with_state(fixture.clone()); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let run = |args: &[&str]| { + let mut command = tokio::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", "cli_process_entry", "--nocapture"]) + .env( + "BUZZ_TEST_CLI_ARGS", + serde_json::to_string( + &std::iter::once("buzz") + .chain(args.iter().copied()) + .collect::>(), + ) + .unwrap(), + ) + .env("BUZZ_PRIVATE_KEY", keys.secret_key().to_secret_hex()) + .env("BUZZ_RELAY_URL", &base) + .env("BUZZ_NIP_FI_ORIGINS", &base) + .env("BUZZ_NIP_FI_ENDPOINT", format!("{base}/assertions")) + .env("BUZZ_NIP_FI_CREDENTIAL", "fixture-agent-capability") + .env_remove("BUZZ_AUTH_TAG") + .env_remove("GIT_CONFIG_COUNT") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1"); + command.output() + }; + let result = run(&["users", "get"]).await.unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(fixture.queries.load(Ordering::SeqCst) > 0); + let result = run(&["users", "set-presence", "--status", "online"]) + .await + .unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let git_url = format!("{base}/git/owner/repo"); + let result = run(&[ + "git", + "config", + "--get-urlmatch", + "http.extraHeader", + &git_url, + ]) + .await + .unwrap(); + assert!(result.status.success()); + assert!(String::from_utf8_lossy(&result.stdout).contains("Nostr-Federated-Identity: Bearer ")); + let result = run(&[ + "git", + "config", + "--get-urlmatch", + "http.extraHeader", + "https://unrelated.example/git/repo", + ]) + .await + .unwrap(); + assert!(!String::from_utf8_lossy(&result.stdout).contains("Bearer ")); + let before = fixture.queries.load(Ordering::SeqCst); + fixture.rejected.store(true, Ordering::SeqCst); + let result = run(&["users", "get"]).await.unwrap(); + assert!(!result.status.success()); + assert_eq!(fixture.queries.load(Ordering::SeqCst), before); + assert!(!String::from_utf8_lossy(&result.stderr).contains("fixture-agent-capability")); + server.abort(); +} diff --git a/crates/buzz-dev-mcp/Cargo.toml b/crates/buzz-dev-mcp/Cargo.toml index 8b711b80634..6014a6b52c9 100644 --- a/crates/buzz-dev-mcp/Cargo.toml +++ b/crates/buzz-dev-mcp/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] buzz-cli = { path = "../buzz-cli" } +buzz-ws-client = { workspace = true } git-credential-nostr = { path = "../git-credential-nostr" } git-sign-nostr = { path = "../git-sign-nostr" } nostr = { workspace = true } diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index d555b6ea542..9b27ac1e743 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -166,6 +166,12 @@ async fn async_main(cmd: String) -> Result<(), Box> { let _ = rustls::crypto::ring::default_provider().install_default(); // buzz CLI needs tokio (async HTTP client). + if cmd == "git" { + let args = ["buzz".to_owned(), "git".to_owned()] + .into_iter() + .chain(std::env::args().skip(1)); + std::process::exit(buzz_cli::run_from_args(args).await); + } if cmd == "buzz" { std::process::exit(buzz_cli::run_from_args(std::env::args()).await); } diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs index cccf0e6eca1..7d58af0b4e9 100644 --- a/crates/buzz-dev-mcp/src/shim.rs +++ b/crates/buzz-dev-mcp/src/shim.rs @@ -40,6 +40,16 @@ impl Shim { } let original = std::env::var_os("PATH").unwrap_or_default(); + if std::env::var_os("BUZZ_NIP_FI_ENDPOINT").is_some() { + let git = std::env::split_paths(&original) + .map(|p| p.join(if cfg!(windows) { "git.exe" } else { "git" })) + .find(|p| p.is_file()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "git not found") + })?; + std::env::set_var("BUZZ_NIP_FI_GIT_BINARY", git); + symlink(&self_exe, &dir.path().join("git"))?; + } let mut entries = vec![PathBuf::from(dir.path())]; entries.extend(std::env::split_paths(&original)); // join_paths uses the platform separator (':' on Unix, ';' on Windows). diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index 441338ab127..6bd181cf6e9 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -50,7 +50,7 @@ pub(crate) const MAX_DECODER_ALLOC: u64 = 256 * 1024 * 1024; const FETCH_TIMEOUT: Duration = Duration::from_secs(10); /// Lifetime of a Blossom `t=get` read token for relay media fetches. /// Matches the desktop client's `MEDIA_GET_AUTH_EXPIRY_SECS`. -const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Build the decoder allocation cap. Centralised so the resize path uses the /// same value tests can reason about. @@ -322,6 +322,18 @@ async fn fetch_url(url: &str) -> Result, ErrorData> { let parsed = reqwest::Url::parse(url) .map_err(|e| invalid_params(format!("invalid URL: {url} ({e})")))?; let auth = relay_media_get_auth(&parsed); + let enterprise = match std::env::var("BUZZ_NIP_FI_ORIGINS") { + Ok(origins) => buzz_ws_client::federated_identity::IdentitySession::new( + &origins.split(',').collect::>(), + ) + .and_then(|s| s.protects(url)) + .map_err(|e| invalid_params(e.to_string()))?, + Err(std::env::VarError::NotPresent) => false, + Err(_) => return Err(invalid_params("invalid enterprise configuration".into())), + }; + if enterprise && auth.is_none() { + return Err(invalid_params("enterprise media proof unavailable".into())); + } let mut client_builder = reqwest::Client::builder() .connect_timeout(FETCH_TIMEOUT) .timeout(FETCH_TIMEOUT); @@ -344,6 +356,15 @@ async fn fetch_url(url: &str) -> Result, ErrorData> { } } } + if enterprise { + let raw = std::env::var("BUZZ_PRIVATE_KEY") + .map_err(|_| invalid_params("enterprise signing key missing".into()))?; + let keys = nostr::Keys::parse(&raw) + .map_err(|_| invalid_params("enterprise signing key invalid".into()))?; + req = buzz_ws_client::identity_adapter::authorize(req, &keys) + .await + .map_err(|e| invalid_params(e.to_string()))?; + } let resp = req .send() .await diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 5cec925677f..486a333cce5 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -15,3 +15,12 @@ serde_json = { workspace = true } thiserror = { workspace = true } url = { workspace = true } tracing = { workspace = true } +base64 = { workspace = true } +serde = { workspace = true } +reqwest = { workspace = true } +sha2 = "0.11" + +hex = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index bec5b56bb43..4079cd559d8 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -5,13 +5,14 @@ use futures_util::{SinkExt, StreamExt}; use nostr::{Event, Keys, Tag}; use serde_json::{json, Value}; use tokio::time::timeout; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream}; use tracing::debug; use crate::error::WsClientError; use crate::message::{build_auth_event, parse_relay_message, OkResponse, RelayMessage}; -type WsStream = WebSocketStream>; +type WsStream = crate::identity_socket::IdentitySocket>; /// Seconds to wait for the relay to send the NIP-42 AUTH challenge after connecting. pub const AUTH_CHALLENGE_TIMEOUT_SECS: u64 = 20; @@ -39,7 +40,33 @@ impl NostrWsConnection { keys: &Keys, auth_tag: Option<&Tag>, ) -> Result { - let mut conn = Self::connect(url).await?; + let mut request = url + .into_client_request() + .map_err(WsClientError::WebSocket)?; + let identity = crate::identity_adapter::environment_admission(url, keys) + .await + .map_err(|e| WsClientError::AuthFailed(e.to_string()))?; + if let Some((header, _)) = identity.clone() { + request + .headers_mut() + .insert(crate::federated_identity::IDENTITY_HEADER, header); + } + let (socket, _) = connect_async(request) + .await + .map_err(WsClientError::WebSocket)?; + let ws = crate::identity_socket::IdentitySocket::admitted( + socket, + identity.map(|(_, expiry)| expiry), + url.into(), + keys.clone(), + ) + .map_err(|e| WsClientError::AuthFailed(e.to_string()))?; + let mut conn = Self { + ws, + buffer: VecDeque::new(), + pending_challenge: None, + relay_url: url.into(), + }; conn.authenticate(keys, auth_tag).await?; Ok(conn) } @@ -50,14 +77,30 @@ impl NostrWsConnection { .parse::() .map_err(|e| WsClientError::Url(e.to_string()))?; - let (ws, _response) = connect_async(parsed.as_str()) + Self::connect_request( + url, + parsed + .as_str() + .into_client_request() + .map_err(WsClientError::WebSocket)?, + ) + .await + } + + /// Open a native upgrade carrying a scoped NIP-FI assertion. + /// Caller must still authenticate with the same proof key. + pub async fn connect_request( + url: &str, + request: tokio_tungstenite::tungstenite::http::Request<()>, + ) -> Result { + let (ws, _response) = connect_async(request) .await .map_err(WsClientError::WebSocket)?; debug!("connected to relay at {url}"); Ok(Self { - ws, + ws: ws.into(), buffer: VecDeque::new(), pending_challenge: None, relay_url: url.to_string(), @@ -282,8 +325,7 @@ pub async fn publish_event( timeout_secs: u64, ) -> Result { let result = tokio::time::timeout(Duration::from_secs(timeout_secs), async { - let mut conn = NostrWsConnection::connect(relay_url).await?; - conn.authenticate(keys, auth_tag).await?; + let mut conn = NostrWsConnection::connect_authenticated(relay_url, keys, auth_tag).await?; let ok = conn.send_event(event).await?; let _ = conn.disconnect().await; Ok::<_, WsClientError>(ok) diff --git a/crates/buzz-ws-client/src/federated_identity.rs b/crates/buzz-ws-client/src/federated_identity.rs new file mode 100644 index 00000000000..7927226686a --- /dev/null +++ b/crates/buzz-ws-client/src/federated_identity.rs @@ -0,0 +1,423 @@ +//! Client-side NIP-FI assertion lifetime and destination binding. +//! +//! This is not a JWT signature verifier. The adapter supplies assertions over a +//! trusted authenticated API; the relay remains the verifier. Keep this session +//! separate from local signing, and never put its credential in event tags. + +use std::{ + collections::{HashMap, HashSet}, + fmt, + sync::RwLock, +}; + +use nostr::PublicKey; +use tokio_tungstenite::tungstenite::{ + client::IntoClientRequest, + http::{HeaderValue, Request}, +}; +use url::Url; + +/// Current Unix seconds, failing closed if the clock is unavailable. +pub fn unix_now() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|t| t.as_secs()) + .map_err(|_| IdentityError::Unavailable) +} + +/// The only permitted assertion carrier. +pub const IDENTITY_HEADER: &str = "nostr-federated-identity"; + +/// Fixed, credential-free client errors; safe for UI and diagnostics. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum IdentityError { + /// Required authority is absent; prompt for login, not local-key fallback. + #[error("enterprise sign-in required")] + LoginRequired, + /// Renewal is needed before another request can start. + #[error("enterprise assertion expired")] + Expired, + /// The requested proof key differs from the assertion's key. + #[error("enterprise assertion does not match signing identity")] + KeyMismatch, + /// A replaced login attempt must not install its result. + #[error("enterprise authentication changed")] + Superseded, + /// Invalid local configuration, API response, or request destination. + #[error("invalid enterprise authentication configuration or response")] + Invalid, + /// Lock failure must not downgrade to ordinary Nostr admission. + #[error("enterprise authentication unavailable")] + Unavailable, +} + +/// An opaque short-lived assertion received from the trusted adapter. +/// +/// No Serialize implementation or public token accessor: renderer state and +/// diagnostics should only receive readiness/expiry, never the JWT. +pub struct Assertion { + header: HeaderValue, + pubkey: PublicKey, + expires_at: u64, + binding: Option<(String, String, String)>, +} + +impl fmt::Debug for Assertion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Assertion([REDACTED])") + } +} + +impl Assertion { + /// Pin issuer, subject, and audience for this signing key until explicit logout. + pub(crate) fn bind(mut self, issuer: String, subject: String, audience: String) -> Self { + self.binding = Some((issuer, subject, audience)); + self + } + + /// Construct from a trusted adapter result. `expires_at` must be no later + /// than the token's effective deadline; API integration owns that check. + pub fn new( + jwt: &str, + pubkey: PublicKey, + expires_at: u64, + now: u64, + ) -> Result { + if jwt.len() > 16 * 1024 + || jwt.split('.').count() != 3 + || jwt.split('.').any(|part| { + part.is_empty() + || !part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) + { + return Err(IdentityError::Invalid); + } + if expires_at <= now { + return Err(IdentityError::Expired); + } + let mut header = + HeaderValue::from_str(&format!("Bearer {jwt}")).map_err(|_| IdentityError::Invalid)?; + header.set_sensitive(true); + Ok(Self { + header, + pubkey, + expires_at, + binding: None, + }) + } +} + +#[derive(Default)] +struct State { + generation: u64, + assertions: HashMap, +} + +/// Process-owned assertion slot for one configured enterprise realm. +/// +/// Destinations are explicit origins, never learned from NIP-11, an assertion, +/// an arbitrary image URL, or renderer input. Empty origins means OSS/off mode. +/// Multiple origins require explicit deployment configuration (e.g. git host). +#[derive(Default)] +pub struct IdentitySession { + origins: HashSet, + state: RwLock, +} + +fn origin(value: &str) -> Result { + let mut url = Url::parse(value).map_err(|_| IdentityError::Invalid)?; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(IdentityError::Invalid); + } + match url.scheme() { + "wss" => { + url.set_scheme("https") + .map_err(|_| IdentityError::Invalid)?; + } + "ws" => { + url.set_scheme("http").map_err(|_| IdentityError::Invalid)?; + } + "http" | "https" => {} + _ => return Err(IdentityError::Invalid), + } + if url.host_str().is_none() { + return Err(IdentityError::Invalid); + } + // Plaintext is solely for explicit loopback fixtures, never corporate hosts. + if url.scheme() == "http" + && !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + { + return Err(IdentityError::Invalid); + } + Ok(url.origin().ascii_serialization()) +} + +impl IdentitySession { + /// Configure the enterprise destinations. No dynamic transport downgrade. + pub fn new(origins: &[&str]) -> Result { + Ok(Self { + origins: origins + .iter() + .map(|value| origin(value)) + .collect::>()?, + state: RwLock::default(), + }) + } + + /// Whether this destination belongs to the explicitly configured realm. + pub fn protects(&self, destination: &str) -> Result { + if self.origins.is_empty() { + return Ok(false); + } + Ok(self.origins.contains(&origin(destination)?)) + } + + /// Read the effective expiry without exposing the assertion. + pub fn expires_at(&self, key: PublicKey) -> Result, IdentityError> { + Ok(self + .state + .read() + .map_err(|_| IdentityError::Unavailable)? + .assertions + .get(&key) + .map(|a| a.expires_at)) + } + + /// Transport lease: closes on logout, key replacement, or this token's expiry. + /// Renewal does not extend a previously admitted socket's lease. + pub fn lease_ended( + &self, + destination: &str, + key: PublicKey, + ) -> impl std::future::Future + Send + '_ { + let protected = self.protects(destination) != Ok(false); + let generation = self.generation(); + let deadline = self.expires_at(key).ok().flatten().unwrap_or(0); + async move { + if !protected { + std::future::pending::<()>().await; + return; + } + loop { + if self.generation() != generation || unix_now().unwrap_or(u64::MAX) >= deadline { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + } + } + + /// Begin login/logout/identity replacement, invalidating outstanding results. + /// Existing transport owners must also cancel sockets and in-flight work. + pub fn invalidate(&self) -> Result { + let mut state = self.state.write().map_err(|_| IdentityError::Unavailable)?; + state.generation = state + .generation + .checked_add(1) + .ok_or(IdentityError::Unavailable)?; + state.assertions.clear(); + Ok(state.generation) + } + + /// Read a generation before asynchronous renewal; does not discard a still + /// valid assertion while the adapter is temporarily unavailable. + pub fn generation(&self) -> Result { + Ok(self + .state + .read() + .map_err(|_| IdentityError::Unavailable)? + .generation) + } + + /// Install only into the login generation that requested the assertion. + pub fn install(&self, generation: u64, assertion: Assertion) -> Result<(), IdentityError> { + let mut state = self.state.write().map_err(|_| IdentityError::Unavailable)?; + if generation != state.generation { + return Err(IdentityError::Superseded); + } + if state.assertions.len() >= 256 && !state.assertions.contains_key(&assertion.pubkey) { + return Err(IdentityError::Unavailable); + } + if let Some(previous) = state.assertions.get(&assertion.pubkey) { + if previous.binding != assertion.binding { + return Err(IdentityError::KeyMismatch); + } + } + state.assertions.insert(assertion.pubkey, assertion); + Ok(()) + } + + /// Obtain the assertion immediately before sending a matching possession + /// proof. Other origins get no credential; configured origins fail closed. + pub fn header( + &self, + destination: &str, + proof_key: PublicKey, + now: u64, + ) -> Result, IdentityError> { + if self.origins.is_empty() { + return Ok(None); + } + if !self.origins.contains(&origin(destination)?) { + return Ok(None); + } + let state = self.state.read().map_err(|_| IdentityError::Unavailable)?; + let assertion = state + .assertions + .get(&proof_key) + .ok_or(if state.assertions.is_empty() { + IdentityError::LoginRequired + } else { + IdentityError::KeyMismatch + })?; + if now >= assertion.expires_at { + return Err(IdentityError::Expired); + } + if assertion.pubkey != proof_key { + return Err(IdentityError::KeyMismatch); + } + Ok(Some(assertion.header.clone())) + } + + /// Build a native upgrade request without exposing the JWT to JavaScript. + pub fn websocket_request( + &self, + destination: &str, + proof_key: PublicKey, + now: u64, + ) -> Result, IdentityError> { + let mut request = destination + .into_client_request() + .map_err(|_| IdentityError::Invalid)?; + if let Some(header) = self.header(destination, proof_key, now)? { + request.headers_mut().insert(IDENTITY_HEADER, header); + } + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + #[tokio::test] + #[allow(clippy::result_large_err)] // tungstenite's server callback fixes the error type. + async fn native_connection_sends_assertion_only_on_upgrade() { + use futures_util::StreamExt; + use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let session = IdentitySession::new(&[&url]).unwrap(); + let key = Keys::generate().public_key(); + session + .install(0, Assertion::new("aaa.bbb.ccc", key, 20, 10).unwrap()) + .unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_hdr_async( + stream, + |request: &Request, response: Response| { + assert_eq!(request.headers()[IDENTITY_HEADER], "Bearer aaa.bbb.ccc"); + assert!(request.uri().query().is_none()); + Ok(response) + }, + ) + .await + .unwrap(); + let frame = socket.next().await.unwrap().unwrap(); + assert!(frame.is_close()); + }); + let request = session.websocket_request(&url, key, 10).unwrap(); + let connection = crate::NostrWsConnection::connect_request(&url, request) + .await + .unwrap(); + connection.disconnect().await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + } + + #[test] + fn off_mode_is_unchanged() { + let key = Keys::generate().public_key(); + assert!(IdentitySession::default() + .header("ws://fixture", key, 10) + .unwrap() + .is_none()); + } + + #[test] + fn configured_origin_requires_matching_unexpired_evidence() { + let key = Keys::generate().public_key(); + let session = IdentitySession::new(&["https://relay.example"]).unwrap(); + assert_eq!( + session.header("wss://relay.example", key, 10), + Err(IdentityError::LoginRequired) + ); + session + .install(0, Assertion::new("aaa.bbb.ccc", key, 20, 10).unwrap()) + .unwrap(); + let request = session + .websocket_request("wss://relay.example/huddle/x/audio", key, 19) + .unwrap(); + assert_eq!(request.headers()[IDENTITY_HEADER], "Bearer aaa.bbb.ccc"); + assert!(request.headers()[IDENTITY_HEADER].is_sensitive()); + assert_eq!( + session.header("https://relay.example/query", key, 20), + Err(IdentityError::Expired) + ); + assert_eq!( + session.header( + "https://relay.example/query", + Keys::generate().public_key(), + 19 + ), + Err(IdentityError::KeyMismatch) + ); + assert!(session + .header("https://other.example/media/x", key, 19) + .unwrap() + .is_none()); + assert!(session + .header("https://relay.example:444/query", key, 19) + .unwrap() + .is_none()); + } + + #[test] + fn logout_fences_pending_login_and_same_key_replacement() { + let session = IdentitySession::new(&["https://relay.example"]).unwrap(); + let generation = session.generation().unwrap(); + session.invalidate().unwrap(); + let assertion = + Assertion::new("aaa.bbb.ccc", Keys::generate().public_key(), 20, 10).unwrap(); + assert_eq!( + session.install(generation, assertion), + Err(IdentityError::Superseded) + ); + } + + #[test] + fn configuration_and_token_errors_do_not_echo_credentials() { + assert!(IdentitySession::new(&["http://relay.example"]).is_err()); + assert!(IdentitySession::new(&["https://user:secret@relay.example"]).is_err()); + let key = Keys::generate().public_key(); + for token in [ + "", + "aaa.bbb", + "aaa..ccc", + "aaa.bbb.ccc\r\n", + "aaa.bbb.ccc extra", + ] { + assert!(Assertion::new(token, key, 20, 10).is_err()); + } + assert_eq!( + format!("{:?}", Assertion::new("aaa.bbb.ccc", key, 20, 10).unwrap()), + "Assertion([REDACTED])" + ); + } +} diff --git a/crates/buzz-ws-client/src/identity_adapter.rs b/crates/buzz-ws-client/src/identity_adapter.rs new file mode 100644 index 00000000000..e60a2da3c52 --- /dev/null +++ b/crates/buzz-ws-client/src/identity_adapter.rs @@ -0,0 +1,245 @@ +//! Assumed kgoose assertion API and agent-side credential source. +//! Assertions are opaque outside native transports; this validates metadata, +//! not signatures (the relay verifies issuer signatures and authorization). +use crate::federated_identity::{ + unix_now, Assertion, IdentityError, IdentitySession, IDENTITY_HEADER, +}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use nostr::{JsonUtil, Keys, PublicKey}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// Explicit process configuration. Never log values or put them in agent prompts. +pub const ENV_KEYS: [&str; 3] = [ + "BUZZ_NIP_FI_ENDPOINT", + "BUZZ_NIP_FI_CREDENTIAL", + "BUZZ_NIP_FI_ORIGINS", +]; + +/// Validate an explicitly configured adapter address. Loopback is for the native broker/tests. +pub fn endpoint(value: &str) -> Result { + let url = url::Url::parse(value).map_err(|_| IdentityError::Invalid)?; + let local = matches!(url.host_str(), Some("127.0.0.1" | "[::1]")); + if (url.scheme() != "https" && !(local && url.scheme() == "http")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err(IdentityError::Invalid); + } + Ok(url) +} + +/// Bounded adapter response, intentionally neither Debug nor Serialize. +#[derive(Deserialize)] +pub struct AdapterResponse { + /// Compact JWS, never surfaced to renderer state. + pub assertion: String, + /// Exact proof key. + pub nostr_pubkey: String, + /// Adapter's effective deadline, no later than JWT exp. + pub expires_at: u64, + /// Returned only by the assumed /agent-delegations endpoint. Key-scoped renewal credential. + #[serde(default)] + pub agent_credential: Option, +} + +impl AdapterResponse { + /// Check the selected token class and untrusted metadata before caching. + /// Issuer/audience policy and cryptographic verification remain server-side. + pub fn into_assertion(self, key: PublicKey, now: u64) -> Result { + let mut parts = self.assertion.split('.'); + let decode = |part: Option<&str>| -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(part.ok_or(IdentityError::Invalid)?) + .map_err(|_| IdentityError::Invalid)?; + serde_json::from_slice(&bytes).map_err(|_| IdentityError::Invalid) + }; + if self.assertion.len() > 16 * 1024 { + return Err(IdentityError::Invalid); + } + let header = decode(parts.next())?; + let claims = decode(parts.next())?; + let exp = claims["exp"].as_u64().ok_or(IdentityError::Invalid)?; + let iat = claims["iat"].as_u64().ok_or(IdentityError::Invalid)?; + if header["typ"] != "nip-fi+jwt" + || header["alg"] == "none" + || header["alg"].as_str().is_none_or(str::is_empty) + || self.nostr_pubkey != key.to_hex() + || claims["nostr_pubkey"] != key.to_hex() + || claims["iss"].as_str().is_none_or(str::is_empty) + || claims["sub"].as_str().is_none_or(str::is_empty) + || claims["aud"].as_str().is_none_or(str::is_empty) + || iat > now.saturating_add(5) + || exp <= iat + || self.expires_at > exp + || claims + .get("nbf") + .is_some_and(|v| v.as_u64().is_none_or(|n| n > now.saturating_add(5))) + { + return Err(IdentityError::Invalid); + } + Ok( + Assertion::new(&self.assertion, key, self.expires_at, now)?.bind( + claims["iss"].as_str().ok_or(IdentityError::Invalid)?.into(), + claims["sub"].as_str().ok_or(IdentityError::Invalid)?.into(), + claims["aud"].as_str().ok_or(IdentityError::Invalid)?.into(), + ), + ) + } +} + +/// Exchange a login/delegated credential and a locally signed proof for a JWT. +/// The adapter must authorize the binding; merely knowing a pubkey is insufficient. +pub async fn exchange( + client: &reqwest::Client, + endpoint_url: &str, + credential: &str, + keys: &Keys, + relay_url: &str, + auth_tag: Option<&nostr::Tag>, +) -> Result { + let endpoint = endpoint(endpoint_url)?; + let body = serde_json::to_vec(&serde_json::json!({ + "nostr_pubkey": keys.public_key().to_hex(), "relay_url": relay_url, + "auth_tag": auth_tag.map(|tag| tag.as_slice()) + })) + .map_err(|_| IdentityError::Invalid)?; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags( + [ + nostr::Tag::parse(["u", endpoint.as_str()]), + nostr::Tag::parse(["method", "POST"]), + nostr::Tag::parse(["payload", &hex::encode(Sha256::digest(&body))]), + ] + .into_iter() + .collect::, _>>() + .map_err(|_| IdentityError::Invalid)?, + ) + .sign_with_keys(keys) + .map_err(|_| IdentityError::Invalid)?; + let mut secret = + reqwest::header::HeaderValue::from_str(credential).map_err(|_| IdentityError::Invalid)?; + secret.set_sensitive(true); + let mut response = client + .post(endpoint) + .header("X-BB-Session-Credential", secret) + .header( + "Authorization", + format!("Nostr {}", STANDARD.encode(event.as_json())), + ) + .header("Content-Type", "application/json") + .body(body) + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|_| IdentityError::Unavailable)?; + if !response.status().is_success() { + return Err(if response.status().is_client_error() { + IdentityError::LoginRequired + } else { + IdentityError::Unavailable + }); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| IdentityError::Unavailable)? + { + if bytes.len().saturating_add(chunk.len()) > 24 * 1024 { + return Err(IdentityError::Invalid); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| IdentityError::Invalid) +} + +/// Resolve agent credentials at each operation, allowing long-running CLI/MCP +/// processes to renew rather than inheriting a single expiring human JWT. +/// Missing all configuration preserves OSS behavior; partial config fails closed. +pub async fn environment_header( + destination: &str, + keys: &Keys, +) -> Result, IdentityError> { + Ok(environment_admission(destination, keys) + .await? + .map(|(header, _)| header)) +} + +/// Obtain a header with its adapter-bounded deadline for a new socket. +pub async fn environment_admission( + destination: &str, + keys: &Keys, +) -> Result, IdentityError> { + let values = ENV_KEYS.map(std::env::var); + if values + .iter() + .all(|v| matches!(v, Err(std::env::VarError::NotPresent))) + { + return Ok(None); + } + let [endpoint, credential, origins] = values; + let endpoint = endpoint.map_err(|_| IdentityError::Invalid)?; + let credential = credential.map_err(|_| IdentityError::Invalid)?; + let origins = origins.map_err(|_| IdentityError::Invalid)?; + if origins.is_empty() || credential.is_empty() { + return Err(IdentityError::Invalid); + } + let session = IdentitySession::new(&origins.split(',').collect::>())?; + if !session.protects(destination)? { + return Ok(None); + } + let relay_url = std::env::var("BUZZ_RELAY_URL").map_err(|_| IdentityError::Invalid)?; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| IdentityError::Unavailable)?; + let auth_tag = std::env::var("BUZZ_AUTH_TAG") + .ok() + .map(|v| serde_json::from_str::(&v)) + .transpose() + .map_err(|_| IdentityError::Invalid)?; + let response = exchange( + &client, + &endpoint, + &credential, + keys, + &relay_url, + auth_tag.as_ref(), + ) + .await?; + let deadline = response.expires_at; + session.install(0, response.into_assertion(keys.public_key(), unix_now()?)?)?; + Ok(session + .header(destination, keys.public_key(), unix_now()?)? + .map(|header| (header, deadline))) +} + +/// Apply assertion to a fully constructed request, using its actual URL. +/// All clients using this helper must disable redirects. +pub async fn authorize( + request: reqwest::RequestBuilder, + keys: &Keys, +) -> Result { + let url = request + .try_clone() + .ok_or(IdentityError::Invalid)? + .build() + .map_err(|_| IdentityError::Invalid)? + .url() + .to_string(); + Ok(match environment_header(&url, keys).await? { + Some(header) => request.header(IDENTITY_HEADER, header), + None => request, + }) +} + +#[cfg(test)] +#[path = "identity_adapter_tests.rs"] +mod tests; diff --git a/crates/buzz-ws-client/src/identity_adapter_tests.rs b/crates/buzz-ws-client/src/identity_adapter_tests.rs new file mode 100644 index 00000000000..5f1073c171e --- /dev/null +++ b/crates/buzz-ws-client/src/identity_adapter_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +fn response(key: PublicKey, now: u64) -> AdapterResponse { + let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"nip-fi+jwt","alg":"ES256"}"#); + let claims = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&serde_json::json!({ + "iss":"https://adapter.example", "sub":"opaque-employee", "aud":"buzz", + "nostr_pubkey":key.to_hex(), "iat":now, "exp":now + 120 + })) + .unwrap(), + ); + AdapterResponse { + assertion: format!("{header}.{claims}.fixture"), + nostr_pubkey: key.to_hex(), + expires_at: now + 100, + agent_credential: None, + } +} + +#[test] +fn adapter_metadata_matches_local_signer_and_effective_deadline() { + let key = Keys::generate().public_key(); + assert!(response(key, 10).into_assertion(key, 10).is_ok()); + assert!(response(key, 10) + .into_assertion(Keys::generate().public_key(), 10) + .is_err()); + let mut token = response(key, 10); + token.expires_at = 131; + assert!(token.into_assertion(key, 10).is_err()); + let mut token = response(key, 10); + token.assertion = token.assertion.replacen( + &URL_SAFE_NO_PAD.encode(br#"{"typ":"nip-fi+jwt","alg":"ES256"}"#), + &URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"ES256"}"#), + 1, + ); + assert!(token.into_assertion(key, 10).is_err()); + assert!(response(key, 20).into_assertion(key, 10).is_err()); + assert!(response(key, 10).into_assertion(key, 120).is_err()); +} + +#[tokio::test] +async fn real_adapter_exchange_has_exact_body_possession_and_no_redirect_forwarding() { + use axum::{ + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Router, + }; + let keys = Keys::generate(); + let key = keys.public_key(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let expected = format!("{base}/assertions"); + let app = Router::new().route("/assertions", post(move |headers: HeaderMap, body: String| { + let expected = expected.clone(); + async move { + assert_eq!(headers["x-bb-session-credential"], "fixture-session"); + assert!(!headers.contains_key(IDENTITY_HEADER)); + let proof: nostr::Event = serde_json::from_slice(&STANDARD.decode(headers["authorization"].to_str().unwrap().strip_prefix("Nostr ").unwrap()).unwrap()).unwrap(); + proof.verify().unwrap(); + assert_eq!(proof.pubkey, key); + for tag in [["u", expected.as_str()], ["method", "POST"], ["payload", &hex::encode(Sha256::digest(body.as_bytes()))]] { + assert!(proof.tags.iter().any(|t| t.as_slice() == tag)); + } + let token = response(key, unix_now().unwrap()); + axum::Json(serde_json::json!({"assertion":token.assertion,"nostr_pubkey":token.nostr_pubkey,"expires_at":token.expires_at})) + } + })).route("/redirect", post(|| async { (StatusCode::TEMPORARY_REDIRECT, [("location", "http://127.0.0.1:9/never")]).into_response() })); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let token = exchange( + &client, + &format!("{base}/assertions"), + "fixture-session", + &keys, + "wss://relay.example", + None, + ) + .await + .unwrap(); + assert!(token.into_assertion(key, unix_now().unwrap()).is_ok()); + assert!(exchange( + &client, + &format!("{base}/redirect"), + "fixture-session", + &keys, + "wss://relay.example", + None + ) + .await + .is_err()); + server.abort(); +} + +#[tokio::test] +async fn key_specific_sessions_renew_without_extending_existing_socket_lease() { + let session = std::sync::Arc::new(IdentitySession::new(&["https://relay.example"]).unwrap()); + let human = Keys::generate().public_key(); + let agent = Keys::generate().public_key(); + let now = unix_now().unwrap(); + session + .install(0, Assertion::new("a.b.c", human, now + 1, now).unwrap()) + .unwrap(); + session + .install(0, Assertion::new("d.e.f", agent, now + 100, now).unwrap()) + .unwrap(); + assert_eq!( + session + .header("https://relay.example/events", agent, now) + .unwrap() + .unwrap(), + "Bearer d.e.f" + ); + let lease = session.lease_ended("wss://relay.example", human); + tokio::pin!(lease); + tokio::select! { + _ = &mut lease => panic!("premature expiry"), + _ = tokio::time::sleep(std::time::Duration::from_millis(1)) => {}, + } + session + .install(0, Assertion::new("g.h.i", human, now + 100, now).unwrap()) + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), &mut lease) + .await + .unwrap(); + session.invalidate().unwrap(); + assert!(session + .header("https://relay.example/events", agent, now) + .is_err()); + assert!(session + .install(0, Assertion::new("a.b.c", human, now + 100, now).unwrap()) + .is_err()); +} + +#[tokio::test] +async fn socket_wrapper_ends_idle_stream_at_effective_expiry() { + use futures_util::StreamExt; + let (client_io, server_io) = tokio::io::duplex(1024); + let client = tokio_tungstenite::WebSocketStream::from_raw_socket( + client_io, + tokio_tungstenite::tungstenite::protocol::Role::Client, + None, + ) + .await; + let _server = tokio_tungstenite::WebSocketStream::from_raw_socket( + server_io, + tokio_tungstenite::tungstenite::protocol::Role::Server, + None, + ) + .await; + let mut socket = crate::identity_socket::IdentitySocket::admitted( + client, + Some(unix_now().unwrap()), + "wss://relay.example".into(), + Keys::generate(), + ) + .unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), socket.next()) + .await + .unwrap() + .is_none() + ); +} diff --git a/crates/buzz-ws-client/src/identity_git.rs b/crates/buzz-ws-client/src/identity_git.rs new file mode 100644 index 00000000000..c0fcb6622f6 --- /dev/null +++ b/crates/buzz-ws-client/src/identity_git.rs @@ -0,0 +1,55 @@ +//! Per-invocation Git assertion configuration; never persist JWTs to git config. +use crate::{federated_identity::IdentityError, identity_adapter}; + +/// Add exactly one origin-scoped identity header after refreshing authority. +/// Redirects and curl tracing are disabled so credentials cannot cross origins +/// or enter Git diagnostics. The existing Nostr credential helper remains in use. +pub async fn configure( + command: &mut std::process::Command, + keys: &nostr::Keys, + relay: &str, + base: usize, +) -> Result<(), IdentityError> { + let Some(header) = identity_adapter::environment_header(relay, keys).await? else { + return Ok(()); + }; + let mut origin = url::Url::parse(relay).map_err(|_| IdentityError::Invalid)?; + if origin.scheme() == "ws" { + origin + .set_scheme("http") + .map_err(|_| IdentityError::Invalid)?; + } + if origin.scheme() == "wss" { + origin + .set_scheme("https") + .map_err(|_| IdentityError::Invalid)?; + } + let prefix = format!("http.{}/git", origin.origin().ascii_serialization()); + let entries = [ + (format!("{prefix}.extraHeader"), String::new()), + ( + format!("{prefix}.extraHeader"), + format!( + "Nostr-Federated-Identity: {}", + header.to_str().map_err(|_| IdentityError::Invalid)? + ), + ), + ("http.followRedirects".into(), "false".into()), + ]; + command.env("GIT_CONFIG_COUNT", (base + entries.len()).to_string()); + for (offset, (key, value)) in entries.into_iter().enumerate() { + command.env(format!("GIT_CONFIG_KEY_{}", base + offset), key); + command.env(format!("GIT_CONFIG_VALUE_{}", base + offset), value); + } + for key in [ + "GIT_TRACE", + "GIT_TRACE_CURL", + "GIT_CURL_VERBOSE", + "GIT_TRACE2", + "GIT_TRACE2_EVENT", + "GIT_TRACE2_PERF", + ] { + command.env_remove(key); + } + Ok(()) +} diff --git a/crates/buzz-ws-client/src/identity_socket.rs b/crates/buzz-ws-client/src/identity_socket.rs new file mode 100644 index 00000000000..318b71b4c77 --- /dev/null +++ b/crates/buzz-ws-client/src/identity_socket.rs @@ -0,0 +1,123 @@ +//! WebSocket wrapper with an immutable admission deadline and broker liveness. +use futures_util::{Future, Sink, Stream}; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +use tokio_tungstenite::{ + tungstenite::{Error, Message}, + WebSocketStream, +}; + +/// A socket's admitted lifetime never extends when a later assertion is issued. +/// The renewal probe also notices desktop logout while a local agent is idle. +pub struct IdentitySocket { + socket: WebSocketStream, + ended: Option + Send>>>, + closed: bool, +} +impl std::fmt::Debug for IdentitySocket { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("IdentitySocket([REDACTED])") + } +} +impl IdentitySocket { + /// Close using tungstenite's familiar close-frame API. + pub async fn close( + &mut self, + frame: Option, + ) -> Result<(), Error> { + self.socket.close(frame).await + } +} +impl From> for IdentitySocket { + fn from(socket: WebSocketStream) -> Self { + Self { + socket, + ended: None, + closed: false, + } + } +} +impl IdentitySocket { + /// Install a fixed deadline plus periodic key-scoped authority check. + pub fn admitted( + socket: WebSocketStream, + expiry: Option, + url: String, + keys: nostr::Keys, + ) -> Result { + let Some(expiry) = expiry else { + return Ok(socket.into()); + }; + let deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs( + expiry.saturating_sub(crate::federated_identity::unix_now()?), + ); + let ended = Box::pin(async move { + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => return, + _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {} + } + tokio::select! { + _ = tokio::time::sleep_until(deadline) => return, + result = crate::identity_adapter::environment_header(&url, &keys) => { + if result.is_err() { return; } + } + } + } + }); + Ok(Self { + socket, + ended: Some(ended), + closed: false, + }) + } + fn expired(&mut self, cx: &mut Context<'_>) -> bool { + if self.closed { + return true; + } + if self + .ended + .as_mut() + .is_some_and(|future| future.as_mut().poll(cx).is_ready()) + { + self.closed = true; + } + self.closed + } +} +impl Stream for IdentitySocket { + type Item = Result; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.expired(cx) { + return Poll::Ready(None); + } + Pin::new(&mut self.socket).poll_next(cx) + } +} +impl Sink for IdentitySocket { + type Error = Error; + fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.expired(cx) { + return Poll::Ready(Err(Error::ConnectionClosed)); + } + Pin::new(&mut self.socket).poll_ready(cx) + } + fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Error> { + if self.closed { + return Err(Error::ConnectionClosed); + } + Pin::new(&mut self.socket).start_send(item) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.expired(cx) { + return Poll::Ready(Err(Error::ConnectionClosed)); + } + Pin::new(&mut self.socket).poll_flush(cx) + } + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.socket).poll_close(cx) + } +} diff --git a/crates/buzz-ws-client/src/lib.rs b/crates/buzz-ws-client/src/lib.rs index 02c7d4b1b20..1a87ba17cc5 100644 --- a/crates/buzz-ws-client/src/lib.rs +++ b/crates/buzz-ws-client/src/lib.rs @@ -2,6 +2,10 @@ pub mod connection; pub mod error; +pub mod federated_identity; +pub mod identity_adapter; +pub mod identity_git; +pub mod identity_socket; pub mod message; pub use connection::{publish_event, NostrWsConnection}; diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176d..6b110d3b07b 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-sign-in.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 87d9eb1b474..6d25ad22807 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1260,9 +1260,14 @@ dependencies = [ name = "buzz-ws-client" version = "0.1.0" dependencies = [ + "base64 0.22.1", "futures-util", + "hex", "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", diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 8b0e63f12bc..34ed6e87bcd 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -8,6 +8,9 @@ include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; fn main() { + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_LOGIN_API_URL"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_NIP_FI_ORIGINS"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_NIP_FI_ASSERTION_URL"); 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..1aac790730b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -18,6 +18,11 @@ use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; pub struct AppState { pub keys: Mutex, + pub(crate) federated_broker: crate::federated_agent_broker::AgentBroker, + pub(crate) federated_retry_after: AtomicU64, + pub(crate) federated_acquisition: AsyncMutex<()>, + pub(crate) federated_identity: + Result, String>, /// 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, @@ -200,6 +205,10 @@ pub fn build_app_state() -> AppState { AppState { keys: Mutex::new(keys), + federated_identity: crate::federated_identity::configured_session(), + federated_broker: Default::default(), + federated_retry_after: AtomicU64::new(0), + federated_acquisition: AsyncMutex::new(()), identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) diff --git a/desktop/src-tauri/src/builderlab.rs b/desktop/src-tauri/src/builderlab.rs index 1252946c58b..688d5d28e10 100644 --- a/desktop/src-tauri/src/builderlab.rs +++ b/desktop/src-tauri/src/builderlab.rs @@ -12,7 +12,10 @@ use tauri_plugin_opener::OpenerExt; use tokio::{net::TcpListener, sync::oneshot}; use url::Url; -const BUILDERLAB_API_BASE_URL: &str = "https://app.builderlab.xyz/api/goose"; +const BUILDERLAB_API_BASE_URL: &str = match option_env!("BUZZ_BUILD_LOGIN_API_URL") { + Some(value) => value, + None => "https://app.builderlab.xyz/api/goose", +}; const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60); const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential"; // Builderlab enforces an Origin check on the identity bind endpoints. Browsers @@ -142,6 +145,17 @@ const AUTH_COMPLETE_HTML: &str = r#" #[derive(Default)] pub(crate) struct BuilderlabSession(Mutex>); +impl BuilderlabSession { + pub(crate) fn credential_for_federated_identity(&self) -> Result { + self.0 + .lock() + .map_err(|_| "login session unavailable")? + .as_ref() + .map(|session| session.credential.clone()) + .ok_or("Sign in first".into()) + } +} + #[derive(Default)] pub(crate) struct BuilderlabLogin(Mutex>); @@ -210,8 +224,8 @@ async fn login_callback( } fn api_url(path: &str) -> Result { - Url::parse(&format!("{BUILDERLAB_API_BASE_URL}{path}")) - .map_err(|error| format!("invalid Builderlab API URL: {error}")) + buzz_ws_client_pkg::identity_adapter::endpoint(&format!("{BUILDERLAB_API_BASE_URL}{path}")) + .map_err(|error| error.to_string()) } fn login_url(return_to: &str) -> Result { @@ -254,6 +268,13 @@ pub(crate) async fn start_builderlab_login( session: tauri::State<'_, BuilderlabSession>, login: tauri::State<'_, BuilderlabLogin>, ) -> Result { + *session.0.lock().map_err(|_| "login unavailable")? = None; + let enterprise_generation = crate::federated_identity::session(&app_state)? + .invalidate() + .map_err(|e| e.to_string())?; + app_state + .federated_retry_after + .store(0, std::sync::atomic::Ordering::Release); let listener = TcpListener::bind("127.0.0.1:0") .await .map_err(|error| format!("could not start local authentication callback: {error}"))?; @@ -318,7 +339,7 @@ pub(crate) async fn start_builderlab_login( server.abort(); let response = app_state - .http_client + .media_fetch_client .post(api_url("/v1/auth/login/exchange")?) .json(&serde_json::json!({ "code": exchange_code })) .timeout(Duration::from_secs(30)) @@ -339,7 +360,8 @@ pub(crate) async fn start_builderlab_login( return Err("Builderlab code exchange returned an empty credential".to_owned()); } - let me = authenticated_user(&app_state.http_client, &exchanged.session_credential).await?; + let me = + authenticated_user(&app_state.media_fetch_client, &exchanged.session_credential).await?; if exchanged.expires_at != me.expires_at { return Err("Builderlab session expiry did not match code exchange".to_owned()); } @@ -356,11 +378,24 @@ pub(crate) async fn start_builderlab_login( { return Err("Builderlab authentication canceled".to_owned()); } + if crate::federated_identity::session(&app_state)? + .generation() + .map_err(|e| e.to_string())? + != enterprise_generation + { + return Err("enterprise login scope changed".into()); + } *pending = None; + *session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession { + credential: exchanged.session_credential, + }); } - *session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession { - credential: exchanged.session_credential, - }); + crate::federated_identity::ensure( + &app_state, + &crate::relay::relay_ws_url_with_override(&app_state), + app_state.signing_keys()?.public_key(), + ) + .await?; Ok(info) } @@ -378,17 +413,20 @@ pub(crate) async fn get_builderlab_auth( let Some(credential) = stored else { return Ok(None); }; - match authenticated_user(&app_state.http_client, &credential).await { + match authenticated_user(&app_state.media_fetch_client, &credential).await { Ok(me) => Ok(Some(BuilderlabAuthInfo { expires_at: me.expires_at, email: me.email, name: me.name, })), Err(error) => { - *session - .0 - .lock() - .map_err(|lock_error| lock_error.to_string())? = None; + let mut stored = session.0.lock().map_err(|_| "login unavailable")?; + if stored.as_ref().is_some_and(|s| s.credential == credential) { + *stored = None; + crate::federated_identity::session(&app_state)? + .invalidate() + .map_err(|e| e.to_string())?; + } Err(error) } } @@ -406,9 +444,18 @@ pub(crate) fn cancel_builderlab_login( #[tauri::command] pub(crate) fn clear_builderlab_auth( + app_state: tauri::State<'_, crate::app_state::AppState>, session: tauri::State<'_, BuilderlabSession>, + login: tauri::State<'_, BuilderlabLogin>, ) -> Result<(), String> { - *session.0.lock().map_err(|error| error.to_string())? = None; + let mut pending = login.0.lock().map_err(|_| "login unavailable")?; + if let Some(previous) = pending.take() { + let _ = previous.cancel.send(()); + } + *session.0.lock().map_err(|_| "login unavailable")? = None; + crate::federated_identity::session(&app_state)? + .invalidate() + .map_err(|e| e.to_string())?; Ok(()) } @@ -469,7 +516,7 @@ pub(crate) async fn get_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/current", @@ -484,7 +531,7 @@ pub(crate) async fn bind_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { let challenge_value = authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/challenge", @@ -510,7 +557,7 @@ pub(crate) async fn bind_builderlab_nostr_identity( &challenge.expires_at, )?; authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/verify", @@ -529,7 +576,7 @@ pub(crate) async fn delete_builderlab_nostr_identity( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/nostr-identities/delete", @@ -544,7 +591,7 @@ pub(crate) async fn list_builderlab_communities( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities/list", @@ -560,7 +607,7 @@ pub(crate) async fn check_builderlab_community_name( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities/availability", @@ -576,7 +623,7 @@ pub(crate) async fn create_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities", @@ -592,7 +639,7 @@ pub(crate) async fn archive_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities/archive", @@ -608,7 +655,7 @@ pub(crate) async fn unarchive_builderlab_community( session: tauri::State<'_, BuilderlabSession>, ) -> Result { authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities/unarchive", @@ -628,7 +675,7 @@ pub(crate) async fn transfer_builderlab_community( // archive/unarchive endpoints which take `community_id`; mirror the web // client's payload exactly. authenticated_json( - &app_state.http_client, + &app_state.media_fetch_client, &session, reqwest::Method::POST, "/v1/buzz/communities/transfer", diff --git a/desktop/src-tauri/src/commands/agents/provider_deploy.rs b/desktop/src-tauri/src/commands/agents/provider_deploy.rs index 15db4dec5aa..03b9147972c 100644 --- a/desktop/src-tauri/src/commands/agents/provider_deploy.rs +++ b/desktop/src-tauri/src/commands/agents/provider_deploy.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use tauri::AppHandle; +use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, @@ -109,6 +109,67 @@ pub(crate) async fn deploy_to_provider( }) .map_or_else(|| resolve_provider_binary(&provider_id), Ok)?; + // The assertion service, not NIP-OA alone, authorizes detached agent access. + // Only the scoped agent renewal credential enters the provider's secret env; + // the employee login credential and human assertion never leave this process. + let relay = agent_json["relay_url"] + .as_str() + .ok_or("missing agent relay")? + .to_owned(); + let identity = crate::federated_identity::session(state)?; + if identity.protects(&relay).map_err(|e| e.to_string())? { + let generation = identity.generation().map_err(|e| e.to_string())?; + let keys = nostr::Keys::parse( + agent_json["private_key_nsec"] + .as_str() + .ok_or("missing agent key")?, + ) + .map_err(|_| "invalid agent key")?; + let auth_tag = agent_json["auth_tag"] + .as_str() + .map(serde_json::from_str::) + .transpose() + .map_err(|_| "invalid agent delegation")?; + let credential = app + .state::() + .credential_for_federated_identity()?; + let assertion_endpoint = option_env!("BUZZ_BUILD_NIP_FI_ASSERTION_URL") + .unwrap_or("https://app.builderlab.xyz/api/goose/v1/buzz/identity/assertions"); + let mut delegation_endpoint = + buzz_ws_client_pkg::identity_adapter::endpoint(assertion_endpoint) + .map_err(|e| e.to_string())?; + delegation_endpoint.set_path(&format!( + "{}/agent-delegations", + delegation_endpoint.path().trim_end_matches("/assertions") + )); + let result = buzz_ws_client_pkg::identity_adapter::exchange( + &state.media_fetch_client, + delegation_endpoint.as_str(), + &credential, + &keys, + &relay, + auth_tag.as_ref(), + ) + .await + .map_err(|e| e.to_string())?; + let delegated = result + .agent_credential + .clone() + .filter(|s| !s.is_empty() && s.len() <= 16 * 1024) + .ok_or("adapter did not grant detached agent renewal")?; + result + .into_assertion(keys.public_key(), crate::federated_identity::now()?) + .map_err(|e| e.to_string())?; + if identity.generation().map_err(|e| e.to_string())? != generation { + return Err("enterprise login changed during agent deployment".into()); + } + let policy = agent_json["launch"]["policy_env"] + .as_object_mut() + .ok_or("missing launch policy")?; + policy.insert("BUZZ_NIP_FI_ENDPOINT".into(), assertion_endpoint.into()); + policy.insert("BUZZ_NIP_FI_CREDENTIAL".into(), delegated.into()); + policy.insert("BUZZ_NIP_FI_ORIGINS".into(), relay.into()); + } let deployed_agent_json = agent_json.clone(); let config_clone = config.clone(); let deploy_result = diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 8852fcb7e01..910facd1663 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -441,6 +441,9 @@ pub(crate) fn commit_imported_identity( let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); let storage = persist(&keys)?; + crate::federated_identity::session(state)? + .invalidate() + .map_err(|e| e.to_string())?; // Update in-memory keys BEFORE clearing recovery flags. The Release // stores below pair with Acquire loads in get_identity: a reader diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8cf8cc41747..6262677ea88 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -294,10 +294,8 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } -/// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive -/// across a video's range-request stream while staying well inside the -/// server's `created_at` freshness window (3600s, matching upload). -pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 600; +/// NIP-FI Blossom proofs live at most 60 seconds; range requests must remint. +pub(crate) const MEDIA_GET_AUTH_EXPIRY_SECS: u64 = 60; /// Sign a Blossom (BUD-01) `t=get` authorization event, server-scoped to the /// relay's authority, and return the full `Authorization` header value. @@ -372,9 +370,8 @@ fn sign_blossom_upload_auth( Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) .map_err(|e| e.to_string())?, ]; - if let Some(domain) = extract_server_authority(base_url) { - tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); - } + let domain = extract_server_authority(base_url).ok_or("invalid media tenant host")?; + tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); EventBuilder::new(Kind::from(24242), "Upload buzz-media") .tags(tags) .sign_with_keys(keys) @@ -414,19 +411,11 @@ async fn do_upload( ) -> 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/") { - 3600 - } else { - 300 - }; + // Admission freshness is bounded to 60s, independently of upload duration. + let expiry_secs = 60; let base_url = relay_api_base_url_with_override(state); - let auth_event = { - let keys = state.signing_keys()?; - sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)? - }; + let keys = state.signing_keys()?; + let auth_event = sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?; let auth_header = format!( "Nostr {}", @@ -450,6 +439,11 @@ async fn do_upload( ) .await?; if should_retry_legacy_upload(resp.status()) { + let fresh = sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)?; + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(fresh.as_json().as_bytes()) + ); resp = send_upload_attempt( state, UploadAttempt { diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index e841d4b2f54..5bcd083e64a 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -269,64 +269,82 @@ 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) { - req = req.header("authorization", auth); + let auth = mint_media_get_auth(state, &relay_base); + if auth.is_none() + && crate::federated_identity::session(state)? + .protects(url) + .map_err(|e| e.to_string())? + { + return Err("enterprise sign-in required".into()); + } + if let Some(auth) = auth { + req = crate::federated_identity::authorize( + state, + req.header("authorization", &auth), + url, + &auth, + ) + .await?; } - let request = req.send(); - let resp = if let Some(cancellation) = cancellation { - tokio::select! { - _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), - result = request => result, + let key = state.signing_keys()?.public_key(); + crate::federated_identity::guard(state, url, key, async { + let request = req.send(); + let resp = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + result = request => result, + } + } else { + request.await } - } else { - request.await - } - .map_err(|e| classify_request_error(&e))?; + .map_err(|e| classify_request_error(&e))?; - if let Some(err) = redirect_refusal_error(resp.status()) { - return Err(err); - } + if let Some(err) = redirect_refusal_error(resp.status()) { + return Err(err); + } - if !resp.status().is_success() { - return Err(relay_error_message(resp).await); - } + if !resp.status().is_success() { + return Err(relay_error_message(resp).await); + } - // Check Content-Length header upfront if present. - if let Some(content_length) = resp.content_length() { - if content_length > cap { - return Err(format!( - "file too large ({} MiB, max {} MiB)", - content_length / (1024 * 1024), - cap / (1024 * 1024) - )); + // Check Content-Length header upfront if present. + if let Some(content_length) = resp.content_length() { + if content_length > cap { + return Err(format!( + "file too large ({} MiB, max {} MiB)", + content_length / (1024 * 1024), + cap / (1024 * 1024) + )); + } } - } - // Stream the response with a running byte count to enforce the size cap - // even when Content-Length is missing or dishonest. - let mut bytes = Vec::new(); - let mut stream = resp.bytes_stream(); - loop { - let next = if let Some(cancellation) = cancellation { - tokio::select! { - _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), - next = stream.next() => next, + // Stream the response with a running byte count to enforce the size cap + // even when Content-Length is missing or dishonest. + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + loop { + let next = if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("media fetch cancelled".to_string()), + next = stream.next() => next, + } + } else { + stream.next().await + }; + let Some(chunk) = next else { + break; + }; + let chunk = chunk.map_err(|e| classify_request_error(&e))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); } - } else { - stream.next().await - }; - let Some(chunk) = next else { - break; - }; - let chunk = chunk.map_err(|e| classify_request_error(&e))?; - if bytes.len() as u64 + chunk.len() as u64 > cap { - return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); + bytes.extend_from_slice(&chunk); } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) + Ok(bytes) + }) + .await } /// The snapshot file format inferred from the sanitized filename suffix. diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 5ed3f786521..de235d28c1c 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -82,55 +82,69 @@ pub(super) async fn send_upload_attempt( progress, cancellation, } = attempt; - let req = state - .http_client - .put(url) - .header("Authorization", auth_header) - .header("Content-Type", mime) - .header("X-SHA-256", sha256); - - let response = if let Some((app, progress_id)) = progress { - let app = app.clone(); - let progress_id = progress_id.clone(); - let total = body.len() as u64; - let chunk_size = 64 * 1024; - let chunk_count = body.len().div_ceil(chunk_size); - let mut sent: u64 = 0; - let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { - let start = i * chunk_size; - let end = usize::min(start + chunk_size, body.len()); - let chunk = body.slice(start..end); - sent += chunk.len() as u64; - let _ = app.emit( - "media-upload-progress", - serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), - ); - Ok::(chunk) - })); - let request = req - .header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send(); - if let Some(cancellation) = cancellation { - tokio::select! { - _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), - response = request => response, - } - } else { - request.await - } - } else { - let request = req.body(body).send(); - if let Some(cancellation) = cancellation { - tokio::select! { - _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), - response = request => response, - } - } else { - request.await - } - }; - response.map_err(|error| classify_request_error(&error)) + let req = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .put(&url) + .header("Authorization", auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", sha256), + &url, + auth_header, + ) + .await?; + + crate::federated_identity::guard( + state, + &url, + crate::federated_identity::proof_key(auth_header)?, + async { + let response = if let Some((app, progress_id)) = progress { + let app = app.clone(); + let progress_id = progress_id.clone(); + let total = body.len() as u64; + let chunk_size = 64 * 1024; + let chunk_count = body.len().div_ceil(chunk_size); + let mut sent: u64 = 0; + let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { + let start = i * chunk_size; + let end = usize::min(start + chunk_size, body.len()); + let chunk = body.slice(start..end); + sent += chunk.len() as u64; + let _ = app.emit( + "media-upload-progress", + serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), + ); + Ok::(chunk) + })); + let request = req + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)) + .send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + } else { + let request = req.body(body).send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + }; + response.map_err(|error| classify_request_error(&error)) + }, + ) + .await } pub(super) fn emit_media_upload_phase( diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index 517e333b293..f7f73dad626 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -675,7 +675,11 @@ pub async fn mint_agent_card( let auth = is_same_origin(url, &relay_base) .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) .flatten(); - fetch_avatar(url, auth.as_deref()).await? + let identity = match auth.as_deref() { + Some(auth) => crate::federated_identity::http_header(&state, url, auth).await?, + None => None, + }; + fetch_avatar(url, auth.as_deref(), identity).await? } _ => { return Err( @@ -915,7 +919,11 @@ fn is_same_origin(url: &str, relay_base: &str) -> bool { /// Content-Length header is checked before any body bytes are read, and the /// body is streamed with a running count so a missing or dishonest header /// still cannot exceed the cap (same contract as `media_download.rs`). -async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result, String> { +async fn fetch_avatar( + url: &str, + auth: Option<&str>, + identity: Option, +) -> Result, String> { use futures_util::StreamExt; let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); @@ -930,6 +938,12 @@ async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result, String> if let Some(auth) = auth { req = req.header("authorization", auth); } + if let Some(identity) = identity { + req = req.header( + buzz_ws_client_pkg::federated_identity::IDENTITY_HEADER, + identity, + ); + } let resp = req .send() .await diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index f5e18e722b2..f86a0e9b166 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -828,11 +828,17 @@ pub(crate) async fn submit_engram_event( // wait produces a stale `created_at` that the relay will reject. crate::relay_admission::wait_for_rate_limit().await; let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; - let mut request = state - .http_client - .post(url) - .header("Authorization", auth) - .header("Content-Type", "application/json"); + let mut request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .post(url) + .header("Authorization", &auth) + .header("Content-Type", "application/json"), + url, + &auth, + ) + .await?; if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } diff --git a/desktop/src-tauri/src/commands/project_git_exec.rs b/desktop/src-tauri/src/commands/project_git_exec.rs index c616d39db1e..22492dad875 100644 --- a/desktop/src-tauri/src/commands/project_git_exec.rs +++ b/desktop/src-tauri/src/commands/project_git_exec.rs @@ -49,6 +49,7 @@ pub(crate) struct GitAuthConfig { credential_helper: Option, nsec: String, allow_file_transport: bool, + enterprise_env: Vec<(String, String)>, } fn read_pipe_lossy(pipe: Option) -> String { @@ -77,6 +78,93 @@ pub(crate) fn run_git( LOCAL_GIT_TIMEOUT }; configure_git_auth(&mut command, auth, needs_credentials); + if needs_credentials && !auth.enterprise_env.is_empty() { + // This function runs in spawn_blocking. Native broker refreshes just + // before Git starts; capabilities never enter a git config file. + let keys = Keys::parse(&auth.nsec).map_err(|_| "invalid git identity")?; + let endpoint = auth + .enterprise_env + .iter() + .find(|(k, _)| k == "BUZZ_NIP_FI_ENDPOINT") + .map(|(_, v)| v) + .ok_or("missing broker")?; + let credential = auth + .enterprise_env + .iter() + .find(|(k, _)| k == "BUZZ_NIP_FI_CREDENTIAL") + .map(|(_, v)| v) + .ok_or("missing broker credential")?; + let relay = auth + .enterprise_env + .iter() + .find(|(k, _)| k == "BUZZ_NIP_FI_ORIGINS") + .map(|(_, v)| v) + .ok_or("missing broker origin")?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "git authentication unavailable")?; + let assertion = runtime.block_on(async { + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "git authentication unavailable")?; + let result = buzz_ws_client_pkg::identity_adapter::exchange( + &client, endpoint, credential, &keys, relay, None, + ) + .await + .map_err(|e| e.to_string())?; + let identity = buzz_ws_client_pkg::federated_identity::IdentitySession::new(&[relay]) + .map_err(|e| e.to_string())?; + identity + .install( + 0, + result + .into_assertion(keys.public_key(), crate::federated_identity::now()?) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + identity + .header(relay, keys.public_key(), crate::federated_identity::now()?) + .map_err(|e| e.to_string())? + .ok_or_else(|| "git assertion missing".to_string()) + })?; + let base = command + .get_envs() + .find(|(k, _)| *k == "GIT_CONFIG_COUNT") + .and_then(|(_, v)| v) + .and_then(|v| v.to_str()) + .and_then(|v| v.parse::().ok()) + .ok_or("git configuration missing")?; + let origin = crate::relay::relay_http_base_url(relay); + let entries = [ + (format!("http.{origin}/git.extraHeader"), String::new()), + ( + format!("http.{origin}/git.extraHeader"), + format!( + "Nostr-Federated-Identity: {}", + assertion.to_str().map_err(|_| "invalid assertion")? + ), + ), + ("http.followRedirects".into(), "false".into()), + ]; + command.env("GIT_CONFIG_COUNT", (base + entries.len()).to_string()); + for (i, (key, value)) in entries.into_iter().enumerate() { + command + .env(format!("GIT_CONFIG_KEY_{}", base + i), key) + .env(format!("GIT_CONFIG_VALUE_{}", base + i), value); + } + for key in [ + "GIT_TRACE", + "GIT_TRACE_CURL", + "GIT_CURL_VERBOSE", + "GIT_TRACE2", + "GIT_TRACE2_EVENT", + "GIT_TRACE2_PERF", + ] { + command.env_remove(key); + } + } command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -200,7 +288,29 @@ fn apply_git_config(command: &mut Command, entries: &[(&str, String)]) { pub(crate) fn build_git_auth_config(state: &AppState) -> Result { let keys = state.signing_keys()?; - build_git_auth_config_for_keys(&keys) + build_scoped_git_auth_config(state, &keys) +} + +pub(crate) fn build_scoped_git_auth_config( + state: &AppState, + keys: &Keys, +) -> Result { + let mut auth = build_git_auth_config_for_keys(keys)?; + let relay = crate::relay::relay_ws_url_with_override(state); + if crate::federated_identity::session(state)? + .protects(&relay) + .map_err(|e| e.to_string())? + { + let app = state + .app_handle + .lock() + .map_err(|_| "application unavailable")? + .clone() + .ok_or("application unavailable")?; + auth.enterprise_env = + crate::federated_agent_broker::launch_env(&app, &keys.public_key().to_hex(), &relay)?; + } + Ok(auth) } pub(crate) fn build_git_clone_auth_config( @@ -214,6 +324,7 @@ pub(crate) fn build_git_clone_auth_config( credential_helper: None, nsec: String::new(), allow_file_transport: false, + enterprise_env: vec![], }); } build_git_auth_config(state) @@ -231,6 +342,7 @@ pub(crate) fn build_git_auth_config_for_keys(keys: &Keys) -> Result Result { diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 2473c97ddd0..8fddf1676e4 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -933,11 +933,17 @@ pub(crate) async fn submit_engram_event( // wait produces a stale `created_at` that the relay will reject. crate::relay_admission::wait_for_rate_limit().await; let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, url, event_json)?; - let mut request = state - .http_client - .post(url) - .header("Authorization", auth) - .header("Content-Type", "application/json"); + let mut request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .post(url) + .header("Authorization", &auth) + .header("Content-Type", "application/json"), + url, + &auth, + ) + .await?; if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 67f2f36c208..9b115566e62 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -210,6 +210,11 @@ pub async fn apply_workspace( // cannot advance it until this transaction releases the guard. assert_current_apply_generation(&state.workspace_apply_generation, apply_generation)?; + if crate::relay::relay_ws_url_with_override(&state) != relay_url || parsed_keys.is_some() { + crate::federated_identity::session(&state)? + .invalidate() + .map_err(|e| e.to_string())?; + } // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; diff --git a/desktop/src-tauri/src/federated_agent_broker.rs b/desktop/src-tauri/src/federated_agent_broker.rs new file mode 100644 index 00000000000..468b19f6318 --- /dev/null +++ b/desktop/src-tauri/src/federated_agent_broker.rs @@ -0,0 +1,212 @@ +//! Loopback assertion broker for desktop-owned agents. Per-key capabilities are +//! ephemeral and login-generation-bound; the corporate session never leaves Buzz. +use axum::{ + extract::{DefaultBodyLimit, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Json, Router, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU16, Ordering}, + Mutex, + }, +}; +use tauri::Manager; + +#[derive(Default)] +pub(crate) struct AgentBroker { + port: AtomicU16, + grants: Mutex>, +} +struct Grant { + key: String, + relay: String, + generation: u64, +} + +/// Construct reserved launch env only, never persisted to records or snapshots. +pub(crate) fn launch_env( + app: &tauri::AppHandle, + key: &str, + relay: &str, +) -> Result, String> { + let state = app.state::(); + let identity = crate::federated_identity::session(&state)?; + if !identity.protects(relay).map_err(|e| e.to_string())? { + return Ok(vec![]); + } + // Refuse launch until the owner's login is live; issuance still checks agent policy. + app.state::() + .credential_for_federated_identity()?; + let generation = identity.generation().map_err(|e| e.to_string())?; + let port = state.federated_broker.port.load(Ordering::Acquire); + if port == 0 { + return Err("enterprise agent broker is starting; retry".into()); + } + let mut grants = state + .federated_broker + .grants + .lock() + .map_err(|_| "enterprise broker unavailable")?; + grants.retain(|_, g| g.generation == generation); + if grants.len() >= 256 { + return Err("enterprise agent broker capacity reached".into()); + } + let capability = grants + .iter() + .find(|(_, g)| g.key == key && g.relay == relay) + .map(|(secret, _)| secret.clone()) + .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); + grants.insert( + capability.clone(), + Grant { + key: key.into(), + relay: relay.into(), + generation, + }, + ); + Ok(vec![ + ( + "BUZZ_NIP_FI_ENDPOINT".into(), + format!("http://127.0.0.1:{port}/assertions"), + ), + ("BUZZ_NIP_FI_CREDENTIAL".into(), capability), + ("BUZZ_NIP_FI_ORIGINS".into(), relay.into()), + ]) +} + +#[derive(Deserialize)] +struct Request { + nostr_pubkey: String, + relay_url: String, +} + +async fn handle( + State(app): State, + headers: HeaderMap, + body: axum::body::Bytes, +) -> axum::response::Response { + match issue(&app, &headers, &body).await { + Ok(value) => ([("cache-control", "no-store")], Json(value)).into_response(), + Err(_) => (StatusCode::UNAUTHORIZED, "enterprise sign-in required").into_response(), + } +} + +async fn issue( + app: &tauri::AppHandle, + headers: &HeaderMap, + body: &[u8], +) -> Result { + // Browser pages may not drive a credential broker, even from the Tauri origin. + if headers.contains_key("origin") { + return Err("browser request denied".into()); + } + let state = app.state::(); + let identity = crate::federated_identity::session(&state)?; + let request: Request = serde_json::from_slice(body).map_err(|_| "invalid request")?; + let generation = identity.generation().map_err(|e| e.to_string())?; + let secret = headers + .get("x-bb-session-credential") + .and_then(|v| v.to_str().ok()) + .ok_or("missing capability")?; + { + let grants = state + .federated_broker + .grants + .lock() + .map_err(|_| "broker unavailable")?; + let grant = grants.get(secret).ok_or("invalid capability")?; + if grant.key != request.nostr_pubkey + || grant.relay != request.relay_url + || grant.generation != generation + { + return Err("stale capability".into()); + } + } + let encoded = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Nostr ")) + .ok_or("missing proof")?; + let proof: nostr::Event = + serde_json::from_slice(&STANDARD.decode(encoded).map_err(|_| "invalid proof")?) + .map_err(|_| "invalid proof")?; + proof.verify().map_err(|_| "invalid proof")?; + let now = crate::federated_identity::now()?; + let endpoint = format!( + "http://127.0.0.1:{}/assertions", + state.federated_broker.port.load(Ordering::Acquire) + ); + let one_tag = |name: &str, expected: &str| { + let tags: Vec<_> = proof + .tags + .iter() + .filter(|t| t.as_slice().first().is_some_and(|s| s == name)) + .collect(); + tags.len() == 1 && tags[0].as_slice().len() == 2 && tags[0].as_slice()[1] == expected + }; + if proof.pubkey.to_hex() != request.nostr_pubkey + || proof.kind.as_u16() != 27235 + || proof.created_at.as_secs() > now.saturating_add(5) + || now.saturating_sub(proof.created_at.as_secs()) > 60 + || !one_tag("u", &endpoint) + || !one_tag("method", "POST") + || !one_tag("payload", &hex::encode(Sha256::digest(body))) + { + return Err("invalid proof".into()); + } + app.state::() + .credential_for_federated_identity()?; + if proof.pubkey != state.signing_keys()?.public_key() + && !crate::managed_agents::load_managed_agents(app)? + .iter() + .any(|r| r.pubkey == request.nostr_pubkey) + { + return Err("agent no longer managed".into()); + } + crate::federated_identity::ensure(&state, &request.relay_url, proof.pubkey).await?; + if identity.generation().map_err(|e| e.to_string())? != generation { + return Err("login changed".into()); + } + let header = identity + .header(&request.relay_url, proof.pubkey, now) + .map_err(|e| e.to_string())? + .ok_or("enterprise origin required")?; + let token = header + .to_str() + .map_err(|_| "invalid assertion")? + .strip_prefix("Bearer ") + .ok_or("invalid assertion")?; + Ok( + serde_json::json!({"assertion": token, "nostr_pubkey": request.nostr_pubkey, + "expires_at": identity.expires_at(proof.pubkey).map_err(|e| e.to_string())?}), + ) +} + +pub(crate) async fn run(app: tauri::AppHandle) { + let Ok(listener) = tokio::net::TcpListener::bind("127.0.0.1:0").await else { + return; + }; + let Ok(address) = listener.local_addr() else { + return; + }; + app.state::() + .federated_broker + .port + .store(address.port(), Ordering::Release); + let router = Router::new() + .route("/assertions", post(handle)) + .layer(DefaultBodyLimit::max(24 * 1024)) + .with_state(app.clone()); + let _ = axum::serve(listener, router).await; + app.state::() + .federated_broker + .port + .store(0, Ordering::Release); +} diff --git a/desktop/src-tauri/src/federated_identity.rs b/desktop/src-tauri/src/federated_identity.rs new file mode 100644 index 00000000000..61899608612 --- /dev/null +++ b/desktop/src-tauri/src/federated_identity.rs @@ -0,0 +1,341 @@ +//! NIP-FI native client: enterprise admission, still local signing. +//! +//! Build configuration is deliberately explicit and disabled by default. The +//! adapter exchange is an assumed API, not a claim that kgoose implements it. +use std::{ + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +#[cfg(test)] +use buzz_ws_client_pkg::federated_identity::Assertion; +use buzz_ws_client_pkg::federated_identity::{IdentitySession, IDENTITY_HEADER}; +use nostr::PublicKey; +use tauri::Manager; +use tauri::State; + +use crate::app_state::AppState; + +pub(crate) fn now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .map_err(|_| "system clock unavailable".into()) +} + +/// Invalid corporate build config is retained as an error, never an OSS fallback. +pub(crate) fn configured_session() -> Result, String> { + let origins = option_env!("BUZZ_BUILD_NIP_FI_ORIGINS").unwrap_or(""); + let origins: Vec<&str> = origins + .split(',') + .filter(|value| !value.is_empty()) + .collect(); + IdentitySession::new(&origins) + .map(Arc::new) + .map_err(|error| error.to_string()) +} + +pub(crate) fn session(state: &AppState) -> Result<&Arc, String> { + state.federated_identity.as_ref().map_err(Clone::clone) +} + +/// Pair with the actual HTTP/Blossom proof, including explicit agent-key paths. +/// Do not silently substitute the currently selected human's key. +pub(crate) fn proof_key(auth: &str) -> Result { + let encoded = auth + .strip_prefix("Nostr ") + .ok_or("invalid local possession proof")?; + let bytes = STANDARD + .decode(encoded) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded)) + .map_err(|_| "invalid local possession proof")?; + let event: nostr::Event = + serde_json::from_slice(&bytes).map_err(|_| "invalid local possession proof")?; + Ok(event.pubkey) +} + +pub(crate) async fn http_header( + state: &AppState, + url: &str, + auth: &str, +) -> Result, String> { + let key = proof_key(auth)?; + ensure(state, url, key).await?; + session(state)? + .header(url, key, now()?) + .map_err(|error| error.to_string()) +} + +/// Call only on a no-redirect client. Never add this to shared default headers. +pub(crate) async fn authorize( + state: &AppState, + request: reqwest::RequestBuilder, + url: &str, + auth: &str, +) -> Result { + Ok(match http_header(state, url, auth).await? { + Some(header) => request.header(IDENTITY_HEADER, header), + None => request, + }) +} + +/// Cancel an operation and discard its result after login replacement or expiry. +/// Wrap body consumption too, so late responses cannot repopulate caches. +pub(crate) async fn guard( + state: &AppState, + url: &str, + key: PublicKey, + operation: impl std::future::Future>, +) -> Result { + let identity = session(state)?; + tokio::select! { + biased; + _ = identity.lease_ended(url, key) => Err("enterprise authentication changed; operation outcome may be unknown".into()), + result = operation => result, + } +} + +/// Acquire/renew under one mutex so racing requests cannot overwrite newer tokens. +/// Credentials stay native; a key-specific assertion is requested for agent proofs. +pub(crate) async fn ensure( + state: &AppState, + destination: &str, + key: PublicKey, +) -> Result<(), String> { + let identity = session(state)?; + if !identity.protects(destination).map_err(|e| e.to_string())? { + return Ok(()); + } + let generation = identity.generation().map_err(|e| e.to_string())?; + let _single_flight = tokio::time::timeout( + std::time::Duration::from_secs(35), + state.federated_acquisition.lock(), + ) + .await + .map_err(|_| "enterprise assertion service busy; retry")?; + if identity.generation().map_err(|e| e.to_string())? != generation { + return Err("enterprise authentication changed".into()); + } + if identity + .expires_at(key) + .map_err(|e| e.to_string())? + .is_some_and(|exp| exp > now().unwrap_or(u64::MAX).saturating_add(60)) + { + return Ok(()); + } + if state + .federated_retry_after + .load(std::sync::atomic::Ordering::Acquire) + > now()? + { + return Err("enterprise assertion service unavailable; retry shortly".into()); + } + let app = state + .app_handle + .lock() + .map_err(|_| "application unavailable")? + .clone() + .ok_or("application unavailable")?; + let login = app.state::(); + let credential = login.credential_for_federated_identity()?; + let human = state.signing_keys()?; + let (keys, auth_tag) = if human.public_key() == key { + (human.clone(), None) + } else { + let records = crate::managed_agents::load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == key.to_hex()) + .ok_or("enterprise agent identity is not managed by this desktop")?; + let keys = nostr::Keys::parse(&record.private_key_nsec) + .map_err(|_| "agent signing identity unavailable")?; + let auth = record + .auth_tag + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|_| "invalid agent delegation")?; + (keys, auth) + }; + let active_relay = crate::relay::relay_ws_url_with_override(state); + let mut target = url::Url::parse(destination).map_err(|_| "invalid enterprise destination")?; + target.set_path(""); + target.set_query(None); + let relay_url = target.as_str().trim_end_matches('/').to_string(); + let endpoint = option_env!("BUZZ_BUILD_NIP_FI_ASSERTION_URL") + .unwrap_or("https://app.builderlab.xyz/api/goose/v1/buzz/identity/assertions"); + let result = buzz_ws_client_pkg::identity_adapter::exchange( + &state.media_fetch_client, + endpoint, + &credential, + &keys, + &relay_url, + auth_tag.as_ref(), + ) + .await; + let result = match result { + Ok(result) => { + state + .federated_retry_after + .store(0, std::sync::atomic::Ordering::Release); + result + } + Err(error) => { + state.federated_retry_after.store( + now()?.saturating_add(10), + std::sync::atomic::Ordering::Release, + ); + return Err(error.to_string()); + } + }; + if state.signing_keys()?.public_key() != human.public_key() + || crate::relay::relay_ws_url_with_override(state) != active_relay + { + return Err("enterprise login scope changed".into()); + } + identity + .install( + generation, + result + .into_assertion(key, now()?) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) +} + +/// Native sign-in gate. OSS communities pass through without corporate login. +#[tauri::command] +pub(crate) async fn acquire_federated_assertion(state: State<'_, AppState>) -> Result<(), String> { + ensure( + &state, + &crate::relay::relay_ws_url_with_override(&state), + state.signing_keys()?.public_key(), + ) + .await +} + +/// Credential-free build capability used by the sign-in screen. +#[tauri::command] +pub(crate) fn federated_identity_required(state: State<'_, AppState>) -> Result { + session(&state)? + .protects(&crate::relay::relay_ws_url_with_override(&state)) + .map_err(|e| e.to_string()) +} + +/// Refresh the primary assertion before expiry. Each failed attempt waits ten +/// seconds; current authority is never extended locally on adapter failures. +pub(crate) async fn renew_loop(app: tauri::AppHandle) { + loop { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + let state = app.state::(); + if state + .shutdown_started + .load(std::sync::atomic::Ordering::Acquire) + { + return; + } + let Ok(key) = state.signing_keys().map(|keys| keys.public_key()) else { + continue; + }; + let Ok(identity) = session(&state) else { + continue; + }; + if identity.expires_at(key).ok().flatten().is_some() { + let _ = ensure( + &state, + &crate::relay::relay_ws_url_with_override(&state), + key, + ) + .await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn query_sink_sends_assertion_and_matching_local_proof() { + use axum::{http::HeaderMap, routing::post, Router}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let keys = nostr::Keys::generate(); + let expected_key = keys.public_key(); + let app = Router::new().route( + "/query", + post(move |headers: HeaderMap, body: String| async move { + assert_eq!(headers[IDENTITY_HEADER], "Bearer aaa.bbb.ccc"); + let auth = headers["authorization"].to_str().unwrap(); + assert_eq!(proof_key(auth).unwrap(), expected_key); + assert_eq!(body, "[]"); + "[]" + }), + ); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut state = crate::app_state::build_app_state(); + state.federated_identity = Ok(Arc::new(IdentitySession::new(&[&base]).unwrap())); + let identity = session(&state).unwrap(); + identity + .install( + 0, + Assertion::new( + "aaa.bbb.ccc", + keys.public_key(), + now().unwrap() + 600, + now().unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let result = crate::relay::query_relay_at_with_keys(&state, &base, &[], &keys, None).await; + server.abort(); + server.await.ok(); + assert!(result.unwrap().is_empty()); + } + + #[tokio::test] + async fn logout_discards_in_flight_http_result_and_requires_new_login() { + let mut state = crate::app_state::build_app_state(); + let key = state.signing_keys().unwrap().public_key(); + state.federated_identity = Ok(Arc::new( + IdentitySession::new(&["https://relay.example"]).unwrap(), + )); + let identity = session(&state).unwrap(); + identity + .install( + 0, + Assertion::new("a.b.c", key, now().unwrap() + 600, now().unwrap()).unwrap(), + ) + .unwrap(); + let operation = guard(&state, "https://relay.example/query", key, async { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + Ok("must not reach cache") + }); + let invalidate = async { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + identity.invalidate().unwrap(); + }; + let (result, ()) = tokio::join!(operation, invalidate); + assert!(result.is_err()); + assert!(identity + .header("https://relay.example/query", key, now().unwrap()) + .is_err()); + } + + #[test] + fn derives_pairing_key_from_actual_local_proof() { + let keys = nostr::Keys::generate(); + let auth = crate::relay::build_nip98_auth_header_for_keys( + &keys, + &reqwest::Method::POST, + "https://relay.example/query", + b"[]", + ) + .unwrap(); + assert_eq!(proof_key(&auth).unwrap(), keys.public_key()); + assert!(proof_key("Bearer never-a-possession-proof").is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 47d4aeb43d1..05d5f256494 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -637,7 +637,9 @@ pub(crate) fn spawn_transcription_task( // Capture the current generation at spawn time. let spawned_gen = session_generation.load(Ordering::Acquire); - let http_client = state.http_client.clone(); + let http_client = state.media_fetch_client.clone(); + let identity = state.federated_identity.clone(); + let identity_generation = identity.as_ref().ok().and_then(|i| i.generation().ok()); let keys = match state.keys.lock() { Ok(k) => k.clone(), Err(_) => return, @@ -654,7 +656,9 @@ pub(crate) fn spawn_transcription_task( // Session guard: if the generation has changed, this task is stale. // Drop the transcript silently — the huddle has ended or been replaced. - if session_generation.load(Ordering::Acquire) != spawned_gen { + if session_generation.load(Ordering::Acquire) != spawned_gen + || identity.as_ref().ok().and_then(|i| i.generation().ok()) != identity_generation + { break; // Exit the loop entirely — no more posts from this task. } @@ -709,14 +713,36 @@ pub(crate) fn spawn_transcription_task( } }; + let identity_header = + match identity + .as_ref() + .map_err(Clone::clone) + .and_then(|identity| { + identity + .header(&url, keys.public_key(), crate::federated_identity::now()?) + .map_err(|e| e.to_string()) + }) { + Ok(header) => header, + Err(error) => { + eprintln!("buzz-desktop: STT enterprise admission: {error}"); + break; + } + }; let response = { - http_client + let request = http_client .post(&url) .header("Authorization", auth_header) .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await + .body(body_bytes); + let request = if let Some(header) = identity_header { + request.header( + buzz_ws_client_pkg::federated_identity::IDENTITY_HEADER, + header, + ) + } else { + request + }; + request.send().await }; match response { diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 190397aa054..b9e387acf8c 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -70,6 +70,7 @@ fn build_audio_auth_event( } async fn connect_authenticated_audio_socket( + state: &AppState, channel_id: &str, parent_channel_id: Option<&str>, relay_url: &str, @@ -79,7 +80,15 @@ async fn connect_authenticated_audio_socket( use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); - let (ws_stream, _) = connect_async(&ws_url) + crate::federated_identity::ensure(state, &ws_url, keys.public_key()).await?; + let request = crate::federated_identity::session(state)? + .websocket_request( + &ws_url, + keys.public_key(), + crate::federated_identity::now()?, + ) + .map_err(|e| e.to_string())?; + let (ws_stream, _) = connect_async(request) .await .map_err(|e| format!("audio WS connect failed: {e}"))?; let (mut ws_tx, mut ws_rx) = ws_stream.split(); @@ -208,11 +217,27 @@ 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 (ws_tx, ws_rx, _peer_index, initial_peers) = connect_authenticated_audio_socket( + state, + channel_id, + parent_channel_id, + &relay_url, + &keys, + None, + ) + .await?; let cancel = CancellationToken::new(); + let identity = Arc::clone(crate::federated_identity::session(state)?); + let lease_cancel = cancel.clone(); + let lease_url = relay_url.clone(); + let lease_key = keys.public_key(); + tokio::spawn(async move { + tokio::select! { + _ = lease_cancel.cancelled() => {}, + _ = identity.lease_ended(&lease_url, lease_key) => lease_cancel.cancel(), + } + }); let cancel_clone = cancel.clone(); let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::>(50); let output_device_name = state @@ -323,6 +348,7 @@ pub(crate) async fn connect_tts_audio_publisher( ) -> Result { let relay_url = crate::relay::relay_ws_url_with_override(state); let (ws_tx, ws_rx, peer_index, _) = connect_authenticated_audio_socket( + state, channel_id, parent_channel_id, &relay_url, @@ -332,6 +358,16 @@ pub(crate) async fn connect_tts_audio_publisher( .await?; let cancel = CancellationToken::new(); + let identity = Arc::clone(crate::federated_identity::session(state)?); + let lease_cancel = cancel.clone(); + let lease_url = relay_url.clone(); + let lease_key = keys.public_key(); + tokio::spawn(async move { + tokio::select! { + _ = lease_cancel.cancelled() => {}, + _ = identity.lease_ended(&lease_url, lease_key) => lease_cancel.cancel(), + } + }); let publisher_cancel = cancel.clone(); let (tx, rx) = tokio::sync::mpsc::channel(TTS_BROADCAST_QUEUE_DEPTH); let publisher = super::tts::TtsAudioPublisher::new(tx, cancel); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b038577..d795d10110e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,6 +10,8 @@ mod deep_link; mod egress_guard; mod event_sync; mod events; +mod federated_agent_broker; +mod federated_identity; mod huddle; mod identity_storage; mod initial_window; @@ -213,6 +215,9 @@ pub fn run() { } else { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; + let app_state = build_app_state(); + let native_relay = + native_relay_client::NativeRelayClient::with_identity(app_state.federated_identity.clone()); let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -221,7 +226,7 @@ pub fn run() { responder.respond(response); }); }) - .manage(build_app_state()) + .manage(app_state) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) .manage(PendingNavigationDeepLinks::default()) @@ -231,7 +236,7 @@ pub fn run() { .manage(commands::pairing::PairingHandle::new()) .manage(terminal_runtime::TerminalSessions::default()) .manage(archive::sync::ArchiveSyncState::default()) - .manage(native_relay_client::NativeRelayClient::default()) + .manage(native_relay) .manage(observed_unread::ObservedUnreadStore::default()) .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { @@ -375,6 +380,8 @@ pub fn run() { eprintln!("buzz-desktop: failed to create nest: {error}"); } archive::spawn_warm_init(app_handle.clone()); + tauri::async_runtime::spawn(federated_identity::renew_loop(app_handle.clone())); + tauri::async_runtime::spawn(federated_agent_broker::run(app_handle.clone())); // Resolve the REPOS symlink from the persisted repos_dir BEFORE // agents are restored below, and decide whether restore is safe. @@ -538,6 +545,8 @@ pub fn run() { take_pending_entity_deep_link, acknowledge_pending_entity_deep_link, start_builderlab_login, + federated_identity::acquire_federated_assertion, + federated_identity::federated_identity_required, cancel_builderlab_login, get_builderlab_auth, clear_builderlab_auth, diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index 07fcf8d2592..a0c0072bff3 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -28,6 +28,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Identity / secrets. "BUZZ_PRIVATE_KEY", + "BUZZ_NIP_FI_ENDPOINT", + "BUZZ_NIP_FI_CREDENTIAL", + "BUZZ_NIP_FI_ORIGINS", "NOSTR_PRIVATE_KEY", "BUZZ_AUTH_TAG", "BUZZ_API_TOKEN", diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 5b44f95de92..94d541fe7c1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -828,6 +828,15 @@ pub fn spawn_agent_child( command.creation_flags(CREATE_NO_WINDOW); } + // Apply last, after persona/environment layers. Never snapshot capabilities. + for name in buzz_ws_client_pkg::identity_adapter::ENV_KEYS { + command.env_remove(name); + } + for (name, value) in + crate::federated_agent_broker::launch_env(app, &record.pubkey, &effective_relay_url)? + { + command.env(name, value); + } let child = spawn_with_effort_proof(&mut command, effort).map_err(|error| { format!( "failed to spawn `{}` for agent {}: {error}", diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 21692ce5237..51d909420b6 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -22,7 +22,6 @@ const MAX_PROXY_RESPONSE: u64 = 20 * 1024 * 1024; #[derive(Clone)] struct ProxyState { - client: reqwest::Client, app_handle: tauri::AppHandle, } @@ -52,15 +51,39 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> let has_range = req.headers().contains_key("range"); - let mut upstream = state - .client + let mut upstream = app_state + .media_fetch_client .get(&upstream_url) .timeout(std::time::Duration::from_secs(120)); // `upstream_url` is always `{relay base}{path}`, so the token can't reach // a third-party origin (mint_media_get_auth safety contract). - if let Some(auth) = mint_media_get_auth(&app_state, &base_url) { - upstream = upstream.header("authorization", auth); + let auth = mint_media_get_auth(&app_state, &base_url); + if auth.is_none() + && crate::federated_identity::session(&app_state) + .and_then(|s| s.protects(&base_url).map_err(|e| e.to_string())) + .unwrap_or(true) + { + return (StatusCode::UNAUTHORIZED, "enterprise sign-in required").into_response(); + } + if let Some(auth) = auth { + upstream = match crate::federated_identity::authorize( + &app_state, + upstream.header("authorization", &auth), + &upstream_url, + &auth, + ) + .await + { + Ok(request) => request, + Err(_) => { + return ( + StatusCode::UNAUTHORIZED, + "enterprise authentication required", + ) + .into_response() + } + }; } if let Some(range) = req.headers().get("range") { @@ -118,6 +141,14 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> } // Stream the body — no buffering. + if crate::federated_identity::session(&app_state) + .and_then(|s| s.protects(&base_url).map_err(|e| e.to_string())) + .unwrap_or(true) + { + headers.insert("cache-control", HeaderValue::from_static("no-store")); + headers.remove("etag"); + headers.remove("last-modified"); + } let stream = resp.bytes_stream().map_err(std::io::Error::other); let body = Body::from_stream(stream); @@ -127,11 +158,8 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> /// Spawn a localhost HTTP proxy that streams media via reqwest, avoiding the /// Tauri protocol handler's requirement to buffer the entire response into /// `Vec`. Returns the OS-assigned port. -pub async fn spawn_media_proxy(http_client: reqwest::Client, app_handle: tauri::AppHandle) -> u16 { - let proxy_state = ProxyState { - client: http_client, - app_handle, - }; +pub async fn spawn_media_proxy(_http_client: reqwest::Client, app_handle: tauri::AppHandle) -> u16 { + let proxy_state = ProxyState { app_handle }; let app = Router::new() .route("/media/{*path}", get(proxy_handler)) @@ -181,14 +209,32 @@ pub async fn handle_buzz_media( // Forward Range header if present — enables video seeking through the proxy. let mut upstream = state - .http_client + .media_fetch_client .get(&upstream_url) .timeout(std::time::Duration::from_secs(60)); // `upstream_url` is always `{relay base}{path}`, so the token can't reach // a third-party origin (mint_media_get_auth safety contract). - if let Some(auth) = mint_media_get_auth(&state, &base) { - upstream = upstream.header("authorization", auth); + let auth = mint_media_get_auth(&state, &base); + if auth.is_none() + && crate::federated_identity::session(&state) + .and_then(|s| s.protects(&base).map_err(|e| e.to_string())) + .unwrap_or(true) + { + return error_response(401, "enterprise sign-in required"); + } + if let Some(auth) = auth { + upstream = match crate::federated_identity::authorize( + &state, + upstream.header("authorization", &auth), + &upstream_url, + &auth, + ) + .await + { + Ok(request) => request, + Err(_) => return error_response(401, "enterprise authentication required"), + }; } if let Some(range) = request.headers().get("range") { @@ -231,22 +277,30 @@ pub async fn handle_buzz_media( // channel switch. The relay sends // `Cache-Control: public, max-age=31536000, immutable`; // `etag`/`last-modified` are forwarded if upstream supplies them. - let cache_control = resp + let mut cache_control = resp .headers() .get("cache-control") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let etag = resp + let mut etag = resp .headers() .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let last_modified = resp + let mut last_modified = resp .headers() .get("last-modified") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + if crate::federated_identity::session(&state) + .and_then(|s| s.protects(&base).map_err(|e| e.to_string())) + .unwrap_or(true) + { + cache_control = Some("no-store".into()); + etag = None; + last_modified = None; + } // OOM guard: if this is a non-range GET and the upstream body is // larger than our cap, bail with 413 instead of buffering into RAM. // Tauri's protocol handler requires Vec so we can't truly stream. diff --git a/desktop/src-tauri/src/native_relay_client.rs b/desktop/src-tauri/src/native_relay_client.rs index 19740dd0197..651525899ac 100644 --- a/desktop/src-tauri/src/native_relay_client.rs +++ b/desktop/src-tauri/src/native_relay_client.rs @@ -84,6 +84,7 @@ pub(crate) struct MatchedEvent { #[derive(Default)] pub(crate) struct NativeRelayClient { current: Mutex>, + identity: Option, String>>, } struct ManagedSession { @@ -132,6 +133,15 @@ impl Drop for SessionLease { } impl NativeRelayClient { + pub(crate) fn with_identity( + identity: Result, String>, + ) -> Self { + Self { + current: Mutex::default(), + identity: Some(identity), + } + } + /// Installs the session for `scope`, shutting down whatever scope held the /// slot. Destructive on entry, so every caller must already hold proof it /// is the current owner — today that is @@ -145,7 +155,7 @@ impl NativeRelayClient { if let Some(previous) = current.take() { previous.session.shutdown(); } - let session = start_managed(relay_url, keys, None); + let session = start_managed(relay_url, keys, None, self.identity.clone()); *current = Some(ManagedSession { scope, session: Arc::clone(&session), @@ -187,12 +197,12 @@ impl NativeRelayClient { } } else { SessionLease { - session: start_managed(relay_url, keys, None), + session: start_managed(relay_url, keys, None, self.identity.clone()), private: true, } }; } - let session = start_managed(relay_url, keys, None); + let session = start_managed(relay_url, keys, None, self.identity.clone()); *current = Some(ManagedSession { scope, session: Arc::clone(&session), @@ -389,12 +399,17 @@ pub(crate) async fn start( keys: Keys, auth_tag: Option, ) -> (Arc, mpsc::Receiver) { - let session = start_managed(relay_url, keys, auth_tag); + let session = start_managed(relay_url, keys, auth_tag, None); let events = session.attach_archive().await; (session, events) } -fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> Arc { +fn start_managed( + relay_url: String, + keys: Keys, + auth_tag: Option, + identity: Option, String>>, +) -> Arc { let (wake, wake_rx) = mpsc::channel(1); let session = Arc::new(RelaySession { state: Arc::new(Mutex::new(SessionState::default())), @@ -408,6 +423,7 @@ fn start_managed(relay_url: String, keys: Keys, auth_tag: Option) -> relay_url, keys, auth_tag, + identity, Arc::clone(&session), wake_rx, )); @@ -419,6 +435,7 @@ async fn run_session( relay_url: String, keys: Keys, auth_tag: Option, + identity: Option, String>>, session: Arc, mut wake_rx: mpsc::Receiver<()>, ) { @@ -428,14 +445,62 @@ async fn run_session( return; } - match NostrWsConnection::connect_authenticated(&relay_url, &keys, auth_tag.as_ref()).await { + let identity_lease = identity + .as_ref() + .and_then(|i| i.as_ref().ok()) + .map(|i| i.lease_ended(&relay_url, keys.public_key())); + let identity_lease = async move { + match identity_lease { + Some(lease) => lease.await, + None => std::future::pending::<()>().await, + } + }; + tokio::pin!(identity_lease); + let connect = async { + let mut conn = if let Some(identity) = &identity { + let request = identity + .as_ref() + .map_err(Clone::clone)? + .websocket_request( + &relay_url, + keys.public_key(), + crate::federated_identity::now()?, + ) + .map_err(|e| e.to_string())?; + NostrWsConnection::connect_request(&relay_url, request) + .await + .map_err(|e| e.to_string())? + } else { + NostrWsConnection::connect(&relay_url) + .await + .map_err(|e| e.to_string())? + }; + conn.authenticate(&keys, auth_tag.as_ref()) + .await + .map_err(|e| e.to_string())?; + Ok::<_, String>(conn) + }; + let connection = tokio::select! { + biased; + _ = &mut identity_lease => Err("enterprise authentication changed".to_string()), + _ = session.cancel.cancelled() => return, + result = connect => result, + }; + match connection { Ok(conn) => { // A connection that authenticated is healthy regardless of how // long it then lived, so backoff resets here rather than on // clean exit — a socket that drops after one event must not // inherit the previous failure's delay. delay = RECONNECT_BASE_DELAY; - run_connection(conn, &session, &mut wake_rx).await; + if let Some(Ok(_)) = &identity { + tokio::select! { + _ = &mut identity_lease => {}, + _ = run_connection(conn, &session, &mut wake_rx) => {}, + } + } else { + run_connection(conn, &session, &mut wake_rx).await; + } } Err(error) => { eprintln!("buzz-desktop: native_relay_client: connect failed: {error}"); diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index a7a51fb2904..3c711b6855d 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -10,6 +10,8 @@ use tauri::{ use crate::native_websocket_batch::{is_auth_challenge, FrameBatch, BATCH_MAX_SERIALIZED_BYTES}; use tokio::sync::{mpsc, oneshot, Mutex}; +#[cfg(test)] +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::{ connect_async, tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, @@ -127,15 +129,30 @@ impl WebSocketManager { } } +#[cfg(test)] async fn open_connection( manager: &WebSocketManager, url: &str, on_message: Channel, +) -> Result { + open_connection_request( + manager, + url.into_client_request() + .map_err(|_| "invalid WebSocket URL")?, + on_message, + ) + .await +} + +async fn open_connection_request( + manager: &WebSocketManager, + request: tokio_tungstenite::tungstenite::http::Request<()>, + on_message: Channel, ) -> Result { let connect_cancel = manager.connect_cancel.lock().await.clone(); let (socket, _) = tokio::select! { _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), - result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result + result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request)) => result .map_err(|_| "WebSocket connection timed out".to_string())? .map_err(|error| error.to_string())?, }; @@ -180,12 +197,49 @@ async fn open_connection( #[tauri::command] async fn connect( + state: tauri::State<'_, crate::app_state::AppState>, manager: tauri::State<'_, WebSocketManager>, url: String, on_message: Channel, _config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + let key = state.signing_keys()?.public_key(); + crate::federated_identity::ensure(&state, &url, key).await?; + let identity = Arc::clone(crate::federated_identity::session(&state)?); + let request = identity + .websocket_request(&url, key, crate::federated_identity::now()?) + .map_err(|e| e.to_string())?; + let generation = identity.generation().map_err(|e| e.to_string())?; + let deadline = identity + .expires_at(key) + .map_err(|e| e.to_string())? + .unwrap_or(0); + let lease = identity.lease_ended(&url, key); + tokio::pin!(lease); + let id = tokio::select! { + biased; + _ = &mut lease => return Err("enterprise authentication changed".into()), + result = open_connection_request(manager.inner(), request, on_message) => result?, + }; + if identity.protects(&url).map_err(|e| e.to_string())? { + let manager = manager.inner().clone(); + let identity = Arc::clone(&identity); + tauri::async_runtime::spawn(async move { + loop { + if !manager.connections.lock().await.contains_key(&id) { + return; + } + if identity.generation().ok() != Some(generation) + || crate::federated_identity::now().unwrap_or(u64::MAX) >= deadline + { + manager.disconnect(id).await; + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }); + } + Ok(id) } pub(crate) async fn send_message( @@ -283,6 +337,10 @@ async fn run_connection( reason: "disconnect".into(), }))), ).await; + if let Ok(frame) = serde_json::to_string(&OutboundMessage::Close(None)) { + batch.push(frame); + batch.flush(&on_message); + } break; } _ = batch.due() => batch.flush(&on_message), diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 2484fc7d14c..342c804b736 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -375,13 +375,23 @@ pub async fn query_relay_at( let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; - send_query_request( - &state.http_client, + let identity = crate::federated_identity::http_header(state, &url, &auth).await?; + crate::federated_identity::guard( + state, &url, - &auth, - None, - body_bytes, - QUERY_REQUEST_TIMEOUT, + crate::federated_identity::proof_key(&auth)?, + async { + send_query_request( + &state.media_fetch_client, + &url, + &auth, + identity, + None, + body_bytes, + QUERY_REQUEST_TIMEOUT, + ) + .await + }, ) .await } @@ -398,13 +408,23 @@ pub async fn query_relay_at_with_keys( let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - send_query_request( - &state.http_client, + let identity = crate::federated_identity::http_header(state, &url, &auth).await?; + crate::federated_identity::guard( + state, &url, - &auth, - auth_tag, - body_bytes, - QUERY_REQUEST_TIMEOUT, + crate::federated_identity::proof_key(&auth)?, + async { + send_query_request( + &state.media_fetch_client, + &url, + &auth, + identity, + auth_tag, + body_bytes, + QUERY_REQUEST_TIMEOUT, + ) + .await + }, ) .await } @@ -421,6 +441,7 @@ async fn send_query_request( http_client: &reqwest::Client, url: &str, auth: &str, + identity: Option, auth_tag: Option<&str>, body_bytes: Vec, timeout: std::time::Duration, @@ -430,6 +451,12 @@ async fn send_query_request( .header("Authorization", auth) .header("Content-Type", "application/json") .timeout(timeout); + if let Some(identity) = identity { + request = request.header( + buzz_ws_client_pkg::federated_identity::IDENTITY_HEADER, + identity, + ); + } if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } @@ -533,11 +560,17 @@ pub async fn sync_managed_agent_profile( let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client - .post(&url) - .header("Authorization", auth) - .header("Content-Type", "application/json"); + let mut request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .post(&url) + .header("Authorization", &auth) + .header("Content-Type", "application/json"), + &url, + &auth, + ) + .await?; if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } @@ -659,11 +692,17 @@ pub async fn submit_signed_event_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json"); + let mut request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .post(&url) + .header("Authorization", &auth_header) + .header("Content-Type", "application/json"), + &url, + &auth_header, + ) + .await?; if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } diff --git a/desktop/src-tauri/src/relay/get.rs b/desktop/src-tauri/src/relay/get.rs index 7d0855f463f..b94a06450eb 100644 --- a/desktop/src-tauri/src/relay/get.rs +++ b/desktop/src-tauri/src/relay/get.rs @@ -23,15 +23,30 @@ pub async fn get_relay_json( path_with_query ); let auth = build_nip98_auth_header(&Method::GET, &url, &[], state)?; - let response = state - .http_client - .get(&url) - .header("Authorization", auth) - .send() - .await - .map_err(|error| classify_request_error(&error))?; - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - parse_json_response(response).await + let request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .get(&url) + .header("Authorization", &auth), + &url, + &auth, + ) + .await?; + crate::federated_identity::guard( + state, + &url, + crate::federated_identity::proof_key(&auth)?, + async { + let response = request + .send() + .await + .map_err(|error| classify_request_error(&error))?; + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + parse_json_response(response).await + }, + ) + .await } diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index b6a5703fd96..9ab7b90f57a 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -28,26 +28,36 @@ pub async fn submit_signed_event_at_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + let request = crate::federated_identity::authorize( + state, + state + .media_fetch_client + .post(&url) + .header("Authorization", &auth_header) + .header("Content-Type", "application/json") + .body(body_bytes), + &url, + &auth_header, + ) + .await?; + crate::federated_identity::guard(state, &url, keys.public_key(), async { + let response = request + .send() + .await + .map_err(|e| classify_request_error(&e))?; - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } - let result: SubmitEventResponse = parse_json_response(response).await?; - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } + let result: SubmitEventResponse = parse_json_response(response).await?; + if !result.accepted { + return Err(format!("relay rejected event: {}", result.message)); + } - Ok(result) + Ok(result) + }) + .await } /// Sign with an explicit identity and POST the event to an explicit relay. diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs index f2928cbf612..b2bb868889f 100644 --- a/desktop/src-tauri/src/relay/tests.rs +++ b/desktop/src-tauri/src/relay/tests.rs @@ -294,6 +294,7 @@ async fn stalled_query_request_times_out_with_classified_error() { &url, "Nostr test-auth", None, + None, b"[]".to_vec(), Duration::from_millis(200), ), @@ -354,6 +355,7 @@ async fn stalled_response_body_times_out_with_classified_error() { &url, "Nostr test-auth", None, + None, b"[]".to_vec(), Duration::from_millis(200), ), @@ -415,6 +417,7 @@ async fn stalled_error_response_body_times_out_with_classified_error() { &url, "Nostr test-auth", None, + None, b"[]".to_vec(), Duration::from_millis(200), ), @@ -472,6 +475,7 @@ async fn non_stalled_error_response_yields_status_message() { &url, "Nostr test-auth", None, + None, b"[]".to_vec(), Duration::from_millis(200), ), diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e632e158de8..22e2946783e 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -57,6 +57,7 @@ import { requestAddCommunityPrefill, } from "@/features/communities/addCommunityPrefill"; import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; +import { EnterpriseSessionNotice } from "@/features/communities/ui/EnterpriseSessionNotice"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; @@ -645,6 +646,7 @@ function CommunityApp({ onIdentityReplaced={bumpSignerEpoch} /> + (null); + useEffect(() => { + let active = true; + void invoke("federated_identity_required") + .then((required) => { + if (active) setEnterprise(required); + }) + .catch(() => { + if (active) + setLoginError("Could not read enterprise sign-in configuration."); + }); + return () => { + active = false; + }; + }, []); + async function signIn() { + setBusy(true); + setLoginError(null); + try { + await invoke("start_builderlab_login"); + onRetry(); + } catch (error) { + setLoginError(String(error)); + } finally { + setBusy(false); + } + } return (

{error}

+ {loginError ? ( +

+ {loginError} +

+ ) : null}
+ {enterprise ? ( + + ) : null} + {busy ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 21a49fb729f..38fb33ea3b1 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { isMacPlatform } from "@/shared/lib/platform"; import { relayClient } from "@/shared/api/relayClient"; @@ -316,6 +316,7 @@ export function useCommunityInit( activeCommunity.reposDir, getOverrides().agentManagedProfiles === true, ); + await invoke("acquire_federated_assertion"); } catch (error) { // A bad `repos_dir` no longer reaches here — `apply_workspace` treats // it as non-fatal (relay/keys apply, bad value not persisted, REPOS diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 78f2ed3eb57..5c1b2eb63c0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -221,6 +221,7 @@ type E2eConfig = { /** Delay remote repository snapshots so project loading UI is observable. */ projectRepoSnapshotDelayMs?: number; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + enterpriseIdentityRequired?: boolean; builderlabAuth?: { email?: string; name?: string; @@ -12481,6 +12482,16 @@ export function maybeInstallE2eTauriMocks() { registry: await handleMockCommand("list_voice_registry", null), }; } + case "federated_identity_required": + return activeConfig?.mock?.enterpriseIdentityRequired ?? false; + case "acquire_federated_assertion": + if ( + activeConfig?.mock?.enterpriseIdentityRequired && + !activeConfig.mock.builderlabAuth + ) { + throw new Error("enterprise sign-in required"); + } + return null; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/enterprise-sign-in.spec.ts b/desktop/tests/e2e/enterprise-sign-in.spec.ts new file mode 100644 index 00000000000..46c3948386e --- /dev/null +++ b/desktop/tests/e2e/enterprise-sign-in.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +test("enterprise admission gates app startup and browser login recovers it", async ({ + page, +}) => { + await installMockBridge(page, { enterpriseIdentityRequired: true }); + await page.goto("/"); + await expect(page.getByTestId("community-apply-error")).toBeVisible(); + await expect( + page.getByText("enterprise sign-in required", { exact: true }), + ).toBeVisible(); + await page.getByTestId("enterprise-sign-in").click(); + await expect(page.getByTestId("community-apply-error")).toHaveCount(0); + await expect(page.getByTestId("channel-general")).toBeVisible(); + // The browser receives readiness/login metadata only, never a JWT. + const storage = await page.evaluate(() => JSON.stringify(localStorage)); + expect(storage).not.toContain("Bearer "); + expect(storage).not.toContain("assertion"); +}); + +test("OSS startup does not require work-account login", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + await expect(page.getByTestId("enterprise-sign-in")).toHaveCount(0); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 87fe86b5b23..ffb2228619b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -162,6 +162,7 @@ type MockBridgeOptions = { /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ + enterpriseIdentityRequired?: boolean; builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null; /** Optional policy returned by the native join-policy discovery command. */ joinPolicy?: { diff --git a/docs/nip-fi-client-poc.md b/docs/nip-fi-client-poc.md new file mode 100644 index 00000000000..482a8e2d168 --- /dev/null +++ b/docs/nip-fi-client-poc.md @@ -0,0 +1,193 @@ +# NIP-FI desktop + agent POC + +This PR now contains executable client integration, not just an effort sketch. +It remains a **draft POC, disabled by default**, against an assumed adapter API. +It does not implement or deploy kgoose, Okta configuration, or relay enforcement. +Mobile files from the first pass remain partial plumbing; this pass targets +**desktop, local agents, detached agents, CLI, and MCP**. + +## What to run + +Build the desktop with explicit protected origins and the adapter location: + +```sh +. ./bin/activate-hermit +export BUZZ_BUILD_NIP_FI_ORIGINS=https://enterprise-relay.example +export BUZZ_BUILD_LOGIN_API_URL=https://adapter.example/api/goose +export BUZZ_BUILD_NIP_FI_ASSERTION_URL=https://adapter.example/api/goose/v1/buzz/identity/assertions +just dev +``` + +These are build-time settings: changing them requires rebuilding the Rust app. +No corporate origin is learned from NIP-11, token claims, image URLs, or renderer +input. Empty origins retain ordinary OSS operation. Invalid configuration fails +closed. HTTPS is required except explicit IP-loopback development fixtures. + +Open the configured community. The normal community-init gate now acquires an +assertion before mounting the connected app. Without a login it offers **Sign in +with your work account**, runs the existing browser/code exchange, acquires the +assertion natively, and retries initialization. There is also an in-app recovery +notice: an expired login does not unmount the draft-containing app subtree. +The existing hosted-account sign-out clears the native session and assertions. +Sessions are memory-only in this POC: restarting the app requires sign-in again. + +## Assumed API contract + +Only the login/code-exchange portion of PR #7626 was used as a reference. Local +Nostr keys still sign everything; none of the remote-custody implementation is +used. The existing Builderlab login flow is reused, with a configurable API base: + +1. Browser `/v1/auth/login?type=cli&product=buzz&returnTo=...`. +2. Random ephemeral loopback callback receives a one-time code. +3. Native `POST /v1/auth/login/exchange` and `GET /v1/auth/me`. +4. Native `POST /v1/buzz/identity/assertions`. + +The assertion request contains: + +```json +{"nostr_pubkey":"","relay_url":"","auth_tag":null} +``` + +It has `X-BB-Session-Credential: ` and +`Authorization: Nostr `. The proof binds the exact endpoint, +POST method, and SHA-256 of the exact body bytes. For an agent, `auth_tag` carries +its existing NIP-OA attestation; the adapter **must independently authorize** the +employee-to-agent relationship and lifecycle. Knowing/signing for a key is not +permission to enroll it. + +Response: + +```json +{"assertion":"","nostr_pubkey":"","expires_at":1790000000} +``` + +`expires_at` is Unix seconds and is the effective deadline: the adapter must +include any maximum-age/connection policy in that deadline, not just JWT `exp`. +The POC accepts only `typ: nip-fi+jwt`; it rejects generic/ID-token JWTs, missing +claims, inconsistent keys, invalid times, and response expiry beyond JWT expiry. +It pins `(iss, sub, aud)` per key during the native login generation. This is +metadata validation, not client-side signature verification: the trusted adapter +is the acquisition boundary and the relay still verifies signatures, issuer, +audience, deny entries, and membership. + +Responses are capped at 24 KiB; tokens at 16 KiB. Acquisition has a 30-second +request deadline, no redirects, a native single-flight mutex, generation fences, +and a ten-second failure cooldown. The native renewal loop checks every ten +seconds and refreshes with less than sixty seconds remaining. HTTP callers also +ensure current authority before sending. No assertion or login credential enters +renderer IPC responses, localStorage, events, filters, URLs, or public discovery. + +## Implemented desktop paths + +- Main renderer-facing native WebSocket upgrade; ordinary local NIP-42 AUTH. +- Native background relay socket: assertion-bearing upgrade and expiry/logout + lease; desired subscriptions are restored by its existing reconnect loop. +- HTTP `/query`, signed event submission, authenticated relay GETs, explicit + agent profile/engram publishers, and huddle transcription posts. +- Human huddle and agent TTS upgrades; expiry/logout cancels their existing audio + transport token. **The POC ends the huddle at the admitted deadline; the user + rejoins. Seamless audio reconnect remains work.** +- Blossom upload/download, avatar fetch, localhost streaming proxy, and + `buzz-media` URI handler. Proofs have sixty-second lifetimes; legacy upload + retry signs a fresh proof. Enterprise missing-proof paths fail closed and + authenticated redirects are refused. Proxy responses use `no-store` in + enterprise mode. +- Git project clone/fetch/push/merge gets a fresh assertion at invocation time, + including explicit agent-owner merge paths. The ordinary Nostr credential + helper is retained; the second header is supplied through ephemeral, + origin-scoped Git configuration, never written to a config file. + +Login replacement, logout, community changes, and key imports invalidate the +native generation. Main/native sockets and huddles observe leases. Primary HTTP +query/GET/submit/download/upload operations cancel on generation/expiry changes; +response consumption is inside the guard for query/GET/submit/download. A +cancelled write reports that its outcome may be unknown, not definitive failure +followed by an automatic second mutation. Existing signed event IDs are retained. + +## Local agents + +The desktop starts a loopback assertion broker. At the actual process-spawn +boundary it supplies a random capability bound to the agent key, relay, and login +generation. It never gives the agent the employee's login credential or human JWT. + +The broker requires both that capability and a signed, fresh, exact-body NIP-98 +proof. It checks that the agent remains managed locally, requests/caches an +assertion for **that agent's key**, and returns it only to native agent clients. +It rejects browser-origin requests. There are at most 256 grant/key entries and +24 KiB request bodies. Logout invalidates the grants; agents must be restarted +following a new login to receive new capabilities. + +Reserved environment variables: + +- `BUZZ_NIP_FI_ENDPOINT`: loopback broker (local) or adapter (detached). +- `BUZZ_NIP_FI_CREDENTIAL`: key-scoped capability/renewal credential. +- `BUZZ_NIP_FI_ORIGINS`: explicit protected relay origins. + +The ACP harness, CLI HTTP operations, CLI one-shot WebSocket publications, MCP +media image fetches, and MCP subprocess forwarding consume these variables. +Partial configuration fails closed. Agent sockets carry a fixed effective +admission deadline and check broker/adapter liveness every ten seconds; failed +checks drop the socket into existing bounded reconnect/catch-up behavior. Agent +HTTP requests renew per operation (and ACP per retry), not just at process boot. + +`buzz git ...` acquires fresh authority and invokes Git with the second header. +The built-in dev-MCP installs a Git shim that routes ordinary `git` calls through +that launcher. Other/custom agent shells must use `buzz git` for enterprise Git; +there is no universal interception of arbitrary external harnesses' Git binaries. +Curl/Git tracing is disabled for credential-bearing invocations. ACP request +logging no longer serializes `session/new` credentials and EnvVar Debug is redacted. + +## Detached agents: one additional backend assumption + +A laptop-local broker cannot support an agent after the laptop closes. The POC +therefore assumes `POST /v1/buzz/identity/agent-delegations`, with the same signed +request plus the employee login credential. Response is the assertion response +above **plus `agent_credential`**, a revocable renewal credential bound by the +adapter to exactly that agent, owner, and realm. Subsequent `/assertions` calls +use that credential and the agent's possession proof. + +Desktop provider deployment injects these three values into `launch.policy_env` +only in the invocation payload, never the managed-agent record or config snapshot. +The Kubernetes provider already materializes launch environment into its per-run +Secret. No new desktop-to-substrate control channel is introduced, preserving +VISION_REMOTE_AGENTS.md. Third-party providers must preserve this secret-env +contract. Adapter offboarding/revocation must revoke agent credentials as well as +employee access. Desktop logout alone does not revoke a detached credential. + +**This is an explicit extension assumption, not a claim that NIP-FI/NIP-OA or the +current kgoose implementation already grants agent access.** If issuance policy +does not authorize agents, the client fails rather than substituting a human JWT. + +## Evidence and remaining refinement + +Automated coverage includes real native HTTP/WS header fixtures, real assumed +adapter HTTP exchange with possession/body checks, metadata validation, key +isolation, stale generation refusal, immutable socket leases, a spawned CLI +against a protected fake adapter/relay, native late-result cancellation, and +Playwright startup/sign-in/OSS recovery. Fixtures establish client behavior, not +real Okta or deployed relay admission. Test results are recorded in the PR body. + +Remaining work before calling this production-ready: + +- Run the actual adapter and combined enforcing relay PRs, package native builds, + and exercise real account offboarding, long-running agents, huddles, and Git. +- Finalize enrollment/challenge and detached-agent issuance/revocation policy; + validate the exact real issuer/audience configuration and API errors. +- Seamless huddle reconnect; native archive `limit:0` subscriptions still need + finite gap repair on forced reconnect. Renderer live subscriptions and ACP + already have reconnect catch-up machinery; native archive tail does not. +- Finish all late-result/media streaming/playback cache fences. Existing local + history and already-rendered content remain available after logout; this POC + does not claim remote wipe or an enterprise offline-retention policy. +- Revisit per-operation detached-agent acquisition cost, broker concurrency and + per-key cache tuning, minimum useful token TTL, and multi-community realms with + different audiences. Current explicit origin list represents one realm. +- Git headers are fresh at invocation, not per underlying HTTP request inside a + long transfer. A transfer extending past admission expiry can fail; it is not + silently retried. External/custom harnesses require `buzz git` or equivalent. +- Native pairing uses its independent ephemeral-key pairing relay and deliberately + receives no human assertion. Mobile login/renewal, notification extensions, + and browser-only client access are not completed by this desktop/agent pass. + +The intent is now to refine runnable code against the real services, rather than +use a remaining-work document as a substitute for client implementation. diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index 4cb856f3811..fe473acbaed 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -1,3 +1,4 @@ +import '../../shared/auth/federated_identity.dart'; import 'dart:async'; import 'package:flutter/widgets.dart'; @@ -495,6 +496,7 @@ final class MobileHuddleController extends Notifier { throw StateError('A paired identity is required.'); } return HuddleConnectionParameters( + federatedHeaders: ref.read(federatedIdentityProvider).headers, relayWebSocketUrl: config.wsUrl, nsec: nsec, parentChannelId: parentChannelId, diff --git a/mobile/lib/shared/auth/federated_identity.dart b/mobile/lib/shared/auth/federated_identity.dart new file mode 100644 index 00000000000..341c0aecaa8 --- /dev/null +++ b/mobile/lib/shared/auth/federated_identity.dart @@ -0,0 +1,101 @@ +/// NIP-FI integration sketch. Signing stays local; this session supplies only +/// enterprise admission evidence. Adapter exchange and renewal UI are pending. +library; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +/// Called at actual connection/request time, not when a widget was constructed. +typedef FederatedHeaders = + Map Function(String url, String? nsec); + +class FederatedIdentitySession { + FederatedIdentitySession({required Iterable origins}) + : _origins = origins.map(_origin).toSet(); + + final Set _origins; + int _generation = 0; + String? _jwt; + String? _pubkey; + DateTime? _expires; + + int get generation => _generation; + + /// Login replacement/logout must also cancel sockets and pending operations. + void invalidate() { + _generation++; + _jwt = null; + _pubkey = null; + _expires = null; + } + + /// Input is a trusted adapter response, not renderer/user supplied claims. + /// This is not a JWT verifier; the relay verifies signature and policy. + void install({ + required int generation, + required String jwt, + required String pubkey, + required DateTime expires, + DateTime? now, + }) { + if (generation != _generation) throw StateError('Enterprise login changed'); + if (jwt.length > 16384 || + !RegExp( + r'^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$', + ).hasMatch(jwt) || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(pubkey)) { + throw const FormatException('Invalid enterprise assertion response'); + } + if (!expires.isAfter(now ?? DateTime.now())) { + throw StateError('Enterprise assertion expired'); + } + _jwt = jwt; + _pubkey = pubkey; + _expires = expires; + } + + Map headers(String url, String? nsec, {DateTime? now}) { + if (_origins.isEmpty || !_origins.contains(_origin(url))) return const {}; + final jwt = _jwt; + if (jwt == null) throw StateError('Enterprise sign-in required'); + if (!_expires!.isAfter(now ?? DateTime.now())) { + throw StateError('Enterprise assertion expired'); + } + if (nsec == null || + nostr.Keys(nostr.Nip19.decode(payload: nsec).data).public != _pubkey) { + throw StateError('Enterprise assertion does not match signing identity'); + } + return {'Nostr-Federated-Identity': 'Bearer $jwt'}; + } + + static String _origin(String value) { + final uri = Uri.parse(value); + final scheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + final other => other, + }; + if (uri.userInfo.isNotEmpty || + uri.hasFragment || + uri.host.isEmpty || + (scheme != 'https' && + !(scheme == 'http' && + ['127.0.0.1', 'localhost', '::1'].contains(uri.host)))) { + throw const FormatException('Invalid enterprise destination'); + } + return uri.replace(scheme: scheme).origin; + } + + @override + String toString() => 'FederatedIdentitySession([REDACTED])'; +} + +/// Build-owned destinations. A token must never select its own trusted hosts. +final federatedIdentityProvider = Provider((ref) { + const configured = String.fromEnvironment('BUZZ_BUILD_NIP_FI_ORIGINS'); + final session = FederatedIdentitySession( + origins: configured.split(',').where((s) => s.isNotEmpty), + ); + ref.onDispose(session.invalidate); + return session; +}); diff --git a/mobile/lib/shared/huddle/huddle_auth.dart b/mobile/lib/shared/huddle/huddle_auth.dart index f6829c7a121..5d61b469e72 100644 --- a/mobile/lib/shared/huddle/huddle_auth.dart +++ b/mobile/lib/shared/huddle/huddle_auth.dart @@ -3,16 +3,19 @@ import 'package:nostr/nostr.dart' as nostr; import '../relay/nostr_models.dart'; import 'huddle_wire.dart'; +import '../auth/federated_identity.dart'; /// Immutable connection inputs for one Huddle audio WebSocket. @immutable final class HuddleConnectionParameters { + final FederatedHeaders? federatedHeaders; final String relayWebSocketUrl; final String nsec; final String parentChannelId; final String ephemeralChannelId; HuddleConnectionParameters({ + this.federatedHeaders, required this.relayWebSocketUrl, required this.nsec, required this.parentChannelId, diff --git a/mobile/lib/shared/huddle/huddle_transport.dart b/mobile/lib/shared/huddle/huddle_transport.dart index 87ed660d048..d95b2eeeb4c 100644 --- a/mobile/lib/shared/huddle/huddle_transport.dart +++ b/mobile/lib/shared/huddle/huddle_transport.dart @@ -229,7 +229,18 @@ final class HuddleTransport implements HuddleTransportClient { ); try { - final channel = _channelFactory(parameters.audioWebSocketUri); + final headers = parameters.federatedHeaders?.call( + parameters.audioWebSocketUri.toString(), + parameters.nsec, + ); + // Sketch: production authenticated transport; factory seam needs a header argument. + final channel = headers == null || headers.isEmpty + ? _channelFactory(parameters.audioWebSocketUri) + : IOWebSocketChannel.connect( + parameters.audioWebSocketUri, + headers: headers, + pingInterval: const Duration(seconds: 30), + ); _channel = channel; await channel.ready.timeout(connectTimeout); if (!_isCurrent(generation)) { diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d6..208de50d7f6 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -5,13 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'relay_provider.dart'; +import '../auth/federated_identity.dart'; const _mediaGetAuthKind = 24242; -const _mediaGetAuthLifetimeSeconds = 600; +const _mediaGetAuthLifetimeSeconds = 60; /// Re-sign this long before the cached auth event expires, so an in-flight /// request signed just before the boundary still lands well within validity. -const _mediaGetAuthRefreshMarginSeconds = 60; +const _mediaGetAuthRefreshMarginSeconds = 10; /// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs. /// @@ -25,6 +26,7 @@ const _mediaGetAuthRefreshMarginSeconds = 60; /// rebuilt (dropping the memo) whenever the relay config — base URL or signing /// identity — changes, via [mediaGetAuthServiceProvider]. class MediaGetAuthService { + final FederatedHeaders? federatedHeaders; final String _baseUrl; final String? _nsec; final DateTime Function() _now; @@ -33,6 +35,7 @@ class MediaGetAuthService { DateTime? _refreshAt; MediaGetAuthService({ + this.federatedHeaders, required String baseUrl, required String? nsec, DateTime Function()? now, @@ -52,10 +55,13 @@ class MediaGetAuthService { if (nsec == null || nsec.isEmpty) return const {}; if (!isRelayMediaUrl(url)) return const {}; + // Outside the permissive proof catch: corporate failures must propagate. + final identity = + federatedHeaders?.call(url, nsec) ?? const {}; final cached = _cachedHeaders; final refreshAt = _refreshAt; if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) { - return cached; + return identity.isEmpty ? cached : {...cached, ...identity}; } try { @@ -74,7 +80,7 @@ class MediaGetAuthService { _mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds, ), ); - return headers; + return identity.isEmpty ? headers : {...headers, ...identity}; } catch (_) { // Read auth is best-effort: while the relay rollout flag is off, an // unsigned fetch still works. Once the flag is on, this request will 403 @@ -125,7 +131,11 @@ class MediaGetAuthService { final mediaGetAuthServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec); + return MediaGetAuthService( + baseUrl: config.baseUrl, + nsec: config.nsec, + federatedHeaders: ref.watch(federatedIdentityProvider).headers, + ); }); Map mediaGetHeadersFor(WidgetRef ref, String url) { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 4f79080efd8..100d5dd643d 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -13,6 +13,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; import 'animated_image_sanitizer.dart'; +import '../auth/federated_identity.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; @@ -33,7 +34,7 @@ const _requiresLegacyMediaStoragePermissionMethod = const _readClipboardImageMethod = 'readClipboardImage'; const _clipboardHasImageMethod = 'clipboardHasImage'; const _uploadAuthKind = 24242; -const _uploadAuthLifetimeSeconds = 300; +const _uploadAuthLifetimeSeconds = 60; const _heicBrands = { 'heic', 'heix', @@ -235,6 +236,7 @@ class BlobDescriptor { } class MediaUploadService { + final FederatedHeaders? federatedHeaders; final String _baseUrl; final String? _nsec; final PickGalleryImage _pickGalleryImage; @@ -253,6 +255,7 @@ class MediaUploadService { final bool _ownsHttpClient; MediaUploadService({ + this.federatedHeaders, required String baseUrl, required String? nsec, required PickGalleryImage pickGalleryImage, @@ -637,6 +640,7 @@ class MediaUploadService { Uri.parse(_baseUrl).resolve(path), abortTrigger: cancellationToken?.whenCancelled, ); + request.followRedirects = false; request.contentLength = bytes.length; request.headers.addAll( _buildUploadHeaders(mimeType: mimeType, sha256: sha256), @@ -661,6 +665,7 @@ class MediaUploadService { required String sha256, }) { final headers = { + ...?federatedHeaders?.call(_baseUrl, _nsec), 'Authorization': _buildUploadAuthHeader(sha256), 'Content-Type': mimeType, 'X-SHA-256': sha256, diff --git a/mobile/lib/shared/relay/media_upload/platform_bindings.dart b/mobile/lib/shared/relay/media_upload/platform_bindings.dart index 527871f11f7..90f5a3c9021 100644 --- a/mobile/lib/shared/relay/media_upload/platform_bindings.dart +++ b/mobile/lib/shared/relay/media_upload/platform_bindings.dart @@ -15,6 +15,7 @@ final mediaUploadServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); final picker = ImagePicker(); final service = MediaUploadService( + federatedHeaders: ref.watch(federatedIdentityProvider).headers, baseUrl: config.baseUrl, nsec: config.nsec, pickGalleryImage: () => picker.pickImage( diff --git a/mobile/lib/shared/relay/relay_http_query_client.dart b/mobile/lib/shared/relay/relay_http_query_client.dart index 615b28f64cf..71fedf4121b 100644 --- a/mobile/lib/shared/relay/relay_http_query_client.dart +++ b/mobile/lib/shared/relay/relay_http_query_client.dart @@ -26,8 +26,19 @@ class RelayHttpQueryClient { : null; generation?.acquire(); try { - return await (_injectedClient ?? generation!.client) - .post(url, headers: headers, body: body) + final client = _injectedClient ?? generation!.client; + if (!headers.containsKey('Nostr-Federated-Identity')) { + return await client + .post(url, headers: headers, body: body) + .timeout(timeout); + } + final request = http.Request('POST', url) + ..followRedirects = false + ..headers.addAll(headers) + ..bodyBytes = body; + return await client + .send(request) + .then(http.Response.fromStream) .timeout(timeout); } on TimeoutException { if (identical(_currentGeneration, generation)) { diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 6a787cce129..a7ebc106143 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; +import '../auth/federated_identity.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_closed_policy.dart'; @@ -164,6 +165,7 @@ class RelaySessionNotifier extends Notifier { final response = await _httpQueryClient.post( Uri.parse(url), headers: { + ...ref.read(federatedIdentityProvider).headers(url, config.nsec), 'Authorization': buildNip98AuthHeader( method: 'POST', url: url, @@ -493,6 +495,7 @@ class RelaySessionNotifier extends Notifier { onConnected: () => _handleConnected(generation), onDisconnected: (error) => _handleDisconnected(generation, error), ); + socket.federatedHeaders = ref.read(federatedIdentityProvider).headers; _socket = socket; await socket.connect(); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 5b23279e814..2c56997804a 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -7,6 +7,7 @@ import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; +import '../auth/federated_identity.dart'; /// Low-level websocket connection with NIP-42 authentication. /// @@ -43,6 +44,8 @@ class RelaySocket { final void Function() _onConnected; final void Function(Object? error) _onDisconnected; + FederatedHeaders? federatedHeaders; + WebSocketChannel? _channel; StreamSubscription? _subscription; SocketState _state = SocketState.disconnected; @@ -73,6 +76,7 @@ class RelaySocket { _channel = IOWebSocketChannel.connect( Uri.parse(_wsUrl), pingInterval: debugPingInterval, + headers: federatedHeaders?.call(_wsUrl, _nsec), ); await _channel!.ready; } catch (e) { diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index b0106639eb7..09798b84afa 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:nostr/nostr.dart' as nostr; import 'nostr_models.dart'; +import '../auth/federated_identity.dart'; import 'relay_session.dart'; import 'relay_socket.dart'; @@ -66,6 +67,7 @@ class SignedEventRelay { /// This is used for community-removal tombstones because the community being /// removed is not necessarily the app's active relay session. Future submitSignedEventOnce({ + FederatedHeaders? federatedHeaders, required String wsUrl, required String nsec, required int kind, @@ -122,6 +124,7 @@ Future submitSignedEventOnce({ } }, ); + socket.federatedHeaders = federatedHeaders; final resultFuture = result.future.timeout(timeout); try { await socket.connect(); diff --git a/mobile/test/shared/auth/federated_identity_test.dart b/mobile/test/shared/auth/federated_identity_test.dart new file mode 100644 index 00000000000..3c5e870e1fa --- /dev/null +++ b/mobile/test/shared/auth/federated_identity_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/auth/federated_identity.dart'; + +void main() { + final keys = nostr.Keys('1'.padLeft(64, '0')); + final nsec = keys.nsec; + final now = DateTime.utc(2026, 9, 15); + + test('enterprise admission stays scoped and expires at equality', () { + final session = FederatedIdentitySession( + origins: ['https://relay.example'], + ); + expect( + () => session.headers('wss://relay.example', nsec, now: now), + throwsStateError, + ); + session.install( + generation: 0, + jwt: 'aaa.bbb.ccc', + pubkey: keys.public, + expires: now.add(const Duration(seconds: 60)), + now: now, + ); + expect( + session.headers('wss://relay.example/huddle/x/audio', nsec, now: now), + {'Nostr-Federated-Identity': 'Bearer aaa.bbb.ccc'}, + ); + expect( + session.headers('https://other.example/media/x', nsec, now: now), + isEmpty, + ); + expect( + session.headers('https://relay.example:444/query', nsec, now: now), + isEmpty, + ); + expect( + () => session.headers('https://relay.example/query', null, now: now), + throwsStateError, + ); + expect( + () => session.headers( + 'https://relay.example/query', + nsec, + now: now.add(const Duration(seconds: 60)), + ), + throwsStateError, + ); + }); + + test('logout rejects stale completion without exposing assertion', () { + final session = FederatedIdentitySession( + origins: ['https://relay.example'], + ); + final generation = session.generation; + session.invalidate(); + expect( + () => session.install( + generation: generation, + jwt: 'aaa.bbb.ccc', + pubkey: keys.public, + expires: now.add(const Duration(minutes: 1)), + now: now, + ), + throwsStateError, + ); + expect(session.toString(), isNot(contains('aaa.bbb.ccc'))); + }); + + test('OSS does not need enterprise evidence', () { + expect( + FederatedIdentitySession(origins: []).headers('ws://fixture', null), + isEmpty, + ); + }); +} diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 4f43ec47701..df26b96c55a 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -31,6 +31,29 @@ void main() { PaintingBinding.instance.imageCache.clearLiveImages(); }); + test( + 'media composes current enterprise evidence with a cached local proof', + () { + final keys = nostr.Keys.generate(); + var token = 'Bearer first'; + final auth = MediaGetAuthService( + baseUrl: _relayBase, + nsec: keys.nsec, + federatedHeaders: (url, nsec) { + expect(url, _mediaUrl); + expect(nsec, keys.nsec); + return {'Nostr-Federated-Identity': token}; + }, + ); + final first = auth.headersFor(_mediaUrl); + token = 'Bearer second'; + final next = auth.headersFor(_mediaUrl); + expect(next['Authorization'], first['Authorization']); + expect(next['Nostr-Federated-Identity'], 'Bearer second'); + expect(auth.headersFor('https://elsewhere.example/media/x'), isEmpty); + }, + ); + group('MediaGetAuthService memoization', () { test('repeated calls return byte-identical headers', () { final nsec = nostr.Keys.generate().nsec; @@ -47,8 +70,8 @@ void main() { final auth = _auth(nsec: nsec, now: () => current); final first = auth.headersFor(_mediaUrl); - // 600s lifetime - 60s margin = re-sign boundary at +540s. - current = current.add(const Duration(seconds: 539)); + // 60s lifetime - 10s margin = re-sign boundary at +50s. + current = current.add(const Duration(seconds: 49)); expect(identical(auth.headersFor(_mediaUrl), first), isTrue); current = current.add(const Duration(seconds: 2)); diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index e9dbed302ef..dd72efe73ee 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -292,7 +292,7 @@ void main() { expect(authEvent['content'], 'Get buzz-media'); expect(authEvent['tags'], contains(equals(['t', 'get']))); expect(authEvent['tags'], contains(equals(['server', 'relay.example']))); - expect(authEvent['tags'], contains(equals(['expiration', '1700000600']))); + expect(authEvent['tags'], contains(equals(['expiration', '1700000060']))); }); test('does not sign non-relay or non-media URLs', () { @@ -419,7 +419,7 @@ void main() { equals(['x', capturedRequest!.headers['X-SHA-256']!]), ), ); - expect(tags, anyElement(equals(['expiration', '1700000300']))); + expect(tags, anyElement(equals(['expiration', '1700000060']))); expect( tags, anyElement(equals(['server', 'relay.example:8443'])),