tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 232632fde643dd12e97b291ccc70cc9ddbdd2ef8
parent 403764654d62fdd51e7f6d3a9135c47ce11d9055
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 02:54:52 -0700

runtime: issue websocket auth challenges

- generate per-connection AUTH challenges from OS randomness
- queue the relay AUTH message when a WebSocket session starts
- prove two pubkeys can authenticate against one connection challenge
- verify formatting, focused auth tests, runtime tests, workspace checks, and clippy

Diffstat:
MCargo.lock | 1+
Mcrates/tangle_runtime/Cargo.toml | 1+
Mcrates/tangle_runtime/src/relay/auth.rs | 31++++++++++++++++++++++++++++++-
Mcrates/tangle_runtime/src/server.rs | 41+++++++++++++++++++++++++++--------------
Mcrates/tangle_runtime/src/session.rs | 19++++++++++++++++++-
5 files changed, 77 insertions(+), 16 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1259,6 +1259,7 @@ version = "0.1.0" dependencies = [ "axum", "futures-util", + "getrandom 0.3.4", "http", "serde", "serde_json", diff --git a/crates/tangle_runtime/Cargo.toml b/crates/tangle_runtime/Cargo.toml @@ -9,6 +9,7 @@ description = "HTTP, WebSocket, and CLI runtime surfaces for tangle" [dependencies] axum = { version = "0.8", features = ["ws"] } +getrandom = "0.3" http = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/tangle_runtime/src/relay/auth.rs b/crates/tangle_runtime/src/relay/auth.rs @@ -5,6 +5,14 @@ use std::collections::BTreeSet; use tangle_crypto::verify_event_signature; use tangle_protocol::{Event, PublicKeyHex, RelayMessage, UnixTimestamp}; +pub fn generate_auth_challenge() -> Result<String, BaseRelayError> { + let mut bytes = [0_u8; 32]; + getrandom::fill(&mut bytes).map_err(|error| { + BaseRelayError::error(format!("auth challenge generation failed: {error}")) + })?; + Ok(lower_hex(&bytes)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BaseAuthState { relay_url: String, @@ -153,9 +161,19 @@ fn required_single_tag_value(event: &Event, name: &str) -> Result<String, String .ok_or_else(|| format!("tag `{name}` must include a value")) } +fn lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + #[cfg(test)] mod tests { - use super::BaseAuthState; + use super::{BaseAuthState, generate_auth_challenge}; use tangle_crypto::RelaySigner; use tangle_protocol::{Event, Kind, RelayMessage, Tag, UnixTimestamp, UnsignedEvent}; @@ -192,6 +210,17 @@ mod tests { ); } + #[test] + fn generated_auth_challenge_is_lowercase_hex_nonce() { + let first = generate_auth_challenge().expect("first"); + let second = generate_auth_challenge().expect("second"); + + assert_eq!(first.len(), 64); + assert_ne!(first, second); + assert!(first.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert_eq!(first, first.to_ascii_lowercase()); + } + fn signed_auth_event(secret_byte: u8, challenge: &str, created_at: u64) -> Event { let secret = format!("{:02x}", secret_byte).repeat(32); let signer = RelaySigner::from_secret_hex(&secret).expect("signer"); diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs @@ -246,6 +246,7 @@ mod tests { .expect("websocket"); assert_eq!(response.status(), http::StatusCode::SWITCHING_PROTOCOLS); + let _ = read_auth_challenge(&mut socket).await; shutdown.request_shutdown(); @@ -286,10 +287,16 @@ mod tests { .expect("websocket"); let event = tangle_v2_event(FixtureKey::Member, 1_714_124_433, 1, Vec::new(), "hello") .expect("event"); - let auth = tangle_v2_auth_event(FixtureKey::Owner, "missing-challenge", 1_714_124_434) - .expect("auth"); assert_eq!(response.status(), http::StatusCode::SWITCHING_PROTOCOLS); + let challenge = read_auth_challenge(&mut socket).await; + assert_eq!(challenge.len(), 64); + assert_eq!(challenge, challenge.to_ascii_lowercase()); + + let owner_auth = + tangle_v2_auth_event(FixtureKey::Owner, &challenge, 1_714_124_434).expect("owner auth"); + let admin_auth = + tangle_v2_auth_event(FixtureKey::Admin, &challenge, 1_714_124_435).expect("admin auth"); send_client_text(&mut socket, "{").await; let notice = read_relay_value(&mut socket).await; @@ -323,6 +330,18 @@ mod tests { json!(["EOSE", "sub-a"]) ); + send_client_value(&mut socket, json!(["AUTH", event_to_value(&owner_auth)])).await; + assert_eq!( + read_relay_value(&mut socket).await, + json!(["OK", owner_auth.id().as_str(), true, ""]) + ); + + send_client_value(&mut socket, json!(["AUTH", event_to_value(&admin_auth)])).await; + assert_eq!( + read_relay_value(&mut socket).await, + json!(["OK", admin_auth.id().as_str(), true, ""]) + ); + send_client_value(&mut socket, json!(["CLOSE", "sub-a"])).await; assert!( timeout(Duration::from_millis(50), socket.next()) @@ -330,18 +349,6 @@ mod tests { .is_err() ); - send_client_value(&mut socket, json!(["AUTH", event_to_value(&auth)])).await; - let auth_reply = read_relay_value(&mut socket).await; - assert_eq!(auth_reply[0], "OK"); - assert_eq!(auth_reply[1], auth.id().as_str()); - assert_eq!(auth_reply[2], false); - assert!( - auth_reply[3] - .as_str() - .expect("auth message") - .starts_with("auth-required:") - ); - shutdown.request_shutdown(); let report = timeout(Duration::from_secs(1), task) .await @@ -494,4 +501,10 @@ mod tests { }; serde_json::from_str(text.as_str()).expect("relay json") } + + async fn read_auth_challenge(socket: &mut TestWebSocket) -> String { + let auth = read_relay_value(socket).await; + assert_eq!(auth[0], "AUTH"); + auth[1].as_str().expect("auth challenge").to_owned() + } } diff --git a/crates/tangle_runtime/src/session.rs b/crates/tangle_runtime/src/session.rs @@ -1,6 +1,10 @@ #![forbid(unsafe_code)] -use crate::{errors::BaseRelayError, relay::auth::BaseAuthState, runtime::TangleRuntimeHandle}; +use crate::{ + errors::BaseRelayError, + relay::auth::{BaseAuthState, generate_auth_challenge}, + runtime::TangleRuntimeHandle, +}; use axum::extract::ws::{Message, WebSocket}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tangle_protocol::{RelayMessage, UnixTimestamp, parse_client_message}; @@ -55,6 +59,9 @@ impl TangleWebSocketSession { } pub async fn run(mut self, mut socket: WebSocket) { + if !self.issue_auth_challenge() { + return; + } loop { if self.shutdown_requested() { let _ = socket.send(Message::Close(None)).await; @@ -102,6 +109,16 @@ impl TangleWebSocketSession { } } + fn issue_auth_challenge(&mut self) -> bool { + let message = generate_auth_challenge() + .and_then(|challenge| { + self.auth + .issue_challenge(challenge, current_unix_timestamp()) + }) + .unwrap_or_else(|error| RelayMessage::Notice(error.prefixed_message())); + self.send_relay_message(message).is_ok() + } + async fn dispatch_text(&mut self, raw: &str) -> bool { let replies = match parse_client_message(raw) { Ok(message) => {