First Version
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user