feat: http server based key package registry (#124)

* feat: http server based key package registry

* chore: instructions on running the registration service

* chore: remove duplicate post param

* chore: revert out sourced account id for multi devices support

* feat: signature on account id and key packages

* chore: include http registry in contact registry module

* refactor: use device id for retrieve key package

* chore: use string for device id

* feat: server verification on the register

* chore: doc the smoke test

* chore: fix data folder non exist

* chore: use payload for register and retrieve

* chore: fix clippy
This commit is contained in:
kaichao
2026-06-04 10:09:29 +08:00
committed by GitHub
parent 6f5838af51
commit cd7dd6a330
23 changed files with 1825 additions and 53 deletions
+22
View File
@@ -51,6 +51,27 @@ cargo run -p chat-cli -- --name bob --transport file
2. In Bob's terminal, type `/connect <paste bundle here>`.
3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat.
### Optional: KeyPackage registry
When `--registry-url <url>` is set, the client publishes its MLS KeyPackage
to the [keypackage-registry](../keypackage-registry/) service on startup so
other clients can later fetch it by `account_id`. Without the flag, an
in-memory registry is used and is only visible inside the local process.
```bash
# Terminal 1 — registry server
cargo run -p keypackage-registry -- --bind 127.0.0.1:18080
# Terminal 2 / 3 — chat clients pointing at it
cargo run -p chat-cli -- --name alice --transport file \
--registry-url http://127.0.0.1:18080
cargo run -p chat-cli -- --name bob --transport file \
--registry-url http://127.0.0.1:18080
```
The registry is a throwaway testnet helper; v0.3 replaces it with a
λLEZ-based discovery service.
## Options
| Flag | Default | Description |
@@ -60,6 +81,7 @@ cargo run -p chat-cli -- --name bob --transport file
| `--db <path>` | `<data>/<name>.db` | SQLite file for persistent identity |
| `--preset <name>` | `logos.dev` | logos-delivery network preset |
| `--port <n>` | `60000` | TCP port for the embedded logos-delivery node |
| `--registry-url <url>` | *(unset)* | Use the HTTP-backed [keypackage-registry](../keypackage-registry/) at this URL instead of the in-memory registry |
| `--log-file <path>` | *(stderr, off)* | Write logs to a file instead of stderr |
## Commands
+9 -5
View File
@@ -5,7 +5,7 @@ use std::sync::mpsc;
use anyhow::Result;
use arboard::Clipboard;
use logos_chat::{ChatClient, DeliveryService, Event};
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
use serde::{Deserialize, Serialize};
use crate::utils::now;
@@ -41,8 +41,8 @@ pub struct AppState {
pub active_chat: Option<String>,
}
pub struct ChatApp<D: DeliveryService> {
pub client: ChatClient<D>,
pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
pub client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
pub state: AppState,
/// Ephemeral command output — not persisted, cleared on chat switch.
@@ -53,9 +53,13 @@ pub struct ChatApp<D: DeliveryService> {
state_path: PathBuf,
}
impl<D: DeliveryService + 'static> ChatApp<D> {
impl<D, R> ChatApp<D, R>
where
D: DeliveryService + 'static,
R: RegistrationService + 'static,
{
pub fn new(
client: ChatClient<D>,
client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
user_name: &str,
data_dir: &Path,
+42 -15
View File
@@ -8,7 +8,7 @@ use std::sync::mpsc;
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use logos_chat::DeliveryService;
use logos_chat::{ChatClient, DeliveryService, HttpRegistry, RegistrationService, StorageConfig};
use app::ChatApp;
@@ -55,6 +55,12 @@ struct Cli {
/// Initialize and immediately exit without launching the TUI (for CI).
#[arg(long)]
smoketest: bool,
/// Optional KeyPackage registry base URL. When set, uses the HTTP-backed
/// registry instead of the in-memory `EphemeralRegistry`.
/// Example: `--registry-url http://localhost:8080`.
#[arg(long)]
registry_url: Option<String>,
}
fn main() -> Result<()> {
@@ -104,18 +110,38 @@ fn run<D: DeliveryService + 'static>(
.to_str()
.context("db path contains non-UTF-8 characters")?
.to_string();
let storage = StorageConfig::Encrypted {
path: db_str,
key: "chat-cli".to_string(),
};
let client = logos_chat::ChatClient::open(
cli.name.clone(),
logos_chat::StorageConfig::Encrypted {
path: db_str,
key: "chat-cli".to_string(),
},
transport,
)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;
match cli.registry_url.as_deref() {
Some(url) => {
let registry = HttpRegistry::new(url);
let client =
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client with HTTP registry")?;
launch_tui(client, inbound, cli)
}
None => {
let client = ChatClient::open(cli.name.clone(), storage, transport)
.map_err(|e| anyhow::anyhow!("{e:?}"))
.context("failed to open chat client")?;
launch_tui(client, inbound, cli)
}
}
}
fn launch_tui<D, R>(
client: ChatClient<D, R>,
inbound: mpsc::Receiver<Vec<u8>>,
cli: &Cli,
) -> Result<()>
where
D: DeliveryService + 'static,
R: RegistrationService + 'static,
{
let mut app = ChatApp::new(client, inbound, &cli.name, &cli.data)?;
if cli.smoketest {
@@ -193,10 +219,11 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
)
}
fn run_app<D: DeliveryService + 'static>(
terminal: &mut ui::Tui,
app: &mut ChatApp<D>,
) -> Result<()> {
fn run_app<D, R>(terminal: &mut ui::Tui, app: &mut ChatApp<D, R>) -> Result<()>
where
D: DeliveryService + 'static,
R: RegistrationService + 'static,
{
loop {
app.process_incoming()?;
terminal.draw(|frame| ui::draw(frame, app))?;
+28 -7
View File
@@ -16,7 +16,7 @@ use ratatui::{
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use logos_chat::DeliveryService;
use logos_chat::{DeliveryService, RegistrationService};
use crate::app::ChatApp;
@@ -38,7 +38,10 @@ pub fn restore() -> io::Result<()> {
}
/// Draw the UI.
pub fn draw<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>) {
pub fn draw<D: DeliveryService + 'static, R: RegistrationService + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
@@ -55,7 +58,11 @@ pub fn draw<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>) {
draw_status(frame, app, chunks[3]);
}
fn draw_header<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
fn draw_header<D: DeliveryService + 'static, R: RegistrationService + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
area: Rect,
) {
let title = match app.current_session() {
Some(session) => {
let id = &session.chat_id[..8.min(session.chat_id.len())];
@@ -78,7 +85,11 @@ fn draw_header<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>
frame.render_widget(header, area);
}
fn draw_messages<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
fn draw_messages<D: DeliveryService + 'static, R: RegistrationService + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
area: Rect,
) {
let remote_name = app
.current_session()
.map(|s| s.display_name())
@@ -164,7 +175,11 @@ fn draw_messages<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<
frame.render_stateful_widget(messages_widget, area, &mut list_state);
}
fn draw_input<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
area: Rect,
) {
// Inner width: area minus borders (2).
let inner_width = area.width.saturating_sub(2) as usize;
let input_len = app.input.len();
@@ -191,7 +206,11 @@ fn draw_input<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>,
frame.set_cursor_position((cursor_x, area.y + 1));
}
fn draw_status<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
fn draw_status<D: DeliveryService + 'static, R: RegistrationService + 'static>(
frame: &mut Frame,
app: &ChatApp<D, R>,
area: Rect,
) {
let status = Paragraph::new(app.status.as_str())
.style(Style::default().fg(Color::Gray))
.block(Block::default().title(" Status ").borders(Borders::ALL))
@@ -201,7 +220,9 @@ fn draw_status<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>
}
/// Handle keyboard events.
pub fn handle_events<D: DeliveryService + 'static>(app: &mut ChatApp<D>) -> io::Result<bool> {
pub fn handle_events<D: DeliveryService + 'static, R: RegistrationService + 'static>(
app: &mut ChatApp<D, R>,
) -> io::Result<bool> {
// Poll for events with a short timeout to allow checking incoming messages
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "keypackage-registry"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "keypackage-registry"
path = "src/main.rs"
[dependencies]
anyhow = "1.0"
axum = "0.7"
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
ed25519-dalek = "2.2.0"
hex = "0.4"
rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+123
View File
@@ -0,0 +1,123 @@
# keypackage-registry
Testnet KeyPackage Registry — addresses [issue #110](https://github.com/logos-messaging/libchat/issues/110).
Standalone HTTP service that caches MLS KeyPackages keyed by **`device_id`**, so a
client can fetch a contact's keypackage without an out-of-band exchange.
Throwaway by design: scheduled to be replaced by a λLEZ-based service in v0.3, so
it intentionally has no overlap with the rest of libchat (axum + rusqlite only).
`device_id` is the hex-encoded 32-byte Ed25519 verifying key of a device. The
account → device mapping is out of scope here and handled elsewhere.
## Trust model
A bundle is an opaque **payload** plus its **signature**, published under a
**`device_id`** (the hex of the device's 32-byte Ed25519 verifying key).
The signed bytes and the wire bytes are identical, so a verifier checks the
signature over exactly what it received, no reconstruction.
The **server treats `payload` as a black box**: it never decodes it. It only
verifies that `signature` over the payload bytes is valid under `device_id`'s
key, then stores it. A valid signature is proof-of-possession — only the holder
of `device_id`'s key can publish under it — so an adversary can't publish under
a `device_id` it doesn't control, and junk is dropped before storage. The server
is not a trusted authority, so **consumers MUST also verify on retrieve**, and a
valid signature does not prove the device is authorized for any account (that
binding arrives with λLEZ in v0.3).
Consumers define the payload layout. Today it is:
```text
payload = timestamp_ms_le[8] || key_package[..]
```
Fixed-width field first with the variable `key_package` last makes it parse
exactly one way — no delimiter, even though `key_package` is arbitrary bytes.
## Building & running
```bash
cargo build --release -p keypackage-registry
./target/release/keypackage-registry # binds 0.0.0.0:8080, db ./keypackage-registry.db
```
| Flag | Default | Description |
|------|---------|-------------|
| `--bind <addr>` | `0.0.0.0:8080` | HTTP bind address |
| `--db <path>` | `keypackage-registry.db` | SQLite database path |
| `--max-per-identity <n>` | `5` | Bundles retained per `device_id` |
| `--retention-days <n>` | `30` | Drop bundles older than this |
| `--prune-interval-secs <n>` | `3600` | How often the prune task runs |
Logs via `RUST_LOG` (default `info`).
## API
### `POST /v0/keypackage`
```json
{
"device_id": "hex(32-byte ed25519 verifying key)",
"payload": "base64(opaque signed bytes)",
"signature": "base64(64-byte ed25519 signature over payload)"
}
```
The server verifies `signature` over the (opaque) `payload` bytes under
`device_id`'s key before storing, keyed by `device_id`. It does not decode
`payload`. Returns `204` on success, `400` on malformed input or a signature
that fails to verify.
### `GET /v0/keypackage/{device_id}`
Returns the most recently submitted bundle for that `device_id`, or `404`:
```json
{
"payload": "base64(...)",
"signature": "base64(64-byte ed25519 signature)"
}
```
Consumers verify `signature` over the `payload` bytes using the key recovered
from `device_id`, then read `key_package` out of the payload. A bundle that
fails verification must be treated as not found.
## Storage & retention
A SQLite table keyed by `device_id`. A background task runs every
`--prune-interval-secs`, dropping bundles older than `--retention-days` and
keeping at most `--max-per-identity` per `device_id`. The schema is an internal
detail and may change.
## Smoke test
End-to-end check with the real `chat-cli` against a running server:
```bash
cargo build -p keypackage-registry -p chat-cli
# 1. start the server on a test port with a fresh db
./target/debug/keypackage-registry --bind 127.0.0.1:18080 --db tmp/registry.db
# 2. register two identities through chat-cli (--smoketest exits after registering)
./target/debug/chat-cli --name alice --transport file --data tmp/alice \
--registry-url http://127.0.0.1:18080 --smoketest # exits 0 on success
./target/debug/chat-cli --name bob --transport file --data tmp/bob \
--registry-url http://127.0.0.1:18080 --smoketest
# 3. confirm both bundles landed
sqlite3 tmp/registry.db "SELECT substr(device_id,1,12), length(payload) FROM keypackages;"
```
A non-zero exit from `chat-cli` means the server rejected the submission — e.g.
the signature failed verification. `GET /v0/keypackage/{device_id}` returns `200`
for a registered device and `404` otherwise.
## Lifecycle
Exists to unblock contact-by-id flows on testnet; removed once λLEZ-based
discovery lands in v0.3. The seam is the `RegistrationService` trait
(`core/conversations/src/service_traits.rs`) — swapping implementations does not
touch the chat protocol.
+137
View File
@@ -0,0 +1,137 @@
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use crate::store::{Store, StoredBundle};
#[derive(Debug, Deserialize)]
pub struct SubmitRequest {
/// Hex of the 32-byte Ed25519 device verifying key. Used to verify the
/// signature and as the storage/lookup key. `payload` stays opaque.
pub device_id: String,
/// base64 of the signed payload. Opaque to the server — it never decodes it.
pub payload: String,
/// base64 of the 64-byte Ed25519 signature over `payload`. Verifying it
/// under `device_id`'s key is proof-of-possession: only the holder of that
/// key can publish under this `device_id`.
pub signature: String,
}
#[derive(Debug, Serialize)]
pub struct FetchResponse {
/// base64 of the stored payload; consumers verify `signature` over it.
pub payload: String,
pub signature: String,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
pub fn router(store: Arc<Store>) -> Router {
Router::new()
.route("/v0/keypackage", post(submit))
.route("/v0/keypackage/:device_id", get(fetch))
.with_state(store)
}
async fn submit(
State(store): State<Arc<Store>>,
Json(req): Json<SubmitRequest>,
) -> Result<StatusCode, ApiError> {
// Verify proof-of-possession before persisting. `payload` is opaque — the
// server only checks that `signature` over the received payload bytes is
// valid under `device_id`'s key. A valid signature means the submitter holds
// that key. This rejects junk early (DoS mitigation); consumers still verify
// on retrieve, the server is not a trusted authority.
let device_pubkey: [u8; 32] = hex::decode(&req.device_id)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| ApiError::bad("device_id: must be hex of a 32-byte key"))?;
let payload = BASE64
.decode(&req.payload)
.map_err(|_| ApiError::bad("payload: not valid base64"))?;
let signature: [u8; 64] = BASE64
.decode(&req.signature)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| ApiError::bad("signature: must be base64 of 64 bytes"))?;
let verifying_key = VerifyingKey::from_bytes(&device_pubkey)
.map_err(|_| ApiError::bad("device_id: not a valid ed25519 key"))?;
verifying_key
.verify_strict(&payload, &Signature::from_bytes(&signature))
.map_err(|_| ApiError::bad("signature: verification failed"))?;
store
.insert(
&req.device_id,
&StoredBundle {
payload,
signature: signature.to_vec(),
},
)
.map_err(ApiError::internal)?;
Ok(StatusCode::NO_CONTENT)
}
async fn fetch(
State(store): State<Arc<Store>>,
Path(device_id): Path<String>,
) -> Result<Json<FetchResponse>, ApiError> {
let Some(bundle) = store.latest(&device_id).map_err(ApiError::internal)? else {
return Err(ApiError::not_found("no keypackage for device"));
};
Ok(Json(FetchResponse {
payload: BASE64.encode(&bundle.payload),
signature: BASE64.encode(&bundle.signature),
}))
}
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: msg.into(),
}
}
fn internal<E: std::fmt::Display>(err: E) -> Self {
tracing::error!("internal: {err}");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "internal error".into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorBody {
error: self.message,
}),
)
.into_response()
}
}
+89
View File
@@ -0,0 +1,89 @@
//! Testnet KeyPackage Registry HTTP service.
//!
//! Throwaway service for issue #110 — replaced by λLEZ in v0.3. Intentionally
//! self-contained: depends only on axum + sqlite + ed25519, no libchat core.
//!
//! Wire:
//! POST /v0/keypackage — submit a signed bundle
//! GET /v0/keypackage/{acct_id} — fetch the latest stored bundle
mod handlers;
mod store;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use clap::Parser;
use tracing_subscriber::EnvFilter;
use store::Store;
#[derive(Parser, Debug)]
#[command(name = "keypackage-registry", about = "Testnet KeyPackage Registry")]
struct Cli {
/// Address to bind the HTTP server.
#[arg(long, default_value = "0.0.0.0:8080")]
bind: SocketAddr,
/// SQLite database path.
#[arg(long, default_value = "keypackage-registry.db")]
db: PathBuf,
/// Maximum number of bundles retained per account_id.
#[arg(long, default_value_t = 100)]
max_per_identity: usize,
/// Retention window in days; older bundles are pruned.
#[arg(long, default_value_t = 30)]
retention_days: u64,
/// How often the prune task runs.
#[arg(long, default_value_t = 3600)]
prune_interval_secs: u64,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let store = Arc::new(Store::open(&cli.db).context("failed to open store")?);
let prune_store = store.clone();
let max_per_id = cli.max_per_identity;
let retention = Duration::from_secs(cli.retention_days * 24 * 3600);
let interval = Duration::from_secs(cli.prune_interval_secs);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
loop {
ticker.tick().await;
if let Err(e) = prune_store.prune(max_per_id, retention) {
tracing::warn!("prune failed: {e}");
}
}
});
let app = handlers::router(store);
let listener = tokio::net::TcpListener::bind(cli.bind)
.await
.with_context(|| format!("failed to bind {}", cli.bind))?;
tracing::info!("keypackage-registry listening on {}", cli.bind);
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
Ok(())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutdown signal received");
}
+116
View File
@@ -0,0 +1,116 @@
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params};
pub struct Store {
conn: Mutex<Connection>,
}
#[derive(Debug, Clone)]
pub struct StoredBundle {
/// The canonical signed payload, stored verbatim and returned as-is so
/// consumers verify over the exact bytes that were signed.
pub payload: Vec<u8>,
/// 64-byte Ed25519 signature over `payload`. Opaque to the server.
pub signature: Vec<u8>,
}
impl Store {
pub fn open(path: &Path) -> Result<Self> {
// Create the db's parent directory if the caller pointed at a nested
// path (e.g. `tmp/registry.db`); SQLite won't create it and errors with
// "unable to open database file" otherwise.
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.with_context(|| format!("create db directory {}", parent.display()))?;
}
let conn = Connection::open(path).context("open sqlite")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS keypackages (
device_id TEXT NOT NULL,
received_at INTEGER NOT NULL,
payload BLOB NOT NULL,
signature BLOB NOT NULL,
PRIMARY KEY (device_id, received_at)
);",
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn insert(&self, device_id: &str, bundle: &StoredBundle) -> Result<()> {
let received_at = now_ms() as i64;
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO keypackages
(device_id, received_at, payload, signature)
VALUES (?1, ?2, ?3, ?4)",
params![device_id, received_at, bundle.payload, bundle.signature],
)?;
Ok(())
}
/// Returns the most recently received bundle for `device_id`. Scope A: the
/// chat layer consumes one bundle per device. When multi-keypackage fanout
/// lands, switch this to return a `Vec<StoredBundle>`.
pub fn latest(&self, device_id: &str) -> Result<Option<StoredBundle>> {
let conn = self.conn.lock().unwrap();
let row = conn
.query_row(
"SELECT payload, signature FROM keypackages
WHERE device_id = ?1
ORDER BY received_at DESC
LIMIT 1",
params![device_id],
|r| {
Ok(StoredBundle {
payload: r.get::<_, Vec<u8>>(0)?,
signature: r.get::<_, Vec<u8>>(1)?,
})
},
)
.optional()?;
Ok(row)
}
/// Drops bundles older than `retention` and keeps at most
/// `max_per_identity` per `device_id` — each device's history is bounded
/// independently.
pub fn prune(&self, max_per_identity: usize, retention: Duration) -> Result<()> {
let cutoff_ms = now_ms().saturating_sub(retention.as_millis() as u64) as i64;
let conn = self.conn.lock().unwrap();
conn.execute(
"DELETE FROM keypackages WHERE received_at < ?1",
params![cutoff_ms],
)?;
conn.execute(
"DELETE FROM keypackages
WHERE rowid IN (
SELECT rowid FROM (
SELECT rowid,
ROW_NUMBER() OVER (
PARTITION BY device_id
ORDER BY received_at DESC
) AS rn
FROM keypackages
)
WHERE rn > ?1
)",
params![max_per_identity as i64],
)?;
Ok(())
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}