Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/_ci-clients.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Web lint and format
run: just web-check
- name: Web unit tests
run: just web-test
- name: Web build
run: just web-build
- name: Save pnpm store cache
Expand Down
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ desktop-e2e-pre-push: _ensure-migrations
cd {{desktop_dir}} && pnpm build:e2e && pnpm exec playwright test --only-changed=origin/main

# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-test web-build mobile-test

# ─── Test ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -799,6 +799,10 @@ web-fix:
web-typecheck:
cd {{web_dir}} && pnpm typecheck

# Run browser signing and protocol unit tests
web-test:
cd {{web_dir}} && pnpm test

# Build web frontend assets
web-build:
cd {{web_dir}} && pnpm build
Expand Down
5 changes: 5 additions & 0 deletions crates/buzz-ws-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
url = { workspace = true }
tracing = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
zeroize = { workspace = true }
base64 = { workspace = true }
sha2 = { workspace = true }
40 changes: 39 additions & 1 deletion crates/buzz-ws-client/src/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(())
}
Expand Down
240 changes: 240 additions & 0 deletions crates/buzz-ws-client/src/enterprise.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
//! 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 {
corporate: Zeroizing<String>,
}

impl EnterpriseCredentials {
/// Own credentials for a request; the login owner is responsible for refresh and secure persistence.
pub fn new(corporate: String) -> Self {
Self {
corporate: Zeroizing::new(corporate),
}
}

fn headers(&self) -> Result<HeaderMap, WsClientError> {
let mut headers = HeaderMap::new();
let value = self.corporate.as_str();
if value.is_empty() || value.len() > 16 * 1024 {
return Err(failure());
}
let mut header =
HeaderValue::from_str(&format!("Bearer {value}")).map_err(|_| failure())?;
header.set_sensitive(true);
headers.insert("authorization", 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,
})
}

/// Discover the server-selected identity from a trusted signer and pin it for this login.
pub async fn login(
base: &str,
credentials: &EnterpriseCredentials,
) -> Result<Self, WsClientError> {
// The provisional value is never exposed and cannot sign: discovery only calls /session.
let mut signer = Self::new(
base,
EnterpriseSession {
pubkey: "0".repeat(64),
relay_ws_url: "wss://invalid.example".into(),
relay_http_url: "https://invalid.example".into(),
},
)?;
let session = signer
.post("session", serde_json::json!({}), credentials)
.await?;
signer = Self::new(base, session)?;
Ok(signer)
}

/// 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;
Loading
Loading