From f41fb40c2fbdbcd949b6b75638e3c219422b5573 Mon Sep 17 00:00:00 2001 From: kaichao Date: Thu, 11 Jun 2026 21:07:11 +0800 Subject: [PATCH] feat: extend the http registry to store account's installations (#129) * feat: account to device store * feat: accout traits and codec * feat: integrate accounts abstraction * chore: clean docs and naming * remove account public key from payload * chore: fix clippy * feat: lamport check before update account store * chore: rebase to core * chore: register account in new core * chore: rebase changes and use account pub for index account store * chore: move chat store outside of libchat * chore: use account pub for registry --- Cargo.lock | 130 ------ Cargo.toml | 1 - bin/chat-cli/README.md | 13 +- bin/keypackage-registry/Cargo.toml | 23 - bin/keypackage-registry/README.md | 123 ----- bin/keypackage-registry/src/handlers.rs | 137 ------ bin/keypackage-registry/src/main.rs | 89 ---- bin/keypackage-registry/src/store.rs | 116 ----- core/conversations/src/account_directory.rs | 420 ++++++++++++++++++ .../src/conversation/group_v1.rs | 58 ++- core/conversations/src/core.rs | 9 + core/conversations/src/inbox_v2.rs | 79 +++- core/conversations/src/inbox_v2/identity.rs | 18 + core/conversations/src/lib.rs | 6 + core/conversations/src/service_context.rs | 27 +- core/conversations/src/service_traits.rs | 23 +- core/crypto/src/signatures.rs | 2 +- extensions/components/src/contact_registry.rs | 75 +++- .../components/src/contact_registry/http.rs | 89 +++- 19 files changed, 759 insertions(+), 679 deletions(-) delete mode 100644 bin/keypackage-registry/Cargo.toml delete mode 100644 bin/keypackage-registry/README.md delete mode 100644 bin/keypackage-registry/src/handlers.rs delete mode 100644 bin/keypackage-registry/src/main.rs delete mode 100644 bin/keypackage-registry/src/store.rs create mode 100644 core/conversations/src/account_directory.rs diff --git a/Cargo.lock b/Cargo.lock index 04e2968..1aeb5b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,17 +143,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -166,61 +155,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -1426,12 +1360,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.10.1" @@ -1445,7 +1373,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1743,25 +1670,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "keypackage-registry" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "base64", - "clap", - "ed25519-dalek", - "hex", - "rusqlite", - "serde", - "serde_json", - "thiserror", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2184,24 +2092,12 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3350,17 +3246,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3735,23 +3620,10 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -3805,7 +3677,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -3844,7 +3715,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index fcc3891..2b6ddda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ resolver = "3" members = [ "bin/chat-cli", - "bin/keypackage-registry", "core/account", "core/conversations", "core/crypto", diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index b7c91aa..3d23c68 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -54,13 +54,14 @@ cargo run -p chat-cli -- --name bob --transport file ### Optional: KeyPackage registry When `--registry-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. +to the [keypackage-registry](https://github.com/logos-messaging/chat-store) +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 1 — registry server (from a chat-store checkout) +cargo run -- --bind 127.0.0.1:18080 # Terminal 2 / 3 — chat clients pointing at it cargo run -p chat-cli -- --name alice --transport file \ @@ -81,7 +82,7 @@ The registry is a throwaway testnet helper; v0.3 replaces it with a | `--db ` | `/.db` | SQLite file for persistent identity | | `--preset ` | `logos.dev` | logos-delivery network preset | | `--port ` | `60000` | TCP port for the embedded logos-delivery node | -| `--registry-url ` | *(unset)* | Use the HTTP-backed [keypackage-registry](../keypackage-registry/) at this URL instead of the in-memory registry | +| `--registry-url ` | *(unset)* | Use the HTTP-backed [keypackage-registry](https://github.com/logos-messaging/chat-store) at this URL instead of the in-memory registry | | `--log-file ` | *(stderr, off)* | Write logs to a file instead of stderr | ## Commands diff --git a/bin/keypackage-registry/Cargo.toml b/bin/keypackage-registry/Cargo.toml deleted file mode 100644 index b53161c..0000000 --- a/bin/keypackage-registry/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[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"] } diff --git a/bin/keypackage-registry/README.md b/bin/keypackage-registry/README.md deleted file mode 100644 index a74f630..0000000 --- a/bin/keypackage-registry/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# 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 ` | `0.0.0.0:8080` | HTTP bind address | -| `--db ` | `keypackage-registry.db` | SQLite database path | -| `--max-per-identity ` | `5` | Bundles retained per `device_id` | -| `--retention-days ` | `30` | Drop bundles older than this | -| `--prune-interval-secs ` | `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. diff --git a/bin/keypackage-registry/src/handlers.rs b/bin/keypackage-registry/src/handlers.rs deleted file mode 100644 index 9da0d42..0000000 --- a/bin/keypackage-registry/src/handlers.rs +++ /dev/null @@ -1,137 +0,0 @@ -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) -> Router { - Router::new() - .route("/v0/keypackage", post(submit)) - .route("/v0/keypackage/:device_id", get(fetch)) - .with_state(store) -} - -async fn submit( - State(store): State>, - Json(req): Json, -) -> Result { - // 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>, - Path(device_id): Path, -) -> Result, 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) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: msg.into(), - } - } - fn not_found(msg: impl Into) -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: msg.into(), - } - } - fn internal(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() - } -} diff --git a/bin/keypackage-registry/src/main.rs b/bin/keypackage-registry/src/main.rs deleted file mode 100644 index fa1a8f3..0000000 --- a/bin/keypackage-registry/src/main.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! 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"); -} diff --git a/bin/keypackage-registry/src/store.rs b/bin/keypackage-registry/src/store.rs deleted file mode 100644 index c3c5a05..0000000 --- a/bin/keypackage-registry/src/store.rs +++ /dev/null @@ -1,116 +0,0 @@ -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, -} - -#[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, - /// 64-byte Ed25519 signature over `payload`. Opaque to the server. - pub signature: Vec, -} - -impl Store { - pub fn open(path: &Path) -> Result { - // 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`. - pub fn latest(&self, device_id: &str) -> Result> { - 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>(0)?, - signature: r.get::<_, Vec>(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 -} diff --git a/core/conversations/src/account_directory.rs b/core/conversations/src/account_directory.rs new file mode 100644 index 0000000..2a994cb --- /dev/null +++ b/core/conversations/src/account_directory.rs @@ -0,0 +1,420 @@ +//! Account → device directory: traits and the signed device-list bundle codec. +//! +//! An Account (AccountAddress, an Ed25519 key) endorses a set of device +//! (LocalIdentity) public keys by signing a bundle. The directory service stores +//! one such bundle per account so that an inviter can resolve an account public +//! key to every device it must invite. +//! +//! Two roles are kept distinct from the per-device [`IdentityProvider`]: +//! +//! - [`AccountAuthority`] — the injected account key. Custody (wallet, enclave, +//! another device) stays outside libchat; we only ever ask it to sign. Present +//! only where the user authorizes a device change. +//! - [`AccountDirectory`] — the client that publishes and fetches+verifies the +//! bundle against the directory service. +//! +//! The bundle `payload` is opaque to the server. Both the signing side +//! ([`encode_bundle_payload`]) and the verifying side ([`verify_bundle`]) live +//! here so they cannot drift apart. + +use std::fmt::{Debug, Display}; + +use crypto::{Ed25519Signature, Ed25519VerifyingKey}; +use shared_traits::IdentIdRef; +use thiserror::Error; + +/// A device (LocalIdentity) verifying key, hex-encoded — the same shape as the +/// keypackage registry's `device_id`, so values flow straight into +/// [`KeyPackageProvider::retrieve`](crate::service_traits::KeyPackageProvider). +pub type DeviceId = String; + +/// The account's monotonic version counter, bumped on every membership change. +/// The directory server reads it from the signed payload and rejects a publish +/// whose lamport is not strictly higher than the stored one, so an older bundle +/// can't be replayed to downgrade the device list. Consumers also keep the +/// highest value seen per account and reject anything lower as defence in depth. +pub type Lamport = u64; + +/// Current bundle payload version. Bump when the layout in +/// [`encode_bundle_payload`] changes. +pub const BUNDLE_VERSION: u8 = 1; + +/// Domain-separation tag prepended to every signed payload. The account key may +/// live in an external signer (wallet/enclave) that signs other things too, so +/// binding the signature to this exact purpose stops a signature obtained +/// elsewhere from being replayed as a device-bundle signature (and vice-versa). +/// It is a fixed constant prefix — not a field separator — so it adds no parsing +/// ambiguity. The trailing NUL keeps it from being a prefix of any other domain. +pub const BUNDLE_DOMAIN: &[u8] = b"libchat:account-device-bundle\0"; + +/// The signed device-list bundle. The `payload` bytes are exactly +/// what [`AccountAuthority::sign`] signed, so verifiers check the +/// signature over the same bytes they received. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignedDeviceBundle { + /// The account verifying key this bundle belongs to. Used for addressing on + /// publish; on verify the caller supplies the expected account key separately + /// and the signature is checked under it. + pub account_pub: Ed25519VerifyingKey, + /// Canonical signed bytes — see [`encode_bundle_payload`]. + pub payload: Vec, + /// Account signature over `payload`. + pub signature: Ed25519Signature, +} + +/// The verified result of a directory fetch: an account's device set at a given +/// version. Produced only after the account signature has been checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeviceSet { + pub lamport: Lamport, + /// Device verifying keys, hex-encoded, ready for keypackage retrieval. + pub devices: Vec, +} + +/// The account capability, injected by the platform. +/// +/// Custody of the account key stays outside libchat — the library only ever asks +/// it to sign a device-list bundle. The same trait covers a local on-device key +/// (testnet) and an external signer (wallet/enclave), which is why [`sign`] is +/// fallible: an external signer can be offline or decline the prompt. +/// +/// Verification needs no authority — anyone holding the account verifying key +/// verifies with [`verify_bundle`]. +/// +/// [`sign`]: AccountAuthority::sign +pub trait AccountAuthority { + type Error: Display + Debug; + + /// The account verifying key identifying this participant. + fn account_pub(&self) -> &Ed25519VerifyingKey; + /// Sign the canonical bundle bytes with the account key. + fn sign(&self, payload: &[u8]) -> Result; +} + +/// Client for the account → device directory service. +/// +/// Mirrors [`RegistrationService`](crate::service_traits::RegistrationService): +/// an injected trait in core with an HTTP implementation in the extension layer. +/// The service is untrusted, so [`fetch`](AccountDirectory::fetch) verifies the +/// account signature before returning a [`DeviceSet`]. +pub trait AccountDirectory: Debug { + type Error: Display + Debug; + + /// Upsert the signed device list for an account, replacing any previous one. + fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error>; + + /// Fetch and verify the device set for `account`. `Ok(None)` means the + /// account has never published — callers fall back to legacy 1:1 resolution. + fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error>; +} + +/// Failures decoding or verifying a [`SignedDeviceBundle`]. +#[derive(Debug, Error)] +pub enum BundleError { + #[error("payload shorter than its declared layout")] + Short, + #[error("payload is missing the account-device-bundle domain prefix")] + Domain, + #[error("unsupported bundle version {0}")] + Version(u8), + #[error("account signature verification failed")] + SignatureInvalid, +} + +/// The decoded (but not yet signature-verified) contents of a bundle payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedBundle { + pub lamport: Lamport, + pub devices: Vec<[u8; 32]>, +} + +/// Canonical binary payload — the bytes that are both signed and transmitted. +/// Opaque to the server; decoded only by consumers: +/// +/// ```text +/// domain : BUNDLE_DOMAIN (constant prefix, NUL-terminated) +/// version : u8 (1 byte) +/// lamport : u64 LE (8 bytes) +/// count : u16 LE (2 bytes) — number of device keys that follow +/// devices : [u8; 32] * count (32 * count bytes) +/// ``` +/// +/// Fixed-width fields with an explicit `count` make every byte string parse +/// exactly one way. The [`BUNDLE_DOMAIN`] prefix binds the signature to this +/// purpose (see its docs). The account key is *not* embedded: the account is +/// identified out-of-band by the account verifying key the caller requests, and +/// [`verify_bundle`] checks the signature under that key — so a bundle for one +/// account cannot be passed off as another's. +pub fn encode_bundle_payload(lamport: Lamport, devices: &[Ed25519VerifyingKey]) -> Vec { + let mut out = Vec::with_capacity(BUNDLE_DOMAIN.len() + 1 + 8 + 2 + devices.len() * 32); + out.extend_from_slice(BUNDLE_DOMAIN); + out.push(BUNDLE_VERSION); + out.extend_from_slice(&lamport.to_le_bytes()); + out.extend_from_slice(&(devices.len() as u16).to_le_bytes()); + for device in devices { + out.extend_from_slice(device.as_ref()); + } + out +} + +/// Inverse of [`encode_bundle_payload`]. Strips the domain prefix, then validates +/// the version and that the declared device count matches the remaining bytes +/// exactly. +pub fn decode_bundle_payload(payload: &[u8]) -> Result { + const HEADER: usize = 1 + 8 + 2; + let payload = payload + .strip_prefix(BUNDLE_DOMAIN) + .ok_or(BundleError::Domain)?; + if payload.len() < HEADER { + return Err(BundleError::Short); + } + let version = payload[0]; + if version != BUNDLE_VERSION { + return Err(BundleError::Version(version)); + } + let lamport = u64::from_le_bytes(payload[1..9].try_into().expect("9 - 1 == 8")); + let count = u16::from_le_bytes(payload[9..11].try_into().expect("11 - 9 == 2")) as usize; + + let body = &payload[HEADER..]; + if body.len() != count * 32 { + return Err(BundleError::Short); + } + let devices = body + .chunks_exact(32) + .map(|c| c.try_into().expect("chunks_exact(32) yields 32 bytes")) + .collect(); + + Ok(DecodedBundle { lamport, devices }) +} + +/// Decode `bundle`, confirm it belongs to `expected_account`, and verify the +/// account signature over the exact payload bytes. Returns the verified +/// [`DeviceSet`] (device keys hex-encoded for keypackage retrieval). +pub fn verify_bundle( + expected_account: &Ed25519VerifyingKey, + bundle: &SignedDeviceBundle, +) -> Result { + let decoded = decode_bundle_payload(&bundle.payload)?; + + // Verifying the signature under the *requested* account key is what binds the + // bundle to that account: another account's validly-signed bundle won't verify + // under this key, so an untrusted server cannot substitute one. + expected_account + .verify(&bundle.payload, &bundle.signature) + .map_err(|_| BundleError::SignatureInvalid)?; + + Ok(DeviceSet { + lamport: decoded.lamport, + devices: decoded.devices.iter().map(hex::encode).collect(), + }) +} + +/// Resolve an account to the device ids whose KeyPackages must be fetched. +/// +/// The directory is keyed by the account verifying key. When `account` is the hex +/// of such a key and a bundle exists, returns its verified device set. Otherwise +/// falls back to treating the identifier itself as a single device id — the +/// pre-directory behaviour — so opaque or never-published ids keep working. +pub fn resolve_device_ids( + directory: &D, + account: IdentIdRef, +) -> Result, D::Error> { + if let Some(account_key) = account_key_from_id(account) + && let Some(set) = directory.fetch(&account_key)? + { + return Ok(set.devices); + } + Ok(vec![account.to_string()]) +} + +/// Interpret an identity id as the hex of an account verifying key, if it is one. +fn account_key_from_id(id: IdentIdRef) -> Option { + let bytes: [u8; 32] = hex::decode(id.as_str()).ok()?.try_into().ok()?; + Ed25519VerifyingKey::from_bytes(&bytes).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + use shared_traits::IdentId; + + /// encode → decode round-trips, including zero and many devices. + #[test] + fn payload_roundtrips() { + let devices: Vec<_> = (0..3) + .map(|_| Ed25519SigningKey::generate().verifying_key()) + .collect(); + + let payload = encode_bundle_payload(7, &devices); + let decoded = decode_bundle_payload(&payload).unwrap(); + + assert_eq!(decoded.lamport, 7); + let want: Vec<[u8; 32]> = devices + .iter() + .map(|d| d.as_ref().try_into().unwrap()) + .collect(); + assert_eq!(decoded.devices, want); + + // Empty device set is valid (an account with no devices). + let empty = encode_bundle_payload(0, &[]); + assert!(decode_bundle_payload(&empty).unwrap().devices.is_empty()); + } + + #[test] + fn decode_rejects_short_and_truncated() { + // A domain-prefixed payload too short to hold the header. + let mut short = BUNDLE_DOMAIN.to_vec(); + short.extend_from_slice(&[0u8; 5]); + assert!(matches!( + decode_bundle_payload(&short), + Err(BundleError::Short) + )); + + let device = Ed25519SigningKey::generate().verifying_key(); + let mut payload = encode_bundle_payload(1, &[device]); + payload.pop(); // drop a device byte: count no longer matches the body + assert!(matches!( + decode_bundle_payload(&payload), + Err(BundleError::Short) + )); + } + + #[test] + fn decode_rejects_missing_domain() { + // Bytes that would be a valid body but lack the domain prefix. + let payload = encode_bundle_payload(1, &[]); + let without_domain = &payload[BUNDLE_DOMAIN.len()..]; + assert!(matches!( + decode_bundle_payload(without_domain), + Err(BundleError::Domain) + )); + } + + #[test] + fn decode_rejects_bad_version() { + let mut payload = encode_bundle_payload(1, &[]); + payload[BUNDLE_DOMAIN.len()] = 99; // first byte after the domain prefix + assert!(matches!( + decode_bundle_payload(&payload), + Err(BundleError::Version(99)) + )); + } + + /// Full happy path: sign with the account key, verify under the account key. + #[test] + fn verify_accepts_well_formed_bundle() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let devices: Vec<_> = (0..2) + .map(|_| Ed25519SigningKey::generate().verifying_key()) + .collect(); + + let payload = encode_bundle_payload(42, &devices); + let bundle = SignedDeviceBundle { + account_pub: account_pub.clone(), + signature: account_key.sign(&payload), + payload, + }; + + let set = verify_bundle(&account_pub, &bundle).unwrap(); + assert_eq!(set.lamport, 42); + assert_eq!(set.devices.len(), 2); + assert_eq!(set.devices[0], hex::encode(devices[0].as_ref())); + } + + /// A bundle validly signed by account A, served as the answer to a query for + /// account B, fails: B's key does not verify A's signature. This is the + /// anti-substitution guarantee, now resting entirely on the signature check. + #[test] + fn verify_rejects_wrong_account() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let payload = encode_bundle_payload(1, &[]); + let bundle = SignedDeviceBundle { + account_pub, + signature: account_key.sign(&payload), + payload, + }; + + let other = Ed25519SigningKey::generate().verifying_key(); + assert!(matches!( + verify_bundle(&other, &bundle), + Err(BundleError::SignatureInvalid) + )); + } + + /// Minimal in-test directory so `resolve_device_ids` can be exercised + /// without pulling in the `components` crate. + #[derive(Debug, Default)] + struct FakeDir(Option); + + impl AccountDirectory for FakeDir { + type Error = BundleError; + fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> { + self.0 = Some(bundle.clone()); + Ok(()) + } + fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { + self.0 + .as_ref() + .map(|b| verify_bundle(account, b)) + .transpose() + } + } + + /// No published bundle → fall back to the identifier as a single device id. + #[test] + fn resolve_falls_back_to_account_id() { + let account = IdentId::new("pax"); + let resolved = resolve_device_ids(&FakeDir(None), &account).unwrap(); + assert_eq!(resolved, vec![account.to_string()]); + } + + /// A published bundle → resolve to its verified device ids (hex pubkeys). + #[test] + fn resolve_returns_published_devices() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let devices: Vec<_> = (0..2) + .map(|_| Ed25519SigningKey::generate().verifying_key()) + .collect(); + + let payload = encode_bundle_payload(1, &devices); + let bundle = SignedDeviceBundle { + account_pub: account_pub.clone(), + signature: account_key.sign(&payload), + payload, + }; + + // The identifier is the hex of the account key, so resolution consults the + // directory rather than falling back. + let account_id = IdentId::new(hex::encode(account_pub.as_ref())); + let resolved = resolve_device_ids(&FakeDir(Some(bundle)), &account_id).unwrap(); + let want: Vec = devices.iter().map(|d| hex::encode(d.as_ref())).collect(); + assert_eq!(resolved, want); + } + + /// Tampering with any payload byte breaks verification. + #[test] + fn verify_rejects_tampered_payload() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let device = Ed25519SigningKey::generate().verifying_key(); + + let payload = encode_bundle_payload(1, std::slice::from_ref(&device)); + let signature = account_key.sign(&payload); + + // Re-encode with a different lamport, keep the old signature. + let tampered = encode_bundle_payload(2, &[device]); + let bundle = SignedDeviceBundle { + account_pub: account_pub.clone(), + payload: tampered, + signature, + }; + assert!(matches!( + verify_bundle(&account_pub, &bundle), + Err(BundleError::SignatureInvalid) + )); + } +} diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 24cc85d..1570763 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -10,6 +10,7 @@ use openmls::prelude::*; use prost::Message as _; use shared_traits::IdentIdRef; +use crate::account_directory::{AccountDirectory, resolve_device_ids}; use crate::inbox_v2::MlsProvider; use crate::service_context::{ExternalServices, ServiceContext}; @@ -139,28 +140,38 @@ impl GroupV1Convo { Self::ctrl_delivery_address_from_id(&self.convo_id) } - fn key_package_for_account( + /// Resolve an account to a KeyPackage for *every* device it authorizes. + /// + /// First resolves the account to its device ids through the account + /// directory ([`resolve_device_ids`]), then fetches each device's + /// KeyPackage. When the account never published a bundle, resolution falls + /// back to a single device id equal to the account id — the pre-directory + /// behaviour — so single-device accounts are unaffected. + fn key_packages_for_account( &self, ident: IdentIdRef, provider: &impl MlsProvider, - keypkg_provider: &impl KeyPackageProvider, - ) -> Result { - // INTERIM: the key package registry is keyed by `DeviceId`, but resolving an - // `AccountId` to its device(s) is a future task. For now (single device - // per account) we use the account-id string directly as the device id. - // When account->device resolution lands, only this conversion changes. - let device_id = ident.to_string(); - let retrieved_bytes = keypkg_provider - .retrieve(&device_id) - .map_err(|e| ChatError::Generic(e.to_string()))?; + registry: &(impl KeyPackageProvider + AccountDirectory), + ) -> Result, ChatError> { + let device_ids = + resolve_device_ids(registry, ident).map_err(|e| ChatError::Generic(e.to_string()))?; - let Some(keypkg_bytes) = retrieved_bytes else { - return Err(ChatError::Protocol("Contact Not Found".into())); - }; + let mut keypackages = Vec::with_capacity(device_ids.len()); + for device_id in &device_ids { + let retrieved = registry + .retrieve(device_id) + .map_err(|e| ChatError::Generic(e.to_string()))?; + let Some(keypkg_bytes) = retrieved else { + return Err(ChatError::Protocol(format!( + "no keypackage for device {device_id} of account {ident}" + ))); + }; - let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?; - let key_package = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version - Ok(key_package) + let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?; + let keypkg = key_package_in.validate(provider.crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version + keypackages.push(keypkg); + } + Ok(keypackages) } pub fn id(&self) -> &str { @@ -284,12 +295,13 @@ impl GroupConvo for GroupV1Convo { )); } - // Get the Keypacakages and transpose any errors. - // The account_id is kept so invites can be addressed properly - let keypkgs = members - .iter() - .map(|ident| self.key_package_for_account(ident, &cx.mls_provider, &cx.registry)) - .collect::, ChatError>>()?; + // Resolve each account to a KeyPackage per authorized device and flatten + // them into one list — every device of every invitee becomes an MLS + // leaf, so all of a user's installations join the group. + let mut keypkgs = Vec::with_capacity(members.len()); + for ident in members { + keypkgs.extend(self.key_packages_for_account(ident, &cx.mls_provider, &cx.registry)?); + } let (commit, welcome, _group_info) = self .mls_group diff --git a/core/conversations/src/core.rs b/core/conversations/src/core.rs index 8dcbf2f..103599c 100644 --- a/core/conversations/src/core.rs +++ b/core/conversations/src/core.rs @@ -72,6 +72,7 @@ where let mut core = Self::assemble(ident, identity, delivery, registration, store)?; core.register_keypackage()?; + core.register_account_bundle()?; Ok(core) } @@ -140,6 +141,14 @@ impl<'a, S: ExternalServices + 'static> Core { self.pq_inbox.register(&mut self.services) } + /// Publish this installation's device key into the account → device + /// directory, so inviters can resolve this account to its device(s). Pairs + /// with [`register_keypackage`](Self::register_keypackage); call both after + /// provisioning so the account is fully discoverable. + pub fn register_account_bundle(&mut self) -> Result<(), ChatError> { + self.pq_inbox.publish_device_bundle(&mut self.services) + } + pub fn installation_name(&self) -> &str { self.services.identity.get_name() } diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index 5f3c40b..a713b66 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -1,6 +1,7 @@ mod identity; mod mls_provider; +use crypto::Ed25519VerifyingKey; pub use identity::MlsIdentityProvider; pub(crate) use mls_provider::MlsEphemeralPqProvider; use shared_traits::IdentId; @@ -12,15 +13,19 @@ use openmls::prelude::*; use prost::{Message, Oneof}; use storage::{ConversationKind, ConversationMeta, ConversationStore}; -use crate::AddressedEnvelope; use crate::ChatError; use crate::DeliveryService; +use crate::IdentityProvider; use crate::RegistrationService; use crate::conversation::ConversationId; use crate::conversation::GroupV1Convo; use crate::outcomes::{ConversationClass, InboxOutcome, NewConversation}; use crate::service_context::{ExternalServices, ServiceContext}; use crate::utils::{blake2b_hex, hash_size}; +use crate::{ + AccountAuthority, AccountDirectory, AddressedEnvelope, SignedDeviceBundle, + encode_bundle_payload, +}; // Define unique Identifiers derivations used in InboxV2 fn delivery_address_for(ident_id: IdentIdRef) -> String { @@ -165,6 +170,78 @@ impl InboxV2 { } } +// Publishing the account → device bundle needs the account key, so this method +// is available only when the registry also implements `AccountDirectory`. The +// signing authority is the `LogosAccount` wrapped by `mls_identity`; on testnet +// that is a local key (account key == device key), while an external signer +// would supply its own authority. +impl InboxV2 { + /// Add this installation's device key to the account's directory bundle. + /// + /// Fetches the current (verified) device set, adds this device if absent, + /// bumps the lamport, re-signs with the account key, and publishes. Safe to + /// call repeatedly — an unchanged set is simply re-published, which also + /// refreshes the server's retention clock. + pub fn publish_device_bundle( + &self, + cx: &mut ServiceContext, + ) -> Result<(), ChatError> { + // On testnet `mls_identity` doubles as the `AccountAuthority` — the + // account key is the installation's own key. + let authority = &cx.mls_identity; + + let account_pub = AccountAuthority::account_pub(authority).clone(); + let device_key = cx.mls_identity.public_key().clone(); + let device_hex = hex::encode(device_key.as_ref()); + + // Start from the devices already registered so other installations of + // this account are preserved across the upsert. + let existing = cx + .registry + .fetch(&account_pub) + .map_err(|e| ChatError::Generic(e.to_string()))?; + let (mut devices, next_lamport) = match existing { + Some(set) => { + let mut keys = Vec::with_capacity(set.devices.len() + 1); + for hex_id in &set.devices { + let bytes: [u8; 32] = hex::decode(hex_id) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| { + ChatError::Generic("directory returned a malformed device id".into()) + })?; + let key = Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| { + ChatError::Generic("directory returned a malformed device key".into()) + })?; + keys.push(key); + } + (keys, set.lamport + 1) + } + None => (Vec::new(), 0), + }; + + if !devices + .iter() + .any(|d| hex::encode(d.as_ref()) == device_hex) + { + devices.push(device_key); + } + + let payload = encode_bundle_payload(next_lamport, &devices); + let signature = AccountAuthority::sign(authority, &payload) + .map_err(|e| ChatError::Generic(e.to_string()))?; + let bundle = SignedDeviceBundle { + account_pub, + payload, + signature, + }; + + cx.registry + .publish(&bundle) + .map_err(|e| ChatError::Generic(e.to_string())) + } +} + #[derive(Clone, PartialEq, Message)] pub struct InboxV2Frame { #[prost(oneof = "InviteType", tags = "1")] diff --git a/core/conversations/src/inbox_v2/identity.rs b/core/conversations/src/inbox_v2/identity.rs index 7691cc4..779f90b 100644 --- a/core/conversations/src/inbox_v2/identity.rs +++ b/core/conversations/src/inbox_v2/identity.rs @@ -1,5 +1,6 @@ use std::ops::Deref; +use crypto::{Ed25519Signature, Ed25519VerifyingKey}; use openmls::credentials::{BasicCredential, CredentialWithKey}; use openmls_traits::{ signatures::{Signer, SignerError}, @@ -7,6 +8,7 @@ use openmls_traits::{ }; use shared_traits::IdentIdRef; +use crate::AccountAuthority; use crate::IdentityProvider; /// A Wrapper for an IdentityProvider which provides MLS specific functionality @@ -55,6 +57,22 @@ impl IdentityProvider for MlsIdentityProvider { } } +// On testnet the installation identity is also the account authority: the +// account key is the installation's own key, so the device bundle is signed and +// addressed under `public_key()`. A real deployment injects a separate +// `AccountAuthority` (wallet/enclave) whose key custody lives outside libchat. +impl AccountAuthority for MlsIdentityProvider { + type Error = std::convert::Infallible; + + fn account_pub(&self) -> &Ed25519VerifyingKey { + self.public_key() + } + + fn sign(&self, payload: &[u8]) -> Result { + Ok(IdentityProvider::sign(self, payload)) + } +} + // Implement Signer directly for MlsIdentityProvider, so that openmls Signer contstraint // does not leave the module. impl Signer for MlsIdentityProvider { diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index a6b375d..6be2c50 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -1,3 +1,4 @@ +mod account_directory; mod causal_history; mod conversation; mod core; @@ -12,6 +13,11 @@ mod service_traits; mod types; mod utils; +pub use account_directory::{ + AccountAuthority, AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, + DeviceSet, Lamport, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, + resolve_device_ids, verify_bundle, +}; pub use causal_history::{Frontier, MissingMessage}; pub use chat_sqlite::ChatStorage; pub use chat_sqlite::StorageConfig; diff --git a/core/conversations/src/service_context.rs b/core/conversations/src/service_context.rs index aad489f..01cd095 100644 --- a/core/conversations/src/service_context.rs +++ b/core/conversations/src/service_context.rs @@ -44,8 +44,10 @@ pub(crate) struct ServiceContext { #[cfg(test)] mod test_support { use super::*; + use crate::account_directory::{AccountDirectory, DeviceSet, SignedDeviceBundle}; use crate::types::AddressedEnvelope; use crate::{ChatError, IdentityProvider}; + use crypto::Ed25519VerifyingKey; /// Delivery double that drops every payload. #[derive(Debug)] @@ -74,11 +76,32 @@ mod test_support { &mut self, _identity: &dyn IdentityProvider, _key_bundle: Vec, - ) -> Result<(), Self::Error> { + ) -> Result<(), ::Error> { Ok(()) } - fn retrieve(&self, _device_id: &str) -> Result>, Self::Error> { + fn retrieve( + &self, + _device_id: &str, + ) -> Result>, ::Error> { + Ok(None) + } + } + + impl AccountDirectory for NoopRegistration { + type Error = std::convert::Infallible; + + fn publish( + &mut self, + _bundle: &SignedDeviceBundle, + ) -> Result<(), ::Error> { + Ok(()) + } + + fn fetch( + &self, + _account: &Ed25519VerifyingKey, + ) -> Result, ::Error> { Ok(None) } } diff --git a/core/conversations/src/service_traits.rs b/core/conversations/src/service_traits.rs index d5af222..dfad9a0 100644 --- a/core/conversations/src/service_traits.rs +++ b/core/conversations/src/service_traits.rs @@ -4,7 +4,7 @@ use shared_traits::IdentityProvider; use std::{fmt::Debug, fmt::Display}; -use crate::types::AddressedEnvelope; +use crate::{AccountDirectory, types::AddressedEnvelope}; /// A Delivery service is responsible for payload transport. /// This interface allows Conversations to send payloads on the wire as well as @@ -26,14 +26,25 @@ pub trait DeliveryService: Debug { /// implementations that need to authenticate the submission — e.g. a network /// service that verifies the bundle is signed by the correct account — can /// sign or attest with the caller's key material. -pub trait RegistrationService: Debug { +/// +/// On testnet a single service (the keypackage-registry) provides both the +/// keypackage store and the account → device directory, so [`AccountDirectory`] +/// is a supertrait: any `RegistrationService` also resolves accounts to devices. +/// This co-location is intentional and temporary; the two can be split into +/// separate injected services once λLEZ lands. +pub trait RegistrationService: Debug + AccountDirectory { + // Disambiguated below: with `AccountDirectory` as a supertrait, a bare + // `Self::Error` is ambiguous between the two traits' associated types. type Error: Display + Debug; fn register( &mut self, identity: &dyn IdentityProvider, key_bundle: Vec, - ) -> Result<(), Self::Error>; - fn retrieve(&self, device_id: &str) -> Result>, Self::Error>; + ) -> Result<(), ::Error>; + fn retrieve( + &self, + device_id: &str, + ) -> Result>, ::Error>; } /// Read-only view of a contact registry. Not part of the public API. @@ -44,7 +55,9 @@ pub trait KeyPackageProvider: Debug { } impl KeyPackageProvider for T { - type Error = T::Error; + // Disambiguate: `RegistrationService` now has `AccountDirectory` as a + // supertrait, so both expose an associated `Error`. + type Error = ::Error; fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { RegistrationService::retrieve(self, device_id) } diff --git a/core/crypto/src/signatures.rs b/core/crypto/src/signatures.rs index ac5282b..bba6e10 100644 --- a/core/crypto/src/signatures.rs +++ b/core/crypto/src/signatures.rs @@ -54,7 +54,7 @@ impl Debug for Ed25519SigningKey { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct Ed25519VerifyingKey(ed25519_dalek::VerifyingKey); impl Ed25519VerifyingKey { diff --git a/extensions/components/src/contact_registry.rs b/extensions/components/src/contact_registry.rs index 338a18d..acb42c7 100644 --- a/extensions/components/src/contact_registry.rs +++ b/extensions/components/src/contact_registry.rs @@ -4,37 +4,36 @@ use std::{ sync::{Arc, Mutex}, }; -use libchat::{IdentityProvider, RegistrationService}; +use crypto::Ed25519VerifyingKey; +use libchat::{ + AccountDirectory, DeviceSet, IdentityProvider, RegistrationService, SignedDeviceBundle, + verify_bundle, +}; pub mod http; /// A Contact Registry used for Tests. /// This implementation stores bundle bytes and then returns them when -/// retrieved +/// retrieved. /// - -#[derive(Clone)] +/// Like the real `keypackage-registry`, one object serves both roles: a +/// keypackage store ([`RegistrationService`]) keyed by `device_id`, and an +/// account → device directory ([`AccountDirectory`]) keyed by the hex account key. +#[derive(Clone, Default)] pub struct EphemeralRegistry { - registry: Arc>>>, + key_packages: Arc>>>, + installations: Arc>>, } impl EphemeralRegistry { pub fn new() -> Self { - Self { - registry: Arc::new(Mutex::new(HashMap::new())), - } - } -} - -impl Default for EphemeralRegistry { - fn default() -> Self { - Self::new() + Self::default() } } impl Debug for EphemeralRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let registry = self.registry.lock().unwrap(); + let registry = self.key_packages.lock().unwrap(); let truncated: Vec<(&String, String)> = registry .iter() .map(|(k, v)| { @@ -63,15 +62,53 @@ impl RegistrationService for EphemeralRegistry { &mut self, identity: &dyn IdentityProvider, key_bundle: Vec, - ) -> Result<(), Self::Error> { - self.registry + ) -> Result<(), ::Error> { + self.key_packages .lock() .unwrap() .insert(identity.id().to_string(), key_bundle); Ok(()) } - fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { - Ok(self.registry.lock().unwrap().get(device_id).cloned()) + fn retrieve( + &self, + device_id: &str, + ) -> Result>, ::Error> { + Ok(self.key_packages.lock().unwrap().get(device_id).cloned()) + } +} + +/// Account → device directory, verifying each bundle on `fetch` exactly as the +/// HTTP client does so callers exercise the same trust path without a server. +impl AccountDirectory for EphemeralRegistry { + type Error = String; + + fn publish( + &mut self, + bundle: &SignedDeviceBundle, + ) -> Result<(), ::Error> { + self.installations + .lock() + .unwrap() + .insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone()); + Ok(()) + } + + fn fetch( + &self, + account: &Ed25519VerifyingKey, + ) -> Result, ::Error> { + let Some(bundle) = self + .installations + .lock() + .unwrap() + .get(&hex::encode(account.as_ref())) + .cloned() + else { + return Ok(None); + }; + verify_bundle(account, &bundle) + .map(Some) + .map_err(|e| e.to_string()) } } diff --git a/extensions/components/src/contact_registry/http.rs b/extensions/components/src/contact_registry/http.rs index 771fbe4..7238cab 100644 --- a/extensions/components/src/contact_registry/http.rs +++ b/extensions/components/src/contact_registry/http.rs @@ -4,7 +4,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; use crypto::{Ed25519Signature, Ed25519VerifyingKey}; -use libchat::{IdentityProvider, RegistrationService}; +use libchat::{ + AccountDirectory, BundleError, DeviceSet, IdentityProvider, RegistrationService, + SignedDeviceBundle, verify_bundle, +}; use serde::{Deserialize, Serialize}; /// HTTP client for the testnet KeyPackage Registry service. @@ -36,6 +39,8 @@ pub enum HttpRegistryError { Clock, #[error("signature verification failed")] SignatureInvalid, + #[error("bundle: {0}")] + Bundle(#[from] BundleError), } #[derive(Debug, Serialize)] @@ -54,6 +59,24 @@ struct FetchResponse { signature: String, } +#[derive(Debug, Serialize)] +struct SubmitAccountRequest { + /// hex of the 32-byte account verifying key — verification + storage key. + account_pub: String, + /// base64 of the canonical signed device-list payload. + payload: String, + /// base64 of the 64-byte account signature over `payload`. + signature: String, +} + +#[derive(Debug, Deserialize)] +struct FetchAccountResponse { + payload: String, + signature: String, + #[allow(dead_code)] // server's prune clock; freshness is taken from the bundle's lamport + updated_at: i64, +} + impl HttpRegistry { pub fn new(base_url: impl Into) -> Self { Self::with_timeout(base_url, Duration::from_secs(10)) @@ -86,7 +109,7 @@ impl RegistrationService for HttpRegistry { &mut self, identity: &dyn IdentityProvider, key_bundle: Vec, - ) -> Result<(), Self::Error> { + ) -> Result<(), HttpRegistryError> { let device_id = hex::encode(identity.public_key().as_ref()); let timestamp_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -113,7 +136,7 @@ impl RegistrationService for HttpRegistry { Ok(()) } - fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { + fn retrieve(&self, device_id: &str) -> Result>, HttpRegistryError> { let url = format!("{}/v0/keypackage/{}", self.base_url, device_id); let resp = self.http.get(&url).send()?; if resp.status().as_u16() == 404 { @@ -156,6 +179,66 @@ impl RegistrationService for HttpRegistry { } } +impl AccountDirectory for HttpRegistry { + type Error = HttpRegistryError; + + fn publish(&mut self, bundle: &SignedDeviceBundle) -> Result<(), Self::Error> { + let req = SubmitAccountRequest { + account_pub: hex::encode(bundle.account_pub.as_ref()), + payload: BASE64.encode(&bundle.payload), + signature: BASE64.encode(bundle.signature.as_ref()), + }; + + let url = format!("{}/v0/account", self.base_url); + let resp = self.http.post(&url).json(&req).send()?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().unwrap_or_default(); + return Err(HttpRegistryError::Server(status, body)); + } + Ok(()) + } + + fn fetch(&self, account: &Ed25519VerifyingKey) -> Result, Self::Error> { + let url = format!( + "{}/v0/account/{}", + self.base_url, + hex::encode(account.as_ref()) + ); + let resp = self.http.get(&url).send()?; + if resp.status().as_u16() == 404 { + return Ok(None); + } + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().unwrap_or_default(); + return Err(HttpRegistryError::Server(status, body)); + } + let body: FetchAccountResponse = resp.json()?; + + let payload = BASE64 + .decode(&body.payload) + .map_err(|e| HttpRegistryError::Decode(e.to_string()))?; + let signature_arr: [u8; 64] = BASE64 + .decode(&body.signature) + .map_err(|e| HttpRegistryError::Decode(e.to_string()))? + .as_slice() + .try_into() + .map_err(|_| HttpRegistryError::Decode("signature not 64 bytes".into()))?; + + // The directory service is untrusted: verify the account signature over + // the exact received bytes, and that the bundle is bound to the account + // we asked for, before handing back any device keys. + let bundle = SignedDeviceBundle { + account_pub: account.clone(), + payload, + signature: Ed25519Signature::from(signature_arr), + }; + let device_set = verify_bundle(account, &bundle)?; + Ok(Some(device_set)) + } +} + /// Canonical binary payload — the bytes that are both signed and transmitted /// verbatim. Opaque to the server; decoded only by consumers: ///