* feat: publish via logos delivery * feat: protobuf definition for publish records * feat: docker build for logos delivery deps * feat: refactor modules splition * chore: update docker file for exposed port
Chat Store
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 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,
with no libchat-core dependency (the embedded logos-delivery node comes from
libchat's transport crate, pulled in as a pinned git dependency).
Submissions arrive on either of two write paths feeding the same verification + storage pipeline:
- HTTP POST (
/v0/keypackage,/v0/account) — synchronous and acknowledged; - logos-delivery subscription — clients publish protobuf submissions on the store's content topics and the server picks them up from the network (see Delivery ingestion).
The query API is HTTP 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.
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:
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
Building a runnable binary links the native liblogosdelivery, which this
repo's flake builds. The dev shell exports LOGOS_DELIVERY_LIB_DIR for you:
nix develop # or set LOGOS_DELIVERY_LIB_DIR yourself
cargo build --release
./target/release/chat-store # binds 0.0.0.0:8080, db ./chat-store.db
Without the library, cargo check and clippy still pass — only linking fails.
| 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 |
--no-delivery |
off | Disable the logos-delivery subscriber (HTTP POST ingestion only) |
--preset <name> |
logos.dev |
logos-delivery network preset the subscriber joins |
--p2p-port <port> |
0 |
TCP + discv5 UDP port for the embedded node (0 = OS-assigned) |
Logs via RUST_LOG (default info).
Delivery ingestion
Unless --no-delivery is given, the server runs an embedded logos-delivery
node and subscribes to two content topics:
/logos-chat/1/store-keypackage-v0/proto keypackage submissions
/logos-chat/1/store-account-v0/proto account device-list submissions
Each received message is a protobuf KeyPackageSubmissionV1 or
AccountSubmissionV1 — matching the /proto topic suffix — carrying the same
fields as the corresponding POST body and going through identical signature
verification and storage rules. The schemas live in
chat_proto (protos/store.proto),
pinned by rev in Cargo.toml so the wire format only changes deliberately.
Publishing is fire-and-forget on the client side: rejected submissions are only
logged by the server, which the trust model can afford because consumers verify
every bundle on retrieval anyway. libchat's ContactRegistry publishes on these
topics when constructed with RegistryPublishMode::Delivery.
Docker
# Build the image
docker build -t chat-store .
# Run it, persisting the SQLite db on a named volume. 8080 is the HTTP API;
# 60000 is the delivery node's libp2p (TCP) and discv5 (UDP) port.
docker run --rm -p 8080:8080 -p 60000:60000/tcp -p 60000:60000/udp \
-v chat-store-data:/data chat-store
The build runs inside nix, because the binary links liblogosdelivery and the
nixpkgs glibc it is built against is newer than bookworm's — so cargo has to run
against that same nixpkgs. The runtime image is still debian:bookworm-slim,
carrying only the nix store closure the binary resolves by absolute path. The
first build compiles the native library from source and takes a long time;
afterwards it is a cached layer that only changes with flake.lock.
The image runs the binary with
--bind 0.0.0.0:8080 --db /data/chat-store.db --p2p-port 60000 by default. The
p2p port is pinned rather than left at its default of 0, because an
OS-assigned port cannot be published from a container and would leave the node
undialable. 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 --no-delivery
Note that overriding CMD replaces it wholesale, so a delivery-enabled run has
to repeat --p2p-port. Deployments that only serve the HTTP API can pass
--no-delivery and skip publishing the p2p ports entirely.
API
POST /v0/keypackage
{
"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:
{
"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.
Account device-list endpoints
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.
POST /v0/account
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).
GET /v0/account/{account_pub}
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.
Storage & retention
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.
Smoke test
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:8080
Expected 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)
The delivery write path has its own smoke test,
delivery_smoke_test: it starts a publisher
node, publishes a signed keypackage and account bundle on the store's content
topics, and polls the query API until both appear:
# Terminal 1 — start a server (delivery ingestion is on by default)
cargo run -- --bind 127.0.0.1:8080 --db tmp/chat-store.db
# Terminal 2 — publish over the network and poll the query API
cargo run --example delivery_smoke_test
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.
Add --registry-publish delivery to those chat-cli invocations to exercise
the delivery write path instead of the HTTP POST API. Publishing is then
fire-and-forget, so chat-cli exits 0 regardless and the bundles appear in
the database only once the server has received and accepted them.
Benchmark
The bundled benchmark performs an end-to-end load
test without touching a deployed server. It starts its own chat-store binary
on a loopback-only random port with a uniquely named temporary SQLite database,
then deletes the database afterward. It has no remote URL option.
Each business-flow operation publishes and fetches a signature-verified
keypackage; publishes and fetches an account bundle; confirms that a repeated
lamport is rejected with 409; then publishes, fetches, and verifies a newer
account version. Before the measured flow it also checks both unknown-resource
404 paths and a malformed-request 400 path.
cargo build --release
cargo run --release --example benchmark -- --operations 1000 --concurrency 16
Useful options:
--payload-bytes <n> Opaque bytes appended to each benchmark payload (default: 512)
--server-bin <path> Local binary to launch (default: target/release/chat-store)
--keep-db Keep the temporary SQLite database for post-run inspection
Do not point benchmark traffic at the production deployment. To measure a network deployment, provision a separate chat-store instance and database for benchmarking, then use a load-test runner configured only for that isolated environment.
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 in libchat
(core/conversations/src/service_traits.rs) — swapping implementations does not
touch the chat protocol.