Persistence for group-chat users' key packages — the chat-store HTTP service
(formerly keypackage-registry), extracted from
libchat so it can be deployed on
its own.
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.
It also runs a minimal account service: one signed blob per account_pub
mapping an Account to its set of device (LocalIdentity) public keys, so clients
can invite every LocalIdentity of an account. account_pub is the hex-encoded
32-byte Ed25519 AccountAddress verifying key. See
Account device-list endpoints.
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:
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.
cargo build --release
./target/release/chat-store # binds 0.0.0.0:8080, db ./chat-store.db| Flag | Default | Description |
|---|---|---|
--bind <addr> |
0.0.0.0:8080 |
HTTP bind address |
--db <path> |
chat-store.db |
SQLite database path |
--max-per-identity <n> |
100 |
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).
# Build the image
docker build -t chat-store .
# Run it, persisting the SQLite db on a named volume and exposing port 8080
docker run --rm -p 8080:8080 -v chat-store-data:/data chat-storeThe image runs the binary with --bind 0.0.0.0:8080 --db /data/chat-store.db
by default; override the CMD to change flags, e.g.:
docker run --rm -p 9000:9000 -v chat-store-data:/data chat-store \
--bind 0.0.0.0:9000 --db /data/registry.db --retention-days 14{
"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.
Returns the most recently submitted bundle for that device_id, or 404:
{
"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.
The account service stores exactly one blob per account_pub mapping an
Account to its LocalIdentity device keys. Same trust model as keypackages: the
server verifies signature over payload under account_pub's key
(proof-of-possession), and consumers MUST re-verify on retrieve. Clients encode
a lamport-timestamped list of device public keys in payload; the rest of the
payload stays opaque to the server.
Anti-replay: the server reads the lamport from the (signature-verified)
payloadand replaces the stored bundle only when the incoming lamport is strictly higher, returning409otherwise. Because the lamport is covered by the account signature it cannot be forged, so a replayed older-but-still-valid bundle cannot downgrade the device list, nor refresh the retention clock. Consumers should still compare lamports themselves as defence in depth.
Upsert the device-list bundle for an account; replaces any previous value.
{
"account_pub": "hex(32-byte ed25519 AccountAddress verifying key)",
"payload": "base64(opaque signed bytes: lamport-ts + device pubkeys)",
"signature": "base64(64-byte ed25519 signature over payload by the account key)"
}Returns 204 on success, 400 on malformed input or a signature that fails to
verify, and 409 when the bundle's lamport is not newer than the stored one
(replay / stale publish).
Returns the stored bundle for that account, or 404:
{
"payload": "base64(...)",
"signature": "base64(64-byte ed25519 signature)",
"updated_at": 1700000000000
}updated_at is the server's last-upsert time in Unix ms. Consumers verify
signature over payload under account_pub's key, then decode the device list.
Two SQLite tables: keypackages keyed by device_id, and account_bundles
(one row per account_pub). A background task runs every --prune-interval-secs,
dropping keypackage bundles older than --retention-days (keeping at most
--max-per-identity per device_id) and dropping account bundles not refreshed
within --retention-days. The schema is an internal detail and may change.
The quickest end-to-end check is the bundled smoke_test
example. It generates throwaway Ed25519 keys, signs and publishes a keypackage and
an account bundle, fetches both back, and confirms the replay guard:
# Terminal 1 — start a server with a fresh db
cargo run -- --bind 127.0.0.1:8080 --db tmp/chat-store.db
# Terminal 2 — run the example against it (defaults to http://127.0.0.1:8080)
cargo run --example smoke_test
# or point it elsewhere:
cargo run --example smoke_test -- http://127.0.0.1:8080Expected output:
POST /v0/keypackage -> 204 No Content (expect 204)
GET /v0/keypackage/<id> -> 200 OK (expect 200) {"payload":...,"signature":...}
POST /v0/account -> 204 No Content (expect 204)
GET /v0/account/<id> -> 200 OK (expect 200) {"payload":...,"signature":...,"updated_at":...}
POST /v0/account (replay) -> 409 Conflict (expect 409)
You can also exercise it with the real chat-cli (which lives in the
libchat repo) against a running
server:
# In this repo: start the server on a test port with a fresh db
cargo run -- --bind 127.0.0.1:18080 --db tmp/registry.db
# In a libchat checkout: register two identities (--smoketest exits after registering)
cargo build -p chat-cli
./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
# 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.
Exists to unblock contact-by-id flows on testnet; removed once λLEZ-based
discovery lands in v0.3. The seam is the RegistrationService trait in libchat
(core/conversations/src/service_traits.rs) — swapping implementations does not
touch the chat protocol.