Files
2026-07-10 15:02:09 +01:00

204 lines
7.9 KiB
Rust

//! End-to-end tests for the `/v1/pipe` data plane (docs/relay/pipe.md §2).
//!
//! Two raw WebSocket peers authenticate to the relay (MsgPack `pipe_auth`,
//! Ed25519 signature), get matched by `connection_id`, and stream opaque bytes
//! the relay never reads. Covers the happy path plus the auth/cross-dest
//! rejections.
use std::net::SocketAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::SigningKey;
use futures_util::{SinkExt, StreamExt};
use skald_relay_common::crypto;
use skald_relay_common::pipe::{self, PipeAuth, PipeChallenge};
use tokio_tungstenite::tungstenite::Message;
use skald_relay_server::config::{Config, PipeConfig};
use skald_relay_server::{AppState, router};
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Boot a relay with a throwaway DB, returning its addr and the shared state so
/// tests can seed the namespace / authorized clients directly.
async fn spawn_relay() -> (SocketAddr, AppState) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let db = std::env::temp_dir().join(format!("relay-pipe-it-{nanos}-{}-{seq}.db", std::process::id()));
let cfg = Config {
bind: "127.0.0.1:0".parse().unwrap(),
db_path: db.to_string_lossy().into(),
pipe: PipeConfig::default(),
};
let state = AppState::build(cfg).await.expect("build state");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let serve_state = state.clone();
tokio::spawn(async move {
axum::serve(
listener,
router(serve_state).into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
(addr, state)
}
/// An identity = its Ed25519 signing key + derived pubkeys.
struct Id {
sk: SigningKey,
ed_pub: [u8; 32],
}
fn id_from_seed(seed: u8) -> Id {
let dk = crypto::derive_keys(&[seed; 32]);
Id { sk: SigningKey::from_bytes(&dk.ed25519_priv), ed_pub: dk.ed25519_pub }
}
/// Seed a namespace owned by `agent` and authorize `client` in it.
async fn seed_namespace(state: &AppState, agent: &Id, client: &Id) -> [u8; 32] {
let (ns_raw, ns_hex) = crypto::namespace_id(&agent.ed_pub);
state.store.upsert_namespace(&ns_hex, &agent.ed_pub).await.unwrap();
let client_x = crypto::derive_keys(&[0xC1; 32]).x25519_pub; // any 32B is fine for membership
state
.store
.upsert_pending_client(&ns_hex, &client.ed_pub, &client_x, "", "ios")
.await
.unwrap();
state.store.apply_authorize(&ns_hex, &[client.ed_pub]).await.unwrap();
ns_raw
}
/// Connect to `/v1/pipe`, complete the challenge→auth handshake for `me`
/// targeting `peer_ed`, and return the live socket. `dest_override` lets a test
/// declare the wrong counterparty (cross-dest rejection).
async fn dial_and_auth(
addr: SocketAddr,
me: &Id,
peer_ed: &[u8; 32],
ns_raw: &[u8; 32],
connection_id: &[u8; 32],
corrupt_sig: bool,
dest_override: Option<[u8; 32]>,
) -> Ws {
let url = format!("ws://{addr}/v1/pipe");
let (mut ws, _) = tokio_tungstenite::connect_async(&url).await.expect("connect");
// Relay speaks first: PipeChallenge.
let nonce = loop {
match ws.next().await.expect("frame").expect("ws ok") {
Message::Binary(data) => {
let c: PipeChallenge = pipe::decode(&data).expect("challenge");
break pipe::to_array::<32>(&c.nonce).expect("32B nonce");
}
Message::Ping(_) | Message::Pong(_) => continue,
other => panic!("expected challenge, got {other:?}"),
}
};
let mut sig = crypto::sign_pipe_auth(&me.sk, &nonce, connection_id);
if corrupt_sig {
sig[0] ^= 0x01;
}
let dest = dest_override.unwrap_or_else(|| crypto::sha256(peer_ed));
let auth = PipeAuth {
connection_id: connection_id.to_vec(),
pubkey: me.ed_pub.to_vec(),
dest: dest.to_vec(),
namespace_id: ns_raw.to_vec(),
signature: sig.to_vec(),
};
ws.send(Message::Binary(pipe::encode(&auth).into())).await.expect("send auth");
ws
}
/// Read the next binary frame, or `None` if the socket closed/ended.
async fn next_binary(ws: &mut Ws) -> Option<Vec<u8>> {
loop {
match ws.next().await {
Some(Ok(Message::Binary(d))) => return Some(d.to_vec()),
Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue,
Some(Ok(Message::Close(_))) | None => return None,
Some(Ok(_)) => continue,
Some(Err(_)) => return None,
}
}
}
#[tokio::test]
async fn pipe_matches_and_splices_bytes_both_ways() {
let (addr, state) = spawn_relay().await;
let agent = id_from_seed(1);
let client = id_from_seed(2);
let ns_raw = seed_namespace(&state, &agent, &client).await;
let cid = [0x7Au8; 32];
// Agent dials first (becomes pending), client second (matches).
let mut a = dial_and_auth(addr, &agent, &client.ed_pub, &ns_raw, &cid, false, None).await;
// Small delay so A is registered pending before B arrives.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut b = dial_and_auth(addr, &client, &agent.ed_pub, &ns_raw, &cid, false, None).await;
// A → B
a.send(Message::Binary(b"hello-from-a".to_vec().into())).await.unwrap();
assert_eq!(next_binary(&mut b).await.as_deref(), Some(&b"hello-from-a"[..]));
// B → A
b.send(Message::Binary(b"hello-from-b".to_vec().into())).await.unwrap();
assert_eq!(next_binary(&mut a).await.as_deref(), Some(&b"hello-from-b"[..]));
// Closing one tears down the other (no orphans).
a.close(None).await.unwrap();
assert_eq!(next_binary(&mut b).await, None);
}
#[tokio::test]
async fn pipe_rejects_bad_signature() {
let (addr, state) = spawn_relay().await;
let agent = id_from_seed(3);
let client = id_from_seed(4);
let ns_raw = seed_namespace(&state, &agent, &client).await;
let cid = [0x01u8; 32];
// Corrupt signature → relay closes without registering a pending pipe.
let mut a = dial_and_auth(addr, &agent, &client.ed_pub, &ns_raw, &cid, true, None).await;
assert_eq!(next_binary(&mut a).await, None, "relay must close on bad signature");
}
#[tokio::test]
async fn pipe_rejects_cross_dest_mismatch() {
let (addr, state) = spawn_relay().await;
let agent = id_from_seed(5);
let client = id_from_seed(6);
let ns_raw = seed_namespace(&state, &agent, &client).await;
let cid = [0x02u8; 32];
// A targets the client correctly; B (the client) declares the wrong dest
// (points at a stranger, not the agent) → cross-ref fails, both torn down.
let stranger = crypto::sha256(&[0xEE; 32]);
let mut a = dial_and_auth(addr, &agent, &client.ed_pub, &ns_raw, &cid, false, None).await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let mut b =
dial_and_auth(addr, &client, &agent.ed_pub, &ns_raw, &cid, false, Some(stranger)).await;
assert_eq!(next_binary(&mut b).await, None, "mismatched second side is closed");
assert_eq!(next_binary(&mut a).await, None, "first side is torn down too");
}
#[tokio::test]
async fn pipe_rejects_non_member() {
let (addr, state) = spawn_relay().await;
let agent = id_from_seed(7);
let client = id_from_seed(8);
let _ns_raw = seed_namespace(&state, &agent, &client).await;
let (ns_raw, _) = crypto::namespace_id(&agent.ed_pub);
let outsider = id_from_seed(9); // never authorized in this namespace
let cid = [0x03u8; 32];
let mut a = dial_and_auth(addr, &outsider, &agent.ed_pub, &ns_raw, &cid, false, None).await;
assert_eq!(next_binary(&mut a).await, None, "non-member must be rejected");
}