First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+54
View File
@@ -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"] }
+238
View File
@@ -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()
}
}
+31
View File
@@ -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),
}
+305
View File
@@ -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);
}
}
+33
View File
@@ -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] },
}
+152
View File
@@ -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());
}
}
+39
View File
@@ -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};
+228
View File
@@ -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");
}
}
+537
View File
@@ -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");
}
}
+673
View File
@@ -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));
}
}
+361
View File
@@ -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;
}