mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-06-27 19:49:31 +00:00
feat: http server based key package registry (#124)
* feat: http server based key package registry * chore: instructions on running the registration service * chore: remove duplicate post param * chore: revert out sourced account id for multi devices support * feat: signature on account id and key packages * chore: include http registry in contact registry module * refactor: use device id for retrieve key package * chore: use string for device id * feat: server verification on the register * chore: doc the smoke test * chore: fix data folder non exist * chore: use payload for register and retrieve * chore: fix clippy
This commit is contained in:
parent
6f5838af51
commit
cd7dd6a330
888
Cargo.lock
generated
888
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ resolver = "3"
|
||||
|
||||
members = [
|
||||
"bin/chat-cli",
|
||||
"bin/keypackage-registry",
|
||||
"core/account",
|
||||
"core/conversations",
|
||||
"core/crypto",
|
||||
|
||||
@ -51,6 +51,27 @@ cargo run -p chat-cli -- --name bob --transport file
|
||||
2. In Bob's terminal, type `/connect <paste bundle here>`.
|
||||
3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat.
|
||||
|
||||
### Optional: KeyPackage registry
|
||||
|
||||
When `--registry-url <url>` is set, the client publishes its MLS KeyPackage
|
||||
to the [keypackage-registry](../keypackage-registry/) service on startup so
|
||||
other clients can later fetch it by `account_id`. Without the flag, an
|
||||
in-memory registry is used and is only visible inside the local process.
|
||||
|
||||
```bash
|
||||
# Terminal 1 — registry server
|
||||
cargo run -p keypackage-registry -- --bind 127.0.0.1:18080
|
||||
|
||||
# Terminal 2 / 3 — chat clients pointing at it
|
||||
cargo run -p chat-cli -- --name alice --transport file \
|
||||
--registry-url http://127.0.0.1:18080
|
||||
cargo run -p chat-cli -- --name bob --transport file \
|
||||
--registry-url http://127.0.0.1:18080
|
||||
```
|
||||
|
||||
The registry is a throwaway testnet helper; v0.3 replaces it with a
|
||||
λLEZ-based discovery service.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Default | Description |
|
||||
@ -60,6 +81,7 @@ cargo run -p chat-cli -- --name bob --transport file
|
||||
| `--db <path>` | `<data>/<name>.db` | SQLite file for persistent identity |
|
||||
| `--preset <name>` | `logos.dev` | logos-delivery network preset |
|
||||
| `--port <n>` | `60000` | TCP port for the embedded logos-delivery node |
|
||||
| `--registry-url <url>` | *(unset)* | Use the HTTP-backed [keypackage-registry](../keypackage-registry/) at this URL instead of the in-memory registry |
|
||||
| `--log-file <path>` | *(stderr, off)* | Write logs to a file instead of stderr |
|
||||
|
||||
## Commands
|
||||
|
||||
@ -5,7 +5,7 @@ use std::sync::mpsc;
|
||||
|
||||
use anyhow::Result;
|
||||
use arboard::Clipboard;
|
||||
use logos_chat::{ChatClient, DeliveryService, Event};
|
||||
use logos_chat::{ChatClient, DeliveryService, EphemeralRegistry, Event, RegistrationService};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::now;
|
||||
@ -41,8 +41,8 @@ pub struct AppState {
|
||||
pub active_chat: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ChatApp<D: DeliveryService> {
|
||||
pub client: ChatClient<D>,
|
||||
pub struct ChatApp<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
||||
pub client: ChatClient<D, R>,
|
||||
inbound: mpsc::Receiver<Vec<u8>>,
|
||||
pub state: AppState,
|
||||
/// Ephemeral command output — not persisted, cleared on chat switch.
|
||||
@ -53,9 +53,13 @@ pub struct ChatApp<D: DeliveryService> {
|
||||
state_path: PathBuf,
|
||||
}
|
||||
|
||||
impl<D: DeliveryService + 'static> ChatApp<D> {
|
||||
impl<D, R> ChatApp<D, R>
|
||||
where
|
||||
D: DeliveryService + 'static,
|
||||
R: RegistrationService + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
client: ChatClient<D>,
|
||||
client: ChatClient<D, R>,
|
||||
inbound: mpsc::Receiver<Vec<u8>>,
|
||||
user_name: &str,
|
||||
data_dir: &Path,
|
||||
|
||||
@ -8,7 +8,7 @@ use std::sync::mpsc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use logos_chat::DeliveryService;
|
||||
use logos_chat::{ChatClient, DeliveryService, HttpRegistry, RegistrationService, StorageConfig};
|
||||
|
||||
use app::ChatApp;
|
||||
|
||||
@ -55,6 +55,12 @@ struct Cli {
|
||||
/// Initialize and immediately exit without launching the TUI (for CI).
|
||||
#[arg(long)]
|
||||
smoketest: bool,
|
||||
|
||||
/// Optional KeyPackage registry base URL. When set, uses the HTTP-backed
|
||||
/// registry instead of the in-memory `EphemeralRegistry`.
|
||||
/// Example: `--registry-url http://localhost:8080`.
|
||||
#[arg(long)]
|
||||
registry_url: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@ -104,18 +110,38 @@ fn run<D: DeliveryService + 'static>(
|
||||
.to_str()
|
||||
.context("db path contains non-UTF-8 characters")?
|
||||
.to_string();
|
||||
let storage = StorageConfig::Encrypted {
|
||||
path: db_str,
|
||||
key: "chat-cli".to_string(),
|
||||
};
|
||||
|
||||
let client = logos_chat::ChatClient::open(
|
||||
cli.name.clone(),
|
||||
logos_chat::StorageConfig::Encrypted {
|
||||
path: db_str,
|
||||
key: "chat-cli".to_string(),
|
||||
},
|
||||
transport,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||
.context("failed to open chat client")?;
|
||||
match cli.registry_url.as_deref() {
|
||||
Some(url) => {
|
||||
let registry = HttpRegistry::new(url);
|
||||
let client =
|
||||
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||
.context("failed to open chat client with HTTP registry")?;
|
||||
launch_tui(client, inbound, cli)
|
||||
}
|
||||
None => {
|
||||
let client = ChatClient::open(cli.name.clone(), storage, transport)
|
||||
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||
.context("failed to open chat client")?;
|
||||
launch_tui(client, inbound, cli)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_tui<D, R>(
|
||||
client: ChatClient<D, R>,
|
||||
inbound: mpsc::Receiver<Vec<u8>>,
|
||||
cli: &Cli,
|
||||
) -> Result<()>
|
||||
where
|
||||
D: DeliveryService + 'static,
|
||||
R: RegistrationService + 'static,
|
||||
{
|
||||
let mut app = ChatApp::new(client, inbound, &cli.name, &cli.data)?;
|
||||
|
||||
if cli.smoketest {
|
||||
@ -193,10 +219,11 @@ fn run_logos_delivery(cli: Cli) -> Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
fn run_app<D: DeliveryService + 'static>(
|
||||
terminal: &mut ui::Tui,
|
||||
app: &mut ChatApp<D>,
|
||||
) -> Result<()> {
|
||||
fn run_app<D, R>(terminal: &mut ui::Tui, app: &mut ChatApp<D, R>) -> Result<()>
|
||||
where
|
||||
D: DeliveryService + 'static,
|
||||
R: RegistrationService + 'static,
|
||||
{
|
||||
loop {
|
||||
app.process_incoming()?;
|
||||
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||
|
||||
@ -16,7 +16,7 @@ use ratatui::{
|
||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
use logos_chat::DeliveryService;
|
||||
use logos_chat::{DeliveryService, RegistrationService};
|
||||
|
||||
use crate::app::ChatApp;
|
||||
|
||||
@ -38,7 +38,10 @@ pub fn restore() -> io::Result<()> {
|
||||
}
|
||||
|
||||
/// Draw the UI.
|
||||
pub fn draw<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>) {
|
||||
pub fn draw<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@ -55,7 +58,11 @@ pub fn draw<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>) {
|
||||
draw_status(frame, app, chunks[3]);
|
||||
}
|
||||
|
||||
fn draw_header<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||
fn draw_header<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
) {
|
||||
let title = match app.current_session() {
|
||||
Some(session) => {
|
||||
let id = &session.chat_id[..8.min(session.chat_id.len())];
|
||||
@ -78,7 +85,11 @@ fn draw_header<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>
|
||||
frame.render_widget(header, area);
|
||||
}
|
||||
|
||||
fn draw_messages<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||
fn draw_messages<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
) {
|
||||
let remote_name = app
|
||||
.current_session()
|
||||
.map(|s| s.display_name())
|
||||
@ -164,7 +175,11 @@ fn draw_messages<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<
|
||||
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
||||
}
|
||||
|
||||
fn draw_input<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||
fn draw_input<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
) {
|
||||
// Inner width: area minus borders (2).
|
||||
let inner_width = area.width.saturating_sub(2) as usize;
|
||||
let input_len = app.input.len();
|
||||
@ -191,7 +206,11 @@ fn draw_input<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>,
|
||||
frame.set_cursor_position((cursor_x, area.y + 1));
|
||||
}
|
||||
|
||||
fn draw_status<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||
fn draw_status<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
frame: &mut Frame,
|
||||
app: &ChatApp<D, R>,
|
||||
area: Rect,
|
||||
) {
|
||||
let status = Paragraph::new(app.status.as_str())
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
||||
@ -201,7 +220,9 @@ fn draw_status<D: DeliveryService + 'static>(frame: &mut Frame, app: &ChatApp<D>
|
||||
}
|
||||
|
||||
/// Handle keyboard events.
|
||||
pub fn handle_events<D: DeliveryService + 'static>(app: &mut ChatApp<D>) -> io::Result<bool> {
|
||||
pub fn handle_events<D: DeliveryService + 'static, R: RegistrationService + 'static>(
|
||||
app: &mut ChatApp<D, R>,
|
||||
) -> io::Result<bool> {
|
||||
// Poll for events with a short timeout to allow checking incoming messages
|
||||
if event::poll(std::time::Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
|
||||
23
bin/keypackage-registry/Cargo.toml
Normal file
23
bin/keypackage-registry/Cargo.toml
Normal file
@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "keypackage-registry"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "keypackage-registry"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
axum = "0.7"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ed25519-dalek = "2.2.0"
|
||||
hex = "0.4"
|
||||
rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
123
bin/keypackage-registry/README.md
Normal file
123
bin/keypackage-registry/README.md
Normal file
@ -0,0 +1,123 @@
|
||||
# keypackage-registry
|
||||
|
||||
Testnet KeyPackage Registry — addresses [issue #110](https://github.com/logos-messaging/libchat/issues/110).
|
||||
|
||||
Standalone HTTP service that caches MLS KeyPackages keyed by **`device_id`**, so a
|
||||
client can fetch a contact's keypackage without an out-of-band exchange.
|
||||
Throwaway by design: scheduled to be replaced by a λLEZ-based service in v0.3, so
|
||||
it intentionally has no overlap with the rest of libchat (axum + rusqlite only).
|
||||
|
||||
`device_id` is the hex-encoded 32-byte Ed25519 verifying key of a device. The
|
||||
account → device mapping is out of scope here and handled elsewhere.
|
||||
|
||||
## Trust model
|
||||
|
||||
A bundle is an opaque **payload** plus its **signature**, published under a
|
||||
**`device_id`** (the hex of the device's 32-byte Ed25519 verifying key).
|
||||
The signed bytes and the wire bytes are identical, so a verifier checks the
|
||||
signature over exactly what it received, no reconstruction.
|
||||
|
||||
The **server treats `payload` as a black box**: it never decodes it. It only
|
||||
verifies that `signature` over the payload bytes is valid under `device_id`'s
|
||||
key, then stores it. A valid signature is proof-of-possession — only the holder
|
||||
of `device_id`'s key can publish under it — so an adversary can't publish under
|
||||
a `device_id` it doesn't control, and junk is dropped before storage. The server
|
||||
is not a trusted authority, so **consumers MUST also verify on retrieve**, and a
|
||||
valid signature does not prove the device is authorized for any account (that
|
||||
binding arrives with λLEZ in v0.3).
|
||||
|
||||
Consumers define the payload layout. Today it is:
|
||||
|
||||
```text
|
||||
payload = timestamp_ms_le[8] || key_package[..]
|
||||
```
|
||||
|
||||
Fixed-width field first with the variable `key_package` last makes it parse
|
||||
exactly one way — no delimiter, even though `key_package` is arbitrary bytes.
|
||||
|
||||
## Building & running
|
||||
|
||||
```bash
|
||||
cargo build --release -p keypackage-registry
|
||||
./target/release/keypackage-registry # binds 0.0.0.0:8080, db ./keypackage-registry.db
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--bind <addr>` | `0.0.0.0:8080` | HTTP bind address |
|
||||
| `--db <path>` | `keypackage-registry.db` | SQLite database path |
|
||||
| `--max-per-identity <n>` | `5` | Bundles retained per `device_id` |
|
||||
| `--retention-days <n>` | `30` | Drop bundles older than this |
|
||||
| `--prune-interval-secs <n>` | `3600` | How often the prune task runs |
|
||||
|
||||
Logs via `RUST_LOG` (default `info`).
|
||||
|
||||
## API
|
||||
|
||||
### `POST /v0/keypackage`
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "hex(32-byte ed25519 verifying key)",
|
||||
"payload": "base64(opaque signed bytes)",
|
||||
"signature": "base64(64-byte ed25519 signature over payload)"
|
||||
}
|
||||
```
|
||||
|
||||
The server verifies `signature` over the (opaque) `payload` bytes under
|
||||
`device_id`'s key before storing, keyed by `device_id`. It does not decode
|
||||
`payload`. Returns `204` on success, `400` on malformed input or a signature
|
||||
that fails to verify.
|
||||
|
||||
### `GET /v0/keypackage/{device_id}`
|
||||
|
||||
Returns the most recently submitted bundle for that `device_id`, or `404`:
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": "base64(...)",
|
||||
"signature": "base64(64-byte ed25519 signature)"
|
||||
}
|
||||
```
|
||||
|
||||
Consumers verify `signature` over the `payload` bytes using the key recovered
|
||||
from `device_id`, then read `key_package` out of the payload. A bundle that
|
||||
fails verification must be treated as not found.
|
||||
|
||||
## Storage & retention
|
||||
|
||||
A SQLite table keyed by `device_id`. A background task runs every
|
||||
`--prune-interval-secs`, dropping bundles older than `--retention-days` and
|
||||
keeping at most `--max-per-identity` per `device_id`. The schema is an internal
|
||||
detail and may change.
|
||||
|
||||
## Smoke test
|
||||
|
||||
End-to-end check with the real `chat-cli` against a running server:
|
||||
|
||||
```bash
|
||||
cargo build -p keypackage-registry -p chat-cli
|
||||
|
||||
# 1. start the server on a test port with a fresh db
|
||||
./target/debug/keypackage-registry --bind 127.0.0.1:18080 --db tmp/registry.db
|
||||
|
||||
# 2. register two identities through chat-cli (--smoketest exits after registering)
|
||||
./target/debug/chat-cli --name alice --transport file --data tmp/alice \
|
||||
--registry-url http://127.0.0.1:18080 --smoketest # exits 0 on success
|
||||
./target/debug/chat-cli --name bob --transport file --data tmp/bob \
|
||||
--registry-url http://127.0.0.1:18080 --smoketest
|
||||
|
||||
# 3. confirm both bundles landed
|
||||
sqlite3 tmp/registry.db "SELECT substr(device_id,1,12), length(payload) FROM keypackages;"
|
||||
```
|
||||
|
||||
A non-zero exit from `chat-cli` means the server rejected the submission — e.g.
|
||||
the signature failed verification. `GET /v0/keypackage/{device_id}` returns `200`
|
||||
for a registered device and `404` otherwise.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
Exists to unblock contact-by-id flows on testnet; removed once λLEZ-based
|
||||
discovery lands in v0.3. The seam is the `RegistrationService` trait
|
||||
(`core/conversations/src/service_traits.rs`) — swapping implementations does not
|
||||
touch the chat protocol.
|
||||
137
bin/keypackage-registry/src/handlers.rs
Normal file
137
bin/keypackage-registry/src/handlers.rs
Normal file
@ -0,0 +1,137 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::{Store, StoredBundle};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SubmitRequest {
|
||||
/// Hex of the 32-byte Ed25519 device verifying key. Used to verify the
|
||||
/// signature and as the storage/lookup key. `payload` stays opaque.
|
||||
pub device_id: String,
|
||||
/// base64 of the signed payload. Opaque to the server — it never decodes it.
|
||||
pub payload: String,
|
||||
/// base64 of the 64-byte Ed25519 signature over `payload`. Verifying it
|
||||
/// under `device_id`'s key is proof-of-possession: only the holder of that
|
||||
/// key can publish under this `device_id`.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FetchResponse {
|
||||
/// base64 of the stored payload; consumers verify `signature` over it.
|
||||
pub payload: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
pub fn router(store: Arc<Store>) -> Router {
|
||||
Router::new()
|
||||
.route("/v0/keypackage", post(submit))
|
||||
.route("/v0/keypackage/:device_id", get(fetch))
|
||||
.with_state(store)
|
||||
}
|
||||
|
||||
async fn submit(
|
||||
State(store): State<Arc<Store>>,
|
||||
Json(req): Json<SubmitRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
// Verify proof-of-possession before persisting. `payload` is opaque — the
|
||||
// server only checks that `signature` over the received payload bytes is
|
||||
// valid under `device_id`'s key. A valid signature means the submitter holds
|
||||
// that key. This rejects junk early (DoS mitigation); consumers still verify
|
||||
// on retrieve, the server is not a trusted authority.
|
||||
let device_pubkey: [u8; 32] = hex::decode(&req.device_id)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| ApiError::bad("device_id: must be hex of a 32-byte key"))?;
|
||||
let payload = BASE64
|
||||
.decode(&req.payload)
|
||||
.map_err(|_| ApiError::bad("payload: not valid base64"))?;
|
||||
let signature: [u8; 64] = BASE64
|
||||
.decode(&req.signature)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| ApiError::bad("signature: must be base64 of 64 bytes"))?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_bytes(&device_pubkey)
|
||||
.map_err(|_| ApiError::bad("device_id: not a valid ed25519 key"))?;
|
||||
verifying_key
|
||||
.verify_strict(&payload, &Signature::from_bytes(&signature))
|
||||
.map_err(|_| ApiError::bad("signature: verification failed"))?;
|
||||
|
||||
store
|
||||
.insert(
|
||||
&req.device_id,
|
||||
&StoredBundle {
|
||||
payload,
|
||||
signature: signature.to_vec(),
|
||||
},
|
||||
)
|
||||
.map_err(ApiError::internal)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
State(store): State<Arc<Store>>,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<Json<FetchResponse>, ApiError> {
|
||||
let Some(bundle) = store.latest(&device_id).map_err(ApiError::internal)? else {
|
||||
return Err(ApiError::not_found("no keypackage for device"));
|
||||
};
|
||||
Ok(Json(FetchResponse {
|
||||
payload: BASE64.encode(&bundle.payload),
|
||||
signature: BASE64.encode(&bundle.signature),
|
||||
}))
|
||||
}
|
||||
|
||||
struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn bad(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::BAD_REQUEST,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
fn not_found(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
fn internal<E: std::fmt::Display>(err: E) -> Self {
|
||||
tracing::error!("internal: {err}");
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: "internal error".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
(
|
||||
self.status,
|
||||
Json(ErrorBody {
|
||||
error: self.message,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
89
bin/keypackage-registry/src/main.rs
Normal file
89
bin/keypackage-registry/src/main.rs
Normal file
@ -0,0 +1,89 @@
|
||||
//! Testnet KeyPackage Registry HTTP service.
|
||||
//!
|
||||
//! Throwaway service for issue #110 — replaced by λLEZ in v0.3. Intentionally
|
||||
//! self-contained: depends only on axum + sqlite + ed25519, no libchat core.
|
||||
//!
|
||||
//! Wire:
|
||||
//! POST /v0/keypackage — submit a signed bundle
|
||||
//! GET /v0/keypackage/{acct_id} — fetch the latest stored bundle
|
||||
|
||||
mod handlers;
|
||||
mod store;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use store::Store;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "keypackage-registry", about = "Testnet KeyPackage Registry")]
|
||||
struct Cli {
|
||||
/// Address to bind the HTTP server.
|
||||
#[arg(long, default_value = "0.0.0.0:8080")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// SQLite database path.
|
||||
#[arg(long, default_value = "keypackage-registry.db")]
|
||||
db: PathBuf,
|
||||
|
||||
/// Maximum number of bundles retained per account_id.
|
||||
#[arg(long, default_value_t = 100)]
|
||||
max_per_identity: usize,
|
||||
|
||||
/// Retention window in days; older bundles are pruned.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
retention_days: u64,
|
||||
|
||||
/// How often the prune task runs.
|
||||
#[arg(long, default_value_t = 3600)]
|
||||
prune_interval_secs: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let store = Arc::new(Store::open(&cli.db).context("failed to open store")?);
|
||||
|
||||
let prune_store = store.clone();
|
||||
let max_per_id = cli.max_per_identity;
|
||||
let retention = Duration::from_secs(cli.retention_days * 24 * 3600);
|
||||
let interval = Duration::from_secs(cli.prune_interval_secs);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(e) = prune_store.prune(max_per_id, retention) {
|
||||
tracing::warn!("prune failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app = handlers::router(store);
|
||||
let listener = tokio::net::TcpListener::bind(cli.bind)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind {}", cli.bind))?;
|
||||
tracing::info!("keypackage-registry listening on {}", cli.bind);
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await
|
||||
.context("server error")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
116
bin/keypackage-registry/src/store.rs
Normal file
116
bin/keypackage-registry/src/store.rs
Normal file
@ -0,0 +1,116 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
pub struct Store {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredBundle {
|
||||
/// The canonical signed payload, stored verbatim and returned as-is so
|
||||
/// consumers verify over the exact bytes that were signed.
|
||||
pub payload: Vec<u8>,
|
||||
/// 64-byte Ed25519 signature over `payload`. Opaque to the server.
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
// Create the db's parent directory if the caller pointed at a nested
|
||||
// path (e.g. `tmp/registry.db`); SQLite won't create it and errors with
|
||||
// "unable to open database file" otherwise.
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("create db directory {}", parent.display()))?;
|
||||
}
|
||||
let conn = Connection::open(path).context("open sqlite")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS keypackages (
|
||||
device_id TEXT NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
signature BLOB NOT NULL,
|
||||
PRIMARY KEY (device_id, received_at)
|
||||
);",
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn insert(&self, device_id: &str, bundle: &StoredBundle) -> Result<()> {
|
||||
let received_at = now_ms() as i64;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO keypackages
|
||||
(device_id, received_at, payload, signature)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![device_id, received_at, bundle.payload, bundle.signature],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the most recently received bundle for `device_id`. Scope A: the
|
||||
/// chat layer consumes one bundle per device. When multi-keypackage fanout
|
||||
/// lands, switch this to return a `Vec<StoredBundle>`.
|
||||
pub fn latest(&self, device_id: &str) -> Result<Option<StoredBundle>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT payload, signature FROM keypackages
|
||||
WHERE device_id = ?1
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 1",
|
||||
params![device_id],
|
||||
|r| {
|
||||
Ok(StoredBundle {
|
||||
payload: r.get::<_, Vec<u8>>(0)?,
|
||||
signature: r.get::<_, Vec<u8>>(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Drops bundles older than `retention` and keeps at most
|
||||
/// `max_per_identity` per `device_id` — each device's history is bounded
|
||||
/// independently.
|
||||
pub fn prune(&self, max_per_identity: usize, retention: Duration) -> Result<()> {
|
||||
let cutoff_ms = now_ms().saturating_sub(retention.as_millis() as u64) as i64;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM keypackages WHERE received_at < ?1",
|
||||
params![cutoff_ms],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM keypackages
|
||||
WHERE rowid IN (
|
||||
SELECT rowid FROM (
|
||||
SELECT rowid,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY device_id
|
||||
ORDER BY received_at DESC
|
||||
) AS rn
|
||||
FROM keypackages
|
||||
)
|
||||
WHERE rn > ?1
|
||||
)",
|
||||
params![max_per_identity as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
@ -15,14 +15,18 @@ pub struct LogosAccount {
|
||||
}
|
||||
|
||||
impl LogosAccount {
|
||||
/// Create a test LogosAccount using a pre-defined identifier.
|
||||
/// Create a test LogosAccount. The `AccountId` is derived from the
|
||||
/// generated Ed25519 verifying key (hex-encoded) so signatures over the
|
||||
/// id can be verified by anyone holding the id alone.
|
||||
/// The supplied `_display_name` is currently ignored — id is the key.
|
||||
/// This should only be used during MLS integration. Not suitable for production use.
|
||||
/// TODO: (P1) Remove once implementation is ready.
|
||||
pub fn new_test(explicit_id: impl Into<String>) -> Self {
|
||||
pub fn new_test(_display_name: impl Into<String>) -> Self {
|
||||
let signing_key = Ed25519SigningKey::generate();
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let id = AccountId::new(hex::encode(verifying_key.as_ref()));
|
||||
Self {
|
||||
id: AccountId::new(explicit_id.into()),
|
||||
id,
|
||||
signing_key,
|
||||
verifying_key,
|
||||
}
|
||||
|
||||
@ -164,6 +164,13 @@ where
|
||||
self.identity.public_key()
|
||||
}
|
||||
|
||||
/// Submit the local account's MLS KeyPackage to the registration service.
|
||||
/// Idempotent on the server side (registries that retain history will keep
|
||||
/// the most recent N submissions; older entries are pruned).
|
||||
pub fn register_keypackage(&mut self) -> Result<(), ChatError> {
|
||||
self.pq_inbox.register()
|
||||
}
|
||||
|
||||
pub fn create_private_convo(
|
||||
&mut self,
|
||||
remote_bundle: &Introduction,
|
||||
|
||||
@ -193,10 +193,15 @@ where
|
||||
}
|
||||
|
||||
fn key_package_for_account(&self, ident: &AccountId) -> 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 = self
|
||||
.keypkg_provider
|
||||
.borrow()
|
||||
.retrieve(ident)
|
||||
.retrieve(&device_id)
|
||||
.map_err(|e: KP::Error| ChatError::Generic(e.to_string()))?;
|
||||
|
||||
// dbg!(ctx.contact_registry());
|
||||
|
||||
@ -105,7 +105,7 @@ where
|
||||
// "LastResort" package or publish multiple
|
||||
self.reg_service
|
||||
.borrow_mut()
|
||||
.register(self.account_id().as_str(), keypackage_bytes)
|
||||
.register(&*self.account.borrow(), keypackage_bytes)
|
||||
.map_err(ChatError::generic)
|
||||
}
|
||||
|
||||
|
||||
@ -22,23 +22,32 @@ pub trait DeliveryService: Debug {
|
||||
///
|
||||
/// Implement this to provide a contact registry — ach participant publishes their key package
|
||||
/// on registration; others fetch it to initiate a conversation.
|
||||
///
|
||||
/// `register` receives an [`IdentityProvider`] (not just a name) so
|
||||
/// 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 {
|
||||
type Error: Display + Debug;
|
||||
fn register(&mut self, identity: &str, key_bundle: Vec<u8>) -> Result<(), Self::Error>;
|
||||
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Read-only view of a contact registry. Not part of the public API.
|
||||
/// Satisfied automatically by any `RegistrationService` implementation.
|
||||
pub trait KeyPackageProvider: Debug {
|
||||
type Error: Display + Debug;
|
||||
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error>;
|
||||
}
|
||||
|
||||
impl<T: RegistrationService> KeyPackageProvider for T {
|
||||
type Error = T::Error;
|
||||
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
RegistrationService::retrieve(self, identity)
|
||||
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
RegistrationService::retrieve(self, device_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -68,6 +68,12 @@ impl Ed25519VerifyingKey {
|
||||
.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&inner_signature))
|
||||
.map_err(|_| SignatureVerificationError {})
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self, SignatureVerificationError> {
|
||||
ed25519_dalek::VerifyingKey::from_bytes(bytes)
|
||||
.map(Self)
|
||||
.map_err(|_| SignatureVerificationError {})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ed25519_dalek::VerifyingKey> for Ed25519VerifyingKey {
|
||||
|
||||
@ -2,7 +2,8 @@ use std::sync::Arc;
|
||||
|
||||
use libchat::{
|
||||
AddressedEnvelope, ChatError, ChatStorage, Context, ConversationId, ConvoOutcome,
|
||||
DeliveryService, InboxOutcome, Introduction, PayloadOutcome, StorageConfig,
|
||||
DeliveryService, InboxOutcome, Introduction, PayloadOutcome, RegistrationService,
|
||||
StorageConfig,
|
||||
};
|
||||
|
||||
use components::EphemeralRegistry;
|
||||
@ -10,11 +11,13 @@ use components::EphemeralRegistry;
|
||||
use crate::errors::ClientError;
|
||||
use crate::event::Event;
|
||||
|
||||
pub struct ChatClient<D: DeliveryService> {
|
||||
ctx: Context<D, EphemeralRegistry, ChatStorage>,
|
||||
pub struct ChatClient<D: DeliveryService, R: RegistrationService = EphemeralRegistry> {
|
||||
ctx: Context<D, R, ChatStorage>,
|
||||
}
|
||||
|
||||
impl<D: DeliveryService + 'static> ChatClient<D> {
|
||||
// ── Default-registry constructors ────────────────────────────────────────────
|
||||
|
||||
impl<D: DeliveryService + 'static> ChatClient<D, EphemeralRegistry> {
|
||||
/// Create an in-memory, ephemeral client. Identity is lost on drop.
|
||||
pub fn new(name: impl Into<String>, delivery: D) -> Self {
|
||||
let registry = EphemeralRegistry::new();
|
||||
@ -38,6 +41,34 @@ impl<D: DeliveryService + 'static> ChatClient<D> {
|
||||
let ctx = Context::new_from_store(name, delivery, registry, store)?;
|
||||
Ok(Self { ctx })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Caller-supplied registry + shared methods ────────────────────────────────
|
||||
|
||||
impl<D, R> ChatClient<D, R>
|
||||
where
|
||||
D: DeliveryService + 'static,
|
||||
R: RegistrationService + 'static,
|
||||
{
|
||||
/// Open or create a persistent client with a caller-supplied registration
|
||||
/// service. Use this to swap in a network-backed registry (e.g. the
|
||||
/// testnet KeyPackage Registry) in place of the default in-memory store.
|
||||
///
|
||||
/// Submits this account's KeyPackage to the registry as the last step of
|
||||
/// construction. The default in-memory `open` path skips this call, but
|
||||
/// when a real registry is wired in we want each session to publish so
|
||||
/// other clients can fetch it.
|
||||
pub fn open_with_registry(
|
||||
name: impl Into<String>,
|
||||
config: StorageConfig,
|
||||
delivery: D,
|
||||
registry: R,
|
||||
) -> Result<Self, ClientError<D::Error>> {
|
||||
let store = ChatStorage::new(config).map_err(ChatError::from)?;
|
||||
let mut ctx = Context::new_from_store(name, delivery, registry, store)?;
|
||||
ctx.register_keypackage()?;
|
||||
Ok(Self { ctx })
|
||||
}
|
||||
|
||||
/// Returns the installation name (identity label) of this client.
|
||||
pub fn installation_name(&self) -> &str {
|
||||
|
||||
@ -10,5 +10,10 @@ pub use event::Event;
|
||||
|
||||
// Re-export types callers need to interact with ChatClient.
|
||||
pub use libchat::{
|
||||
AddressedEnvelope, ConversationClass, ConversationId, DeliveryService, StorageConfig,
|
||||
AddressedEnvelope, ConversationClass, ConversationId, DeliveryService, RegistrationService,
|
||||
StorageConfig,
|
||||
};
|
||||
|
||||
// Re-export bundled registry implementations so callers can pick one without
|
||||
// pulling in `components` directly.
|
||||
pub use components::{EphemeralRegistry, HttpRegistry, HttpRegistryError};
|
||||
|
||||
@ -5,9 +5,14 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies (sorted)
|
||||
crypto = { workspace = true } # Needed because Storage traits require "Identity" struct
|
||||
crypto = { workspace = true }
|
||||
libchat = { workspace = true }
|
||||
storage = { workspace = true }
|
||||
|
||||
# External dependencies (sorted)
|
||||
base64 = "0.22"
|
||||
hex = "0.4.3"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
|
||||
@ -4,7 +4,9 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use libchat::{AccountId, RegistrationService};
|
||||
use libchat::{IdentityProvider, RegistrationService};
|
||||
|
||||
pub mod http;
|
||||
|
||||
/// A Contact Registry used for Tests.
|
||||
/// This implementation stores bundle bytes and then returns them when
|
||||
@ -57,20 +59,19 @@ impl Debug for EphemeralRegistry {
|
||||
impl RegistrationService for EphemeralRegistry {
|
||||
type Error = String;
|
||||
|
||||
fn register(&mut self, identity: &str, key_bundle: Vec<u8>) -> Result<(), Self::Error> {
|
||||
fn register(
|
||||
&mut self,
|
||||
identity: &dyn IdentityProvider,
|
||||
key_bundle: Vec<u8>,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(identity.to_string(), key_bundle);
|
||||
.insert(identity.account_id().to_string(), key_bundle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
Ok(self
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(identity.as_str())
|
||||
.cloned())
|
||||
fn retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
Ok(self.registry.lock().unwrap().get(device_id).cloned())
|
||||
}
|
||||
}
|
||||
|
||||
247
extensions/components/src/contact_registry/http.rs
Normal file
247
extensions/components/src/contact_registry/http.rs
Normal file
@ -0,0 +1,247 @@
|
||||
use std::fmt::Debug;
|
||||
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 serde::{Deserialize, Serialize};
|
||||
|
||||
/// HTTP client for the testnet KeyPackage Registry service.
|
||||
///
|
||||
/// Throwaway transport for issue #110 — replaced by λLEZ in v0.3.
|
||||
///
|
||||
/// The wire carries `device_id` (the hex device verifying key), an opaque
|
||||
/// `payload` blob, and its `signature`. The signed bytes and the transmitted
|
||||
/// `payload` bytes are identical, so every verifier checks the signature over
|
||||
/// exactly what it received — no field-by-field reconstruction to keep in sync.
|
||||
/// The `payload` is opaque to the server: it verifies `signature` over `payload`
|
||||
/// with `device_id`'s key (proof-of-possession — only the holder of that key can
|
||||
/// publish under `device_id`) without decoding the payload.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpRegistry {
|
||||
base_url: String,
|
||||
http: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HttpRegistryError {
|
||||
#[error("http: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("server returned status {0}: {1}")]
|
||||
Server(u16, String),
|
||||
#[error("decode: {0}")]
|
||||
Decode(String),
|
||||
#[error("clock before unix epoch")]
|
||||
Clock,
|
||||
#[error("signature verification failed")]
|
||||
SignatureInvalid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubmitRequest {
|
||||
/// hex of the 32-byte device verifying key — the verification + storage key.
|
||||
device_id: String,
|
||||
/// base64 of the canonical signed payload (see [`encode_payload`]).
|
||||
payload: String,
|
||||
/// base64 of the 64-byte Ed25519 signature over `payload`.
|
||||
signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FetchResponse {
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
impl HttpRegistry {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self::with_timeout(base_url, Duration::from_secs(10))
|
||||
}
|
||||
|
||||
pub fn with_timeout(base_url: impl Into<String>, timeout: Duration) -> Self {
|
||||
let http = reqwest::blocking::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.expect("reqwest client builder is infallible with these options");
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
http,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for HttpRegistry {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HttpRegistry")
|
||||
.field("base_url", &self.base_url)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegistrationService for HttpRegistry {
|
||||
type Error = HttpRegistryError;
|
||||
|
||||
fn register(
|
||||
&mut self,
|
||||
identity: &dyn IdentityProvider,
|
||||
key_bundle: Vec<u8>,
|
||||
) -> Result<(), Self::Error> {
|
||||
let device_id = hex::encode(identity.public_key().as_ref());
|
||||
let timestamp_ms = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|_| HttpRegistryError::Clock)?
|
||||
.as_millis() as u64;
|
||||
|
||||
// Sign exactly the bytes that go on the wire.
|
||||
let payload = encode_payload(timestamp_ms, &key_bundle);
|
||||
let signature = identity.sign(&payload);
|
||||
|
||||
let req = SubmitRequest {
|
||||
device_id,
|
||||
payload: BASE64.encode(&payload),
|
||||
signature: BASE64.encode(signature.as_ref()),
|
||||
};
|
||||
|
||||
let url = format!("{}/v0/keypackage", 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 retrieve(&self, device_id: &str) -> Result<Option<Vec<u8>>, Self::Error> {
|
||||
let url = format!("{}/v0/keypackage/{}", self.base_url, device_id);
|
||||
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: FetchResponse = 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()))?;
|
||||
|
||||
// Verify over the received payload bytes, using the key we asked for
|
||||
// (`device_id`). A bundle the requested device didn't sign won't verify.
|
||||
let device_pubkey: [u8; 32] = hex::decode(device_id)
|
||||
.map_err(|e| HttpRegistryError::Decode(e.to_string()))?
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| HttpRegistryError::Decode("device_id not a 32-byte key".into()))?;
|
||||
let verifying_key = Ed25519VerifyingKey::from_bytes(&device_pubkey)
|
||||
.map_err(|_| HttpRegistryError::Decode("device_id not a valid ed25519 vk".into()))?;
|
||||
verifying_key
|
||||
.verify(&payload, &Ed25519Signature::from(signature_arr))
|
||||
.map_err(|_| HttpRegistryError::SignatureInvalid)?;
|
||||
|
||||
let (_timestamp_ms, key_package) = decode_payload(&payload)
|
||||
.ok_or_else(|| HttpRegistryError::Decode("short payload".into()))?;
|
||||
|
||||
Ok(Some(key_package.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical binary payload — the bytes that are both signed and transmitted
|
||||
/// verbatim. Opaque to the server; decoded only by consumers:
|
||||
///
|
||||
/// ```text
|
||||
/// timestamp_ms : u64 little-endian (8 bytes)
|
||||
/// key_package : remaining bytes (variable, last → no length prefix needed)
|
||||
/// ```
|
||||
///
|
||||
/// The fixed-width field first with the one variable field last makes every
|
||||
/// byte string parse exactly one way — no delimiter, no ambiguity, even though
|
||||
/// `key_package` is arbitrary bytes. The device verifying key is carried
|
||||
/// alongside as `device_id`, not embedded here.
|
||||
fn encode_payload(timestamp_ms: u64, key_package: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(8 + key_package.len());
|
||||
out.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
out.extend_from_slice(key_package);
|
||||
out
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_payload`]. Returns `None` if the payload is shorter than
|
||||
/// the fixed header (`8`).
|
||||
fn decode_payload(payload: &[u8]) -> Option<(u64, &[u8])> {
|
||||
if payload.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let timestamp_ms = u64::from_le_bytes(payload[..8].try_into().ok()?);
|
||||
Some((timestamp_ms, &payload[8..]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crypto::Ed25519SigningKey;
|
||||
|
||||
/// `encode_payload` / `decode_payload` round-trip, including a key_package
|
||||
/// containing bytes that a delimiter scheme would choke on (`:`, `|`, NUL).
|
||||
#[test]
|
||||
fn payload_roundtrips_with_arbitrary_bytes() {
|
||||
let ts = 1_700_000_000_000u64;
|
||||
let key_package = b"mls:bytes|with\x00delimiters".to_vec();
|
||||
|
||||
let payload = encode_payload(ts, &key_package);
|
||||
let (got_ts, got_kp) = decode_payload(&payload).unwrap();
|
||||
assert_eq!(got_ts, ts);
|
||||
assert_eq!(got_kp, key_package.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short_payload() {
|
||||
assert!(decode_payload(&[0u8; 7]).is_none());
|
||||
}
|
||||
|
||||
/// Tampering with any byte of the payload breaks verification.
|
||||
#[test]
|
||||
fn signature_binds_payload() {
|
||||
let signing = Ed25519SigningKey::generate();
|
||||
let verifying = signing.verifying_key();
|
||||
|
||||
let payload = encode_payload(1_700_000_000_000, b"original-keypackage");
|
||||
let signature = signing.sign(&payload);
|
||||
|
||||
let tampered = encode_payload(1_700_000_000_000, b"tampered-keypackage");
|
||||
verifying
|
||||
.verify(&tampered, &signature)
|
||||
.expect_err("signature must not verify against a different payload");
|
||||
}
|
||||
|
||||
/// End-to-end of the wire crypto: verify over the received payload bytes
|
||||
/// using the key recovered from device_id, exactly as `retrieve` does.
|
||||
#[test]
|
||||
fn sign_then_verify_over_payload() {
|
||||
let signing = Ed25519SigningKey::generate();
|
||||
let pubkey: [u8; 32] = signing.verifying_key().as_ref().try_into().unwrap();
|
||||
let payload = encode_payload(1_700_000_000_000, b"fake-mls-keypackage-bytes");
|
||||
let signature = signing.sign(&payload);
|
||||
|
||||
// retrieve side: recover key from device_id (hex of pubkey), verify payload.
|
||||
let device_id = hex::encode(pubkey);
|
||||
let recovered: [u8; 32] = hex::decode(&device_id)
|
||||
.unwrap()
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
Ed25519VerifyingKey::from_bytes(&recovered)
|
||||
.unwrap()
|
||||
.verify(&payload, &signature)
|
||||
.expect("recovered key must verify the register-time signature");
|
||||
}
|
||||
}
|
||||
@ -3,5 +3,6 @@ mod delivery;
|
||||
mod storage;
|
||||
|
||||
pub use contact_registry::EphemeralRegistry;
|
||||
pub use contact_registry::http::{HttpRegistry, HttpRegistryError};
|
||||
pub use delivery::*;
|
||||
pub use storage::*;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user