From eb37cf551700a561d4d59d37bd8af4ad7c749301 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Fri, 11 Sep 2026 17:23:55 -0700 Subject: [PATCH] Add native enterprise signer transport and scoped media credentials Signed-off-by: Bradley Axen --- Cargo.lock | 3 + crates/buzz-ws-client/Cargo.toml | 3 + crates/buzz-ws-client/src/connection.rs | 40 +++- crates/buzz-ws-client/src/enterprise.rs | 224 ++++++++++++++++++ crates/buzz-ws-client/src/enterprise_tests.rs | 167 +++++++++++++ crates/buzz-ws-client/src/lib.rs | 1 + desktop/src-tauri/Cargo.lock | 3 + docs/enterprise-remote-signer-api.md | 200 +++++++++------- web/src/shared/lib/enterprise-media.test.mjs | 81 +++++++ web/src/shared/lib/enterprise-media.ts | 122 ++++++++++ web/src/shared/lib/enterprise-signer.test.mjs | 56 ++++- web/src/shared/lib/enterprise-signer.ts | 28 ++- 12 files changed, 835 insertions(+), 93 deletions(-) create mode 100644 crates/buzz-ws-client/src/enterprise.rs create mode 100644 crates/buzz-ws-client/src/enterprise_tests.rs create mode 100644 web/src/shared/lib/enterprise-media.test.mjs create mode 100644 web/src/shared/lib/enterprise-media.ts diff --git a/Cargo.lock b/Cargo.lock index e57698003f..00254168d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,12 +1458,15 @@ version = "0.1.0" dependencies = [ "futures-util", "nostr 0.44.7", + "reqwest 0.13.4", + "serde", "serde_json", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tracing", "url", + "zeroize", ] [[package]] diff --git a/crates/buzz-ws-client/Cargo.toml b/crates/buzz-ws-client/Cargo.toml index 5cec925677..a0cbae4655 100644 --- a/crates/buzz-ws-client/Cargo.toml +++ b/crates/buzz-ws-client/Cargo.toml @@ -15,3 +15,6 @@ serde_json = { workspace = true } thiserror = { workspace = true } url = { workspace = true } tracing = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/buzz-ws-client/src/connection.rs b/crates/buzz-ws-client/src/connection.rs index bec5b56bb4..cd782e29e4 100644 --- a/crates/buzz-ws-client/src/connection.rs +++ b/crates/buzz-ws-client/src/connection.rs @@ -92,6 +92,43 @@ impl NostrWsConnection { Ok(()) } + /// Authenticate through corporate custody, then receive events directly from the relay. + /// + /// Credentials are needed only for this challenge. No incoming message is sent to the signer. + pub async fn authenticate_enterprise( + &mut self, + signer: &crate::enterprise::EnterpriseSigner, + credentials: &crate::enterprise::EnterpriseCredentials, + ) -> Result<(), WsClientError> { + if self.relay_url != signer.identity().relay_ws_url { + return Err(WsClientError::AuthFailed( + "Enterprise relay scope mismatch".to_owned(), + )); + } + let challenge = self + .wait_for_auth_challenge(Duration::from_secs(AUTH_CHALLENGE_TIMEOUT_SECS)) + .await?; + let template = crate::enterprise::EnterpriseTemplate { + kind: 22242, + created_at: nostr::Timestamp::now().as_secs(), + tags: vec![ + vec!["relay".to_owned(), self.relay_url.clone()], + vec!["challenge".to_owned(), challenge], + ], + content: String::new(), + }; + let event = signer.sign(&template, credentials).await?; + let id = event.id.to_hex(); + self.send_raw(&json!(["AUTH", event])).await?; + let ok = self + .wait_for_ok(&id, Duration::from_secs(AUTH_OK_TIMEOUT_SECS)) + .await?; + if !ok.accepted { + return Err(WsClientError::AuthFailed(ok.message)); + } + Ok(()) + } + /// Sends a signed event to the relay and waits for the OK response. pub async fn send_event(&mut self, event: Event) -> Result { let event_id = event.id.to_hex(); @@ -120,7 +157,8 @@ impl NostrWsConnection { /// Sends a raw JSON value as a WebSocket text frame. pub async fn send_raw(&mut self, value: &Value) -> Result<(), WsClientError> { let text = serde_json::to_string(value)?; - debug!("→ relay: {text}"); + // AUTH frames and event tags may contain bearer proofs; never log raw frames. + debug!(bytes = text.len(), "sending relay frame"); self.ws.send(Message::Text(text.into())).await?; Ok(()) } diff --git a/crates/buzz-ws-client/src/enterprise.rs b/crates/buzz-ws-client/src/enterprise.rs new file mode 100644 index 0000000000..cf6da23892 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise.rs @@ -0,0 +1,224 @@ +//! HTTPS corporate signer transport. No local key fallback and no secret-key export. +use std::time::Duration; + +use nostr::Event; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::WsClientError; + +/// Identity and community pinned by the corporate login lifecycle. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EnterpriseSession { + /// Custodied Nostr public key, never selected by a signing request. + pub pubkey: String, + /// Community WebSocket origin. + pub relay_ws_url: String, + /// Community HTTPS origin. + pub relay_http_url: String, +} + +/// Exact retryable NIP-01 template; signed fields are never sent to the service. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EnterpriseTemplate { + /// Nostr event kind. + pub kind: u16, + /// Preserve across retries rather than reconstructing from the current time. + pub created_at: u64, + /// Ordered Nostr tags. + pub tags: Vec>, + /// Event content. + pub content: String, +} + +/// Ephemeral explicit credentials. Deliberately has no Debug/Serialize implementation. +pub struct EnterpriseCredentials { + session: Zeroizing, + corporate: Zeroizing, +} + +impl EnterpriseCredentials { + /// Own credentials for a request; the login owner is responsible for refresh and secure persistence. + pub fn new(session: String, corporate: String) -> Self { + Self { + session: Zeroizing::new(session), + corporate: Zeroizing::new(corporate), + } + } + + fn headers(&self) -> Result { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("x-bb-session-credential", self.session.as_str()), + ("x-buzz-corporate-authorization", self.corporate.as_str()), + ] { + if value.is_empty() || value.len() > 16 * 1024 { + return Err(failure()); + } + let mut header = HeaderValue::from_str(value).map_err(|_| failure())?; + header.set_sensitive(true); + headers.insert(name, header); + } + Ok(headers) + } +} + +/// Shared native HTTPS transport for desktop/CLI integrations. It never owns or generates a Nostr key. +pub struct EnterpriseSigner { + client: reqwest::Client, + base: String, + expected: EnterpriseSession, +} + +impl EnterpriseSigner { + /// Construct from trusted managed configuration and a login-pinned identity. + pub fn new(base: &str, expected: EnterpriseSession) -> Result { + let base_url = url::Url::parse(base).map_err(|_| failure())?; + if !https(&base_url) { + return Err(failure()); + } + let relay = url::Url::parse(&expected.relay_http_url).map_err(|_| failure())?; + if !https(&relay) + || relay.path() != "/" + || expected.relay_ws_url + != format!( + "wss://{}", + relay[url::Position::BeforeHost..url::Position::AfterPort].to_owned() + ) + { + return Err(failure()); + } + if expected.pubkey.len() != 64 + || !expected + .pubkey + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(failure()); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .map_err(|_| failure())?; + Ok(Self { + client, + base: base_url.as_str().trim_end_matches('/').to_owned(), + expected, + }) + } + + /// Return the pinned identity; fresh corporate authorization is still checked on every signing request. + pub fn identity(&self) -> &EnterpriseSession { + &self.expected + } + + async fn post( + &self, + path: &str, + body: serde_json::Value, + credentials: &EnterpriseCredentials, + ) -> Result { + let mut response = self + .client + .post(format!("{}/v1/buzz/enterprise-signer/{path}", self.base)) + .headers(credentials.headers()?) + .header("Cache-Control", "no-store") + .json(&body) + .send() + .await + .map_err(|_| failure())?; + if !response.status().is_success() { + return Err(failure()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| failure())? { + if bytes.len() + chunk.len() > 256 * 1024 { + return Err(failure()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|_| failure()) + } + + /// Confirm provisioning and reject account/community changes during credential refresh. + pub async fn session(&self, credentials: &EnterpriseCredentials) -> Result<(), WsClientError> { + let actual: EnterpriseSession = self + .post("session", serde_json::json!({}), credentials) + .await?; + if actual != self.expected { + return Err(failure()); + } + Ok(()) + } + + /// Sign and verify an exact template; persist the result before publishing and replay it after ambiguous ACKs. + pub async fn sign( + &self, + template: &EnterpriseTemplate, + credentials: &EnterpriseCredentials, + ) -> Result { + let purpose = match template.kind { + 22242 => "nip42-auth", + 27235 => "http-auth", + 24242 + if template + .tags + .iter() + .any(|t| t.len() == 2 && t[0] == "t" && t[1] == "upload") => + { + "media-upload" + } + 24242 => "media-read", + _ => "publish", + }; + #[derive(Deserialize)] + struct Reply { + event: Event, + } + let reply: Reply = self + .post( + "events/sign", + serde_json::json!({"event": template, "purpose": purpose}), + credentials, + ) + .await?; + let event = reply.event; + let tags: Vec> = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect(); + if event.pubkey.to_hex() != self.expected.pubkey + || event.kind.as_u16() != template.kind + || event.created_at.as_secs() != template.created_at + || event.content != template.content + || tags != template.tags + || event.verify().is_err() + { + return Err(failure()); + } + Ok(event) + } +} + +fn https(url: &url::Url) -> bool { + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} +fn failure() -> WsClientError { + WsClientError::AuthFailed( + "Enterprise signing failed; corporate login or authorization is required".to_owned(), + ) +} + +#[cfg(test)] +#[path = "enterprise_tests.rs"] +mod tests; diff --git a/crates/buzz-ws-client/src/enterprise_tests.rs b/crates/buzz-ws-client/src/enterprise_tests.rs new file mode 100644 index 0000000000..5b77ca8fe1 --- /dev/null +++ b/crates/buzz-ws-client/src/enterprise_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn identity(keys: &Keys) -> EnterpriseSession { + EnterpriseSession { + pubkey: keys.public_key().to_hex(), + relay_ws_url: "wss://buzz.example".to_owned(), + relay_http_url: "https://buzz.example".to_owned(), + } +} + +#[test] +fn configuration_and_credentials_fail_closed() { + let keys = Keys::generate(); + for url in [ + "http://signer.example", + "https://user:pass@signer.example", + "https://signer.example/?token=x", + "https://signer.example/#x", + ] { + assert!(EnterpriseSigner::new(url, identity(&keys)).is_err()); + } + let mut wrong = identity(&keys); + wrong.relay_ws_url = "wss://other.example".to_owned(); + assert!(EnterpriseSigner::new("https://signer.example", wrong).is_err()); + assert!( + EnterpriseCredentials::new("".to_owned(), "token".to_owned()) + .headers() + .is_err() + ); + assert!( + EnterpriseCredentials::new("session".to_owned(), "bad\r\nheader".to_owned()) + .headers() + .is_err() + ); + let headers = EnterpriseCredentials::new("session".to_owned(), "token".to_owned()) + .headers() + .unwrap(); + assert!(headers["x-bb-session-credential"].is_sensitive()); + assert!(headers["x-buzz-corporate-authorization"].is_sensitive()); +} + +async fn server_reply( + body: String, + status: &str, + expected: EnterpriseSession, +) -> (EnterpriseSigner, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_owned(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut bytes = [0; 4096]; + let count = stream.read(&mut bytes).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&bytes[..count]); + assert!(request.len() < 256 * 1024); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]).to_lowercase(); + let length = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse::() + .unwrap(); + if request.len() >= end + 4 + length { + break; + } + } + } + stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + // Test-only loopback transport; production construction always enforces HTTPS. + let signer = EnterpriseSigner { + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(2)) + .build() + .unwrap(), + base: format!("http://{address}"), + expected, + }; + (signer, task) +} + +fn template() -> EnterpriseTemplate { + EnterpriseTemplate { + kind: 9, + created_at: 1000, + tags: vec![vec!["h".to_owned(), "channel".to_owned()]], + content: "hello".to_owned(), + } +} +fn signed(keys: &Keys, content: &str) -> Event { + EventBuilder::new(Kind::from(9), content) + .custom_created_at(Timestamp::from(1000)) + .tags([Tag::parse(["h", "channel"]).unwrap()]) + .sign_with_keys(keys) + .unwrap() +} + +#[tokio::test] +async fn verifies_real_signatures_template_and_account_and_sends_explicit_credentials() { + let keys = Keys::generate(); + let event = signed(&keys, "hello"); + let (signer, request) = server_reply( + serde_json::json!({"event": event}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + let credentials = EnterpriseCredentials::new("session".to_owned(), "corporate".to_owned()); + assert_eq!( + signer.sign(&template(), &credentials).await.unwrap().id, + event.id + ); + let request = request.await.unwrap(); + assert!(request.contains("x-bb-session-credential: session")); + assert!(request.contains("x-buzz-corporate-authorization: corporate")); + assert!(!request.to_lowercase().contains("cookie:")); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["purpose"], "publish"); + assert!(body["event"].get("pubkey").is_none()); + for invalid in [signed(&keys, "changed"), signed(&Keys::generate(), "hello")] { + let (signer, request) = server_reply( + serde_json::json!({"event": invalid}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + assert!(signer.sign(&template(), &credentials).await.is_err()); + request.await.unwrap(); + } + let mut forged = serde_json::to_value(event).unwrap(); + forged["sig"] = serde_json::json!("0".repeat(128)); + let (signer, request) = server_reply( + serde_json::json!({"event": forged}).to_string(), + "200 OK", + identity(&keys), + ) + .await; + assert!(signer.sign(&template(), &credentials).await.is_err()); + request.await.unwrap(); +} + +#[tokio::test] +async fn rejects_denial_large_response_and_session_switch() { + let keys = Keys::generate(); + let credentials = EnterpriseCredentials::new("session".to_owned(), "corporate".to_owned()); + for (status, body) in [ + ("403 Forbidden", "{}".to_owned()), + ("200 OK", "x".repeat(256 * 1024 + 1)), + ( + "200 OK", + serde_json::to_string(&identity(&Keys::generate())).unwrap(), + ), + ] { + let (signer, request) = server_reply(body, status, identity(&keys)).await; + assert!(signer.session(&credentials).await.is_err()); + request.await.unwrap(); + } +} diff --git a/crates/buzz-ws-client/src/lib.rs b/crates/buzz-ws-client/src/lib.rs index 02c7d4b1b2..69189bd948 100644 --- a/crates/buzz-ws-client/src/lib.rs +++ b/crates/buzz-ws-client/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_code)] pub mod connection; +pub mod enterprise; pub mod error; pub mod message; diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6af27ee243..1862717672 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1262,12 +1262,15 @@ version = "0.1.0" dependencies = [ "futures-util", "nostr 0.44.7", + "reqwest 0.13.4", + "serde", "serde_json", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", "tracing", "url", + "zeroize", ] [[package]] diff --git a/docs/enterprise-remote-signer-api.md b/docs/enterprise-remote-signer-api.md index e03a05e451..5b0b511031 100644 --- a/docs/enterprise-remote-signer-api.md +++ b/docs/enterprise-remote-signer-api.md @@ -1,23 +1,37 @@ -# Enterprise HTTPS signer: implementation experiment +# Enterprise HTTPS signer — implementation status and contract -**Status: partial proof, not an enterprise-ready build.** This change adds an -explicit browser signer adapter at the existing Nostr signing seam. It does not -wire corporate login, desktop/mobile settings, or release defaults. Do not enable -it for employees until the authorization and rollout requirements below are met. +**Status: draft implementation, not an enterprise-ready build.** The backend now +has a production authorization/provisioning implementation behind opt-in config. +Buzz has browser and shared native Rust signer transports, a native WebSocket auth +entry point, and a browser media-credential cache. Desktop/mobile login, settings, +complete signing-path conversion, durable client outboxes and release defaults +remain unfinished. Do not enable employee rollout yet. -Enterprise custody is an intentional exception to `VISION_SOVEREIGN.md`'s -self-custody promise: an organization owns and can revoke its employee identity. -OSS users retain their local/NIP-07 keys. Hosting and relay permissions remain -independent of the signer; signed events still use NIP-01 unchanged. +Enterprise custody intentionally differs from `VISION_SOVEREIGN.md`: the +organization owns and can revoke its employee identity. OSS users retain local +or NIP-07 keys. Hosting, Nostr events, and community/channel permissions do not +move into the signer. ## Two endpoints -The base URL includes any deployment prefix. All calls are HTTPS POST, JSON, -no redirects, no HTTP/browser caches. The prototype uses an explicit -`X-BB-Session-Credential` header supplied by the platform login integration, not -ambient cookies. The adapter does not persist the credential. A browser deployment -must explicitly allow its trusted origin and this header through CORS; it must not -use wildcard credential forwarding or accept cookie-only cross-origin signing. +The base URL includes the deployment prefix. Both routes are HTTPS POST, JSON, +no redirects, no caches. Require both explicit headers: + +- `X-BB-Session-Credential`: authenticated platform CLI/app credential; cookies + alone are not accepted. +- `X-Buzz-Corporate-Authorization`: a dedicated short-lived Auth0 API access token + bound to the same subject and organization, not an ordinary app ID token. + +The server verifies issuer, RS256 signature, audience, subject, organization, +enterprise connection, `buzz:sign` permission, and signed corporate entitlement. +Authorization expires at most five minutes after the issuer's corporate check, +not five minutes after an arbitrary refresh. An issuer-side entitlement check on +login **and refresh** is an external prerequisite; the signer cannot turn a +long-lived application session into proof of current employment. + +Browser deployment must explicitly allow trusted origins and both headers through +CORS. Never allow ambient cookie signing or wildcard credential forwarding. +Never log these headers, private keys, request bodies, or signed bearer proofs. ### `/v1/buzz/enterprise-signer/session` @@ -31,10 +45,14 @@ Request: `{}`. Response: } ``` -Success means the server has checked corporate authorization and confirmed relay -membership for its custodied account key. It must not return a user-uploaded public -key or claim membership merely because a database identity binding exists. -Failures propagate; the client must not assume admission or switch identities. +The service atomically creates or reads the account's encrypted Nostr key. +First admission and a directory-derived kind:0 profile are journaled as exact +signed events before relay publication. The journal is retained on failure; +retries replay its event IDs, including after an ambiguous acknowledgement. +Membership and key creation cannot be a single database transaction across an +external relay, so session success waits for both relay acknowledgements. +Completed accounts are checked as members, never automatically re-added after +removal. The configured enterprise relay **must be membership-gated**. ### `/v1/buzz/enterprise-signer/events/sign` @@ -52,89 +70,95 @@ Failures propagate; the client must not assume admission or switch identities. Response: `{ "event": }`. No `pubkey`, `id`, `sig`, account selector, or unknown template fields are accepted. -The server derives the account from verified corporate authorization and validates -purpose independently of the client's claim. The adapter verifies the signature, -public key, and exact template before returning the result. +The service selects the key from the authenticated account, rechecks corporate +entitlement and relay membership, then validates the event's purpose. Clients +verify the signature, public key and exact unsigned template. -Purposes: +- `nip42-auth`: kind 22242, empty content, exact relay/challenge. Subscriptions + connect directly to Buzz; incoming messages never visit the signer. +- `http-auth`: kind 27235, authorized HTTPS origin/method; body-bearing requests + require the SHA-256 `payload`. Relay proof expiry/replay checks still apply. +- `media-read`: kind 24242, `t=get`, exact `server`, expiry within five minutes. +- `media-upload`: kind 24242, `t=upload`, exact `server`, short expiry and SHA-256 + `x` tag. File bytes travel directly to Buzz, not the signer. +- `publish`: explicit user-kind allowlist covering chat, forums, channel actions, + read state, social lists, git, workflow controls and huddles. Generic kind:0 + profiles, identity/delegation, pairing, agent custody, relay-admin commands and + relay-only sidecars remain denied. The relay still enforces channel ACLs. -- `nip42-auth`: kind 22242, empty content, exact relay and challenge tags. After - authentication, subscriptions connect directly to Buzz; incoming events never - call the signer. -- `http-auth`: kind 27235, empty content, authorized HTTPS origin and HTTP method; - body-bearing requests require a SHA-256 `payload` tag. HTTP bearer proof expiry - and replay protection are enforced by the relay. -- `media-read`: kind 24242, `t=get`, exact `server` authority, expiration at most - five minutes away. Cache the resulting Nostr header only for this account/host - and only before expiration. This PR does **not** add a media credential cache. -- `media-upload`: kind 24242, `t=upload`, exact `server`, short expiration and - lowercase SHA-256 `x` tag. File bytes go directly to the relay. -- `publish`: the initial backend proof deliberately allows only kinds 1, 5, 7, 9, - and 11. Profiles and delegation are rejected, not silently supported. The full - Buzz event-kind inventory still needs policy review. +Bearer templates accept timestamps from 60 seconds ago to 30 seconds ahead. +Durable publish templates permit older timestamps so offline retries retain their +identity. Content and tag strings each have a 64 KiB limit, with at most 256 tags +and 16 strings/tag. Application requests are capped at 128 KiB and signer replies +at 256 KiB. A **pre-buffer ingress limit** is still required. Backend work admission +is bounded per pod (32 concurrent operations, 120/account/minute, 10,000 buckets); +fleet-wide quotas remain a rollout prerequisite. -The prototype accepts timestamps from 60 seconds ago through 30 seconds ahead, -64 KiB of content and 64 KiB of tag strings, at most 256 tags, with at most 16 -strings per tag. The request is capped at 128 KiB; the adapter response at 256 KiB. -Transport limits must also be applied **before** the server buffers request bodies. -Never log credentials, key bytes, request/event bodies, or signed bearer proofs. +## Client seams -## Retry identity - -Preserve the complete unsigned template including `created_at` across signing -retries; Schnorr auxiliary randomness can change the signature but not the event -ID. Persist the signed event before publication and resend that exact event after -an ambiguous acknowledgement. The signer does not publish and does not provide an -idempotency ledger. Native outbox integration is still required; reconstructing a -template with a fresh timestamp on retry is incorrect. - -## Browser integration seam +Browser: ```ts configureEnterpriseSigner(new EnterpriseSigner({ baseUrl: managedSignerUrl, credential: () => corporateLogin.currentCredential(), + corporateAuthorization: () => corporateLogin.currentSignerAccessToken(), + expectedSession: corporateLogin.pinnedSignerSession(), })); ``` -Install before opening relay connections. The existing `signNostrEvent` path then -uses enterprise custody ahead of NIP-07 or anonymous fallback, and the invite UI -recognizes it as durable signing. Credential expiry must leave this signer -installed and fail closed. Calling `configureEnterpriseSigner(null)` is an explicit -return to a self-custody community, **not** enterprise logout. Reconfiguration -fences in-flight signing results. The login lifecycle is not wired by this PR. +Install before relay connections. Expiry must leave this signer installed and +fail closed. `configureEnterpriseSigner(null)` explicitly selects self-custody, +not enterprise logout. Reconfiguration fences in-flight signatures. Identity and +community are pinned across credential refresh; changed values require login. +First-login discovery/pinning is still owned by the unimplemented login flow. -## Remaining work and release gates +`EnterpriseMediaCredentials` caches one host/account's read proof for two minutes, +refreshes before expiry, deduplicates concurrent requests, and fences in-flight +results when cleared. Uploads send only the hash for signing. Callers must clear +on logout/account/community changes and auth rejection, disable redirects, and +send the exact hashed upload bytes. This helper is not yet wired into app UI. -1. **Corporate authorization:** Auth0 federation is only login. Recheck a - sufficiently fresh corporate authorization lease per operation and propagate - disablement to signer access and relay membership. An existing eight-hour app - session is not an employment check. Live WebSocket termination belongs to the - separate revocation branch, but signer denial and membership revocation do not. -2. **Authoritative profiles:** publish corporate name/email from the directory; - prevent user-chosen profile events from asserting another employee's name. - Decide migration from current self-custody keys and how old profiles display. -3. **Provisioning:** atomically persist the account key, then durably reconcile - external relay admission/profile updates. A database transaction cannot make a - remote relay write atomic. Test crashes between relay acknowledgement and local - state, and make retries reuse the same key and event IDs. -4. **Desktop:** `AppState` owns `Mutex`; submission, native relay auth, media, - huddles, pairing, backups, and agent authorization access key material directly. - Introduce an async identity/signer abstraction and explicitly disable or replace - secret-dependent operations. Replacing only the `sign_event` IPC is insufficient. -5. **Mobile:** message signing, `RelaySocket`, media read auth, uploads and secondary - relay sessions each decode nsecs. Media header getters are synchronous; remote - signing needs asynchronous prefetch/cache and expiration-aware recovery. -6. **Release wiring:** trusted managed configuration, login/refresh UI, corporate - origin policy, enterprise settings, and native signed build defaults remain - absent. Do not advertise the adapter as a usable enterprise release. -7. **Operational security:** key deletion/retention, restore and access audit, - per-account rate limits, pre-buffer transport bounds, short auth lifetimes and - security review are mandatory before enabling custody. +Native Rust: `buzz-ws-client::enterprise::EnterpriseSigner` uses the same protocol, +pins identity/community, bounds transport time/size and treats credential headers +as sensitive. `NostrWsConnection::authenticate_enterprise` signs the challenge +through it, then uses the direct relay connection. Credentials are request-owned, +not persisted by the adapter. Desktop and CLI do not yet select this path. + +## Retry identity + +Persist the full unsigned template **before** signing, including `created_at`. +Schnorr auxiliary randomness can change a signature but not the event ID. Persist +the signed result before publishing, then resend that exact event after an +ambiguous acknowledgement. Refresh HTTP bearer proofs independently, with a new +nonce, without reconstructing the durable event. Native client outbox integration +is not implemented by these adapters. + +## Remaining implementation and rollout gates + +1. Auth0 API/audience and corporate entitlement claim issuance, login/refresh UI, + secure credential lifecycle and trusted first-login identity discovery. +2. Desktop: replace `AppState`'s raw-key dependency across submission, native relay + auth, media, huddles and deferred work. Disable or replace backups, pairing, + mesh/agent authorization and other secret-dependent operations in enterprise + mode. A single IPC replacement is insufficient. +3. Mobile: introduce an async signer in message, socket, media and secondary-relay + paths. Synchronous media headers need prefetch/cache and expiry recovery. +4. Durable client outboxes, settings, enterprise signed-build defaults, profile + refresh and existing self-custody identity migration. +5. Offboarding-driven signer denial and durable membership removal/reconciliation. + Live WebSocket termination is intentionally left to the existing separate + branch. Pending provisioning vs revocation needs explicit generation fencing + before rollout; a short token lease is not instantaneous revocation. +6. Pre-buffer ingress limits, header/log redaction, fleet-wide rate limits, + custody audit/retention/restore policy and security review. ## Validation -`pnpm --dir web test` exercises the actual browser signing seam with a local HTTP -fake and real Nostr signatures; `pnpm --dir web typecheck` checks the adapter. These -are not a deployed Auth0-to-relay integration test. No live corporate identity or -relay membership was changed by this experiment. +Browser tests exercise the production signing seam, real Nostr signatures, +credential denial, pinned identity, response bounds and media cache/hash behavior. +Rust tests exercise the actual transport with a loopback fake and real signatures, +including wrong-account/template/signature rejection. Kgoose tests cover custody, +corporate claims, provisioning crash/retry behavior and TLS relay transport. +These are not a deployed Auth0-to-native-app-to-relay acceptance test. No live +corporate identity or relay membership was changed. diff --git a/web/src/shared/lib/enterprise-media.test.mjs b/web/src/shared/lib/enterprise-media.test.mjs new file mode 100644 index 0000000000..9c299f5d19 --- /dev/null +++ b/web/src/shared/lib/enterprise-media.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { finalizeEvent, generateSecretKey } from "nostr-tools/pure"; +import { EnterpriseMediaCredentials } from "./enterprise-media.ts"; + +const key = generateSecretKey(); +function harness() { + let now = 1000; + const events = []; + const signer = { + signEvent: async (e) => { + events.push(e); + return finalizeEvent(e, key); + }, + }; + return { + cache: new EnterpriseMediaCredentials( + signer, + "https://buzz.example", + () => now, + ), + events, + advance: (seconds) => { + now += seconds; + }, + }; +} + +test("media read credentials are single-flight, host-scoped and expire", async () => { + const { cache, events, advance } = harness(); + const [first, second] = await Promise.all([ + cache.read("https://buzz.example/a"), + cache.read("https://buzz.example/b"), + ]); + assert.equal(first, second); + assert.equal(events.length, 1); + assert.equal(await cache.read("https://buzz.example/c"), first); + await assert.rejects(cache.read("https://evil.example/a"), /outside/); + await assert.rejects(cache.read("https://buzz.example:444/a"), /outside/); + await assert.rejects(cache.read("https://user@buzz.example/a"), /outside/); + advance(106); + assert.notEqual(await cache.read("https://buzz.example/a"), first); + assert.equal(events.length, 2); +}); + +test("clearing credentials fences in-flight reads and failures remain retryable", async () => { + let finish; + let fail = true; + const signer = { + signEvent: (e) => + fail + ? Promise.reject(Error("login required")) + : new Promise((resolve) => { + finish = () => resolve(finalizeEvent(e, key)); + }), + }; + const cache = new EnterpriseMediaCredentials( + signer, + "https://buzz.example", + () => 1000, + ); + await assert.rejects(cache.read("https://buzz.example/a"), /login/); + fail = false; + const pending = cache.read("https://buzz.example/a"); + cache.clear(); + finish(); + await assert.rejects(pending, /identity changed/); +}); + +test("upload sends a file-bound hash to signer, not bytes", async () => { + const { cache, events } = harness(); + const bytes = new TextEncoder().encode("abc").buffer; + const header = await cache.upload("https://buzz.example/upload", bytes); + assert.ok(header.startsWith("Nostr ")); + assert.deepEqual( + events[0].tags.find((t) => t[0] === "x"), + ["x", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"], + ); + assert.equal(events[0].content, "Upload buzz-media"); + assert.equal(events[0].tags.find((t) => t[0] === "expiration")[1], "1120"); +}); diff --git a/web/src/shared/lib/enterprise-media.ts b/web/src/shared/lib/enterprise-media.ts new file mode 100644 index 0000000000..4cd936b3e7 --- /dev/null +++ b/web/src/shared/lib/enterprise-media.ts @@ -0,0 +1,122 @@ +import type { EnterpriseSigner } from "./enterprise-signer"; +import type { SignedNostrEvent } from "./nostr-signer"; + +function authorization(event: SignedNostrEvent): string { + const bytes = new TextEncoder().encode(JSON.stringify(event)); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return `Nostr ${btoa(binary)}`; +} + +/** One cache per logged-in signer, never a process-wide or cross-community bearer cache. */ +export class EnterpriseMediaCredentials { + private cached: { header: string; expires: number } | null = null; + private pending: Promise | null = null; + private generation = 0; + private readonly origin: string; + private readonly signer: Pick; + private readonly now: () => number; + + constructor( + signer: Pick, + relayHttpUrl: string, + now: () => number = () => Math.floor(Date.now() / 1000), + ) { + this.signer = signer; + this.now = now; + const url = new URL(relayHttpUrl); + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/" + ) + throw new Error("Media credentials require an HTTPS relay origin."); + this.origin = url.origin; + } + + /** Clear on logout, account/community changes and a relay authentication rejection. */ + clear(): void { + this.generation++; + this.cached = null; + this.pending = null; + } + + private target(url: string): URL { + const target = new URL(url); + if ( + target.origin !== this.origin || + target.username || + target.password || + target.hash + ) + throw new Error("Media target is outside the enterprise community."); + return target; + } + + /** Reuse only before expiry; callers must disable redirects when attaching this bearer header. */ + async read(url: string): Promise { + this.target(url); + if (this.cached && this.cached.expires > this.now() + 15) + return this.cached.header; + if (this.pending) return this.pending; + const generation = this.generation; + const expires = this.now() + 120; + const pending = this.signer + .signEvent({ + kind: 24242, + created_at: this.now(), + content: "Get buzz-media", + tags: [ + ["t", "get"], + ["server", new URL(this.origin).host], + ["expiration", String(expires)], + ], + }) + .then((event) => { + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + if (expires <= this.now() + 15) + throw new Error("Media credential expired while signing."); + const header = authorization(event); + this.cached = { header, expires }; + return header; + }) + .finally(() => { + if (this.pending === pending) this.pending = null; + }); + this.pending = pending; + return pending; + } + + /** Hash the exact upload bytes; only the hash goes to the signer, never the file. */ + async upload(url: string, bytes: ArrayBuffer): Promise { + this.target(url); + const generation = this.generation; + const digest = await crypto.subtle.digest("SHA-256", bytes); + const hash = Array.from(new Uint8Array(digest), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + const expires = this.now() + 120; + const event = await this.signer.signEvent({ + kind: 24242, + created_at: this.now(), + content: "Upload buzz-media", + tags: [ + ["t", "upload"], + ["server", new URL(this.origin).host], + ["expiration", String(expires)], + ["x", hash], + ], + }); + if (generation !== this.generation) + throw new Error("Enterprise media identity changed."); + if (expires <= this.now()) + throw new Error("Media credential expired while signing."); + return authorization(event); + } +} diff --git a/web/src/shared/lib/enterprise-signer.test.mjs b/web/src/shared/lib/enterprise-signer.test.mjs index 993c88c496..e7be892844 100644 --- a/web/src/shared/lib/enterprise-signer.test.mjs +++ b/web/src/shared/lib/enterprise-signer.test.mjs @@ -33,6 +33,8 @@ function signer() { return new EnterpriseSigner({ baseUrl: "https://signer.example/api", credential: async () => "test-session", + corporateAuthorization: async () => "test-corporate-token", + expectedSession: session, }); } function serve(sign = (e) => finalizeEvent(e, key)) { @@ -41,6 +43,10 @@ function serve(sign = (e) => finalizeEvent(e, key)) { assert.equal(init.cache, "no-store"); assert.equal(init.credentials, "omit"); assert.equal(init.headers["X-BB-Session-Credential"], "test-session"); + assert.equal( + init.headers["X-Buzz-Corporate-Authorization"], + "test-corporate-token", + ); return Response.json( String(url).endsWith("/session") ? session @@ -83,7 +89,13 @@ test("forbids HTTP, embedded credentials and identity selectors before transmitt "https://signer.example?token=x", ]) { assert.throws( - () => new EnterpriseSigner({ baseUrl, credential: async () => "token" }), + () => + new EnterpriseSigner({ + baseUrl, + credential: async () => "token", + corporateAuthorization: async () => "corporate-token", + expectedSession: session, + }), ); } await assert.rejects( @@ -104,7 +116,7 @@ test("logout fences an in-flight signature", async () => { configureEnterpriseSigner({ signEvent: () => pending }); const result = signNostrEvent(template); configureEnterpriseSigner(null); - finish(finalizeEvent(template, key)); + finish(finalizeEvent(structuredClone(template), key)); await assert.rejects(result, /identity changed/); }); test("missing corporate credential does not make a network request", async () => { @@ -115,6 +127,8 @@ test("missing corporate credential does not make a network request", async () => new EnterpriseSigner({ baseUrl: "https://signer.example", credential: async () => "", + corporateAuthorization: async () => "corporate-token", + expectedSession: session, }).session(), /login/, ); @@ -123,3 +137,41 @@ test("self custody remains available when enterprise mode is unset", async () => assert.equal(hasDurableSigner(), false); assert.ok((await signNostrEvent(template)).sig); }); + +test("token refresh cannot silently change pinned identity or community", async () => { + let signCalls = 0; + for (const changed of [ + { ...session, pubkey: getPublicKey(generateSecretKey()) }, + { + ...session, + relayWsUrl: "wss://other.example", + relayHttpUrl: "https://other.example", + }, + ]) { + globalThis.fetch = async (url) => { + if (!String(url).endsWith("/session")) signCalls++; + return Response.json(changed); + }; + await assert.rejects( + signer().signEvent(template), + /identity or community changed/, + ); + } + assert.equal(signCalls, 0); +}); + +test("missing corporate token cannot use an otherwise valid app session", async () => { + let calls = 0; + globalThis.fetch = async () => { + calls++; + throw Error("unexpected network request"); + }; + const client = new EnterpriseSigner({ + baseUrl: "https://signer.example", + credential: async () => "session", + corporateAuthorization: async () => "", + expectedSession: session, + }); + await assert.rejects(client.signEvent(template), /login/); + assert.equal(calls, 0); +}); diff --git a/web/src/shared/lib/enterprise-signer.ts b/web/src/shared/lib/enterprise-signer.ts index ac114a9cd1..ffceb08471 100644 --- a/web/src/shared/lib/enterprise-signer.ts +++ b/web/src/shared/lib/enterprise-signer.ts @@ -11,12 +11,18 @@ export type EnterpriseSignerSession = { export type EnterpriseSignerOptions = { baseUrl: string; credential: () => Promise; + /** Dedicated short-lived corporate API token, not the long-lived app session. */ + corporateAuthorization: () => Promise; + /** Pin the identity and community established by login; token refresh must not switch either. */ + expectedSession: EnterpriseSignerSession; }; /** Explicit, HTTPS-only corporate signer. Errors never fall back to another identity. */ export class EnterpriseSigner { private readonly baseUrl: string; private readonly credential: () => Promise; + private readonly corporateAuthorization: () => Promise; + private readonly expectedSession: EnterpriseSignerSession; constructor(options: EnterpriseSignerOptions) { const url = new URL(options.baseUrl); @@ -33,11 +39,20 @@ export class EnterpriseSigner { } this.baseUrl = url.toString().replace(/\/+$/, ""); this.credential = options.credential; + this.corporateAuthorization = options.corporateAuthorization; + this.expectedSession = { ...options.expectedSession }; } private async post(path: string, body: unknown): Promise { - const credential = await this.credential(); - if (!credential || /[\r\n]/.test(credential)) + const [credential, corporateAuthorization] = await Promise.all([ + this.credential(), + this.corporateAuthorization(), + ]); + if ( + [credential, corporateAuthorization].some( + (value) => !value || value.length > 16 * 1024 || /[\r\n]/.test(value), + ) + ) throw new Error("Corporate login is required."); const response = await fetch( `${this.baseUrl}/v1/buzz/enterprise-signer/${path}`, @@ -49,6 +64,7 @@ export class EnterpriseSigner { headers: { "Content-Type": "application/json", "X-BB-Session-Credential": credential, + "X-Buzz-Corporate-Authorization": corporateAuthorization, }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000), @@ -97,6 +113,14 @@ export class EnterpriseSigner { session.relayWsUrl !== `wss://${relay.host}` ) throw new Error("Invalid enterprise relay scope."); + if ( + session.pubkey !== this.expectedSession.pubkey || + session.relayWsUrl !== this.expectedSession.relayWsUrl || + session.relayHttpUrl !== this.expectedSession.relayHttpUrl + ) + throw new Error( + "Enterprise identity or community changed; login is required.", + ); return session; }