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
This commit is contained in:
kaichao 2026-06-11 21:07:11 +08:00 committed by GitHub
parent 7838d43b30
commit f41fb40c2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 759 additions and 679 deletions

130
Cargo.lock generated
View File

@ -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",

View File

@ -4,7 +4,6 @@ resolver = "3"
members = [
"bin/chat-cli",
"bin/keypackage-registry",
"core/account",
"core/conversations",
"core/crypto",

View File

@ -54,13 +54,14 @@ cargo run -p chat-cli -- --name bob --transport file
### 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.
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 <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 |
| `--registry-url <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 <path>` | *(stderr, off)* | Write logs to a file instead of stderr |
## Commands

View File

@ -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"] }

View File

@ -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 <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.

View File

@ -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<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()
}
}

View File

@ -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");
}

View File

@ -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<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
}

View File

@ -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<u8>,
/// 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<DeviceId>,
}
/// 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<Ed25519Signature, Self::Error>;
}
/// 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<Option<DeviceSet>, 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<u8> {
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<DecodedBundle, BundleError> {
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<DeviceSet, BundleError> {
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<D: AccountDirectory + ?Sized>(
directory: &D,
account: IdentIdRef,
) -> Result<Vec<DeviceId>, 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<Ed25519VerifyingKey> {
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<SignedDeviceBundle>);
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<Option<DeviceSet>, 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<String> = 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)
));
}
}

View File

@ -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<KeyPackage, ChatError> {
// 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<Vec<KeyPackage>, 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<S: ExternalServices> GroupConvo<S> 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::<Result<Vec<_>, 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

View File

@ -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<S> {
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()
}

View File

@ -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<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
) -> 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")]

View File

@ -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<T: IdentityProvider> IdentityProvider for MlsIdentityProvider<T> {
}
}
// 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<T: IdentityProvider> AccountAuthority for MlsIdentityProvider<T> {
type Error = std::convert::Infallible;
fn account_pub(&self) -> &Ed25519VerifyingKey {
self.public_key()
}
fn sign(&self, payload: &[u8]) -> Result<Ed25519Signature, Self::Error> {
Ok(IdentityProvider::sign(self, payload))
}
}
// Implement Signer directly for MlsIdentityProvider, so that openmls Signer contstraint
// does not leave the module.
impl<T: IdentityProvider> Signer for MlsIdentityProvider<T> {

View File

@ -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;

View File

@ -44,8 +44,10 @@ pub(crate) struct ServiceContext<S: ExternalServices> {
#[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<u8>,
) -> Result<(), Self::Error> {
) -> Result<(), <Self as RegistrationService>::Error> {
Ok(())
}
fn retrieve(&self, _device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
fn retrieve(
&self,
_device_id: &str,
) -> Result<Option<Vec<u8>>, <Self as RegistrationService>::Error> {
Ok(None)
}
}
impl AccountDirectory for NoopRegistration {
type Error = std::convert::Infallible;
fn publish(
&mut self,
_bundle: &SignedDeviceBundle,
) -> Result<(), <Self as AccountDirectory>::Error> {
Ok(())
}
fn fetch(
&self,
_account: &Ed25519VerifyingKey,
) -> Result<Option<DeviceSet>, <Self as AccountDirectory>::Error> {
Ok(None)
}
}

View File

@ -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<u8>,
) -> Result<(), Self::Error>;
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error>;
) -> Result<(), <Self as RegistrationService>::Error>;
fn retrieve(
&self,
device_id: &str,
) -> Result<Option<Vec<u8>>, <Self as RegistrationService>::Error>;
}
/// Read-only view of a contact registry. Not part of the public API.
@ -44,7 +55,9 @@ pub trait KeyPackageProvider: Debug {
}
impl<T: RegistrationService> KeyPackageProvider for T {
type Error = T::Error;
// Disambiguate: `RegistrationService` now has `AccountDirectory` as a
// supertrait, so both expose an associated `Error`.
type Error = <T as RegistrationService>::Error;
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
RegistrationService::retrieve(self, device_id)
}

View File

@ -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 {

View File

@ -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<Mutex<HashMap<String, Vec<u8>>>>,
key_packages: Arc<Mutex<HashMap<String, Vec<u8>>>>,
installations: Arc<Mutex<HashMap<String, SignedDeviceBundle>>>,
}
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<u8>,
) -> Result<(), Self::Error> {
self.registry
) -> Result<(), <Self as RegistrationService>::Error> {
self.key_packages
.lock()
.unwrap()
.insert(identity.id().to_string(), key_bundle);
Ok(())
}
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
Ok(self.registry.lock().unwrap().get(device_id).cloned())
fn retrieve(
&self,
device_id: &str,
) -> Result<Option<Vec<u8>>, <Self as RegistrationService>::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<(), <Self as AccountDirectory>::Error> {
self.installations
.lock()
.unwrap()
.insert(hex::encode(bundle.account_pub.as_ref()), bundle.clone());
Ok(())
}
fn fetch(
&self,
account: &Ed25519VerifyingKey,
) -> Result<Option<DeviceSet>, <Self as AccountDirectory>::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())
}
}

View File

@ -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<String>) -> 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<u8>,
) -> 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<Option<Vec<u8>>, Self::Error> {
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, 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<Option<DeviceSet>, 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:
///