mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-27 19:49:31 +00:00
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:
parent
7838d43b30
commit
f41fb40c2f
130
Cargo.lock
generated
130
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,6 @@ resolver = "3"
|
||||
|
||||
members = [
|
||||
"bin/chat-cli",
|
||||
"bin/keypackage-registry",
|
||||
"core/account",
|
||||
"core/conversations",
|
||||
"core/crypto",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"] }
|
||||
@ -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.
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
420
core/conversations/src/account_directory.rs
Normal file
420
core/conversations/src/account_directory.rs
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
///
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user