First Version
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
[package]
|
||||
name = "skald-relay-client"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Standalone relay client (agent role): WS v2 transport, E2E crypto, pairing, device authorization, SQLite persistence. Payload-agnostic — emits decoded bytes via RelayEvent. See docs/relay/"
|
||||
|
||||
[dependencies]
|
||||
# --- shared frame types + crypto (frames + derive/namespace/ecdh/seal/open) ---
|
||||
skald-relay-common = { path = "../skald-relay-common" }
|
||||
|
||||
# --- async runtime ---
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
|
||||
# --- WS transport (v2 binary) ---
|
||||
# `tokio-tungstenite` pinned to 0.29 to match `skald-relay-server`'s
|
||||
# `[dev-dependencies]`: the integration test links both crates in the same
|
||||
# binary and the `WsMessage`/`connect_async` types must be the same compile
|
||||
# units on both sides.
|
||||
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# --- persistence (counters MUST survive restarts; nonce never reused) ---
|
||||
# `sqlx` 0.9.0 pinned to match the server (the integration test shares the
|
||||
# `SqlitePool` / `row` types).
|
||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||
|
||||
# --- v2 wire format (prost 0.13 + bytes 1 match skald-relay-common/server) ---
|
||||
prost = "0.13"
|
||||
bytes = "1"
|
||||
|
||||
# --- identity / crypto helpers used directly here ---
|
||||
ed25519-dalek = "2"
|
||||
# `rand` 0.9 matches the plugin's usage of `rand::rng()` (RngCore) in
|
||||
# `identity.rs`/`pairing.rs`. `skald-relay-common` keeps its own `rand = "0.8"`
|
||||
# in its build — the two crates coexist fine in the workspace.
|
||||
rand = "0.9"
|
||||
|
||||
# --- misc ---
|
||||
anyhow = "1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
hex = "0.4"
|
||||
serde = { version = "1", features = ["derive"] } # QrCodeData
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
# The integration test boots the relay in-process (`AppState::build` + `router`)
|
||||
# and speaks v2 protobuf against it over a real WS on `127.0.0.1:0`.
|
||||
skald-relay-server = { path = "../skald-relay-server" }
|
||||
serde_json = "1"
|
||||
# `axum` to serve the in-process relay; `tokio` `full` for `tokio::test` +
|
||||
# the `net`/`time` features the harness needs.
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,238 @@
|
||||
//! [`RelayClient`] — the public façade over the networking layer.
|
||||
//!
|
||||
//! Concrete struct with inherent async methods (no trait): there is exactly one
|
||||
//! implementation and the consumer wants a thin, direct handle. The client owns
|
||||
//! the WS loop lifecycle and the broadcast event channel; all transport/crypto
|
||||
//! logic lives in [`crate::state::RelayState`], shared behind an `Arc`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
|
||||
use crate::config::RelayClientConfig;
|
||||
use crate::db::{self, ClientRow};
|
||||
use crate::events::RelayEvent;
|
||||
use crate::identity::Identity;
|
||||
use crate::pairing::{QrCodeData, SessionState, StartedPairing};
|
||||
use crate::state::{RelayState, StateConfig};
|
||||
use crate::ws;
|
||||
|
||||
/// How many events the broadcast channel buffers before lagging slow consumers.
|
||||
const EVENT_CHANNEL_CAP: usize = 256;
|
||||
|
||||
/// A standalone, payload-agnostic relay client (agent role).
|
||||
///
|
||||
/// Lifecycle: [`new`](Self::new) derives the identity and initializes the DB but
|
||||
/// does **not** connect; [`start`](Self::start) spawns the reconnecting WS loop;
|
||||
/// [`shutdown`](Self::shutdown) cancels it and joins. Inbound traffic and
|
||||
/// lifecycle transitions are delivered via [`events`](Self::events).
|
||||
pub struct RelayClient {
|
||||
state: Arc<RelayState>,
|
||||
/// Token cancelling the WS loop; `Some` only while started.
|
||||
cancel: Mutex<Option<CancellationToken>>,
|
||||
handle: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl RelayClient {
|
||||
/// Derive the identity from the seed source, ensure the `relay_clients`
|
||||
/// table exists, and build the client. Does NOT connect — call
|
||||
/// [`start`](Self::start).
|
||||
pub async fn new(db: Arc<SqlitePool>, config: RelayClientConfig) -> Result<Self> {
|
||||
db::init(&db).await?;
|
||||
let identity = Identity::from_source(&config.seed)?;
|
||||
info!(
|
||||
crate_name = "skald-relay-client",
|
||||
namespace = identity.namespace_id_hex(),
|
||||
"relay client identity loaded"
|
||||
);
|
||||
let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAP);
|
||||
let state = Arc::new(RelayState::new(
|
||||
identity,
|
||||
db,
|
||||
StateConfig { relay_url: config.relay_url, pairing_ttl: config.pairing_ttl },
|
||||
events_tx,
|
||||
));
|
||||
Ok(Self {
|
||||
state,
|
||||
cancel: Mutex::new(None),
|
||||
handle: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the reconnecting WS loop. No-op (stays idle) if `relay_url` is
|
||||
/// empty. Wires a fresh outbound channel into the state. Calling `start`
|
||||
/// while already started replaces the loop (the caller should `shutdown`
|
||||
/// first; this guards by cancelling any prior token).
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
// Cancel any previous loop defensively.
|
||||
if let Some(c) = self.cancel.lock().await.take() {
|
||||
c.cancel();
|
||||
}
|
||||
if let Some(h) = self.handle.lock().await.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
|
||||
let cancel = CancellationToken::new();
|
||||
let (out_tx, out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
self.state.set_outbound(out_tx);
|
||||
|
||||
if self.state.relay_url().is_empty() {
|
||||
// Idle: no WS loop, but the outbound sender is set so pairing/send
|
||||
// calls fail loudly ("WS not started") rather than panic.
|
||||
*self.cancel.lock().await = Some(cancel);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let st = Arc::clone(&self.state);
|
||||
let c = cancel.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
ws::run_loop(st, out_rx, c).await;
|
||||
});
|
||||
*self.cancel.lock().await = Some(cancel);
|
||||
*self.handle.lock().await = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cancel the WS loop, clear the outbound sender, and join the task.
|
||||
pub async fn shutdown(&self) {
|
||||
if let Some(c) = self.cancel.lock().await.take() {
|
||||
c.cancel();
|
||||
}
|
||||
self.state.clear_outbound();
|
||||
self.state.set_connected(false);
|
||||
if let Some(h) = self.handle.lock().await.take() {
|
||||
let _ = h.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the client's [`RelayEvent`] stream. Each call returns a new
|
||||
/// receiver; a slow consumer lags (`RecvError::Lagged`) rather than blocking
|
||||
/// the WS loop.
|
||||
pub fn events(&self) -> broadcast::Receiver<RelayEvent> {
|
||||
self.state.subscribe()
|
||||
}
|
||||
|
||||
/// Seal `payload` to one authorized client and queue the `message` frame.
|
||||
/// `live=true` routes-or-fails (peer online by construction); `live=false`
|
||||
/// stores-and-forwards + pushes for offline phones.
|
||||
pub async fn send(&self, dest: &[u8; 32], payload: &[u8], live: bool) -> Result<()> {
|
||||
self.state.send_to_client(dest, payload, live).await
|
||||
}
|
||||
|
||||
// ── Pipe (relayed byte-stream, docs/relay/pipe.md) ─────────────────────────
|
||||
|
||||
/// Open an end-to-end-encrypted byte pipe to `peer` (a namespace member).
|
||||
/// Brokers the rendezvous over the E2E channel (`pipe_invite`/`pipe_accept`,
|
||||
/// ephemeral DH → PFS) and returns the live data-plane channel.
|
||||
pub async fn open_pipe(
|
||||
&self,
|
||||
peer: &[u8; 32],
|
||||
stream_type: &str,
|
||||
headers: std::collections::BTreeMap<String, String>,
|
||||
) -> Result<crate::pipe::PipeConnection> {
|
||||
self.state.open_pipe(peer, stream_type, headers).await
|
||||
}
|
||||
|
||||
/// Subscribe to inbound pipe invites (responder side). Each invite is an
|
||||
/// [`IncomingPipe`](crate::pipe::IncomingPipe); call [`accept_pipe`](Self::accept_pipe)
|
||||
/// or [`reject_pipe`](Self::reject_pipe) on it. Single-consumer expected.
|
||||
pub fn incoming_pipes(&self) -> broadcast::Receiver<crate::pipe::IncomingPipe> {
|
||||
self.state.incoming_pipes()
|
||||
}
|
||||
|
||||
/// Accept an inbound invite → returns the live data-plane channel.
|
||||
pub async fn accept_pipe(
|
||||
&self,
|
||||
incoming: &crate::pipe::IncomingPipe,
|
||||
) -> Result<crate::pipe::PipeConnection> {
|
||||
self.state.accept_pipe(incoming).await
|
||||
}
|
||||
|
||||
/// Decline an inbound invite.
|
||||
pub async fn reject_pipe(
|
||||
&self,
|
||||
incoming: &crate::pipe::IncomingPipe,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
self.state.reject_pipe(incoming, reason).await
|
||||
}
|
||||
|
||||
// ── Pairing ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Open the pairing window (single-window, latest-wins). `ttl_secs == 0`
|
||||
/// uses the configured default.
|
||||
pub async fn start_pairing(&self, ttl_secs: u32) -> Result<StartedPairing> {
|
||||
let ttl = if ttl_secs == 0 { self.state.default_pairing_ttl() } else { ttl_secs };
|
||||
self.state.start_pairing(ttl).await
|
||||
}
|
||||
|
||||
/// Close the pairing window locally and tell the relay.
|
||||
pub async fn stop_pairing(&self) -> Result<()> {
|
||||
self.state.stop_pairing().await
|
||||
}
|
||||
|
||||
/// Resolve a pairing `code` to its QR payload + lifecycle state (QR router).
|
||||
pub fn lookup_pairing(&self, code: &str) -> Option<(QrCodeData, SessionState)> {
|
||||
self.state.lookup_pairing(code)
|
||||
}
|
||||
|
||||
/// The configured default pairing TTL (seconds).
|
||||
pub fn default_pairing_ttl(&self) -> u32 {
|
||||
self.state.default_pairing_ttl()
|
||||
}
|
||||
|
||||
// ── Device registry / authorization ───────────────────────────────────────
|
||||
|
||||
/// Mark a Pending device Authorized and push the updated authorize set.
|
||||
/// Payload-agnostic: it does not broadcast any application snapshot — the
|
||||
/// consumer does that after authorizing if needed.
|
||||
pub async fn authorize(&self, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
self.state.authorize(ed25519_pub).await
|
||||
}
|
||||
|
||||
/// Revoke a device (delete keys/counters, re-push the authorize set without
|
||||
/// it). Emits [`RelayEvent::ClientRevoked`].
|
||||
pub async fn revoke(&self, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
self.state.revoke(ed25519_pub).await
|
||||
}
|
||||
|
||||
/// Remove every device and push an empty authorize set. Emits one
|
||||
/// `ClientRevoked` per removed device.
|
||||
pub async fn clear_all(&self) -> Result<()> {
|
||||
self.state.clear_all().await
|
||||
}
|
||||
|
||||
/// All known devices (pending + authorized), ordered by `authorized_at`.
|
||||
pub async fn list_clients(&self) -> Vec<ClientRow> {
|
||||
self.state.list_clients().await
|
||||
}
|
||||
|
||||
/// Persist the `device_info` JSON for a device (the consumer decodes the
|
||||
/// `hello` payload and hands the raw JSON here).
|
||||
pub async fn set_device_info(&self, ed25519_pub: &[u8; 32], json: &str) -> Result<()> {
|
||||
self.state.set_device_info(ed25519_pub, json).await
|
||||
}
|
||||
|
||||
// ── Identity accessors ────────────────────────────────────────────────────
|
||||
|
||||
pub fn agent_ed25519_pub(&self) -> [u8; 32] {
|
||||
self.state.identity().ed25519_pub()
|
||||
}
|
||||
|
||||
pub fn agent_x25519_pub(&self) -> [u8; 32] {
|
||||
self.state.identity().x25519_pub()
|
||||
}
|
||||
|
||||
pub fn namespace_id_hex(&self) -> String {
|
||||
self.state.identity().namespace_id_hex().to_string()
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.state.is_connected()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Configuration for [`crate::RelayClient`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Configuration snapshot passed to `RelayClient::new`.
|
||||
///
|
||||
/// `relay_url` may be empty: the client then stays idle (no WS loop is
|
||||
/// spawned), which keeps the plugin toggleable without a relay configured.
|
||||
pub struct RelayClientConfig {
|
||||
/// `wss://` URL of the relay (e.g. `wss://relay.skaldagent.net/v1/ws`).
|
||||
/// Empty => the client is idle.
|
||||
pub relay_url: String,
|
||||
/// Default pairing TTL in seconds (used when `start_pairing(0)` is called).
|
||||
pub pairing_ttl: u32,
|
||||
/// Where the agent's 32-byte identity seed comes from (crypto.md §9).
|
||||
pub seed: SeedSource,
|
||||
}
|
||||
|
||||
/// Source of the persistent identity seed (crypto.md §9).
|
||||
///
|
||||
/// `Path` preserves an existing on-disk identity: the plugin passes
|
||||
/// `Path("data/relay/seed")` — the same relative path as today — so no device
|
||||
/// is orphaned on upgrade and the namespace id is unchanged. `Bytes` is for
|
||||
/// tests / in-memory identities.
|
||||
pub enum SeedSource {
|
||||
/// A raw 32-byte seed (tests, in-memory).
|
||||
Bytes([u8; 32]),
|
||||
/// Load (or generate + persist `0600`) the seed at the given path. The
|
||||
/// parent directory is created on first use.
|
||||
Path(PathBuf),
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Persistence for authorized devices and their anti-replay counters
|
||||
//! (crypto.md §9). The client does NOT open its own SQLite file: it reuses
|
||||
//! Skald's shared `SqlitePool` (passed into `RelayClient::new`) and namespaces
|
||||
//! its one table with the `relay_` prefix.
|
||||
//!
|
||||
//! Counters MUST survive restarts (crypto.md §9 "⚠️"): a `send_counter` reset
|
||||
//! to 0 would reuse an AES-GCM nonce under the same key, and a `recv_counter`
|
||||
//! reset would re-open the replay window. So both are columns here, not
|
||||
//! in-memory.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
/// Authorization state of a paired device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientState {
|
||||
/// Paired but not yet confirmed by the human (relay-protocol.md §6).
|
||||
Pending,
|
||||
/// Confirmed — receives Inbox snapshots and may answer.
|
||||
Authorized,
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
#[allow(dead_code)] // mirrors from_str; kept for completeness/debugging
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ClientState::Pending => "pending",
|
||||
ClientState::Authorized => "authorized",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::should_implement_trait)] // small internal mapper, not the std trait
|
||||
pub fn from_str(s: &str) -> ClientState {
|
||||
match s {
|
||||
"authorized" => ClientState::Authorized,
|
||||
_ => ClientState::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of `relay_clients`.
|
||||
///
|
||||
/// `send_counter` / `authorized_at` are part of the persisted schema (read back
|
||||
/// for diagnostics / future use) even though the hot paths use the dedicated
|
||||
/// counter helpers.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientRow {
|
||||
pub ed25519_pub: [u8; 32],
|
||||
pub x25519_pub: [u8; 32],
|
||||
pub state: ClientState,
|
||||
pub platform: Option<String>,
|
||||
/// Raw JSON of the `device_info` object received in `hello`.
|
||||
pub device_info: Option<String>,
|
||||
pub send_counter: u64,
|
||||
pub recv_counter: u64,
|
||||
pub authorized_at: Option<i64>,
|
||||
pub last_seen: Option<i64>,
|
||||
}
|
||||
|
||||
/// Create the `relay_clients` table if missing (idempotent — called on start).
|
||||
pub async fn init(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS relay_clients (
|
||||
ed25519_pub BLOB PRIMARY KEY,
|
||||
x25519_pub BLOB NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
device_info TEXT,
|
||||
send_counter INTEGER NOT NULL DEFAULT 0,
|
||||
recv_counter INTEGER NOT NULL DEFAULT 0,
|
||||
authorized_at INTEGER,
|
||||
last_seen INTEGER
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert (or replace) a freshly paired client with counters reset to 0 and
|
||||
/// state = Pending (relay-protocol.md §6 step 7c).
|
||||
pub async fn upsert_paired(
|
||||
pool: &SqlitePool,
|
||||
ed25519_pub: &[u8; 32],
|
||||
x25519_pub: &[u8; 32],
|
||||
platform: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_clients
|
||||
(ed25519_pub, x25519_pub, state, platform, send_counter, recv_counter)
|
||||
VALUES (?, ?, 'pending', ?, 0, 0)
|
||||
ON CONFLICT(ed25519_pub) DO UPDATE SET
|
||||
x25519_pub = excluded.x25519_pub,
|
||||
state = 'pending',
|
||||
platform = excluded.platform,
|
||||
send_counter = 0,
|
||||
recv_counter = 0",
|
||||
)
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.bind(x25519_pub.as_slice())
|
||||
.bind(platform)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a client Authorized, stamping `authorized_at` with the current time.
|
||||
pub async fn set_authorized(pool: &SqlitePool, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
sqlx::query("UPDATE relay_clients SET state = 'authorized', authorized_at = ? WHERE ed25519_pub = ?")
|
||||
.bind(Utc::now().timestamp_millis())
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the device_info JSON received in a `hello` payload.
|
||||
pub async fn set_device_info(pool: &SqlitePool, ed25519_pub: &[u8; 32], device_info_json: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE relay_clients SET device_info = ?, last_seen = ? WHERE ed25519_pub = ?")
|
||||
.bind(device_info_json)
|
||||
.bind(Utc::now().timestamp_millis())
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically reserve the next send counter for a client and return it.
|
||||
///
|
||||
/// The new value is persisted BEFORE the caller seals/sends a message
|
||||
/// (crypto.md §8): even if the process dies right after, the counter never
|
||||
/// regresses, so no AES-GCM nonce is ever reused. Returns the counter value to
|
||||
/// embed in the nonce.
|
||||
///
|
||||
/// A single `UPDATE … RETURNING` (not SELECT-then-UPDATE in a deferred
|
||||
/// transaction): the latter starts as a reader, takes a WAL snapshot, then tries
|
||||
/// to upgrade to a writer — and if another connection committed to the same row
|
||||
/// meanwhile it fails with `SQLITE_BUSY_SNAPSHOT` (517), which `busy_timeout`
|
||||
/// does **not** retry. Concurrent `accept_pipe`/`send` for one peer hit the same
|
||||
/// row at once (e.g. a WebView opening many connections), so the snapshot upgrade
|
||||
/// loses constantly. A lone `UPDATE` starts directly as a write, so callers
|
||||
/// serialize on the write lock (which `busy_timeout` *does* cover).
|
||||
pub async fn next_send_counter(pool: &SqlitePool, ed25519_pub: &[u8; 32]) -> Result<u64> {
|
||||
let next: i64 = sqlx::query_scalar(
|
||||
"UPDATE relay_clients SET send_counter = send_counter + 1 \
|
||||
WHERE ed25519_pub = ? RETURNING send_counter",
|
||||
)
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("next_send_counter: client not found"))?;
|
||||
Ok(next as u64)
|
||||
}
|
||||
|
||||
/// Persist a newly-seen receive counter after a valid `open` (crypto.md §8).
|
||||
pub async fn set_recv_counter(pool: &SqlitePool, ed25519_pub: &[u8; 32], counter: u64) -> Result<()> {
|
||||
sqlx::query("UPDATE relay_clients SET recv_counter = ?, last_seen = ? WHERE ed25519_pub = ?")
|
||||
.bind(counter as i64)
|
||||
.bind(Utc::now().timestamp_millis())
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a client and all its derived state (keys/counters/device_info) on
|
||||
/// revoke (relay-protocol.md §7).
|
||||
pub async fn delete(pool: &SqlitePool, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
sqlx::query("DELETE FROM relay_clients WHERE ed25519_pub = ?")
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete every client row (used by `clear_all`). Does NOT drop the table.
|
||||
pub async fn delete_all(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query("DELETE FROM relay_clients")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch one client by pubkey.
|
||||
pub async fn get(pool: &SqlitePool, ed25519_pub: &[u8; 32]) -> Result<Option<ClientRow>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT ed25519_pub, x25519_pub, state, platform, device_info,
|
||||
send_counter, recv_counter, authorized_at, last_seen
|
||||
FROM relay_clients WHERE ed25519_pub = ?",
|
||||
)
|
||||
.bind(ed25519_pub.as_slice())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(row_to_client))
|
||||
}
|
||||
|
||||
/// List all clients.
|
||||
pub async fn list_all(pool: &SqlitePool) -> Result<Vec<ClientRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ed25519_pub, x25519_pub, state, platform, device_info,
|
||||
send_counter, recv_counter, authorized_at, last_seen
|
||||
FROM relay_clients ORDER BY authorized_at",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(row_to_client).collect())
|
||||
}
|
||||
|
||||
/// Hex pubkeys of all Authorized clients — the `authorize` set sent to the relay.
|
||||
pub async fn authorized_pubkeys_hex(pool: &SqlitePool) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query("SELECT ed25519_pub FROM relay_clients WHERE state = 'authorized'")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let pk: Vec<u8> = r.get("ed25519_pub");
|
||||
hex::encode(pk)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn row_to_client(row: sqlx::sqlite::SqliteRow) -> ClientRow {
|
||||
let ed: Vec<u8> = row.get("ed25519_pub");
|
||||
let x: Vec<u8> = row.get("x25519_pub");
|
||||
let state: String = row.get("state");
|
||||
ClientRow {
|
||||
ed25519_pub: to_array(&ed),
|
||||
x25519_pub: to_array(&x),
|
||||
state: ClientState::from_str(&state),
|
||||
platform: row.get("platform"),
|
||||
device_info: row.get("device_info"),
|
||||
send_counter: row.get::<i64, _>("send_counter") as u64,
|
||||
recv_counter: row.get::<i64, _>("recv_counter") as u64,
|
||||
authorized_at: row.get("authorized_at"),
|
||||
last_seen: row.get("last_seen"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a byte slice into a 32-byte array (zero-padded / truncated defensively).
|
||||
fn to_array(bytes: &[u8]) -> [u8; 32] {
|
||||
let mut out = [0u8; 32];
|
||||
let n = bytes.len().min(32);
|
||||
out[..n].copy_from_slice(&bytes[..n]);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn mem_pool() -> SqlitePool {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await.expect("pool");
|
||||
init(&pool).await.expect("init");
|
||||
pool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn next_send_counter_is_monotonic() {
|
||||
let pool = mem_pool().await;
|
||||
let ed = [1u8; 32];
|
||||
let x = [2u8; 32];
|
||||
upsert_paired(&pool, &ed, &x, None).await.expect("upsert");
|
||||
|
||||
let c1 = next_send_counter(&pool, &ed).await.expect("next1");
|
||||
let c2 = next_send_counter(&pool, &ed).await.expect("next2");
|
||||
let c3 = next_send_counter(&pool, &ed).await.expect("next3");
|
||||
assert_eq!(c1, 1);
|
||||
assert_eq!(c2, 2);
|
||||
assert_eq!(c3, 3, "send counter must be strictly monotonic");
|
||||
|
||||
// The persisted value survives a fresh connection to the same DB file
|
||||
// is not testable with :memory:; instead assert the in-DB value.
|
||||
let row = get(&pool, &ed).await.expect("get").expect("row");
|
||||
assert_eq!(row.send_counter, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_resets_counters_on_repair() {
|
||||
let pool = mem_pool().await;
|
||||
let ed = [3u8; 32];
|
||||
upsert_paired(&pool, &ed, &[4u8; 32], None).await.expect("upsert");
|
||||
next_send_counter(&pool, &ed).await.expect("bump");
|
||||
next_send_counter(&pool, &ed).await.expect("bump");
|
||||
// Re-pairing the same device resets counters to 0.
|
||||
upsert_paired(&pool, &ed, &[5u8; 32], Some("ios")).await.expect("re-upsert");
|
||||
let c = next_send_counter(&pool, &ed).await.expect("next after re-pair");
|
||||
assert_eq!(c, 1, "re-pairing must reset the send counter");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_all_clears_rows() {
|
||||
let pool = mem_pool().await;
|
||||
upsert_paired(&pool, &[1u8; 32], &[2u8; 32], None).await.expect("upsert");
|
||||
upsert_paired(&pool, &[3u8; 32], &[4u8; 32], None).await.expect("upsert");
|
||||
assert_eq!(list_all(&pool).await.unwrap().len(), 2);
|
||||
delete_all(&pool).await.expect("delete_all");
|
||||
assert_eq!(list_all(&pool).await.unwrap().len(), 0, "delete_all must clear every row");
|
||||
// Table still usable afterwards (init not required again).
|
||||
upsert_paired(&pool, &[1u8; 32], &[2u8; 32], None).await.expect("upsert post-clear");
|
||||
assert_eq!(list_all(&pool).await.unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Events broadcast by the relay client. Consumers (the plugin's
|
||||
//! `RelayApp::run_event_loop`) subscribe via `RelayClient::events()`.
|
||||
|
||||
/// One event emitted by the relay client.
|
||||
///
|
||||
/// `Message.payload` is already **decrypted AND decompressed**: the client
|
||||
/// peels off the v2 `version‖comp‖json` framing before emission, so the
|
||||
/// consumer sees only clean bytes and applies its own JSON semantics. This is
|
||||
/// the payload-agnostic boundary (see the "Crate split" section of
|
||||
/// `docs/plugins/mobile-connector.md`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RelayEvent {
|
||||
/// The WS handshake completed and the relay verified our namespace_id.
|
||||
Connected,
|
||||
/// The WS connection dropped. The client will reconnect with backoff.
|
||||
Disconnected,
|
||||
/// An inbound `message` from a client, decoded end-to-end. `from` is the
|
||||
/// sender's ed25519 pubkey; `live` mirrors the wire flag.
|
||||
Message {
|
||||
from: [u8; 32],
|
||||
payload: Vec<u8>,
|
||||
live: bool,
|
||||
},
|
||||
/// A device paired (pending authorization). The consumer decides whether
|
||||
/// to call `client.authorize(ed)` (auto) or to notify the human (manual).
|
||||
ClientPaired {
|
||||
ed25519_pub: [u8; 32],
|
||||
x25519_pub: [u8; 32],
|
||||
platform: String,
|
||||
},
|
||||
/// A device was revoked via `client.revoke` or removed by `client.clear_all`.
|
||||
ClientRevoked { ed25519_pub: [u8; 32] },
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Agent identity: the 32-byte seed and the keys derived from it.
|
||||
//!
|
||||
//! The seed is the only persistent secret (crypto.md §9). When sourced from a
|
||||
//! path it lives on the filesystem with `0600` permissions and is generated on
|
||||
//! first use. All Ed25519 / X25519 key material and the `namespace_id` are
|
||||
//! derived from it at runtime via `skald-relay-common` (crypto.md §3-7), never
|
||||
//! persisted — so the byte-for-byte interop with the reference vectors is
|
||||
//! inherited from the shared crate.
|
||||
//!
|
||||
//! The seed location is supplied by the caller via [`SeedSource`] (no more
|
||||
//! CWD-coupled `const`). The plugin passes `SeedSource::Path("data/relay/seed")`
|
||||
//! to preserve the existing identity on upgrade.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rand::RngCore;
|
||||
use skald_relay_common::crypto::{self, DerivedKeys};
|
||||
|
||||
use crate::config::SeedSource;
|
||||
|
||||
/// The agent's cryptographic identity, derived from the persistent seed.
|
||||
pub struct Identity {
|
||||
keys: DerivedKeys,
|
||||
/// `namespace_id` raw 32 bytes (used to build the AEAD AAD).
|
||||
ns_raw: [u8; 32],
|
||||
/// `namespace_id` lowercase hex (64 chars), used on the wire.
|
||||
ns_hex: String,
|
||||
}
|
||||
|
||||
impl Identity {
|
||||
/// Build an identity from a [`SeedSource`]: for `Bytes` this is pure
|
||||
/// in-memory derivation; for `Path` it loads (or generates + persists
|
||||
/// `0600`) the seed at the given path, preserving any existing identity.
|
||||
pub fn from_source(source: &SeedSource) -> Result<Self> {
|
||||
match source {
|
||||
SeedSource::Bytes(seed) => Ok(Self::from_seed(seed)),
|
||||
SeedSource::Path(p) => {
|
||||
let seed = load_or_create_seed(p)?;
|
||||
Ok(Self::from_seed(&seed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an identity from a raw seed (used by `from_source` and tests).
|
||||
pub fn from_seed(seed: &[u8; 32]) -> Self {
|
||||
let keys = crypto::derive_keys(seed);
|
||||
let (ns_raw, ns_hex) = crypto::namespace_id(&keys.ed25519_pub);
|
||||
Self { keys, ns_raw, ns_hex }
|
||||
}
|
||||
|
||||
pub fn ed25519_pub(&self) -> [u8; 32] {
|
||||
self.keys.ed25519_pub
|
||||
}
|
||||
|
||||
pub fn x25519_pub(&self) -> [u8; 32] {
|
||||
self.keys.x25519_pub
|
||||
}
|
||||
|
||||
pub fn signing_key(&self) -> ed25519_dalek::SigningKey {
|
||||
self.keys.signing_key()
|
||||
}
|
||||
|
||||
pub fn namespace_id_raw(&self) -> [u8; 32] {
|
||||
self.ns_raw
|
||||
}
|
||||
|
||||
pub fn namespace_id_hex(&self) -> &str {
|
||||
&self.ns_hex
|
||||
}
|
||||
|
||||
/// Derive the per-client AES-256-GCM key from this agent's X25519 private key
|
||||
/// and the peer's X25519 public key (crypto.md §4-5).
|
||||
pub fn derive_aes_key(&self, client_x25519_pub: &[u8; 32]) -> [u8; 32] {
|
||||
let shared = crypto::ecdh(&self.keys.x25519_priv, client_x25519_pub);
|
||||
crypto::derive_aes_key(&shared)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the seed, or generate a fresh 32-byte CSPRNG seed and persist it `0600`.
|
||||
fn load_or_create_seed(path: &Path) -> Result<[u8; 32]> {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
if bytes.len() == 32 {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&bytes);
|
||||
return Ok(seed);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"relay seed at {} has wrong length ({}, expected 32) — refusing to overwrite",
|
||||
path.display(),
|
||||
bytes.len()
|
||||
);
|
||||
}
|
||||
|
||||
// First run: create the parent dir (defaulting to "." if the path has no
|
||||
// parent, e.g. a bare filename) and persist a fresh seed.
|
||||
let dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
|
||||
std::fs::create_dir_all(&dir)
|
||||
.with_context(|| format!("creating seed dir {}", dir.display()))?;
|
||||
|
||||
let mut seed = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut seed);
|
||||
write_secret_file(path, &seed)
|
||||
.with_context(|| format!("writing seed file {}", path.display()))?;
|
||||
tracing::info!(
|
||||
crate_name = "skald-relay-client",
|
||||
"generated new relay seed at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` with `0600` permissions on Unix.
|
||||
fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_matches_reference_vectors() {
|
||||
// Same seed as skald-relay-common's pinned vector (bytes 0..32).
|
||||
let seed: [u8; 32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
|
||||
let id = Identity::from_seed(&seed);
|
||||
assert_eq!(
|
||||
hex::encode(id.ed25519_pub()),
|
||||
"b3e202f4ac99fd9929da47df20adedd5b2598411a466a229f086eda3467ffa7b"
|
||||
);
|
||||
assert_eq!(
|
||||
id.namespace_id_hex(),
|
||||
"f7d340d3c3f0b0052fa904ba60ebd38a0f7e7d10672ac80648991a2c632c9e58"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_source_bytes_matches_from_seed() {
|
||||
let seed: [u8; 32] = (0u8..32).collect::<Vec<_>>().try_into().unwrap();
|
||||
let a = Identity::from_source(&SeedSource::Bytes(seed)).expect("bytes");
|
||||
let b = Identity::from_seed(&seed);
|
||||
assert_eq!(a.ed25519_pub(), b.ed25519_pub());
|
||||
assert_eq!(a.namespace_id_hex(), b.namespace_id_hex());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! skald-relay-client — standalone agent-role relay client.
|
||||
//!
|
||||
//! Payload-agnostic: it speaks the v2 WebSocket protocol (challenge/auth,
|
||||
//! `Authorize`, store-and-forward `Message`), performs E2E seal/open with
|
||||
//! per-client AES-256-GCM keys, manages anti-replay counters, pairing windows,
|
||||
//! and device authorization. It never interprets the decrypted bytes: it emits
|
||||
//! them via [`events::RelayEvent`] and lets the application layer (the
|
||||
//! `plugin-mobile-connector` crate) apply JSON semantics. See the
|
||||
//! "Crate split" section of `docs/plugins/mobile-connector.md` and
|
||||
//! `docs/relay/`.
|
||||
//!
|
||||
//! Module map:
|
||||
//! - `config` — `RelayClientConfig` + `SeedSource`
|
||||
//! - `events` — `RelayEvent` (broadcast)
|
||||
//! - `identity` — seed + derived keys + namespace_id
|
||||
//! - `db` — `relay_clients` table (devices + anti-replay counters)
|
||||
//! - `pairing` — in-memory pairing sessions + QR payload
|
||||
//! - `state` — `RelayState` (networking-only: seal/open, counters, send/recv)
|
||||
//! - `ws` — the permanent reconnecting agent WebSocket (v2 binary)
|
||||
//! - `pipe` — pipe data plane: `PipeConnection` + `IncomingPipe` (pipe.md)
|
||||
//! - `client` — `RelayClient`, the public façade
|
||||
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod events;
|
||||
pub mod identity;
|
||||
pub mod pairing;
|
||||
pub mod pipe;
|
||||
mod state;
|
||||
mod ws;
|
||||
|
||||
pub use client::RelayClient;
|
||||
pub use config::{RelayClientConfig, SeedSource};
|
||||
pub use db::{ClientRow, ClientState};
|
||||
pub use events::RelayEvent;
|
||||
pub use identity::Identity;
|
||||
pub use pairing::{QrCodeData, SessionState, StartedPairing};
|
||||
pub use pipe::{IncomingPipe, PipeConnection, PipeReceiver, PipeRole, PipeSender};
|
||||
@@ -0,0 +1,228 @@
|
||||
//! In-memory pairing window (relay-protocol.md §5, §6).
|
||||
//!
|
||||
//! A pairing session is transient (≤ TTL) and is NEVER persisted: it holds a
|
||||
//! live `pairing_token` whose bytes must not touch disk (crypto.md §9). The map
|
||||
//! `code → session` is single-window, latest-wins: a new `start_pairing`
|
||||
//! supersedes the previous session.
|
||||
//!
|
||||
//! The `code` is a random, non-enumerable handle distinct from the
|
||||
//! `pairing_token`; it is what travels in the QR endpoint URL, so a URL that
|
||||
//! leaks into `chat_history` is only a capability that self-revokes once the
|
||||
//! window closes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::Utc;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Lifecycle state of a pairing session, used to pick the QR-endpoint response.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionState {
|
||||
/// Window open, token not yet consumed.
|
||||
Active,
|
||||
/// A device paired against this session (token consumed).
|
||||
Consumed,
|
||||
/// A newer `start_pairing` replaced this session.
|
||||
Superseded,
|
||||
}
|
||||
|
||||
/// The JSON object encoded INSIDE the QR (relay-protocol.md §5). Field
|
||||
/// order/encoding is normative: all 32-byte values are lowercase hex (64 chars).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct QrCodeData {
|
||||
pub v: u8,
|
||||
pub relay_url: String,
|
||||
pub namespace_id: String,
|
||||
pub agent_ed25519_pub: String,
|
||||
pub agent_x25519_pub: String,
|
||||
pub pairing_token: String,
|
||||
}
|
||||
|
||||
/// One in-memory pairing session keyed by its `code`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PairingSession {
|
||||
/// The QR payload to render while the window is active.
|
||||
pub qr: QrCodeData,
|
||||
/// Raw pairing token (kept to correlate, never serialized except in `qr`).
|
||||
pub token: [u8; 32],
|
||||
/// Unix-ms expiry.
|
||||
pub expires_at: i64,
|
||||
pub state: SessionState,
|
||||
}
|
||||
|
||||
impl PairingSession {
|
||||
/// Effective state at `now`, folding in TTL expiry on top of the stored state.
|
||||
pub fn effective_state(&self, now_ms: i64) -> SessionState {
|
||||
match self.state {
|
||||
SessionState::Active if now_ms >= self.expires_at => SessionState::Superseded, // expired ⇒ placeholder
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of opening a pairing window.
|
||||
pub struct StartedPairing {
|
||||
pub code: String,
|
||||
pub token: [u8; 32],
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// Single-window registry of pairing sessions.
|
||||
#[derive(Default)]
|
||||
pub struct PairingStore {
|
||||
inner: Mutex<HashMap<String, PairingSession>>,
|
||||
}
|
||||
|
||||
impl PairingStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Open a new window (latest-wins): every existing session is marked
|
||||
/// Superseded, then the new one is inserted Active. Returns the handle.
|
||||
pub fn start(
|
||||
&self,
|
||||
relay_url: &str,
|
||||
namespace_id: &str,
|
||||
agent_ed25519_pub: &[u8; 32],
|
||||
agent_x25519_pub: &[u8; 32],
|
||||
ttl_secs: u32,
|
||||
) -> StartedPairing {
|
||||
let mut token = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut token);
|
||||
let mut code_bytes = [0u8; 16];
|
||||
rand::rng().fill_bytes(&mut code_bytes);
|
||||
let code = hex::encode(code_bytes);
|
||||
let expires_at = Utc::now().timestamp_millis() + (ttl_secs as i64) * 1000;
|
||||
|
||||
let qr = QrCodeData {
|
||||
v: 1,
|
||||
relay_url: relay_url.to_string(),
|
||||
namespace_id: namespace_id.to_string(),
|
||||
agent_ed25519_pub: hex::encode(agent_ed25519_pub),
|
||||
agent_x25519_pub: hex::encode(agent_x25519_pub),
|
||||
pairing_token: hex::encode(token),
|
||||
};
|
||||
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
for s in map.values_mut() {
|
||||
if s.state == SessionState::Active {
|
||||
s.state = SessionState::Superseded;
|
||||
}
|
||||
}
|
||||
map.insert(
|
||||
code.clone(),
|
||||
PairingSession { qr, token, expires_at, state: SessionState::Active },
|
||||
);
|
||||
|
||||
StartedPairing { code, token, expires_at }
|
||||
}
|
||||
|
||||
/// Mark every active session Superseded (used by `stop_pairing`).
|
||||
pub fn supersede_all(&self) {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
for s in map.values_mut() {
|
||||
if s.state == SessionState::Active {
|
||||
s.state = SessionState::Superseded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the active session whose token matches as Consumed (after a device
|
||||
/// pairs). Returns true if one was found.
|
||||
pub fn consume_by_token(&self, token: &[u8; 32]) -> bool {
|
||||
let mut map = self.inner.lock().unwrap();
|
||||
for s in map.values_mut() {
|
||||
if s.state == SessionState::Active
|
||||
&& skald_relay_common::crypto::ct_eq(&s.token, token)
|
||||
{
|
||||
s.state = SessionState::Consumed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The token of the single currently-active session, if any. The relay echoes
|
||||
/// no token in `client_paired`, so the active session's token is what we
|
||||
/// consume on pairing.
|
||||
pub fn active_token(&self) -> Option<[u8; 32]> {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let map = self.inner.lock().unwrap();
|
||||
map.values()
|
||||
.find(|s| s.effective_state(now) == SessionState::Active)
|
||||
.map(|s| s.token)
|
||||
}
|
||||
|
||||
/// Look up a session by code, returning its QR payload and effective state.
|
||||
pub fn lookup(&self, code: &str) -> Option<(QrCodeData, SessionState)> {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
let map = self.inner.lock().unwrap();
|
||||
map.get(code).map(|s| (s.qr.clone(), s.effective_state(now)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store_with_active() -> (PairingStore, StartedPairing) {
|
||||
let s = PairingStore::new();
|
||||
let started = s.start("wss://relay", "ns", &[1u8; 32], &[2u8; 32], 300);
|
||||
(s, started)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_window_latest_wins() {
|
||||
let (s, first) = store_with_active();
|
||||
let _second = s.start("wss://relay", "ns", &[1u8; 32], &[2u8; 32], 300);
|
||||
let (_, state) = s.lookup(&first.code).expect("first session present");
|
||||
assert_eq!(state, SessionState::Superseded, "opening a new window supersedes the old one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_by_token_marks_consumed() {
|
||||
let (s, started) = store_with_active();
|
||||
let token = started.token;
|
||||
let (_, state) = s.lookup(&started.code).expect("present");
|
||||
assert_eq!(state, SessionState::Active);
|
||||
|
||||
assert!(s.consume_by_token(&token));
|
||||
let (_, state) = s.lookup(&started.code).expect("present");
|
||||
assert_eq!(state, SessionState::Consumed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consume_with_wrong_token_is_noop() {
|
||||
let (s, _started) = store_with_active();
|
||||
assert!(!s.consume_by_token(&[0u8; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_pairing_supersedes_active() {
|
||||
let (s, started) = store_with_active();
|
||||
s.supersede_all();
|
||||
let (_, state) = s.lookup(&started.code).expect("present");
|
||||
assert_eq!(state, SessionState::Superseded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_unknown_code_is_none() {
|
||||
let s = PairingStore::new();
|
||||
assert!(s.lookup("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_active_session_reports_superseded() {
|
||||
let (s, started) = store_with_active();
|
||||
// Construct a session already in the past by inserting manually.
|
||||
let mut session = s.inner.lock().unwrap();
|
||||
let entry = session.get_mut(&started.code).expect("present");
|
||||
entry.expires_at = Utc::now().timestamp_millis() - 1;
|
||||
drop(session);
|
||||
let (_, state) = s.lookup(&started.code).expect("present");
|
||||
assert_eq!(state, SessionState::Superseded, "expiry folds into Superseded");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! Pipe data-plane client (docs/relay/pipe.md §2-3): the [`PipeConnection`]
|
||||
//! secure byte channel over a `/v1/pipe` WebSocket.
|
||||
//!
|
||||
//! The control plane (invite/accept signaling, ephemeral DH) lives in
|
||||
//! [`crate::state`]; by the time `PipeConnection::connect` runs, both peers have
|
||||
//! derived the same per-pipe `pipe_key`. This module only does the data plane:
|
||||
//! dial `/v1/pipe`, prove identity to the relay (`pipe_auth`), then seal/open
|
||||
//! every frame with AES-256-GCM keyed by `pipe_key`, using a per-direction
|
||||
//! counter nonce (the relay forwards opaque ciphertext, pipe.md §2.2).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures_util::stream::{SplitSink, SplitStream};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use skald_relay_common::crypto;
|
||||
use skald_relay_common::pipe::{self, PipeAuth, PipeChallenge, PipeSuite};
|
||||
use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// An inbound pipe invite surfaced to the application (responder side). The app
|
||||
/// inspects `from` / `stream_type` / `headers` and then calls
|
||||
/// `RelayClient::accept_pipe` or `reject_pipe`. The remaining fields carry the
|
||||
/// handshake state the accept path needs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingPipe {
|
||||
/// The initiator's ed25519 pubkey.
|
||||
pub from: [u8; 32],
|
||||
/// App-defined purpose discriminator.
|
||||
pub stream_type: String,
|
||||
/// Arbitrary app-defined headers from the invite.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Rendezvous key (echoed in the accept + data-plane auth).
|
||||
pub(crate) connection_id: [u8; 32],
|
||||
/// Negotiated suite (v1: only `X25519Sealed`).
|
||||
pub(crate) suite: PipeSuite,
|
||||
/// The initiator's opaque handshake material (its ephemeral X25519 pubkey).
|
||||
pub(crate) peer_handshake: Vec<u8>,
|
||||
}
|
||||
|
||||
type ClientWs =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
type ClientSink = SplitSink<ClientWs, WsMessage>;
|
||||
type ClientStream = SplitStream<ClientWs>;
|
||||
|
||||
/// Soft cap on **unflushed** outbound bytes buffered inside the pipe before
|
||||
/// [`PipeSender::send`] blocks (backpressure). The background writer task
|
||||
/// releases each reservation once the corresponding frame is flushed to the
|
||||
/// socket, so this bounds in-flight memory while letting `send` and `recv`
|
||||
/// proceed independently. ~10 MiB.
|
||||
const SEND_BUFFER_BYTES: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Which end of the pipe this peer is — selects the send/receive nonce
|
||||
/// directions so the two AES-GCM streams never collide.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PipeRole {
|
||||
/// Sent `pipe_invite`. Sends on the INITIATOR direction.
|
||||
Initiator,
|
||||
/// Replied with `pipe_accept`. Sends on the RESPONDER direction.
|
||||
Responder,
|
||||
}
|
||||
|
||||
/// Write half of a [`PipeConnection`]: seals plaintext and queues it for the
|
||||
/// background writer task that owns the socket sink. Single-writer (`&mut self`)
|
||||
/// so the per-direction counter nonce stays strictly ordered on the wire.
|
||||
pub struct PipeSender {
|
||||
/// Sealed frames + their byte reservation, drained by the writer task in
|
||||
/// FIFO order (preserving counter order). Count-unbounded but byte-bounded
|
||||
/// by `buffer`.
|
||||
data_tx: mpsc::UnboundedSender<(Vec<u8>, OwnedSemaphorePermit)>,
|
||||
/// ~[`SEND_BUFFER_BYTES`] of permits; `send` blocks when exhausted.
|
||||
buffer: Arc<Semaphore>,
|
||||
key: [u8; 32],
|
||||
send_dir: [u8; 4],
|
||||
send_ctr: u64,
|
||||
/// AAD binding every frame to the rendezvous (the 32-byte connection_id).
|
||||
aad: [u8; 32],
|
||||
}
|
||||
|
||||
/// Read half of a [`PipeConnection`]: reads sealed frames off the socket stream
|
||||
/// and opens them. WS-level pings are answered via the shared writer task.
|
||||
pub struct PipeReceiver {
|
||||
stream: ClientStream,
|
||||
/// Forwards `Pong` replies to the writer task (which owns the sink).
|
||||
ctrl_tx: mpsc::UnboundedSender<WsMessage>,
|
||||
key: [u8; 32],
|
||||
recv_dir: [u8; 4],
|
||||
recv_ctr: u64,
|
||||
aad: [u8; 32],
|
||||
}
|
||||
|
||||
/// An end-to-end-encrypted byte channel to a namespace peer, relayed through
|
||||
/// `/v1/pipe`. The relay never sees plaintext. Full-duplex: a background writer
|
||||
/// task owns the socket sink and drains a byte-bounded buffer, so `send` and
|
||||
/// `recv` never block each other at the socket. Use the unified `send`/`recv`
|
||||
/// on `&mut self`, or [`split`](Self::split) into independent halves to drive
|
||||
/// the two directions from separate tasks.
|
||||
pub struct PipeConnection {
|
||||
sender: PipeSender,
|
||||
receiver: PipeReceiver,
|
||||
/// Cancels the writer task on explicit [`close`](Self::close).
|
||||
cancel: CancellationToken,
|
||||
writer: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl PipeConnection {
|
||||
/// Dial `/v1/pipe`, complete the relay auth handshake, and return the ready
|
||||
/// channel. `pipe_key` must already be derived from the signaling ephemeral
|
||||
/// DH (same value on both peers).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn connect(
|
||||
relay_url: &str,
|
||||
signing_key: &ed25519_dalek::SigningKey,
|
||||
my_ed_pub: &[u8; 32],
|
||||
peer_ed_pub: &[u8; 32],
|
||||
namespace_id_raw: &[u8; 32],
|
||||
connection_id: &[u8; 32],
|
||||
pipe_key: &[u8; 32],
|
||||
role: PipeRole,
|
||||
) -> Result<PipeConnection> {
|
||||
let url = pipe_url(relay_url);
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(&url).await?;
|
||||
|
||||
// Relay speaks first: PipeChallenge.
|
||||
let nonce = read_challenge(&mut ws).await?;
|
||||
|
||||
// Reply with a signature over PIPE_AUTH_DOMAIN ‖ 0x00 ‖ nonce ‖ cid.
|
||||
let sig = crypto::sign_pipe_auth(signing_key, &nonce, connection_id);
|
||||
let auth = PipeAuth {
|
||||
connection_id: connection_id.to_vec(),
|
||||
pubkey: my_ed_pub.to_vec(),
|
||||
dest: crypto::sha256(peer_ed_pub).to_vec(),
|
||||
namespace_id: namespace_id_raw.to_vec(),
|
||||
signature: sig.to_vec(),
|
||||
};
|
||||
ws.send(WsMessage::Binary(pipe::encode(&auth).into())).await?;
|
||||
|
||||
let (send_dir, recv_dir) = match role {
|
||||
PipeRole::Initiator => (crypto::DIR_PIPE_INITIATOR, crypto::DIR_PIPE_RESPONDER),
|
||||
PipeRole::Responder => (crypto::DIR_PIPE_RESPONDER, crypto::DIR_PIPE_INITIATOR),
|
||||
};
|
||||
|
||||
// Split the socket so the two directions are independent: the writer task
|
||||
// owns the sink and drains the byte-bounded buffer; the read half owns
|
||||
// the stream. Counters start at 1 (pipe.md §4).
|
||||
let (sink, stream) = ws.split();
|
||||
let buffer = Arc::new(Semaphore::new(SEND_BUFFER_BYTES));
|
||||
let (data_tx, data_rx) = mpsc::unbounded_channel::<(Vec<u8>, OwnedSemaphorePermit)>();
|
||||
let (ctrl_tx, ctrl_rx) = mpsc::unbounded_channel::<WsMessage>();
|
||||
let cancel = CancellationToken::new();
|
||||
let writer = tokio::spawn(writer_loop(sink, data_rx, ctrl_rx, cancel.clone()));
|
||||
|
||||
Ok(PipeConnection {
|
||||
sender: PipeSender {
|
||||
data_tx,
|
||||
buffer,
|
||||
key: *pipe_key,
|
||||
send_dir,
|
||||
send_ctr: 1,
|
||||
aad: *connection_id,
|
||||
},
|
||||
receiver: PipeReceiver {
|
||||
stream,
|
||||
ctrl_tx,
|
||||
key: *pipe_key,
|
||||
recv_dir,
|
||||
recv_ctr: 1,
|
||||
aad: *connection_id,
|
||||
},
|
||||
cancel,
|
||||
writer,
|
||||
})
|
||||
}
|
||||
|
||||
/// Seal and queue one application chunk (delegates to the write half).
|
||||
pub async fn send(&mut self, plaintext: &[u8]) -> Result<()> {
|
||||
self.sender.send(plaintext).await
|
||||
}
|
||||
|
||||
/// Receive and open the next application chunk (delegates to the read half).
|
||||
/// `Ok(None)` on a clean close.
|
||||
pub async fn recv(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
|
||||
/// Split into independent write/read halves for full-duplex use: move each
|
||||
/// into its own task and the two directions run concurrently. Dropping
|
||||
/// **both** halves tears the pipe down (the writer task closes the socket
|
||||
/// once both of its channels are gone).
|
||||
pub fn split(self) -> (PipeSender, PipeReceiver) {
|
||||
// Detach the writer (drop its JoinHandle); teardown is drop-driven for
|
||||
// this path. `cancel` is dropped without firing — a dropped token does
|
||||
// not cancel — so the writer lives until both halves drop.
|
||||
(self.sender, self.receiver)
|
||||
}
|
||||
|
||||
/// Cancel the writer task and close the underlying socket.
|
||||
pub async fn close(self) {
|
||||
self.cancel.cancel();
|
||||
let _ = self.writer.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeSender {
|
||||
/// Seal and queue one application chunk. Returns once the frame is buffered
|
||||
/// (or, when the ~[`SEND_BUFFER_BYTES`] buffer is full, once space frees up)
|
||||
/// — **not** once it is flushed. The 12-byte nonce is implicit
|
||||
/// (per-direction counter), so it is not transmitted.
|
||||
pub async fn send(&mut self, plaintext: &[u8]) -> Result<()> {
|
||||
let nonce = crypto::build_nonce(self.send_dir, self.send_ctr);
|
||||
let sealed = crypto::seal(&self.key, &nonce, &self.aad, plaintext)
|
||||
.map_err(|e| anyhow!("pipe seal failed: {e}"))?;
|
||||
self.send_ctr += 1;
|
||||
// Reserve the frame's bytes; block here when the buffer is full. Clamp so
|
||||
// a single frame larger than the whole buffer can never deadlock.
|
||||
let want = sealed.len().min(SEND_BUFFER_BYTES) as u32;
|
||||
let permit = Arc::clone(&self.buffer)
|
||||
.acquire_many_owned(want)
|
||||
.await
|
||||
.map_err(|_| anyhow!("pipe send buffer closed"))?;
|
||||
self.data_tx
|
||||
.send((sealed, permit))
|
||||
.map_err(|_| anyhow!("pipe writer stopped"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PipeReceiver {
|
||||
/// Receive and open the next application chunk. `Ok(None)` on a clean close.
|
||||
/// WS-level pings are answered transparently (forwarded to the writer task).
|
||||
pub async fn recv(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
loop {
|
||||
let Some(msg) = self.stream.next().await else { return Ok(None) };
|
||||
match msg? {
|
||||
WsMessage::Binary(data) => {
|
||||
let nonce = crypto::build_nonce(self.recv_dir, self.recv_ctr);
|
||||
let pt = crypto::open(&self.key, &nonce, &self.aad, &data)
|
||||
.map_err(|_| anyhow!("pipe open failed (tag mismatch / desync)"))?;
|
||||
self.recv_ctr += 1;
|
||||
return Ok(Some(pt));
|
||||
}
|
||||
// The writer owns the sink; hand it the Pong. A send error means
|
||||
// the pipe is tearing down anyway — ignore it.
|
||||
WsMessage::Ping(p) => {
|
||||
let _ = self.ctrl_tx.send(WsMessage::Pong(p));
|
||||
}
|
||||
WsMessage::Pong(_) => {}
|
||||
WsMessage::Close(_) => return Ok(None),
|
||||
WsMessage::Text(_) | WsMessage::Frame(_) => {} // pipe is binary-only
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background writer: owns the socket sink and flushes queued frames. Control
|
||||
/// frames (Pong) are prioritized (`biased`) over data so keep-alives are never
|
||||
/// starved by a full data buffer. Each data permit is released **after** the
|
||||
/// frame is flushed, so the buffer measures unflushed bytes. Exits on `cancel`
|
||||
/// or once both channels close (both halves dropped), then closes the socket.
|
||||
async fn writer_loop(
|
||||
mut sink: ClientSink,
|
||||
mut data_rx: mpsc::UnboundedReceiver<(Vec<u8>, OwnedSemaphorePermit)>,
|
||||
mut ctrl_rx: mpsc::UnboundedReceiver<WsMessage>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let mut data_open = true;
|
||||
let mut ctrl_open = true;
|
||||
loop {
|
||||
if !data_open && !ctrl_open {
|
||||
break;
|
||||
}
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => break,
|
||||
msg = ctrl_rx.recv(), if ctrl_open => match msg {
|
||||
Some(m) => {
|
||||
if sink.send(m).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => ctrl_open = false,
|
||||
},
|
||||
msg = data_rx.recv(), if data_open => match msg {
|
||||
Some((bytes, permit)) => {
|
||||
let r = sink.send(WsMessage::Binary(bytes.into())).await;
|
||||
drop(permit); // release the reservation after the flush
|
||||
if r.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => data_open = false,
|
||||
},
|
||||
}
|
||||
}
|
||||
let _ = sink.send(WsMessage::Close(None)).await;
|
||||
let _ = sink.close().await;
|
||||
}
|
||||
|
||||
/// Derive the data-plane URL from the control URL by swapping the path
|
||||
/// `/v1/ws` → `/v1/pipe` (config stores the full control-plane URL).
|
||||
fn pipe_url(relay_url: &str) -> String {
|
||||
if relay_url.contains("/v1/ws") {
|
||||
relay_url.replace("/v1/ws", "/v1/pipe")
|
||||
} else {
|
||||
format!("{}/v1/pipe", relay_url.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read frames until the relay's [`PipeChallenge`]; return the 32-byte nonce.
|
||||
async fn read_challenge(ws: &mut ClientWs) -> Result<[u8; 32]> {
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg? {
|
||||
WsMessage::Binary(data) => {
|
||||
let c: PipeChallenge = pipe::decode(&data)
|
||||
.map_err(|e| anyhow!("malformed pipe challenge: {e}"))?;
|
||||
return pipe::to_array::<32>(&c.nonce)
|
||||
.ok_or_else(|| anyhow!("pipe challenge nonce is not 32 bytes"));
|
||||
}
|
||||
WsMessage::Ping(p) => ws.send(WsMessage::Pong(p)).await?,
|
||||
WsMessage::Close(_) => return Err(anyhow!("relay closed before pipe challenge")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("connection closed before pipe challenge"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pipe_url_swaps_path() {
|
||||
assert_eq!(pipe_url("wss://r.example/v1/ws"), "wss://r.example/v1/pipe");
|
||||
assert_eq!(pipe_url("ws://127.0.0.1:8080/v1/ws"), "ws://127.0.0.1:8080/v1/pipe");
|
||||
assert_eq!(pipe_url("wss://r.example"), "wss://r.example/v1/pipe");
|
||||
}
|
||||
}
|
||||
|
||||
/// Data-plane E2E against the **real** relay server (booted in-process): two
|
||||
/// `PipeConnection`s (initiator + responder, sharing a pre-derived key) dial
|
||||
/// `/v1/pipe`, get matched, and stream sealed bytes both ways through a relay
|
||||
/// that only ever sees ciphertext.
|
||||
#[cfg(test)]
|
||||
mod net_tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use skald_relay_server::config::{Config, PipeConfig};
|
||||
use skald_relay_server::{router, AppState};
|
||||
|
||||
async fn spawn_relay() -> (SocketAddr, AppState) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static C: AtomicU64 = AtomicU64::new(0);
|
||||
let n = C.fetch_add(1, Ordering::Relaxed);
|
||||
let db = std::env::temp_dir().join(format!("relay-pipe-cli-{}-{n}.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.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let serve = state.clone();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router(serve).into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
(addr, state)
|
||||
}
|
||||
|
||||
fn id(seed: u8) -> (ed25519_dalek::SigningKey, [u8; 32]) {
|
||||
let dk = crypto::derive_keys(&[seed; 32]);
|
||||
(dk.signing_key(), dk.ed25519_pub)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipe_connection_streams_through_real_relay() {
|
||||
let (addr, state) = spawn_relay().await;
|
||||
let (agent_sk, agent_ed) = id(0xA1);
|
||||
let (client_sk, client_ed) = id(0xB2);
|
||||
|
||||
// Seed: agent owns the namespace, client is authorized in it.
|
||||
let (ns_raw, ns_hex) = crypto::namespace_id(&agent_ed);
|
||||
state.store.upsert_namespace(&ns_hex, &agent_ed).await.unwrap();
|
||||
let cx = crypto::derive_keys(&[0xC3; 32]).x25519_pub;
|
||||
state.store.upsert_pending_client(&ns_hex, &client_ed, &cx, "", "ios").await.unwrap();
|
||||
state.store.apply_authorize(&ns_hex, &[client_ed]).await.unwrap();
|
||||
|
||||
// A pre-shared per-pipe key (in production: ephemeral DH from signaling).
|
||||
let key = crypto::derive_pipe_key(&[0x07; 32]);
|
||||
let cid = [0x9C; 32];
|
||||
let url = format!("ws://{addr}/v1/ws");
|
||||
|
||||
let mut a = PipeConnection::connect(
|
||||
&url, &agent_sk, &agent_ed, &client_ed, &ns_raw, &cid, &key, PipeRole::Initiator,
|
||||
)
|
||||
.await
|
||||
.expect("initiator connect");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await; // A pending before B
|
||||
let mut b = PipeConnection::connect(
|
||||
&url, &client_sk, &client_ed, &agent_ed, &ns_raw, &cid, &key, PipeRole::Responder,
|
||||
)
|
||||
.await
|
||||
.expect("responder connect");
|
||||
|
||||
// Bytes both ways.
|
||||
a.send(b"ping").await.unwrap();
|
||||
assert_eq!(b.recv().await.unwrap().as_deref(), Some(&b"ping"[..]));
|
||||
b.send(b"pong").await.unwrap();
|
||||
assert_eq!(a.recv().await.unwrap().as_deref(), Some(&b"pong"[..]));
|
||||
|
||||
// A larger blob round-trips intact (seal/open + relay splice).
|
||||
let blob = vec![0x5A_u8; 200_000];
|
||||
a.send(&blob).await.unwrap();
|
||||
assert_eq!(b.recv().await.unwrap(), Some(blob));
|
||||
|
||||
// Closing one tears down the other.
|
||||
a.close().await;
|
||||
assert_eq!(b.recv().await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pipe_wrong_key_fails_to_open() {
|
||||
let (addr, state) = spawn_relay().await;
|
||||
let (agent_sk, agent_ed) = id(0xD4);
|
||||
let (client_sk, client_ed) = id(0xE5);
|
||||
let (ns_raw, ns_hex) = crypto::namespace_id(&agent_ed);
|
||||
state.store.upsert_namespace(&ns_hex, &agent_ed).await.unwrap();
|
||||
let cx = crypto::derive_keys(&[0xF6; 32]).x25519_pub;
|
||||
state.store.upsert_pending_client(&ns_hex, &client_ed, &cx, "", "ios").await.unwrap();
|
||||
state.store.apply_authorize(&ns_hex, &[client_ed]).await.unwrap();
|
||||
|
||||
let cid = [0x1A; 32];
|
||||
let url = format!("ws://{addr}/v1/ws");
|
||||
// Mismatched keys: the relay still splices, but `open` must fail (the
|
||||
// relay never had the plaintext — confidentiality holds end to end).
|
||||
let ka = crypto::derive_pipe_key(&[0x01; 32]);
|
||||
let kb = crypto::derive_pipe_key(&[0x02; 32]);
|
||||
|
||||
let mut a = PipeConnection::connect(
|
||||
&url, &agent_sk, &agent_ed, &client_ed, &ns_raw, &cid, &ka, PipeRole::Initiator,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let mut b = PipeConnection::connect(
|
||||
&url, &client_sk, &client_ed, &agent_ed, &ns_raw, &cid, &kb, PipeRole::Responder,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
a.send(b"secret").await.unwrap();
|
||||
assert!(b.recv().await.is_err(), "wrong key must fail AEAD open");
|
||||
}
|
||||
|
||||
/// Full-duplex: `split` both ends, then stream a large blob **both ways at
|
||||
/// once** — each side sends while it receives. Proves send and recv run
|
||||
/// concurrently (neither direction blocks the other) through the real relay.
|
||||
#[tokio::test]
|
||||
async fn pipe_split_streams_both_directions_concurrently() {
|
||||
let (addr, state) = spawn_relay().await;
|
||||
let (agent_sk, agent_ed) = id(0x11);
|
||||
let (client_sk, client_ed) = id(0x22);
|
||||
let (ns_raw, ns_hex) = crypto::namespace_id(&agent_ed);
|
||||
state.store.upsert_namespace(&ns_hex, &agent_ed).await.unwrap();
|
||||
let cx = crypto::derive_keys(&[0x33; 32]).x25519_pub;
|
||||
state.store.upsert_pending_client(&ns_hex, &client_ed, &cx, "", "ios").await.unwrap();
|
||||
state.store.apply_authorize(&ns_hex, &[client_ed]).await.unwrap();
|
||||
|
||||
let key = crypto::derive_pipe_key(&[0x44; 32]);
|
||||
let cid = [0x55; 32];
|
||||
let url = format!("ws://{addr}/v1/ws");
|
||||
|
||||
let a = PipeConnection::connect(
|
||||
&url, &agent_sk, &agent_ed, &client_ed, &ns_raw, &cid, &key, PipeRole::Initiator,
|
||||
)
|
||||
.await
|
||||
.expect("initiator connect");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await; // A pending before B
|
||||
let b = PipeConnection::connect(
|
||||
&url, &client_sk, &client_ed, &agent_ed, &ns_raw, &cid, &key, PipeRole::Responder,
|
||||
)
|
||||
.await
|
||||
.expect("responder connect");
|
||||
|
||||
let (mut a_tx, mut a_rx) = a.split();
|
||||
let (mut b_tx, mut b_rx) = b.split();
|
||||
|
||||
const CHUNKS: usize = 64;
|
||||
const CHUNK: usize = 16 * 1024; // 64 × 16 KiB = 1 MiB each way
|
||||
const TOTAL: usize = CHUNKS * CHUNK;
|
||||
|
||||
// Both directions send at the same time…
|
||||
let a_send = tokio::spawn(async move {
|
||||
for i in 0..CHUNKS {
|
||||
a_tx.send(&vec![i as u8; CHUNK]).await.unwrap();
|
||||
}
|
||||
});
|
||||
let b_send = tokio::spawn(async move {
|
||||
for i in 0..CHUNKS {
|
||||
b_tx.send(&vec![0xFF - i as u8; CHUNK]).await.unwrap();
|
||||
}
|
||||
});
|
||||
// …while both directions receive concurrently.
|
||||
let a_recv = tokio::spawn(async move {
|
||||
let mut got = 0usize;
|
||||
while got < TOTAL {
|
||||
match a_rx.recv().await.unwrap() {
|
||||
Some(p) => got += p.len(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
got
|
||||
});
|
||||
let b_recv = tokio::spawn(async move {
|
||||
let mut got = 0usize;
|
||||
while got < TOTAL {
|
||||
match b_rx.recv().await.unwrap() {
|
||||
Some(p) => got += p.len(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
got
|
||||
});
|
||||
|
||||
a_send.await.unwrap();
|
||||
b_send.await.unwrap();
|
||||
assert_eq!(a_recv.await.unwrap(), TOTAL, "A must receive all of B's bytes");
|
||||
assert_eq!(b_recv.await.unwrap(), TOTAL, "B must receive all of A's bytes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! Networking-only shared state, owned behind an `Arc` and shared by the WS
|
||||
//! loop, the pairing/authorization surface, and the QR lookup. Everything here
|
||||
//! is transport + crypto + the device registry: there is **no** knowledge of
|
||||
//! what the decrypted bytes mean (the payload-agnostic boundary). Decoded
|
||||
//! inbound bytes and lifecycle transitions are surfaced via [`RelayEvent`].
|
||||
//!
|
||||
//! The wire transport is **v2 protobuf** (docs/relay/relay-protocol.md): every
|
||||
//! frame queued onto the WS outbound channel is the
|
||||
//! `prost::Message::encode_to_vec()` of a `RelayFrame`. E2E plaintexts are
|
||||
//! wrapped in the v2 framing (`compress_payload`) before sealing, and peeled
|
||||
//! (`decompress_payload`) before being emitted, so consumers only ever see the
|
||||
//! clean inner payload.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use prost::Message as _;
|
||||
use rand::RngCore;
|
||||
use skald_relay_common::crypto::{self, DIR_AGENT_TO_CLIENT, DIR_CLIENT_TO_AGENT};
|
||||
use skald_relay_common::pipe::{PipeAccept, PipeInvite, PipeReject, PipeSignal, PipeSuite, to_array};
|
||||
use skald_relay_common::proto::v2::*;
|
||||
use skald_relay_common::proto::v2::relay_frame::Frame;
|
||||
use sqlx::SqlitePool;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::db::{self, ClientRow, ClientState};
|
||||
use crate::events::RelayEvent;
|
||||
use crate::identity::Identity;
|
||||
use crate::pairing::{PairingStore, QrCodeData, SessionState, StartedPairing};
|
||||
use crate::pipe::{IncomingPipe, PipeConnection, PipeRole};
|
||||
|
||||
/// How many inbound pipe invites the broadcast buffers before lagging.
|
||||
const INCOMING_PIPE_CHANNEL_CAP: usize = 64;
|
||||
/// How long `open_pipe` waits for a `pipe_accept` before giving up.
|
||||
const PIPE_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Networking config snapshot the runtime needs.
|
||||
pub(crate) struct StateConfig {
|
||||
pub relay_url: String,
|
||||
pub pairing_ttl: u32,
|
||||
}
|
||||
|
||||
/// Everything the runloop and surfaces share. Payload-agnostic.
|
||||
pub(crate) struct RelayState {
|
||||
identity: Identity,
|
||||
db: Arc<SqlitePool>,
|
||||
pairing: PairingStore,
|
||||
config: StateConfig,
|
||||
/// Sender into the WS outbound queue. `None` until the loop is started.
|
||||
/// Carries **encoded protobuf bytes** ready to be wrapped in
|
||||
/// `Message::Binary` by the WS layer (v2 transport).
|
||||
outbound: Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>,
|
||||
/// Cache of per-client aes_key, keyed by ed25519 pubkey (crypto.md §8).
|
||||
/// Derived from the seed + the client's x25519 pubkey; never persisted.
|
||||
aes_cache: Mutex<HashMap<[u8; 32], [u8; 32]>>,
|
||||
connected: AtomicBool,
|
||||
/// Broadcast sink for [`RelayEvent`]s consumed by the application layer.
|
||||
events_tx: broadcast::Sender<RelayEvent>,
|
||||
/// Pending `open_pipe` waiters: connection_id → accept/reject delivery
|
||||
/// (docs/relay/pipe.md §1). The initiator parks here until the peer replies.
|
||||
pipe_waiters: Mutex<HashMap<[u8; 32], oneshot::Sender<Result<PipeAccept, String>>>>,
|
||||
/// Broadcast of inbound `pipe_invite`s (responder side). The consumer calls
|
||||
/// `accept_pipe`/`reject_pipe`. Single-consumer expected.
|
||||
incoming_pipes_tx: broadcast::Sender<IncomingPipe>,
|
||||
}
|
||||
|
||||
impl RelayState {
|
||||
pub(crate) fn new(
|
||||
identity: Identity,
|
||||
db: Arc<SqlitePool>,
|
||||
config: StateConfig,
|
||||
events_tx: broadcast::Sender<RelayEvent>,
|
||||
) -> Self {
|
||||
let (incoming_pipes_tx, _) = broadcast::channel(INCOMING_PIPE_CHANNEL_CAP);
|
||||
Self {
|
||||
identity,
|
||||
db,
|
||||
pairing: PairingStore::new(),
|
||||
config,
|
||||
outbound: Mutex::new(None),
|
||||
aes_cache: Mutex::new(HashMap::new()),
|
||||
connected: AtomicBool::new(false),
|
||||
events_tx,
|
||||
pipe_waiters: Mutex::new(HashMap::new()),
|
||||
incoming_pipes_tx,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn identity(&self) -> &Identity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub(crate) fn relay_url(&self) -> String {
|
||||
self.config.relay_url.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn default_pairing_ttl(&self) -> u32 {
|
||||
self.config.pairing_ttl
|
||||
}
|
||||
|
||||
/// Emit a [`RelayEvent`]; ignores the "no subscribers" case.
|
||||
pub(crate) fn emit(&self, ev: RelayEvent) {
|
||||
let _ = self.events_tx.send(ev);
|
||||
}
|
||||
|
||||
pub(crate) fn subscribe(&self) -> broadcast::Receiver<RelayEvent> {
|
||||
self.events_tx.subscribe()
|
||||
}
|
||||
|
||||
pub(crate) fn set_connected(&self, v: bool) {
|
||||
let was = self.connected.swap(v, Ordering::Relaxed);
|
||||
if was != v {
|
||||
self.emit(if v { RelayEvent::Connected } else { RelayEvent::Disconnected });
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_connected(&self) -> bool {
|
||||
self.connected.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub(crate) fn set_outbound(&self, tx: mpsc::UnboundedSender<Vec<u8>>) {
|
||||
*self.outbound.lock().unwrap() = Some(tx);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_outbound(&self) {
|
||||
*self.outbound.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// Queue an already-encoded `RelayFrame` onto the WS outbound channel.
|
||||
fn send_frame(&self, bytes: Vec<u8>) -> Result<()> {
|
||||
let guard = self.outbound.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(tx) => tx
|
||||
.send(bytes)
|
||||
.map_err(|_| anyhow::anyhow!("WS outbound channel closed")),
|
||||
None => Err(anyhow::anyhow!("WS not started")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn authorized_pubkeys_hex(&self) -> Result<Vec<String>> {
|
||||
db::authorized_pubkeys_hex(&self.db).await
|
||||
}
|
||||
|
||||
/// Re-send the full authorize set (replacement semantics,
|
||||
/// relay-protocol.md §7). v2: each client pubkey travels as a raw 32-byte
|
||||
/// `bytes` field.
|
||||
async fn send_authorize(&self) -> Result<()> {
|
||||
let clients_hex = db::authorized_pubkeys_hex(&self.db).await?;
|
||||
let clients: Vec<prost::bytes::Bytes> = clients_hex
|
||||
.iter()
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.map(prost::bytes::Bytes::from)
|
||||
.collect();
|
||||
let frame = RelayFrame {
|
||||
frame: Some(Frame::Authorize(Authorize { clients })),
|
||||
};
|
||||
self.send_frame(frame.encode_to_vec())
|
||||
}
|
||||
|
||||
// ── Pairing ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Open a pairing window: generate a token, send `pairing_start`, register
|
||||
/// the in-memory session (latest-wins). Returns the handle for the QR URL.
|
||||
pub(crate) async fn start_pairing(&self, ttl_secs: u32) -> Result<StartedPairing> {
|
||||
let started = self.pairing.start(
|
||||
&self.config.relay_url,
|
||||
self.identity.namespace_id_hex(),
|
||||
&self.identity.ed25519_pub(),
|
||||
&self.identity.x25519_pub(),
|
||||
ttl_secs,
|
||||
);
|
||||
let frame = RelayFrame {
|
||||
frame: Some(Frame::PairingStart(PairingStart {
|
||||
pairing_token: prost::bytes::Bytes::copy_from_slice(&started.token),
|
||||
ttl: ttl_secs,
|
||||
})),
|
||||
};
|
||||
self.send_frame(frame.encode_to_vec())?;
|
||||
debug!(crate_name = "skald-relay-client", ttl_secs, "pairing window opened");
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
/// Close the pairing window locally and tell the relay.
|
||||
pub(crate) async fn stop_pairing(&self) -> Result<()> {
|
||||
self.pairing.supersede_all();
|
||||
let frame = RelayFrame {
|
||||
frame: Some(Frame::PairingStop(PairingStop {})),
|
||||
};
|
||||
self.send_frame(frame.encode_to_vec())
|
||||
}
|
||||
|
||||
/// Look up a pairing session for the QR endpoint.
|
||||
pub(crate) fn lookup_pairing(&self, code: &str) -> Option<(QrCodeData, SessionState)> {
|
||||
self.pairing.lookup(code)
|
||||
}
|
||||
|
||||
/// Handle `client_paired` (relay-protocol.md §6 step 7): derive aes_key,
|
||||
/// persist the client as Pending, consume the pairing session, then emit
|
||||
/// [`RelayEvent::ClientPaired`]. The **authorization policy is the
|
||||
/// consumer's** — this layer never auto-authorizes.
|
||||
pub(crate) async fn handle_client_paired(
|
||||
&self,
|
||||
client_ed25519_pub: &[u8; 32],
|
||||
client_x25519_pub: &[u8; 32],
|
||||
platform: &str,
|
||||
) {
|
||||
let ed = *client_ed25519_pub;
|
||||
let x = *client_x25519_pub;
|
||||
|
||||
// Derive + cache the per-client aes_key.
|
||||
let aes_key = self.identity.derive_aes_key(&x);
|
||||
self.aes_cache.lock().unwrap().insert(ed, aes_key);
|
||||
|
||||
// Persist as Pending with counters at 0.
|
||||
if let Err(e) = db::upsert_paired(&self.db, &ed, &x, Some(platform)).await {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "failed to persist paired client");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the active pairing session as consumed.
|
||||
if let Some(tok) = self.pairing.active_token() {
|
||||
self.pairing.consume_by_token(&tok);
|
||||
}
|
||||
|
||||
self.emit(RelayEvent::ClientPaired {
|
||||
ed25519_pub: ed,
|
||||
x25519_pub: x,
|
||||
platform: platform.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Mark a client Authorized and push the updated authorize set. Does NOT
|
||||
/// broadcast any application payload — that is the consumer's job after
|
||||
/// authorizing (the client is payload-agnostic).
|
||||
pub(crate) async fn authorize(&self, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
db::set_authorized(&self.db, ed25519_pub).await?;
|
||||
self.send_authorize().await?;
|
||||
debug!(crate_name = "skald-relay-client", device = %hex::encode(ed25519_pub), "device authorized");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revoke a client (relay-protocol.md §7): drop from the set, re-authorize
|
||||
/// without it, delete its keys/counters/device_info, emit `ClientRevoked`.
|
||||
pub(crate) async fn revoke(&self, ed25519_pub: &[u8; 32]) -> Result<()> {
|
||||
db::delete(&self.db, ed25519_pub).await?;
|
||||
self.aes_cache.lock().unwrap().remove(ed25519_pub);
|
||||
self.send_authorize().await?;
|
||||
debug!(crate_name = "skald-relay-client", device = %hex::encode(ed25519_pub), "device revoked");
|
||||
self.emit(RelayEvent::ClientRevoked { ed25519_pub: *ed25519_pub });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every device, clear the aes cache, and push an empty authorize
|
||||
/// set. Emits one `ClientRevoked` per removed device.
|
||||
pub(crate) async fn clear_all(&self) -> Result<()> {
|
||||
let removed = db::list_all(&self.db).await.unwrap_or_default();
|
||||
db::delete_all(&self.db).await?;
|
||||
self.aes_cache.lock().unwrap().clear();
|
||||
self.send_authorize().await?;
|
||||
for c in removed {
|
||||
self.emit(RelayEvent::ClientRevoked { ed25519_pub: c.ed25519_pub });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the device_info JSON for a client (from a `hello` payload, decoded
|
||||
/// by the consumer).
|
||||
pub(crate) async fn set_device_info(&self, ed25519_pub: &[u8; 32], json: &str) -> Result<()> {
|
||||
db::set_device_info(&self.db, ed25519_pub, json).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_clients(&self) -> Vec<ClientRow> {
|
||||
db::list_all(&self.db).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── E2E: aes_key cache ────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve (and cache) the aes_key for a client, deriving from the stored
|
||||
/// x25519 pubkey on a cache miss.
|
||||
async fn aes_key_for(&self, ed25519_pub: &[u8; 32]) -> Option<[u8; 32]> {
|
||||
if let Some(k) = self.aes_cache.lock().unwrap().get(ed25519_pub) {
|
||||
return Some(*k);
|
||||
}
|
||||
let row = db::get(&self.db, ed25519_pub).await.ok().flatten()?;
|
||||
let key = self.identity.derive_aes_key(&row.x25519_pub);
|
||||
self.aes_cache.lock().unwrap().insert(*ed25519_pub, key);
|
||||
Some(key)
|
||||
}
|
||||
|
||||
// ── Send ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Seal an opaque `payload` to one client and queue the `message` frame.
|
||||
///
|
||||
/// v2 transport: the payload is wrapped in the `version ‖ comp ‖ payload`
|
||||
/// framing (`compress_payload`) before sealing, then wrapped in
|
||||
/// `RelayFrame{Message{ciphertext, nonce, peer, live}}`. `live=true` routes
|
||||
/// or fails (the peer is online by construction); `live=false` stores-and-
|
||||
/// forwards + pushes for offline phones.
|
||||
pub(crate) async fn send_to_client(
|
||||
&self,
|
||||
client_ed25519_pub: &[u8; 32],
|
||||
payload: &[u8],
|
||||
live: bool,
|
||||
) -> Result<()> {
|
||||
// v2 framing: version(1B) ‖ comp(1B) ‖ payload (compresses over threshold).
|
||||
let framed = crypto::compress_payload(payload);
|
||||
self.seal_and_queue(client_ed25519_pub, &framed, live).await
|
||||
}
|
||||
|
||||
/// Seal an already-framed plaintext to `dest` and queue the `message` frame.
|
||||
/// Shared by [`send_to_client`](Self::send_to_client) (v2 app framing) and
|
||||
/// [`send_pipe_signal`](Self::send_pipe_signal) (pipe framing).
|
||||
async fn seal_and_queue(&self, dest: &[u8; 32], framed: &[u8], live: bool) -> Result<()> {
|
||||
let aes_key = self
|
||||
.aes_key_for(dest)
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("no aes_key for client"))?;
|
||||
|
||||
// Persist the send counter BEFORE sealing/sending (crypto.md §8/§9):
|
||||
// a crash after this point never reuses a nonce.
|
||||
let counter = db::next_send_counter(&self.db, dest).await?;
|
||||
let nonce = crypto::build_nonce(DIR_AGENT_TO_CLIENT, counter);
|
||||
let aad = crypto::build_aad(
|
||||
&self.identity.namespace_id_raw(),
|
||||
&self.identity.ed25519_pub(),
|
||||
dest,
|
||||
);
|
||||
let sealed = crypto::seal(&aes_key, &nonce, &aad, framed)
|
||||
.map_err(|e| anyhow!("seal failed: {e}"))?;
|
||||
|
||||
let frame = RelayFrame {
|
||||
frame: Some(Frame::Message(Message {
|
||||
ciphertext: prost::bytes::Bytes::from(sealed),
|
||||
nonce: prost::bytes::Bytes::copy_from_slice(&nonce),
|
||||
peer: prost::bytes::Bytes::copy_from_slice(dest),
|
||||
live,
|
||||
})),
|
||||
};
|
||||
self.send_frame(frame.encode_to_vec())
|
||||
}
|
||||
|
||||
/// Seal + queue a pipe-signaling message (docs/relay/pipe.md §1) over the E2E
|
||||
/// channel, wrapped in the reserved pipe framing so the peer routes it to its
|
||||
/// pipe layer. Always `live` (a stale invite is useless, pipe.md §1).
|
||||
async fn send_pipe_signal(&self, dest: &[u8; 32], signal: &PipeSignal) -> Result<()> {
|
||||
let framed = crypto::frame_pipe_signal(&skald_relay_common::pipe::encode(signal));
|
||||
self.seal_and_queue(dest, &framed, true).await
|
||||
}
|
||||
|
||||
// ── Receive ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handle an inbound `message` (relay-protocol.md §3.1): authorize the
|
||||
/// sender, check nonce direction + counter monotonicity, open, advance the
|
||||
/// recv counter, peel the v2 framing, then emit [`RelayEvent::Message`] with
|
||||
/// the clean inner payload. The client never inspects the payload contents.
|
||||
pub(crate) async fn handle_inbound_message(
|
||||
&self,
|
||||
from: &[u8; 32],
|
||||
nonce: &[u8; 12],
|
||||
ciphertext: &[u8],
|
||||
live: bool,
|
||||
) {
|
||||
// `from` must be an Authorized client.
|
||||
let row = match db::get(&self.db, from).await {
|
||||
Ok(Some(r)) if r.state == ClientState::Authorized => r,
|
||||
_ => {
|
||||
warn!(crate_name = "skald-relay-client", "message from non-authorized sender dropped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the counter from the nonce and check direction + monotonicity.
|
||||
if nonce[..4] != DIR_CLIENT_TO_AGENT {
|
||||
warn!(crate_name = "skald-relay-client", "message with wrong nonce direction dropped");
|
||||
return;
|
||||
}
|
||||
let counter = u64::from_be_bytes(nonce[4..].try_into().unwrap());
|
||||
if counter <= row.recv_counter {
|
||||
warn!(crate_name = "skald-relay-client", "replayed/old counter dropped");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(aes_key) = self.aes_key_for(from).await else { return };
|
||||
let aad = crypto::build_aad(
|
||||
&self.identity.namespace_id_raw(),
|
||||
from,
|
||||
&self.identity.ed25519_pub(),
|
||||
);
|
||||
let framed = match crypto::open(&aes_key, nonce, &aad, ciphertext) {
|
||||
Ok(pt) => pt,
|
||||
Err(_) => {
|
||||
// No content logging on decrypt failure (crypto.md §8).
|
||||
warn!(crate_name = "skald-relay-client", "decrypt failed, message dropped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Valid open → advance recv_counter.
|
||||
if let Err(e) = db::set_recv_counter(&self.db, from, counter).await {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "failed to persist recv_counter");
|
||||
}
|
||||
|
||||
// Pipe signaling rides this same E2E channel under a reserved framing
|
||||
// version (crypto::FRAMING_VERSION_PIPE). Route it to the pipe layer
|
||||
// instead of emitting a Message; all other payloads stay pass-through.
|
||||
if crypto::is_pipe_signal(&framed) {
|
||||
match crypto::unframe_pipe_signal(&framed) {
|
||||
Some(body) => self.handle_pipe_signal(from, body),
|
||||
None => warn!(crate_name = "skald-relay-client", "malformed pipe signal framing dropped"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Peel the v2 framing so the consumer sees the clean inner payload.
|
||||
let payload = match crypto::decompress_payload(&framed) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "framing decompress failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
self.emit(RelayEvent::Message { from: *from, payload, live });
|
||||
}
|
||||
|
||||
// ── Pipe control plane (docs/relay/pipe.md §1, §3) ────────────────────────
|
||||
|
||||
/// Subscribe to inbound `pipe_invite`s (responder side). Single-consumer
|
||||
/// expected: the consumer accepts/rejects each pipe exactly once.
|
||||
pub(crate) fn incoming_pipes(&self) -> broadcast::Receiver<IncomingPipe> {
|
||||
self.incoming_pipes_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Route a decoded pipe-signaling message: invites surface to the app via the
|
||||
/// incoming-pipes broadcast; accept/reject wake the matching `open_pipe`
|
||||
/// waiter. This is the only payload kind the otherwise payload-agnostic client
|
||||
/// interprets (it owns the pipe control plane end-to-end).
|
||||
fn handle_pipe_signal(&self, from: &[u8; 32], body: &[u8]) {
|
||||
let signal: PipeSignal = match skald_relay_common::pipe::decode(body) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "malformed pipe signal dropped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match signal {
|
||||
PipeSignal::Invite(inv) => {
|
||||
let Some(connection_id) = to_array::<32>(&inv.connection_id) else {
|
||||
warn!(crate_name = "skald-relay-client", "pipe invite with bad connection_id");
|
||||
return;
|
||||
};
|
||||
let _ = self.incoming_pipes_tx.send(IncomingPipe {
|
||||
from: *from,
|
||||
stream_type: inv.stream_type,
|
||||
headers: inv.headers,
|
||||
connection_id,
|
||||
suite: inv.suite,
|
||||
peer_handshake: inv.handshake,
|
||||
});
|
||||
}
|
||||
PipeSignal::Accept(acc) => {
|
||||
if let Some(cid) = to_array::<32>(&acc.connection_id)
|
||||
&& let Some(tx) = self.pipe_waiters.lock().unwrap().remove(&cid)
|
||||
{
|
||||
let _ = tx.send(Ok(acc));
|
||||
}
|
||||
}
|
||||
PipeSignal::Reject(rej) => {
|
||||
if let Some(cid) = to_array::<32>(&rej.connection_id)
|
||||
&& let Some(tx) = self.pipe_waiters.lock().unwrap().remove(&cid)
|
||||
{
|
||||
let _ = tx.send(Err(rej.reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initiator: open a pipe to `peer`. Generates an ephemeral X25519, sends
|
||||
/// `pipe_invite`, waits for `pipe_accept`, derives the per-pipe key (PFS),
|
||||
/// then dials the data plane.
|
||||
pub(crate) async fn open_pipe(
|
||||
&self,
|
||||
peer: &[u8; 32],
|
||||
stream_type: &str,
|
||||
headers: BTreeMap<String, String>,
|
||||
) -> Result<PipeConnection> {
|
||||
let mut eph_priv = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut eph_priv);
|
||||
let eph_pub = crypto::x25519_pubkey(&eph_priv);
|
||||
let mut connection_id = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut connection_id);
|
||||
|
||||
let rx = self.register_pipe_waiter(connection_id);
|
||||
let invite = PipeSignal::Invite(PipeInvite {
|
||||
connection_id: connection_id.to_vec(),
|
||||
suite: PipeSuite::X25519Sealed,
|
||||
handshake: eph_pub.to_vec(),
|
||||
stream_type: stream_type.to_string(),
|
||||
compress: vec![skald_relay_common::pipe::PipeCompress::None],
|
||||
headers,
|
||||
});
|
||||
if let Err(e) = self.send_pipe_signal(peer, &invite).await {
|
||||
self.pipe_waiters.lock().unwrap().remove(&connection_id);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let accept = match tokio::time::timeout(PIPE_ACCEPT_TIMEOUT, rx).await {
|
||||
Ok(Ok(Ok(acc))) => acc,
|
||||
Ok(Ok(Err(reason))) => return Err(anyhow!("pipe rejected by peer: {reason}")),
|
||||
_ => {
|
||||
self.pipe_waiters.lock().unwrap().remove(&connection_id);
|
||||
return Err(anyhow!("pipe accept timed out"));
|
||||
}
|
||||
};
|
||||
let peer_eph = to_array::<32>(&accept.handshake)
|
||||
.ok_or_else(|| anyhow!("pipe accept has a bad ephemeral key"))?;
|
||||
let pipe_key = crypto::derive_pipe_key(&crypto::ecdh(&eph_priv, &peer_eph));
|
||||
|
||||
PipeConnection::connect(
|
||||
&self.relay_url(),
|
||||
&self.identity.signing_key(),
|
||||
&self.identity.ed25519_pub(),
|
||||
peer,
|
||||
&self.identity.namespace_id_raw(),
|
||||
&connection_id,
|
||||
&pipe_key,
|
||||
PipeRole::Initiator,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Responder: accept an inbound invite. Replies with `pipe_accept`, derives
|
||||
/// the per-pipe key, then dials the data plane.
|
||||
pub(crate) async fn accept_pipe(&self, incoming: &IncomingPipe) -> Result<PipeConnection> {
|
||||
// v1 supports only the X25519Sealed suite; a future Noise suite is a new
|
||||
// arm here (the wire shape is unchanged — pipe.md forward-compat).
|
||||
if incoming.suite != PipeSuite::X25519Sealed {
|
||||
return Err(anyhow!("unsupported pipe suite"));
|
||||
}
|
||||
let peer_eph = to_array::<32>(&incoming.peer_handshake)
|
||||
.ok_or_else(|| anyhow!("pipe invite has a bad ephemeral key"))?;
|
||||
let mut eph_priv = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut eph_priv);
|
||||
let eph_pub = crypto::x25519_pubkey(&eph_priv);
|
||||
let pipe_key = crypto::derive_pipe_key(&crypto::ecdh(&eph_priv, &peer_eph));
|
||||
|
||||
let accept = PipeSignal::Accept(PipeAccept {
|
||||
connection_id: incoming.connection_id.to_vec(),
|
||||
suite: PipeSuite::X25519Sealed,
|
||||
handshake: eph_pub.to_vec(),
|
||||
compress: skald_relay_common::pipe::PipeCompress::None,
|
||||
});
|
||||
self.send_pipe_signal(&incoming.from, &accept).await?;
|
||||
|
||||
PipeConnection::connect(
|
||||
&self.relay_url(),
|
||||
&self.identity.signing_key(),
|
||||
&self.identity.ed25519_pub(),
|
||||
&incoming.from,
|
||||
&self.identity.namespace_id_raw(),
|
||||
&incoming.connection_id,
|
||||
&pipe_key,
|
||||
PipeRole::Responder,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Decline an inbound invite (sends `pipe_reject`).
|
||||
pub(crate) async fn reject_pipe(&self, incoming: &IncomingPipe, reason: &str) -> Result<()> {
|
||||
let reject = PipeSignal::Reject(PipeReject {
|
||||
connection_id: incoming.connection_id.to_vec(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
self.send_pipe_signal(&incoming.from, &reject).await
|
||||
}
|
||||
|
||||
/// Register an `open_pipe` waiter keyed by `connection_id`; the inbound
|
||||
/// `pipe_accept`/`pipe_reject` resolves it.
|
||||
fn register_pipe_waiter(
|
||||
&self,
|
||||
connection_id: [u8; 32],
|
||||
) -> oneshot::Receiver<Result<PipeAccept, String>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.pipe_waiters.lock().unwrap().insert(connection_id, tx);
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pipe_signal_tests {
|
||||
use super::*;
|
||||
use skald_relay_common::pipe::PipeCompress;
|
||||
|
||||
async fn make_state() -> RelayState {
|
||||
let db = std::env::temp_dir().join(format!("relay-cli-state-{}.db", std::process::id()));
|
||||
let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", db.display()))
|
||||
.await
|
||||
.unwrap();
|
||||
db::init(&pool).await.unwrap();
|
||||
let (events_tx, _) = broadcast::channel(16);
|
||||
RelayState::new(
|
||||
Identity::from_seed(&[1u8; 32]),
|
||||
Arc::new(pool),
|
||||
StateConfig { relay_url: String::new(), pairing_ttl: 300 },
|
||||
events_tx,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invite_surfaces_on_incoming_pipes() {
|
||||
let st = make_state().await;
|
||||
let mut rx = st.incoming_pipes();
|
||||
let invite = PipeSignal::Invite(PipeInvite {
|
||||
connection_id: vec![7; 32],
|
||||
suite: PipeSuite::X25519Sealed,
|
||||
handshake: vec![8; 32],
|
||||
stream_type: "log".into(),
|
||||
compress: vec![PipeCompress::None],
|
||||
headers: BTreeMap::from([("k".into(), "v".into())]),
|
||||
});
|
||||
st.handle_pipe_signal(&[2u8; 32], &skald_relay_common::pipe::encode(&invite));
|
||||
let got = rx.try_recv().expect("invite surfaced");
|
||||
assert_eq!(got.from, [2u8; 32]);
|
||||
assert_eq!(got.stream_type, "log");
|
||||
assert_eq!(got.connection_id, [7u8; 32]);
|
||||
assert_eq!(got.headers.get("k").map(String::as_str), Some("v"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_resolves_the_waiter() {
|
||||
let st = make_state().await;
|
||||
let cid = [3u8; 32];
|
||||
let rx = st.register_pipe_waiter(cid);
|
||||
let accept = PipeSignal::Accept(PipeAccept {
|
||||
connection_id: cid.to_vec(),
|
||||
suite: PipeSuite::X25519Sealed,
|
||||
handshake: vec![9; 32],
|
||||
compress: PipeCompress::None,
|
||||
});
|
||||
st.handle_pipe_signal(&[2u8; 32], &skald_relay_common::pipe::encode(&accept));
|
||||
let resolved = rx.await.expect("waiter not dropped");
|
||||
assert_eq!(resolved.expect("accept ok").handshake, vec![9; 32]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_resolves_waiter_with_reason() {
|
||||
let st = make_state().await;
|
||||
let cid = [4u8; 32];
|
||||
let rx = st.register_pipe_waiter(cid);
|
||||
let reject = PipeSignal::Reject(PipeReject { connection_id: cid.to_vec(), reason: "busy".into() });
|
||||
st.handle_pipe_signal(&[2u8; 32], &skald_relay_common::pipe::encode(&reject));
|
||||
assert_eq!(rx.await.expect("waiter").unwrap_err(), "busy");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_connection_id_is_ignored() {
|
||||
let st = make_state().await;
|
||||
// An accept for a connection_id with no waiter must not panic.
|
||||
let accept = PipeSignal::Accept(PipeAccept {
|
||||
connection_id: vec![0xEE; 32],
|
||||
suite: PipeSuite::X25519Sealed,
|
||||
handshake: vec![0; 32],
|
||||
compress: PipeCompress::None,
|
||||
});
|
||||
st.handle_pipe_signal(&[2u8; 32], &skald_relay_common::pipe::encode(&accept));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! The permanent agent WebSocket toward the relay, speaking **v2 protobuf**
|
||||
//! (docs/relay/relay-protocol.md).
|
||||
//!
|
||||
//! A single WS carries everything: challenge-response auth, the `Authorize` set,
|
||||
//! outbound E2E `Message`s, and inbound `Message` / `ClientPaired` frames. v2
|
||||
//! transport is **binary-only**: every wire frame is a `RelayFrame` protobuf
|
||||
//! message wrapped in `Message::Binary`; WS-level `Ping`/`Pong`/`Close` are
|
||||
//! their own `WsMessage` variants and never appear as protobuf.
|
||||
//!
|
||||
//! Reconnection uses exponential backoff (1,2,4,…,60 s) with jitter, and the
|
||||
//! whole loop is cancellable on stop.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as _;
|
||||
use rand::Rng;
|
||||
use skald_relay_common::crypto;
|
||||
use skald_relay_common::proto::v2::*;
|
||||
use skald_relay_common::proto::v2::relay_frame::Frame;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::state::RelayState;
|
||||
|
||||
/// Run the reconnecting WS loop until `cancel` fires (relay-protocol.md §8).
|
||||
pub(crate) async fn run_loop(
|
||||
state: Arc<RelayState>,
|
||||
mut outbound_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
cancel: CancellationToken,
|
||||
) {
|
||||
let mut backoff_step: u32 = 0;
|
||||
loop {
|
||||
if cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
match connect_once(&state, &mut outbound_rx, &cancel).await {
|
||||
Ok(()) => {
|
||||
// Clean disconnect (cancelled or graceful): reset backoff.
|
||||
backoff_step = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "relay connection ended");
|
||||
}
|
||||
}
|
||||
|
||||
if cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let delay = backoff_delay(backoff_step);
|
||||
backoff_step = backoff_step.saturating_add(1);
|
||||
state.set_connected(false);
|
||||
debug!(crate_name = "skald-relay-client", secs = delay.as_secs_f64(), "reconnect backoff");
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => return,
|
||||
_ = tokio::time::sleep(delay) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backoff schedule 1,2,4,…,60 s plus up to 50% jitter (relay-protocol.md §8).
|
||||
fn backoff_delay(step: u32) -> Duration {
|
||||
let base = 1u64.checked_shl(step).unwrap_or(60).min(60);
|
||||
let jitter_ms = rand::rng().random_range(0..=(base * 500));
|
||||
Duration::from_millis(base * 1000 + jitter_ms)
|
||||
}
|
||||
|
||||
/// One full connection lifecycle: connect → challenge → auth → authorize → loop.
|
||||
async fn connect_once(
|
||||
state: &Arc<RelayState>,
|
||||
outbound_rx: &mut mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<()> {
|
||||
let url = state.relay_url();
|
||||
info!(crate_name = "skald-relay-client", %url, "connecting to relay");
|
||||
|
||||
let (ws_stream, _resp) = tokio::select! {
|
||||
_ = cancel.cancelled() => return Ok(()),
|
||||
r = tokio_tungstenite::connect_async(&url) => r?,
|
||||
};
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
// 1. Wait for the relay's challenge (it speaks first, relay-protocol.md §4).
|
||||
let challenge_nonce = wait_for_challenge(&mut stream).await?;
|
||||
|
||||
// 2. Sign AUTH_DOMAIN ‖ 0x00 ‖ nonce and send the agent Auth frame.
|
||||
let sig = crypto::sign_challenge(&state.identity().signing_key(), &challenge_nonce);
|
||||
let auth = RelayFrame {
|
||||
frame: Some(Frame::Auth(Auth {
|
||||
role: Some(auth::Role::Agent(AuthAgent {
|
||||
agent_ed25519_pub: prost::bytes::Bytes::copy_from_slice(
|
||||
&state.identity().ed25519_pub(),
|
||||
),
|
||||
})),
|
||||
signature: prost::bytes::Bytes::copy_from_slice(&sig),
|
||||
})),
|
||||
};
|
||||
sink.send(WsMessage::Binary(auth.encode_to_vec().into())).await?;
|
||||
|
||||
// 3. Expect AuthOk and verify the namespace_id locally.
|
||||
let ns_raw = wait_for_auth_ok(&mut stream).await?;
|
||||
if ns_raw != state.identity().namespace_id_raw() {
|
||||
return Err(anyhow!(
|
||||
"relay returned mismatched namespace_id (got {}, expected {})",
|
||||
hex::encode(ns_raw),
|
||||
hex::encode(state.identity().namespace_id_raw())
|
||||
));
|
||||
}
|
||||
info!(crate_name = "skald-relay-client", "relay auth ok, namespace verified");
|
||||
state.set_connected(true);
|
||||
|
||||
// 4. Send the current authorize set from the DB (empty on first run).
|
||||
// We push it directly via the sink rather than through `outbound_rx` so it
|
||||
// lands immediately — the queue is only drained inside the main loop below.
|
||||
let authorized = state.authorized_pubkeys_hex().await.unwrap_or_default();
|
||||
let clients: Vec<prost::bytes::Bytes> = authorized
|
||||
.iter()
|
||||
.filter_map(|h| hex::decode(h).ok())
|
||||
.map(prost::bytes::Bytes::from)
|
||||
.collect();
|
||||
let authorize = RelayFrame {
|
||||
frame: Some(Frame::Authorize(Authorize { clients })),
|
||||
};
|
||||
sink.send(WsMessage::Binary(authorize.encode_to_vec().into())).await?;
|
||||
|
||||
// 5. Main dispatch loop: outbound queue, inbound frames, WS-level Ping/Pong.
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => {
|
||||
let _ = sink.send(WsMessage::Close(None)).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Outbound: already-encoded protobuf frames queued by pairing / send
|
||||
// / revoke. The channel carries `Vec<u8>` ready to be shipped as a
|
||||
// binary WS frame.
|
||||
maybe = outbound_rx.recv() => {
|
||||
match maybe {
|
||||
Some(bytes) => sink.send(WsMessage::Binary(bytes.into())).await?,
|
||||
None => return Ok(()), // channel closed → client stopping
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound: relay → agent frames.
|
||||
maybe = stream.next() => {
|
||||
let Some(msg) = maybe else { return Ok(()) }; // stream ended
|
||||
match msg? {
|
||||
WsMessage::Binary(data) => {
|
||||
handle_incoming(state, &data).await;
|
||||
}
|
||||
WsMessage::Ping(p) => sink.send(WsMessage::Pong(p)).await?,
|
||||
WsMessage::Pong(_) => {}
|
||||
WsMessage::Close(_) => return Ok(()),
|
||||
WsMessage::Text(_) | WsMessage::Frame(_) => {
|
||||
// v2 transport is binary-only; ignore text/frame
|
||||
// variants (forward-compat, no protocol-defined reaction).
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read binary frames until `Challenge` arrives; returns the raw 32-byte nonce.
|
||||
async fn wait_for_challenge<S>(stream: &mut S) -> Result<[u8; 32]>
|
||||
where
|
||||
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg? {
|
||||
WsMessage::Binary(data) => {
|
||||
let frame = RelayFrame::decode(&data[..])?;
|
||||
if let Some(Frame::Challenge(c)) = frame.frame {
|
||||
if c.nonce.len() != 32 {
|
||||
return Err(anyhow!("challenge nonce is not 32 bytes"));
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&c.nonce);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
WsMessage::Close(_) => return Err(anyhow!("closed before challenge")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("connection closed before challenge"))
|
||||
}
|
||||
|
||||
/// Read binary frames until `AuthOk`; returns the raw 32-byte namespace_id.
|
||||
async fn wait_for_auth_ok<S>(stream: &mut S) -> Result<[u8; 32]>
|
||||
where
|
||||
S: StreamExt<Item = Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
|
||||
{
|
||||
while let Some(msg) = stream.next().await {
|
||||
match msg? {
|
||||
WsMessage::Binary(data) => {
|
||||
let frame = RelayFrame::decode(&data[..])?;
|
||||
match frame.frame {
|
||||
Some(Frame::AuthOk(AuthOk { namespace_id })) => {
|
||||
if namespace_id.len() != 32 {
|
||||
return Err(anyhow!("namespace_id is not 32 bytes"));
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&namespace_id);
|
||||
return Ok(out);
|
||||
}
|
||||
Some(Frame::AuthError(AuthError { code, message })) => {
|
||||
return Err(anyhow!("auth_error from relay: {code} ({message})"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
WsMessage::Close(_) => return Err(anyhow!("closed before auth_ok")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("connection closed before auth_ok"))
|
||||
}
|
||||
|
||||
/// Dispatch one decoded relay→agent `RelayFrame`. WS-level Ping/Pong are
|
||||
/// handled at the transport layer above; everything that arrives as a binary
|
||||
/// frame is decoded to `RelayFrame` and matched on the `Frame` oneof here.
|
||||
async fn handle_incoming(state: &Arc<RelayState>, data: &[u8]) {
|
||||
let frame = match RelayFrame::decode(data) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!(crate_name = "skald-relay-client", error = %e, "malformed protobuf frame dropped");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(f) = frame.frame else {
|
||||
debug!(crate_name = "skald-relay-client", "empty relay frame dropped");
|
||||
return;
|
||||
};
|
||||
match f {
|
||||
Frame::Message(m) => {
|
||||
// Validate lengths before handing off to the E2E layer.
|
||||
if m.peer.len() != 32 || m.nonce.len() != 12 {
|
||||
warn!(crate_name = "skald-relay-client", "message with wrong peer/nonce length dropped");
|
||||
return;
|
||||
}
|
||||
let mut from = [0u8; 32];
|
||||
from.copy_from_slice(&m.peer);
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&m.nonce);
|
||||
state.handle_inbound_message(&from, &nonce, &m.ciphertext, m.live).await;
|
||||
}
|
||||
Frame::ClientPaired(cp) => {
|
||||
if cp.client_ed25519_pub.len() != 32 || cp.client_x25519_pub.len() != 32 {
|
||||
warn!(crate_name = "skald-relay-client", "client_paired with wrong pubkey length dropped");
|
||||
return;
|
||||
}
|
||||
let mut ed = [0u8; 32];
|
||||
ed.copy_from_slice(&cp.client_ed25519_pub);
|
||||
let mut x = [0u8; 32];
|
||||
x.copy_from_slice(&cp.client_x25519_pub);
|
||||
// Decode the protobuf `Platform` enum to the lowercase string the DB
|
||||
// expects. The wire value defaults to `0` (`UNSPECIFIED`) — the helper
|
||||
// maps that to `"unknown"`.
|
||||
let platform = platform_i32_to_str(cp.platform);
|
||||
state.handle_client_paired(&ed, &x, platform).await;
|
||||
}
|
||||
Frame::AuthorizeOk(aok) => {
|
||||
debug!(crate_name = "skald-relay-client", authorized = aok.authorized, "authorize_ok");
|
||||
}
|
||||
Frame::PairingReady(_) | Frame::PairingStopOk(_) => {}
|
||||
Frame::PresenceEvent(pe) => {
|
||||
debug!(
|
||||
crate_name = "skald-relay-client",
|
||||
pubkey = %hex::encode(&pe.pubkey),
|
||||
status = pe.status,
|
||||
"presence event"
|
||||
);
|
||||
}
|
||||
Frame::PresenceList(pl) => {
|
||||
debug!(crate_name = "skald-relay-client", online = pl.online.len(), "presence list");
|
||||
}
|
||||
Frame::PeerOffline(po) => {
|
||||
// Expected backstop for route-or-fail live sends (relay-protocol.md
|
||||
// §3): a `live=true` send found the peer gone. A normal protocol
|
||||
// event, not an error.
|
||||
debug!(
|
||||
crate_name = "skald-relay-client",
|
||||
peer = %hex::encode(&po.peer),
|
||||
"peer offline for live send; dropping"
|
||||
);
|
||||
}
|
||||
Frame::Error(e) => {
|
||||
warn!(crate_name = "skald-relay-client", code = %e.code, message = %e.message, "relay error frame");
|
||||
}
|
||||
// Server-to-client or handshake frames the agent never expects inbound.
|
||||
Frame::Challenge(_)
|
||||
| Frame::Auth(_)
|
||||
| Frame::AuthOk(_)
|
||||
| Frame::AuthError(_)
|
||||
| Frame::Authorize(_)
|
||||
| Frame::PairingStart(_)
|
||||
| Frame::PairingStop(_)
|
||||
| Frame::PresenceRequest(_) => {
|
||||
warn!(crate_name = "skald-relay-client", "unexpected relay→agent frame dropped");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a protobuf `Platform` enum wire value to the lowercase string the DB
|
||||
/// stores in the `platform` column. Unknown values become `"unknown"`.
|
||||
fn platform_i32_to_str(v: i32) -> &'static str {
|
||||
if v == Platform::Ios as i32 {
|
||||
"ios"
|
||||
} else if v == Platform::Android as i32 {
|
||||
"android"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `platform_i32_to_str` is total on the wire values the relay emits and
|
||||
/// never panics on bogus inputs (relay-protocol.md §11 forward-compat).
|
||||
#[test]
|
||||
fn platform_conversion() {
|
||||
assert_eq!(platform_i32_to_str(0), "unknown");
|
||||
assert_eq!(platform_i32_to_str(1), "ios");
|
||||
assert_eq!(platform_i32_to_str(2), "android");
|
||||
assert_eq!(platform_i32_to_str(99), "unknown");
|
||||
}
|
||||
|
||||
/// A minimal `Message` frame round-trips through `prost` so the wire
|
||||
/// encoding we emit is the same one the relay will decode.
|
||||
#[test]
|
||||
fn message_frame_round_trip() {
|
||||
let frame = RelayFrame {
|
||||
frame: Some(Frame::Message(Message {
|
||||
ciphertext: vec![0xAA; 64].into(),
|
||||
nonce: vec![0x01; 12].into(),
|
||||
peer: vec![0x02; 32].into(),
|
||||
live: false,
|
||||
})),
|
||||
};
|
||||
let bytes = frame.encode_to_vec();
|
||||
let decoded = RelayFrame::decode(&bytes[..]).expect("decode");
|
||||
match decoded.frame {
|
||||
Some(Frame::Message(m)) => {
|
||||
assert_eq!(m.ciphertext.len(), 64);
|
||||
assert_eq!(m.nonce.len(), 12);
|
||||
assert_eq!(m.peer.len(), 32);
|
||||
assert!(!m.live);
|
||||
}
|
||||
other => panic!("expected Message, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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