mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
Add native enterprise signer transport and scoped media credentials
Signed-off-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
224
crates/buzz-ws-client/src/enterprise.rs
Normal file
224
crates/buzz-ws-client/src/enterprise.rs
Normal 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;
|
||||
167
crates/buzz-ws-client/src/enterprise_tests.rs
Normal file
167
crates/buzz-ws-client/src/enterprise_tests.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
pub mod connection;
|
||||
pub mod enterprise;
|
||||
pub mod error;
|
||||
pub mod message;
|
||||
|
||||
|
||||
3
desktop/src-tauri/Cargo.lock
generated
3
desktop/src-tauri/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
81
web/src/shared/lib/enterprise-media.test.mjs
Normal file
81
web/src/shared/lib/enterprise-media.test.mjs
Normal 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");
|
||||
});
|
||||
122
web/src/shared/lib/enterprise-media.ts
Normal file
122
web/src/shared/lib/enterprise-media.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user