Add native enterprise signer transport and scoped media credentials

Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Bradley Axen
2026-09-11 17:23:55 -07:00
parent fe0acc6e24
commit eb37cf5517
12 changed files with 835 additions and 93 deletions

3
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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 }

View File

@@ -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<OkResponse, WsClientError> {
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(())
}

View File

@@ -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<Vec<String>>,
/// Event content.
pub content: String,
}
/// Ephemeral explicit credentials. Deliberately has no Debug/Serialize implementation.
pub struct EnterpriseCredentials {
session: Zeroizing<String>,
corporate: Zeroizing<String>,
}
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<HeaderMap, WsClientError> {
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<Self, WsClientError> {
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<T: serde::de::DeserializeOwned>(
&self,
path: &str,
body: serde_json::Value,
credentials: &EnterpriseCredentials,
) -> Result<T, WsClientError> {
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<Event, WsClientError> {
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<Vec<String>> = 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;

View File

@@ -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<String>) {
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::<usize>()
.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();
}
}

View File

@@ -1,6 +1,7 @@
#![deny(unsafe_code)]
pub mod connection;
pub mod enterprise;
pub mod error;
pub mod message;

View File

@@ -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]]

View File

@@ -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": <seven-field signed Nostr 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<Keys>`; 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.

View File

@@ -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");
});

View File

@@ -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<string> | null = null;
private generation = 0;
private readonly origin: string;
private readonly signer: Pick<EnterpriseSigner, "signEvent">;
private readonly now: () => number;
constructor(
signer: Pick<EnterpriseSigner, "signEvent">,
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<string> {
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<string> {
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);
}
}

View File

@@ -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);
});

View File

@@ -11,12 +11,18 @@ export type EnterpriseSignerSession = {
export type EnterpriseSignerOptions = {
baseUrl: string;
credential: () => Promise<string>;
/** Dedicated short-lived corporate API token, not the long-lived app session. */
corporateAuthorization: () => Promise<string>;
/** 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<string>;
private readonly corporateAuthorization: () => Promise<string>;
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<unknown> {
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;
}