First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
//! 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");
}
+697
View File
@@ -0,0 +1,697 @@
//! End-to-end protocol tests for the v2 relay transport
//! (data/iOS-app/v2/relay-protocol.md). Speaks protobuf binary frames over
//! WebSocket against a real axum server bound to an ephemeral port.
//!
//! Every post-upgrade WS frame is a binary frame (opcode `0x2`) that carries
//! exactly one `RelayFrame` protobuf message. The relay speaks first
//! (`Challenge`), then the client authenticates with an Ed25519 signature over
//! `AUTH_DOMAIN ‖ 0x00 ‖ challenge_nonce_raw(32B)`; see
//! `skald_relay_common::crypto::challenge_message`.
use std::net::SocketAddr;
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use ed25519_dalek::{Signer, SigningKey};
use futures_util::{SinkExt, StreamExt};
use prost::Message as _;
use sha2::{Digest, Sha256};
use skald_relay_common::proto::v2::{
self, Auth, AuthAgent, AuthClient, AuthError, AuthOk, AuthPairing, Authorize, AuthorizeOk,
ClientPaired, Message as ProtoMessage, PairingReady, PairingStart, PeerOffline, PresenceEvent,
PresenceList, PresenceRequest, RelayFrame,
};
use skald_relay_common::proto::v2::auth::Role as AuthRole;
use skald_relay_common::proto::v2::relay_frame::Frame;
use tokio_tungstenite::tungstenite::Message;
use skald_relay_server::config::Config;
use skald_relay_server::{AppState, router};
type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
/// Boot a relay on a random port with a throwaway SQLite file. Returns its addr.
///
/// Each test gets its own DB file. We use `std::process::id()` + a per-call
/// counter (incremented atomically across the whole process) so two tests
/// calling `spawn_relay()` in parallel — even on the same nanosecond — never
/// collide on the file path. A `spawn-relay-tests` counter is also fine, but
/// `AtomicU64` is independent of any test framework / test name.
async fn spawn_relay() -> SocketAddr {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let db = std::env::temp_dir().join(format!(
"relay-it-{nanos}-{}-{seq}.db",
std::process::id()
));
let cfg = Config {
bind: "127.0.0.1:0".parse().expect("bind addr"),
db_path: db.to_string_lossy().into(),
pipe: skald_relay_server::config::PipeConfig::default(),
};
let state = AppState::build(cfg).await.expect("build state");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
axum::serve(
listener,
router(state).into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.expect("serve");
});
addr
}
async fn connect(addr: SocketAddr) -> Ws {
let url = format!("ws://{addr}/v1/ws");
let (ws, _) = tokio_tungstenite::connect_async(url)
.await
.expect("connect");
ws
}
/// Send a protobuf `RelayFrame` as a WebSocket **binary** frame (v2 transport,
/// relay-protocol.md §1).
async fn send(ws: &mut Ws, frame: &RelayFrame) {
let bytes = frame.encode_to_vec();
ws.send(Message::Binary(bytes.into()))
.await
.expect("send binary");
}
/// Read the next `RelayFrame`. WS-level Ping/Pong are silently consumed
/// (axum/tokio-tungstenite handle the actual pong). A `Text` frame or a WS
/// `Close` is a protocol violation under v2 — we panic with a clear message.
async fn recv(ws: &mut Ws) -> RelayFrame {
loop {
let m = ws.next().await.expect("stream open").expect("ws frame");
match m {
Message::Binary(b) => {
return RelayFrame::decode(b.as_ref()).expect("decode protobuf");
}
Message::Ping(_) | Message::Pong(_) => continue,
Message::Close(f) => panic!("unexpected ws close: {f:?}"),
Message::Text(t) => panic!("unexpected text frame in v2 transport: {t}"),
other => panic!("unexpected ws frame: {other:?}"),
}
}
}
// ---------------------------------------------------------------------------
// Frame builders + crypto helpers
// ---------------------------------------------------------------------------
/// Sign the v2 challenge message: `AUTH_DOMAIN ‖ 0x00 ‖ nonce(32B)`.
/// Mirrors `skald_relay_common::crypto::challenge_message` exactly.
fn sign_challenge(sk: &SigningKey, nonce: &[u8; 32]) -> [u8; 64] {
let mut msg = Vec::with_capacity(b"skald-relay-auth-v1".len() + 1 + 32);
msg.extend_from_slice(b"skald-relay-auth-v1");
msg.push(0);
msg.extend_from_slice(nonce);
sk.sign(&msg).to_bytes()
}
/// `namespace_id` = `hex(SHA256(NS_DOMAIN ‖ 0x00 ‖ agent_ed25519_pub))`
/// (crypto.md §7). Returns the raw 32-byte value and the lowercase hex string.
fn namespace_id(pubkey: &[u8; 32]) -> ([u8; 32], String) {
let mut h = Sha256::new();
h.update(b"skald-namespace-v1");
h.update([0u8]);
h.update(pubkey);
let raw = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&raw);
(out, hex::encode(raw))
}
/// Read the relay's first frame — must be `RelayFrame::Challenge{nonce}`.
async fn read_challenge(ws: &mut Ws) -> [u8; 32] {
let frame = recv(ws).await;
match frame.frame {
Some(Frame::Challenge(c)) => c.nonce.as_ref().try_into().expect("32B challenge"),
other => panic!("expected Challenge, got {other:?}"),
}
}
/// `Auth{role=Agent(...), signature}` — agent handshake.
fn auth_agent_frame(sk: &SigningKey, challenge: &[u8; 32]) -> RelayFrame {
let sig = sign_challenge(sk, challenge);
let pubkey = sk.verifying_key().to_bytes();
RelayFrame {
frame: Some(Frame::Auth(Auth {
signature: Bytes::copy_from_slice(&sig),
role: Some(AuthRole::Agent(AuthAgent {
agent_ed25519_pub: Bytes::copy_from_slice(&pubkey),
})),
})),
}
}
/// `Auth{role=Client(...), signature}` — client handshake.
fn auth_client_frame(sk: &SigningKey, challenge: &[u8; 32], ns_hex: &str) -> RelayFrame {
let sig = sign_challenge(sk, challenge);
let pubkey = sk.verifying_key().to_bytes();
let ns_raw: [u8; 32] = hex::decode(ns_hex).expect("ns hex").try_into().expect("32B ns");
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(&pubkey),
device_token: "devtok".into(),
platform: v2::Platform::Ios as i32,
})),
})),
}
}
/// `Auth{role=Pairing(...), signature}` — short-lived pairing connection.
#[allow(clippy::too_many_arguments)]
fn auth_pairing_frame(
sk: &SigningKey,
challenge: &[u8; 32],
ns_hex: &str,
token: &[u8; 32],
x25519_pub: &[u8; 32],
) -> RelayFrame {
let sig = sign_challenge(sk, challenge);
let pubkey = sk.verifying_key().to_bytes();
let ns_raw: [u8; 32] = hex::decode(ns_hex).expect("ns hex").try_into().expect("32B ns");
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(&pubkey),
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,
})),
})),
}
}
/// Authenticate as `agent`; returns the live connection and the namespace hex.
async fn auth_agent(addr: SocketAddr, sk: &SigningKey) -> (Ws, String) {
let pubkey = sk.verifying_key().to_bytes();
let mut ws = connect(addr).await;
let challenge = read_challenge(&mut ws).await;
send(&mut ws, &auth_agent_frame(sk, &challenge)).await;
let frame = recv(&mut ws).await;
let AuthOk { namespace_id: ns_bytes } = match frame.frame {
Some(Frame::AuthOk(ok)) => ok,
other => panic!("expected AuthOk, got {other:?}"),
};
let ns_hex = hex::encode(&ns_bytes);
let (want_raw, want_hex) = namespace_id(&pubkey);
assert_eq!(
ns_hex, want_hex,
"AuthOk.namespace_id must match SHA256(NS_DOMAIN‖0x00‖pubkey)"
);
// The wire carries the raw 32B value; compare bytes too.
assert_eq!(ns_bytes.as_ref(), want_raw.as_ref());
(ws, ns_hex)
}
/// Authenticate as `client`; returns the live connection. Caller is
/// responsible for draining the agent-side `PresenceEvent{ONLINE}` that the
/// relay broadcasts on auth_ok.
async fn auth_client(addr: SocketAddr, sk: &SigningKey, ns_hex: &str) -> Ws {
let mut ws = connect(addr).await;
let challenge = read_challenge(&mut ws).await;
send(&mut ws, &auth_client_frame(sk, &challenge, ns_hex)).await;
let frame = recv(&mut ws).await;
match frame.frame {
Some(Frame::AuthOk(_)) => {}
other => panic!("expected AuthOk, got {other:?}"),
}
ws
}
/// `PairingStart{pairing_token, ttl}` — open a pairing window on the agent.
async fn send_pairing_start(ws: &mut Ws, token: &[u8; 32], ttl: u32) {
let frame = RelayFrame {
frame: Some(Frame::PairingStart(PairingStart {
pairing_token: Bytes::copy_from_slice(token),
ttl,
})),
};
send(ws, &frame).await;
}
/// `Authorize{clients[]}` — replace-semantics on the authorized set.
async fn send_authorize(ws: &mut Ws, clients: &[[u8; 32]]) {
let frame = RelayFrame {
frame: Some(Frame::Authorize(Authorize {
clients: clients
.iter()
.map(|c| Bytes::copy_from_slice(c))
.collect(),
})),
};
send(ws, &frame).await;
}
/// End-to-end pairing flow on a side connection: `challenge → auth(pairing)
/// → AuthOk → close`. Returns the freshly-paired `client_pub` and the
/// `x25519_pub` we lied about — the relay never inspects X25519 material.
async fn pair_client(
addr: SocketAddr,
client_sk: &SigningKey,
ns_hex: &str,
token: &[u8; 32],
x25519_pub: [u8; 32],
) -> [u8; 32] {
let mut pairing = connect(addr).await;
let c = read_challenge(&mut pairing).await;
send(
&mut pairing,
&auth_pairing_frame(client_sk, &c, ns_hex, token, &x25519_pub),
)
.await;
let ok = recv(&mut pairing).await;
match ok.frame {
Some(Frame::AuthOk(_)) => {}
other => panic!("pairing expected AuthOk, got {other:?}"),
};
// The relay sends a Close after AuthOk on a pairing connection — draining
// the next frame is optional; let it drop here.
drop(pairing);
client_sk.verifying_key().to_bytes()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
/// Agent-side happy path: the relay speaks first, returns `AuthOk` with the
/// correct 32-byte `namespace_id`, and accepts no further peer (just registers
/// the agent in the registry). Ported from the v1 test, but speaks binary
/// protobuf.
#[tokio::test]
async fn agent_handshake_creates_namespace() {
let addr = spawn_relay().await;
let sk = SigningKey::from_bytes(&[1u8; 32]);
let (_agent, ns) = auth_agent(addr, &sk).await;
let (want_raw, want_hex) = namespace_id(&sk.verifying_key().to_bytes());
assert_eq!(hex::encode(want_raw), ns);
assert_eq!(ns, want_hex);
}
/// A signature that doesn't cover the real challenge must be rejected with
/// `AuthError{code = "invalid_signature"}`. The relay closes the socket right
/// after; we drain the Close so the test doesn't panic on it.
#[tokio::test]
async fn bad_signature_is_rejected() {
let addr = spawn_relay().await;
let sk = SigningKey::from_bytes(&[1u8; 32]);
let pubkey = sk.verifying_key().to_bytes();
let mut ws = connect(addr).await;
let _challenge = read_challenge(&mut ws).await;
// Sign a different message — the signature won't verify against the
// real challenge nonce.
let bogus = sk.sign(b"not the challenge").to_bytes();
send(
&mut ws,
&RelayFrame {
frame: Some(Frame::Auth(Auth {
signature: Bytes::copy_from_slice(&bogus),
role: Some(AuthRole::Agent(AuthAgent {
agent_ed25519_pub: Bytes::copy_from_slice(&pubkey),
})),
})),
},
)
.await;
let err = recv(&mut ws).await;
let AuthError { code, message: _ } = match err.frame {
Some(Frame::AuthError(e)) => e,
other => panic!("expected AuthError, got {other:?}"),
};
assert_eq!(code, "invalid_signature");
// The relay follows AuthError with a Close — drain it so we exit cleanly.
let close = ws.next().await.expect("stream").expect("ws frame");
assert!(matches!(close, Message::Close(_)));
}
/// A client that never paired/was authorized cannot connect as `client`. The
/// relay must answer `AuthError{code = "unauthorized"}` and close.
#[tokio::test]
async fn unauthorized_client_is_rejected() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let (_agent, ns) = auth_agent(addr, &agent_sk).await;
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let mut ws = connect(addr).await;
let challenge = read_challenge(&mut ws).await;
send(&mut ws, &auth_client_frame(&client_sk, &challenge, &ns)).await;
let err = recv(&mut ws).await;
let AuthError { code, .. } = match err.frame {
Some(Frame::AuthError(e)) => e,
other => panic!("expected AuthError, got {other:?}"),
};
assert_eq!(code, "unauthorized");
let close = ws.next().await.expect("stream").expect("ws frame");
assert!(matches!(close, Message::Close(_)));
}
/// End-to-end pairing → `Authorize` → E2E `Message` flow. The relay must:
/// 1. Accept a `PairingStart` from the agent and respond with `PairingReady`.
/// 2. Accept a short-lived `auth(pairing)` connection, close it, and forward
/// `ClientPaired` to the agent.
/// 3. Accept an `Authorize` and reply with `AuthorizeOk{authorized: 1}`.
/// 4. Accept the `auth(client)` connection, send `AuthOk`, and broadcast
/// `PresenceEvent{ONLINE}` to the agent.
/// 5. Forward `Message{live:false}` agent→client, rewriting `peer = from` and
/// passing `ciphertext`/`nonce` byte-for-byte; same for client→agent.
#[tokio::test]
async fn pairing_authorize_and_live_message() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let agent_pub = agent_sk.verifying_key().to_bytes();
let (mut agent, ns) = auth_agent(addr, &agent_sk).await;
// 1) Agent opens a pairing window.
let token = [0x11u8; 32];
send_pairing_start(&mut agent, &token, 300).await;
let ready = recv(&mut agent).await;
let PairingReady { ttl } = match ready.frame {
Some(Frame::PairingReady(p)) => p,
other => panic!("expected PairingReady, got {other:?}"),
};
assert_eq!(ttl, 300);
// 2) Client pairs on a side connection.
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let client_x = [0x33u8; 32]; // opaque X25519 pubkey; relay never inspects
let client_pub = pair_client(addr, &client_sk, &ns, &token, client_x).await;
assert_eq!(client_pub, client_sk.verifying_key().to_bytes());
// 3) Agent is told a device paired.
let paired = recv(&mut agent).await;
let ClientPaired {
client_ed25519_pub,
client_x25519_pub,
platform,
} = match paired.frame {
Some(Frame::ClientPaired(p)) => p,
other => panic!("expected ClientPaired, got {other:?}"),
};
assert_eq!(client_ed25519_pub.as_ref(), &client_pub[..]);
assert_eq!(client_x25519_pub.as_ref(), &client_x[..]);
assert_eq!(platform, v2::Platform::Ios as i32);
// 4) Agent authorizes the client.
send_authorize(&mut agent, &[client_pub]).await;
let authorized = recv(&mut agent).await;
let AuthorizeOk { authorized } = match authorized.frame {
Some(Frame::AuthorizeOk(a)) => a,
other => panic!("expected AuthorizeOk, got {other:?}"),
};
assert_eq!(authorized, 1);
// 5) Client connects as the authorized role.
let mut client = auth_client(addr, &client_sk, &ns).await;
// 5a) Drain the agent-side PresenceEvent{ONLINE} for the new client.
let pe = recv(&mut agent).await;
let PresenceEvent { pubkey, status } = match pe.frame {
Some(Frame::PresenceEvent(p)) => p,
other => panic!("expected PresenceEvent, got {other:?}"),
};
assert_eq!(pubkey.as_ref(), &client_pub[..]);
assert_eq!(status, v2::Status::Online as i32);
// 6) Agent → client Message{live:false}. The relay stamps `peer = from`
// (the agent's pubkey) and forwards `ciphertext`/`nonce` byte-for-byte.
let nonce = [0u8; 12];
let ciphertext = b"hello world";
send(
&mut agent,
&RelayFrame {
frame: Some(Frame::Message(ProtoMessage {
ciphertext: Bytes::copy_from_slice(ciphertext),
nonce: Bytes::copy_from_slice(&nonce),
peer: Bytes::copy_from_slice(&client_pub),
live: false,
})),
},
)
.await;
let msg = recv(&mut client).await;
let ProtoMessage {
ciphertext: ct,
nonce: n,
peer: from,
live,
} = match msg.frame {
Some(Frame::Message(m)) => m,
other => panic!("expected Message, got {other:?}"),
};
assert_eq!(ct.as_ref(), ciphertext);
assert_eq!(n.as_ref(), &nonce[..]);
assert_eq!(from.as_ref(), &agent_pub[..]);
assert!(!live, "relay must rewrite live=false on delivery");
// 7) Client → agent reply routes back.
let reply_ct = b"reply";
let reply_nonce: [u8; 12] = [0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 1];
send(
&mut client,
&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_pub),
live: false,
})),
},
)
.await;
let back = recv(&mut agent).await;
let ProtoMessage {
ciphertext: ct,
nonce: n,
peer: from,
..
} = match back.frame {
Some(Frame::Message(m)) => m,
other => panic!("expected Message back, got {other:?}"),
};
assert_eq!(ct.as_ref(), reply_ct);
assert_eq!(n.as_ref(), &reply_nonce[..]);
assert_eq!(from.as_ref(), &client_pub[..]);
}
/// v2 live channel: `Message{live:true}` is route-or-fail. If the destination
/// isn't connected, the relay answers the sender with `PeerOffline{peer}` —
/// no enqueue, no push.
///
/// To exercise this we register an authorized client that never connects as
/// `client` (so its `client_tx` is None), then have the agent live-send to
/// it. The relay must return `PeerOffline`.
#[tokio::test]
async fn live_message_to_offline_peer_returns_peer_offline() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let (mut agent, ns) = auth_agent(addr, &agent_sk).await;
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let client_pub = client_sk.verifying_key().to_bytes();
// Pair + authorize (mirrors the full flow) — the client never connects.
let token = [0x11u8; 32];
send_pairing_start(&mut agent, &token, 300).await;
let _ = recv(&mut agent).await; // PairingReady
let _paired = pair_client(addr, &client_sk, &ns, &token, [0x33; 32]).await;
let cp = recv(&mut agent).await; // ClientPaired
assert!(matches!(cp.frame, Some(Frame::ClientPaired(_))));
send_authorize(&mut agent, &[client_pub]).await;
let _ = recv(&mut agent).await; // AuthorizeOk
// The client is registered as authorized but never connects as `client`,
// so its `client_tx` is None. The relay must return PeerOffline.
let nonce = [0u8; 12];
let ct = vec![0u8; 32];
send(
&mut agent,
&RelayFrame {
frame: Some(Frame::Message(ProtoMessage {
ciphertext: Bytes::copy_from_slice(&ct),
nonce: Bytes::copy_from_slice(&nonce),
peer: Bytes::copy_from_slice(&client_pub),
live: true,
})),
},
)
.await;
let resp = recv(&mut agent).await;
let PeerOffline { peer } = match resp.frame {
Some(Frame::PeerOffline(p)) => p,
other => panic!("expected PeerOffline, got {other:?}"),
};
assert_eq!(peer.as_ref(), &client_pub[..]);
}
/// `PresenceRequest` → `PresenceList{online[]}` snapshot, scoped to the
/// requester's namespace, includes every connected peer (agent + clients).
#[tokio::test]
async fn presence_list_returns_online_peers() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let agent_pub = agent_sk.verifying_key().to_bytes();
let (mut agent, ns) = auth_agent(addr, &agent_sk).await;
// Pair + authorize a client, then connect it.
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let client_pub = client_sk.verifying_key().to_bytes();
let token = [0x11u8; 32];
send_pairing_start(&mut agent, &token, 300).await;
let _ = recv(&mut agent).await; // PairingReady
let _paired = pair_client(addr, &client_sk, &ns, &token, [0x33; 32]).await;
let _ = recv(&mut agent).await; // ClientPaired
send_authorize(&mut agent, &[client_pub]).await;
let _ = recv(&mut agent).await; // AuthorizeOk
let _client = auth_client(addr, &client_sk, &ns).await;
// Drain the ONLINE presence event from the agent.
let pe = recv(&mut agent).await;
let PresenceEvent { pubkey, status } = match pe.frame {
Some(Frame::PresenceEvent(p)) => p,
other => panic!("expected PresenceEvent, got {other:?}"),
};
assert_eq!(pubkey.as_ref(), &client_pub[..]);
assert_eq!(status, v2::Status::Online as i32);
// Now ask for the namespace's presence snapshot.
send(
&mut agent,
&RelayFrame {
frame: Some(Frame::PresenceRequest(PresenceRequest {})),
},
)
.await;
let list = recv(&mut agent).await;
let PresenceList { online } = match list.frame {
Some(Frame::PresenceList(p)) => p,
other => panic!("expected PresenceList, got {other:?}"),
};
let mut got: Vec<[u8; 32]> = online
.iter()
.map(|b| b.as_ref().try_into().expect("32B pubkey"))
.collect();
got.sort();
let mut want = vec![agent_pub, client_pub];
want.sort();
assert_eq!(got, want, "PresenceList must contain agent + client pubkeys");
}
/// `PresenceEvent{ONLINE}` is broadcast at the peer's `auth_ok`. When the
/// client disconnects, `PresenceEvent{OFFLINE}` is broadcast to the other
/// members of the namespace.
#[tokio::test]
async fn presence_event_on_auth_ok_and_disconnect() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let (mut _agent, ns) = auth_agent(addr, &agent_sk).await;
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let client_pub = client_sk.verifying_key().to_bytes();
let token = [0x11u8; 32];
send_pairing_start(&mut _agent, &token, 300).await;
let _ = recv(&mut _agent).await; // PairingReady
let _paired = pair_client(addr, &client_sk, &ns, &token, [0x33; 32]).await;
let _ = recv(&mut _agent).await; // ClientPaired
send_authorize(&mut _agent, &[client_pub]).await;
let _ = recv(&mut _agent).await; // AuthorizeOk
// Connect the client. The agent must see PresenceEvent{ONLINE} for it.
let mut client = auth_client(addr, &client_sk, &ns).await;
let pe_on = recv(&mut _agent).await;
let PresenceEvent { pubkey, status } = match pe_on.frame {
Some(Frame::PresenceEvent(p)) => p,
other => panic!("expected PresenceEvent (online), got {other:?}"),
};
assert_eq!(pubkey.as_ref(), &client_pub[..]);
assert_eq!(status, v2::Status::Online as i32);
// Drop the client. The relay must broadcast PresenceEvent{OFFLINE} to the
// agent. Dropping a tungstenite stream sends a WS Close; the agent's
// reader task observes the end-of-stream and runs the disconnect
// cleanup.
drop(client);
// Give the agent's reader task time to detect the close and broadcast
// OFFLINE. 100ms is plenty on a fast loopback; bump if flaky on CI.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let pe_off = recv(&mut _agent).await;
let PresenceEvent { pubkey, status } = match pe_off.frame {
Some(Frame::PresenceEvent(p)) => p,
other => panic!("expected PresenceEvent (offline), got {other:?}"),
};
assert_eq!(pubkey.as_ref(), &client_pub[..]);
assert_eq!(status, v2::Status::Offline as i32);
}
/// `Message{live:false}` to an offline peer: the relay enqueues the message
/// and never returns `PeerOffline`. (The `live=true` counterpart is covered
/// by `live_message_to_offline_peer_returns_peer_offline`.) We assert the
/// negative invariant: no `PeerOffline` arrives at the sender.
#[tokio::test]
async fn store_and_forward_when_peer_offline() {
let addr = spawn_relay().await;
let agent_sk = SigningKey::from_bytes(&[1u8; 32]);
let (mut agent, _ns) = auth_agent(addr, &agent_sk).await;
let client_sk = SigningKey::from_bytes(&[2u8; 32]);
let client_pub = client_sk.verifying_key().to_bytes();
let token = [0x11u8; 32];
send_pairing_start(&mut agent, &token, 300).await;
let _ = recv(&mut agent).await; // PairingReady
let _paired = pair_client(addr, &client_sk, &_ns, &token, [0x33; 32]).await;
let _ = recv(&mut agent).await; // ClientPaired
send_authorize(&mut agent, &[client_pub]).await;
let _ = recv(&mut agent).await; // AuthorizeOk
// Send `live=false` to the offline client. We give the relay a moment
// to enqueue and (best-effort) push, then assert that the next frame
// the agent reads is NOT a PeerOffline.
let nonce = [0u8; 12];
let ct = vec![0u8; 32];
send(
&mut agent,
&RelayFrame {
frame: Some(Frame::Message(ProtoMessage {
ciphertext: Bytes::copy_from_slice(&ct),
nonce: Bytes::copy_from_slice(&nonce),
peer: Bytes::copy_from_slice(&client_pub),
live: false,
})),
},
)
.await;
// No frame should be coming back. Race against a short timeout.
let r = tokio::time::timeout(std::time::Duration::from_millis(150), recv(&mut agent)).await;
match r {
Err(_) => { /* expected: no response on live=false */ }
Ok(RelayFrame {
frame: Some(Frame::PeerOffline(_)),
}) => panic!("live=false must NOT trigger PeerOffline"),
Ok(other) => panic!("unexpected frame on live=false: {other:?}"),
}
}