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

356 lines
14 KiB
Rust

//! End-to-end integration test for `skald-relay-client` against the **real**
//! relay server (`skald-relay-server`) booted in-process on an ephemeral port.
//!
//! It drives the full agent-role flow through the public `RelayClient` API while
//! a hand-rolled "mobile client" (raw `tokio-tungstenite` speaking v2 protobuf +
//! the shared E2E crypto) plays the counterpart:
//!
//! start → Connected → start_pairing → pair → ClientPaired → authorize →
//! send (mobile decrypts) → mobile reply → Message → replay dropped →
//! revoke → ClientRevoked.
//!
//! This exercises the persist-before-seal counter path, the nonce direction /
//! AAD construction, the pairing window, and the events channel end to end.
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use ed25519_dalek::SigningKey;
use futures_util::{SinkExt, StreamExt};
use prost::Message as _;
use skald_relay_common::crypto::{self, DIR_CLIENT_TO_AGENT};
use skald_relay_common::proto::v2::{
self, Auth, AuthClient, AuthPairing, Message as ProtoMessage, RelayFrame,
};
use skald_relay_common::proto::v2::auth::Role as AuthRole;
use skald_relay_common::proto::v2::relay_frame::Frame;
use sqlx::SqlitePool;
use tokio::sync::broadcast;
use tokio_tungstenite::tungstenite::Message as WsMessage;
use skald_relay_client::{RelayClient, RelayClientConfig, RelayEvent, SeedSource};
use skald_relay_server::config::Config;
use skald_relay_server::{AppState, router};
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_suffix() -> String {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{nanos}-{}-{seq}", std::process::id())
}
/// Boot a relay on a random port with a throwaway SQLite file. Returns its addr.
async fn spawn_relay() -> SocketAddr {
let db = std::env::temp_dir().join(format!("relay-srv-{}.db", unique_suffix()));
let cfg = Config {
bind: "127.0.0.1:0".parse().unwrap(),
db_path: db.to_string_lossy().into(),
pipe: skald_relay_server::config::PipeConfig::default(),
};
let state = AppState::build(cfg).await.expect("build relay state");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(
listener,
router(state).into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
});
addr
}
/// A fresh on-disk SQLite pool for the client (a temp-file DB, not `:memory:`,
/// so the WS-loop task and the test task share the same database across the
/// pool's connections).
async fn client_pool() -> Arc<SqlitePool> {
let path = std::env::temp_dir().join(format!("relay-cli-{}.db", unique_suffix()));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite://{}?mode=rwc", path.display());
Arc::new(SqlitePool::connect(&url).await.expect("client pool"))
}
// ── raw WS helpers (mobile side) ────────────────────────────────────────────
async fn connect(addr: SocketAddr) -> Ws {
let url = format!("ws://{addr}/v1/ws");
let (ws, _) = tokio_tungstenite::connect_async(url).await.expect("connect");
ws
}
async fn send(ws: &mut Ws, frame: &RelayFrame) {
ws.send(WsMessage::Binary(frame.encode_to_vec().into()))
.await
.expect("send binary");
}
async fn recv(ws: &mut Ws) -> RelayFrame {
loop {
let m = ws.next().await.expect("stream open").expect("ws frame");
match m {
WsMessage::Binary(b) => return RelayFrame::decode(b.as_ref()).expect("decode"),
WsMessage::Ping(_) | WsMessage::Pong(_) => continue,
WsMessage::Close(f) => panic!("unexpected ws close: {f:?}"),
other => panic!("unexpected ws frame: {other:?}"),
}
}
}
async fn read_challenge(ws: &mut Ws) -> [u8; 32] {
match recv(ws).await.frame {
Some(Frame::Challenge(c)) => c.nonce.as_ref().try_into().expect("32B challenge"),
other => panic!("expected Challenge, got {other:?}"),
}
}
fn auth_pairing_frame(
sk: &SigningKey,
challenge: &[u8; 32],
ns_raw: &[u8; 32],
token: &[u8; 32],
x25519_pub: &[u8; 32],
) -> RelayFrame {
let sig = crypto::sign_challenge(sk, challenge);
RelayFrame {
frame: Some(Frame::Auth(Auth {
signature: Bytes::copy_from_slice(&sig),
role: Some(AuthRole::Pairing(AuthPairing {
namespace_id: Bytes::copy_from_slice(ns_raw),
client_ed25519_pub: Bytes::copy_from_slice(&sk.verifying_key().to_bytes()),
client_x25519_pub: Bytes::copy_from_slice(x25519_pub),
pairing_token: Bytes::copy_from_slice(token),
device_token: "devtok".into(),
platform: v2::Platform::Ios as i32,
})),
})),
}
}
fn auth_client_frame(sk: &SigningKey, challenge: &[u8; 32], ns_raw: &[u8; 32]) -> RelayFrame {
let sig = crypto::sign_challenge(sk, challenge);
RelayFrame {
frame: Some(Frame::Auth(Auth {
signature: Bytes::copy_from_slice(&sig),
role: Some(AuthRole::Client(AuthClient {
namespace_id: Bytes::copy_from_slice(ns_raw),
client_ed25519_pub: Bytes::copy_from_slice(&sk.verifying_key().to_bytes()),
device_token: "devtok".into(),
platform: v2::Platform::Ios as i32,
})),
})),
}
}
/// Pair on a short-lived side connection (challenge → auth(pairing) → AuthOk).
async fn pair(addr: SocketAddr, sk: &SigningKey, ns_raw: &[u8; 32], token: &[u8; 32], x_pub: &[u8; 32]) {
let mut ws = connect(addr).await;
let c = read_challenge(&mut ws).await;
send(&mut ws, &auth_pairing_frame(sk, &c, ns_raw, token, x_pub)).await;
match recv(&mut ws).await.frame {
Some(Frame::AuthOk(_)) => {}
other => panic!("pairing expected AuthOk, got {other:?}"),
}
drop(ws);
}
/// Connect as the authorized client role; returns the live socket.
async fn auth_client(addr: SocketAddr, sk: &SigningKey, ns_raw: &[u8; 32]) -> Ws {
let mut ws = connect(addr).await;
let c = read_challenge(&mut ws).await;
send(&mut ws, &auth_client_frame(sk, &c, ns_raw)).await;
match recv(&mut ws).await.frame {
Some(Frame::AuthOk(_)) => {}
other => panic!("client expected AuthOk, got {other:?}"),
}
ws
}
// ── event helpers ───────────────────────────────────────────────────────────
async fn next_event(rx: &mut broadcast::Receiver<RelayEvent>) -> RelayEvent {
tokio::time::timeout(Duration::from_secs(3), rx.recv())
.await
.expect("timed out waiting for event")
.expect("event recv")
}
/// Next event that is not a `Connected`/`Disconnected` heartbeat.
async fn next_significant(rx: &mut broadcast::Receiver<RelayEvent>) -> RelayEvent {
loop {
match next_event(rx).await {
RelayEvent::Connected | RelayEvent::Disconnected => continue,
other => return other,
}
}
}
#[tokio::test]
async fn full_round_trip() {
let addr = spawn_relay().await;
// Build the client (agent role) pointed at the in-process relay.
let pool = client_pool().await;
let config = RelayClientConfig {
relay_url: format!("ws://{addr}/v1/ws"),
pairing_ttl: 300,
seed: SeedSource::Bytes([1u8; 32]),
};
let client = RelayClient::new(pool, config).await.expect("new client");
let agent_ed = client.agent_ed25519_pub();
let agent_x = client.agent_x25519_pub();
let ns_raw: [u8; 32] = hex::decode(client.namespace_id_hex()).unwrap().try_into().unwrap();
// Mobile identity.
let mobile = crypto::derive_keys(&[7u8; 32]);
let mobile_sk = mobile.signing_key();
let mobile_ed = mobile.ed25519_pub;
// Shared AES key both sides derive independently.
let aes = crypto::derive_aes_key(&crypto::ecdh(&mobile.x25519_priv, &agent_x));
let mut rx = client.events();
client.start().await.expect("start");
// Connected handshake completes.
match next_event(&mut rx).await {
RelayEvent::Connected => {}
other => panic!("expected Connected, got {other:?}"),
}
// 1) Open a pairing window and pair the mobile. `start_pairing` only queues
// the frame; let the relay register the token before the mobile pairs.
let started = client.start_pairing(0).await.expect("start_pairing");
tokio::time::sleep(Duration::from_millis(150)).await;
pair(addr, &mobile_sk, &ns_raw, &started.token, &mobile.x25519_pub).await;
match next_significant(&mut rx).await {
RelayEvent::ClientPaired { ed25519_pub, platform, .. } => {
assert_eq!(ed25519_pub, mobile_ed);
assert_eq!(platform, "ios");
}
other => panic!("expected ClientPaired, got {other:?}"),
}
// The device is Pending until we authorize it.
let clients = client.list_clients().await;
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].state, skald_relay_client::ClientState::Pending);
// 2) Authorize, then the mobile connects as the authorized client role.
client.authorize(&mobile_ed).await.expect("authorize");
// Give the relay a moment to process the Authorize set before connecting.
tokio::time::sleep(Duration::from_millis(150)).await;
let mut mobile_ws = auth_client(addr, &mobile_sk, &ns_raw).await;
// 3) Agent → mobile: send an opaque payload; the mobile decrypts it.
let agent_payload = b"hello-from-agent";
client.send(&mobile_ed, agent_payload, false).await.expect("send");
let frame = recv(&mut mobile_ws).await;
let m = match frame.frame {
Some(Frame::Message(m)) => m,
other => panic!("mobile expected Message, got {other:?}"),
};
assert_eq!(m.peer.as_ref(), &agent_ed[..], "relay rewrites peer=from");
let nonce: [u8; 12] = m.nonce.as_ref().try_into().unwrap();
let aad = crypto::build_aad(&ns_raw, &agent_ed, &mobile_ed);
let framed = crypto::open(&aes, &nonce, &aad, &m.ciphertext).expect("mobile open");
let got = crypto::decompress_payload(&framed).expect("decompress");
assert_eq!(got, agent_payload);
// 4) Mobile → agent: seal a reply (counter 1, client→agent direction).
let reply = b"hi-from-mobile";
let reply_nonce = crypto::build_nonce(DIR_CLIENT_TO_AGENT, 1);
let reply_aad = crypto::build_aad(&ns_raw, &mobile_ed, &agent_ed);
let reply_framed = crypto::compress_payload(reply);
let reply_ct = crypto::seal(&aes, &reply_nonce, &reply_aad, &reply_framed).expect("mobile seal");
let reply_frame = RelayFrame {
frame: Some(Frame::Message(ProtoMessage {
ciphertext: Bytes::copy_from_slice(&reply_ct),
nonce: Bytes::copy_from_slice(&reply_nonce),
peer: Bytes::copy_from_slice(&agent_ed),
live: false,
})),
};
send(&mut mobile_ws, &reply_frame).await;
match next_significant(&mut rx).await {
RelayEvent::Message { from, payload, .. } => {
assert_eq!(from, mobile_ed);
assert_eq!(payload, reply);
}
other => panic!("expected Message, got {other:?}"),
}
// 5) Replay the exact same frame (counter 1 again) → dropped, no event.
send(&mut mobile_ws, &reply_frame).await;
let replayed = tokio::time::timeout(Duration::from_millis(400), rx.recv()).await;
assert!(
!matches!(replayed, Ok(Ok(RelayEvent::Message { .. }))),
"a replayed counter must not surface a Message event"
);
// 6) Revoke the device → ClientRevoked event + empty registry.
client.revoke(&mobile_ed).await.expect("revoke");
match next_significant(&mut rx).await {
RelayEvent::ClientRevoked { ed25519_pub } => assert_eq!(ed25519_pub, mobile_ed),
other => panic!("expected ClientRevoked, got {other:?}"),
}
assert!(client.list_clients().await.is_empty(), "registry empty after revoke");
client.shutdown().await;
}
/// `clear_all` removes every device and emits one `ClientRevoked` per device.
#[tokio::test]
async fn clear_all_wipes_devices() {
let addr = spawn_relay().await;
let pool = client_pool().await;
let client = RelayClient::new(
pool,
RelayClientConfig {
relay_url: format!("ws://{addr}/v1/ws"),
pairing_ttl: 300,
seed: SeedSource::Bytes([2u8; 32]),
},
)
.await
.expect("new client");
let ns_raw: [u8; 32] = hex::decode(client.namespace_id_hex()).unwrap().try_into().unwrap();
let mut rx = client.events();
client.start().await.expect("start");
match next_event(&mut rx).await {
RelayEvent::Connected => {}
other => panic!("expected Connected, got {other:?}"),
}
// Pair + authorize one device.
let mobile = crypto::derive_keys(&[9u8; 32]);
let started = client.start_pairing(0).await.expect("pairing");
tokio::time::sleep(Duration::from_millis(150)).await;
pair(addr, &mobile.signing_key(), &ns_raw, &started.token, &mobile.x25519_pub).await;
match next_significant(&mut rx).await {
RelayEvent::ClientPaired { .. } => {}
other => panic!("expected ClientPaired, got {other:?}"),
}
client.authorize(&mobile.ed25519_pub).await.expect("authorize");
assert_eq!(client.list_clients().await.len(), 1);
client.clear_all().await.expect("clear_all");
match next_significant(&mut rx).await {
RelayEvent::ClientRevoked { ed25519_pub } => assert_eq!(ed25519_pub, mobile.ed25519_pub),
other => panic!("expected ClientRevoked, got {other:?}"),
}
assert!(client.list_clients().await.is_empty());
client.shutdown().await;
}