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:
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) => {