Files
Skald-Circle/crates/skald-relay-server/src/store.rs
T
2026-07-10 15:02:09 +01:00

559 lines
19 KiB
Rust

//! SQLite persistence (relay.md §3). No sensitive data in the clear: the
//! `ciphertext` blobs are E2E, the pubkeys are public identifiers. The API is
//! designed to be swappable for Postgres+Redis post-v1 (relay.md §3 "scale
//! path"); for now there is a single writer (the SQLite-on-EFS constraint).
use std::collections::HashSet;
use std::str::FromStr;
use std::time::Duration;
use anyhow::Result;
use sqlx::Row;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use crate::auth::ct_eq;
/// Current unix milliseconds (application timestamp encoding, index.md §5).
pub fn now_ms() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn to_arr<const N: usize>(v: &[u8]) -> Option<[u8; N]> {
if v.len() != N {
return None;
}
let mut out = [0u8; N];
out.copy_from_slice(v);
Some(out)
}
/// Persisted client state.
#[derive(Debug, Clone)]
pub struct ClientRow {
pub x25519_pub: [u8; 32],
pub device_token: Option<String>,
pub platform: String,
pub state: String, // 'pending' | 'authorized'
}
/// A store-and-forward queued message.
#[derive(Debug, Clone)]
pub struct QueuedMsg {
pub id: i64,
pub from_pub: [u8; 32],
pub nonce: [u8; 12],
pub ciphertext: Vec<u8>,
pub created_at: i64,
}
/// A client in `pending` state (for re-sending `client_paired` to the agent).
#[derive(Debug, Clone)]
pub struct PendingClient {
pub ed25519_pub: [u8; 32],
pub x25519_pub: [u8; 32],
pub platform: String,
}
#[derive(Clone)]
pub struct Store {
pool: SqlitePool,
}
impl Store {
/// Open/create the DB and apply the schema (idempotent). No WAL: the deploy
/// EFS/NFS does not support it; `busy_timeout` serializes the single writer.
pub async fn init(path: &str) -> Result<Store> {
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
.create_if_missing(true)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(opts)
.await?;
for stmt in SCHEMA {
// SCHEMA entries are 'static string literals (audited, no user data).
sqlx::query(*stmt).execute(&pool).await?;
}
Ok(Store { pool })
}
// ----- namespaces ---------------------------------------------------------
/// Create the namespace if absent (binding it immutably to the pubkey) and
/// bump `last_active`. Idempotent.
pub async fn upsert_namespace(&self, ns: &str, agent_pub: &[u8; 32]) -> Result<()> {
let now = now_ms();
sqlx::query(
"INSERT INTO namespaces (namespace_id, agent_ed25519_pub, created_at, last_active)
VALUES (?1, ?2, ?3, ?3)
ON CONFLICT(namespace_id) DO UPDATE SET last_active = ?3",
)
.bind(ns)
.bind(&agent_pub[..])
.bind(now)
.execute(&self.pool)
.await?;
Ok(())
}
/// `true` if the namespace exists.
pub async fn namespace_exists(&self, ns: &str) -> Result<bool> {
let row = sqlx::query("SELECT 1 FROM namespaces WHERE namespace_id = ?1")
.bind(ns)
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
/// The namespace agent's ed25519 pubkey (None if it does not exist).
pub async fn agent_pub(&self, ns: &str) -> Result<Option<[u8; 32]>> {
let row = sqlx::query("SELECT agent_ed25519_pub FROM namespaces WHERE namespace_id = ?1")
.bind(ns)
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| {
let b: Vec<u8> = r.get(0);
to_arr::<32>(&b)
}))
}
pub async fn touch_namespace(&self, ns: &str) -> Result<()> {
sqlx::query("UPDATE namespaces SET last_active = ?2 WHERE namespace_id = ?1")
.bind(ns)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
// ----- pairing ------------------------------------------------------------
/// Open/replace the pairing window. `expiry_ms` already computed.
pub async fn pairing_start(&self, ns: &str, token: &[u8; 32], expiry_ms: i64) -> Result<()> {
sqlx::query(
"UPDATE namespaces
SET pairing_token = ?2, pairing_expiry = ?3, pairing_consumed = 0
WHERE namespace_id = ?1",
)
.bind(ns)
.bind(&token[..])
.bind(expiry_ms)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn pairing_stop(&self, ns: &str) -> Result<()> {
sqlx::query(
"UPDATE namespaces
SET pairing_token = NULL, pairing_expiry = NULL, pairing_consumed = 0
WHERE namespace_id = ?1",
)
.bind(ns)
.execute(&self.pool)
.await?;
Ok(())
}
/// Try to consume the pairing token (single-use, constant-time, not
/// expired). Returns `true` if pairing is allowed to proceed.
pub async fn consume_pairing_token(&self, ns: &str, token: &[u8; 32]) -> Result<bool> {
let row = sqlx::query(
"SELECT pairing_token, pairing_expiry, pairing_consumed
FROM namespaces WHERE namespace_id = ?1",
)
.bind(ns)
.fetch_optional(&self.pool)
.await?;
let Some(row) = row else { return Ok(false) };
let stored: Option<Vec<u8>> = row.get(0);
let expiry: Option<i64> = row.get(1);
let consumed: i64 = row.get(2);
let (Some(stored), Some(expiry)) = (stored, expiry) else {
return Ok(false); // no open window
};
if consumed != 0 || expiry <= now_ms() || !ct_eq(&stored, &token[..]) {
return Ok(false);
}
// Atomic guard against concurrent double-consume.
let res = sqlx::query(
"UPDATE namespaces SET pairing_consumed = 1
WHERE namespace_id = ?1 AND pairing_consumed = 0",
)
.bind(ns)
.execute(&self.pool)
.await?;
Ok(res.rows_affected() == 1)
}
// ----- clients ------------------------------------------------------------
/// Register/update a client as `pending` (after a successful pairing).
pub async fn upsert_pending_client(
&self,
ns: &str,
ed_pub: &[u8; 32],
x_pub: &[u8; 32],
device_token: &str,
platform: &str,
) -> Result<()> {
sqlx::query(
"INSERT INTO clients
(namespace_id, client_ed25519_pub, client_x25519_pub, device_token, platform, state, last_seen)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)
ON CONFLICT(namespace_id, client_ed25519_pub) DO UPDATE SET
client_x25519_pub = ?3,
device_token = CASE WHEN ?4 = '' THEN clients.device_token ELSE ?4 END,
platform = ?5, state = 'pending', last_seen = ?6",
)
.bind(ns)
.bind(&ed_pub[..])
.bind(&x_pub[..])
.bind(device_token)
.bind(platform)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_client(&self, ns: &str, ed_pub: &[u8; 32]) -> Result<Option<ClientRow>> {
let row = sqlx::query(
"SELECT client_x25519_pub, device_token, platform, state
FROM clients WHERE namespace_id = ?1 AND client_ed25519_pub = ?2",
)
.bind(ns)
.bind(&ed_pub[..])
.fetch_optional(&self.pool)
.await?;
Ok(row.and_then(|r| {
let x: Vec<u8> = r.get(0);
Some(ClientRow {
x25519_pub: to_arr::<32>(&x)?,
device_token: r.get::<Option<String>, _>(1),
platform: r.get(2),
state: r.get(3),
})
}))
}
pub async fn is_authorized_client(&self, ns: &str, ed_pub: &[u8; 32]) -> Result<bool> {
let row = sqlx::query(
"SELECT 1 FROM clients
WHERE namespace_id = ?1 AND client_ed25519_pub = ?2 AND state = 'authorized'",
)
.bind(ns)
.bind(&ed_pub[..])
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
/// Update the client's push token (APNs/FCM rotate it) + last_seen.
///
/// An **empty** `device_token` is treated as "no token available right now"
/// (e.g. the device connected before its APNs registration completed) and
/// must NOT clobber a previously stored, valid token — otherwise every push
/// to that client fails with `MissingDeviceToken`. In that case we still
/// bump `last_seen` but keep the existing token.
pub async fn update_client_device_token(
&self,
ns: &str,
ed_pub: &[u8; 32],
device_token: &str,
) -> Result<()> {
sqlx::query(
"UPDATE clients
SET device_token = CASE WHEN ?3 = '' THEN device_token ELSE ?3 END,
last_seen = ?4
WHERE namespace_id = ?1 AND client_ed25519_pub = ?2",
)
.bind(ns)
.bind(&ed_pub[..])
.bind(device_token)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_pending_clients(&self, ns: &str) -> Result<Vec<PendingClient>> {
let rows = sqlx::query(
"SELECT client_ed25519_pub, client_x25519_pub, platform
FROM clients WHERE namespace_id = ?1 AND state = 'pending'",
)
.bind(ns)
.fetch_all(&self.pool)
.await?;
let mut out = Vec::new();
for r in rows {
let ed: Vec<u8> = r.get(0);
let x: Vec<u8> = r.get(1);
if let (Some(ed), Some(x)) = (to_arr::<32>(&ed), to_arr::<32>(&x)) {
out.push(PendingClient {
ed25519_pub: ed,
x25519_pub: x,
platform: r.get(2),
});
}
}
Ok(out)
}
/// Apply `authorize` (replace semantics, relay-protocol.md §6). Returns
/// `(authorized count, revoked pubkeys)`. Revoked clients must then be
/// disconnected; their queue has already been purged here.
pub async fn apply_authorize(
&self,
ns: &str,
new_list: &[[u8; 32]],
) -> Result<(i64, Vec<[u8; 32]>)> {
let new_set: HashSet<Vec<u8>> = new_list.iter().map(|k| k.to_vec()).collect();
let existing =
sqlx::query("SELECT client_ed25519_pub FROM clients WHERE namespace_id = ?1")
.bind(ns)
.fetch_all(&self.pool)
.await?;
let mut revoked = Vec::new();
for r in existing {
let pub_bytes: Vec<u8> = r.get(0);
if new_set.contains(&pub_bytes) {
// Present in the new list → authorized (leaves pending).
sqlx::query(
"UPDATE clients SET state = 'authorized'
WHERE namespace_id = ?1 AND client_ed25519_pub = ?2",
)
.bind(ns)
.bind(&pub_bytes)
.execute(&self.pool)
.await?;
} else {
// Absent → revoked: purge queue, forget device_token, remove.
self.purge_queue_for_bytes(ns, &pub_bytes).await?;
sqlx::query(
"DELETE FROM clients WHERE namespace_id = ?1 AND client_ed25519_pub = ?2",
)
.bind(ns)
.bind(&pub_bytes)
.execute(&self.pool)
.await?;
if let Some(k) = to_arr::<32>(&pub_bytes) {
revoked.push(k);
}
}
}
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM clients WHERE namespace_id = ?1 AND state = 'authorized'",
)
.bind(ns)
.fetch_one(&self.pool)
.await?
.get(0);
Ok((count, revoked))
}
// ----- queue (store-and-forward) -----------------------------------------
pub async fn queue_count(&self, ns: &str, to_pub: &[u8; 32]) -> Result<i64> {
let n: i64 =
sqlx::query("SELECT COUNT(*) FROM queue WHERE namespace_id = ?1 AND to_pub = ?2")
.bind(ns)
.bind(&to_pub[..])
.fetch_one(&self.pool)
.await?
.get(0);
Ok(n)
}
/// Enqueue a message. `Ok(false)` if the recipient's queue is full.
pub async fn enqueue(
&self,
ns: &str,
to_pub: &[u8; 32],
from_pub: &[u8; 32],
nonce: &[u8; 12],
ciphertext: &[u8],
max_per_dest: i64,
) -> Result<bool> {
if self.queue_count(ns, to_pub).await? >= max_per_dest {
return Ok(false);
}
sqlx::query(
"INSERT INTO queue (namespace_id, to_pub, from_pub, nonce, ciphertext, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(ns)
.bind(&to_pub[..])
.bind(&from_pub[..])
.bind(&nonce[..])
.bind(ciphertext)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(true)
}
pub async fn fetch_pending(&self, ns: &str, to_pub: &[u8; 32]) -> Result<Vec<QueuedMsg>> {
let rows = sqlx::query(
"SELECT id, from_pub, nonce, ciphertext, created_at
FROM queue WHERE namespace_id = ?1 AND to_pub = ?2 ORDER BY id ASC",
)
.bind(ns)
.bind(&to_pub[..])
.fetch_all(&self.pool)
.await?;
let mut out = Vec::new();
for r in rows {
let from: Vec<u8> = r.get(1);
let nonce: Vec<u8> = r.get(2);
if let (Some(from), Some(nonce)) = (to_arr::<32>(&from), to_arr::<12>(&nonce)) {
out.push(QueuedMsg {
id: r.get(0),
from_pub: from,
nonce,
ciphertext: r.get(3),
created_at: r.get(4),
});
}
}
Ok(out)
}
pub async fn delete_pending(&self, id: i64) -> Result<()> {
sqlx::query("DELETE FROM queue WHERE id = ?1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn purge_queue_for_bytes(&self, ns: &str, to_pub: &[u8]) -> Result<()> {
sqlx::query("DELETE FROM queue WHERE namespace_id = ?1 AND to_pub = ?2")
.bind(ns)
.bind(to_pub)
.execute(&self.pool)
.await?;
Ok(())
}
// ----- garbage collection -------------------------------------------------
/// Delete messages older than `ttl_days` and namespaces idle for `ttl_days`
/// (cascade to clients + queue). Returns `(messages, namespaces)` removed.
pub async fn gc(&self, ttl_days: i64) -> Result<(u64, u64)> {
let cutoff = now_ms() - ttl_days * 24 * 60 * 60 * 1000;
let msgs = sqlx::query("DELETE FROM queue WHERE created_at < ?1")
.bind(cutoff)
.execute(&self.pool)
.await?
.rows_affected();
let namespaces = sqlx::query("DELETE FROM namespaces WHERE last_active < ?1")
.bind(cutoff)
.execute(&self.pool)
.await?
.rows_affected();
Ok((msgs, namespaces))
}
}
const SCHEMA: &[&str] = &[
"CREATE TABLE IF NOT EXISTS namespaces (
namespace_id TEXT PRIMARY KEY,
agent_ed25519_pub BLOB NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_active INTEGER NOT NULL,
pairing_token BLOB,
pairing_expiry INTEGER,
pairing_consumed INTEGER NOT NULL DEFAULT 0
)",
"CREATE TABLE IF NOT EXISTS clients (
namespace_id TEXT NOT NULL REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
client_ed25519_pub BLOB NOT NULL,
client_x25519_pub BLOB NOT NULL,
device_token TEXT,
platform TEXT NOT NULL,
state TEXT NOT NULL,
last_seen INTEGER,
PRIMARY KEY (namespace_id, client_ed25519_pub)
)",
"CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
namespace_id TEXT NOT NULL REFERENCES namespaces(namespace_id) ON DELETE CASCADE,
to_pub BLOB NOT NULL,
from_pub BLOB NOT NULL,
nonce BLOB NOT NULL,
ciphertext BLOB NOT NULL,
created_at INTEGER NOT NULL
)",
"CREATE INDEX IF NOT EXISTS idx_queue_dest ON queue(namespace_id, to_pub, id)",
];
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
async fn temp_store() -> Store {
static SEQ: AtomicU64 = AtomicU64::new(0);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"relay-store-ut-{nanos}-{}-{seq}.db",
std::process::id()
));
Store::init(&path.to_string_lossy()).await.expect("init store")
}
/// An empty `device_token` (device connected before APNs registration
/// finished) must NOT wipe a previously stored, valid token — otherwise
/// every push fails with `MissingDeviceToken`.
#[tokio::test]
async fn empty_device_token_does_not_clobber() {
let s = temp_store().await;
let ns = "a".repeat(64);
let ed = [1u8; 32];
let x = [2u8; 32];
s.upsert_namespace(&ns, &[9u8; 32]).await.unwrap();
// Pair with a real token.
s.upsert_pending_client(&ns, &ed, &x, "realtoken", "ios").await.unwrap();
assert_eq!(
s.get_client(&ns, &ed).await.unwrap().unwrap().device_token.as_deref(),
Some("realtoken")
);
// A later connect with an empty token keeps the existing one.
s.update_client_device_token(&ns, &ed, "").await.unwrap();
assert_eq!(
s.get_client(&ns, &ed).await.unwrap().unwrap().device_token.as_deref(),
Some("realtoken")
);
// A non-empty token still updates.
s.update_client_device_token(&ns, &ed, "rotated").await.unwrap();
assert_eq!(
s.get_client(&ns, &ed).await.unwrap().unwrap().device_token.as_deref(),
Some("rotated")
);
// Re-pairing with an empty token also preserves the stored one.
s.upsert_pending_client(&ns, &ed, &x, "", "ios").await.unwrap();
assert_eq!(
s.get_client(&ns, &ed).await.unwrap().unwrap().device_token.as_deref(),
Some("rotated")
);
}
}