First Version
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
//! 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;
|
||||
}
|
||||
Reference in New Issue
Block a user