From cd7dd6a33076a57fc2a02c4e06b8b7714d74a09d Mon Sep 17 00:00:00 2001 From: kaichao Date: Thu, 4 Jun 2026 10:09:29 +0800 Subject: [PATCH] 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 --- Cargo.lock | 888 ++++++++++++++++++ Cargo.toml | 1 + bin/chat-cli/README.md | 22 + bin/chat-cli/src/app.rs | 14 +- bin/chat-cli/src/main.rs | 57 +- bin/chat-cli/src/ui.rs | 35 +- bin/keypackage-registry/Cargo.toml | 23 + bin/keypackage-registry/README.md | 123 +++ bin/keypackage-registry/src/handlers.rs | 137 +++ bin/keypackage-registry/src/main.rs | 89 ++ bin/keypackage-registry/src/store.rs | 116 +++ core/conversations/src/account.rs | 10 +- core/conversations/src/context.rs | 7 + .../src/conversation/group_v1.rs | 7 +- core/conversations/src/inbox_v2.rs | 2 +- core/conversations/src/service_traits.rs | 19 +- core/crypto/src/signatures.rs | 6 + crates/client/src/client.rs | 39 +- crates/client/src/lib.rs | 7 +- extensions/components/Cargo.toml | 7 +- extensions/components/src/contact_registry.rs | 21 +- .../components/src/contact_registry/http.rs | 247 +++++ extensions/components/src/lib.rs | 1 + 23 files changed, 1825 insertions(+), 53 deletions(-) create mode 100644 bin/keypackage-registry/Cargo.toml create mode 100644 bin/keypackage-registry/README.md create mode 100644 bin/keypackage-registry/src/handlers.rs create mode 100644 bin/keypackage-registry/src/main.rs create mode 100644 bin/keypackage-registry/src/store.rs create mode 100644 extensions/components/src/contact_registry/http.rs diff --git a/Cargo.lock b/Cargo.lock index b9e84aa..b358303 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,12 +143,84 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" 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" @@ -246,6 +318,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -401,10 +479,15 @@ dependencies = [ name = "components" version = "0.1.0" dependencies = [ + "base64", "crypto", "hex", "libchat", + "reqwest", + "serde", "storage", + "thiserror", + "tracing", ] [[package]] @@ -699,6 +782,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "document-features" version = "0.2.12" @@ -934,12 +1028,37 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -951,6 +1070,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -970,8 +1095,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1014,8 +1142,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1025,9 +1155,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1244,6 +1376,193 @@ dependencies = [ "zeroize", ] +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1256,6 +1575,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -1333,6 +1673,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1369,6 +1715,8 @@ version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1383,6 +1731,25 @@ 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" @@ -1713,6 +2080,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "litrs" version = "1.0.0" @@ -1762,6 +2135,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "macro_rules_attribute" version = "0.1.3" @@ -1787,12 +2166,24 @@ 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" @@ -2255,6 +2646,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2368,6 +2768,61 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2559,6 +3014,46 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -2569,6 +3064,20 @@ dependencies = [ "subtle", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rstest" version = "0.24.0" @@ -2624,6 +3133,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2659,6 +3174,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2782,6 +3332,29 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2873,6 +3446,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spki" version = "0.7.3" @@ -2918,6 +3501,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2988,6 +3577,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3044,6 +3653,31 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tls_codec" version = "0.4.2" @@ -3066,6 +3700,43 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3096,12 +3767,59 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" 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", @@ -3157,6 +3875,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.0" @@ -3223,12 +3947,36 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "unwind_safe" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0976c77def3f1f75c4ef892a292c31c0bbe9e3d0702c63044d7c76db298171a3" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -3264,6 +4012,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3301,6 +4058,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.118" @@ -3367,6 +4134,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -3401,6 +4197,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -3680,6 +4485,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11rb" version = "0.13.2" @@ -3725,6 +4536,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -3745,6 +4579,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -3765,6 +4620,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 9fb99e0..2b8fbd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "3" members = [ "bin/chat-cli", + "bin/keypackage-registry", "core/account", "core/conversations", "core/crypto", diff --git a/bin/chat-cli/README.md b/bin/chat-cli/README.md index 217400f..b7c91aa 100644 --- a/bin/chat-cli/README.md +++ b/bin/chat-cli/README.md @@ -51,6 +51,27 @@ cargo run -p chat-cli -- --name bob --transport file 2. In Bob's terminal, type `/connect `. 3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat. +### Optional: KeyPackage registry + +When `--registry-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 ` | `/.db` | SQLite file for persistent identity | | `--preset ` | `logos.dev` | logos-delivery network preset | | `--port ` | `60000` | TCP port for the embedded logos-delivery node | +| `--registry-url ` | *(unset)* | Use the HTTP-backed [keypackage-registry](../keypackage-registry/) at this URL instead of the in-memory registry | | `--log-file ` | *(stderr, off)* | Write logs to a file instead of stderr | ## Commands diff --git a/bin/chat-cli/src/app.rs b/bin/chat-cli/src/app.rs index 155c3a5..c97bcab 100644 --- a/bin/chat-cli/src/app.rs +++ b/bin/chat-cli/src/app.rs @@ -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, } -pub struct ChatApp { - pub client: ChatClient, +pub struct ChatApp { + pub client: ChatClient, inbound: mpsc::Receiver>, pub state: AppState, /// Ephemeral command output — not persisted, cleared on chat switch. @@ -53,9 +53,13 @@ pub struct ChatApp { state_path: PathBuf, } -impl ChatApp { +impl ChatApp +where + D: DeliveryService + 'static, + R: RegistrationService + 'static, +{ pub fn new( - client: ChatClient, + client: ChatClient, inbound: mpsc::Receiver>, user_name: &str, data_dir: &Path, diff --git a/bin/chat-cli/src/main.rs b/bin/chat-cli/src/main.rs index 366b100..0d4b621 100644 --- a/bin/chat-cli/src/main.rs +++ b/bin/chat-cli/src/main.rs @@ -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, } fn main() -> Result<()> { @@ -104,18 +110,38 @@ fn run( .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( + client: ChatClient, + inbound: mpsc::Receiver>, + 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( - terminal: &mut ui::Tui, - app: &mut ChatApp, -) -> Result<()> { +fn run_app(terminal: &mut ui::Tui, app: &mut ChatApp) -> Result<()> +where + D: DeliveryService + 'static, + R: RegistrationService + 'static, +{ loop { app.process_incoming()?; terminal.draw(|frame| ui::draw(frame, app))?; diff --git a/bin/chat-cli/src/ui.rs b/bin/chat-cli/src/ui.rs index b17d869..71492e9 100644 --- a/bin/chat-cli/src/ui.rs +++ b/bin/chat-cli/src/ui.rs @@ -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(frame: &mut Frame, app: &ChatApp) { +pub fn draw( + frame: &mut Frame, + app: &ChatApp, +) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -55,7 +58,11 @@ pub fn draw(frame: &mut Frame, app: &ChatApp) { draw_status(frame, app, chunks[3]); } -fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) { +fn draw_header( + frame: &mut Frame, + app: &ChatApp, + 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(frame: &mut Frame, app: &ChatApp frame.render_widget(header, area); } -fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) { +fn draw_messages( + frame: &mut Frame, + app: &ChatApp, + area: Rect, +) { let remote_name = app .current_session() .map(|s| s.display_name()) @@ -164,7 +175,11 @@ fn draw_messages(frame: &mut Frame, app: &ChatApp< frame.render_stateful_widget(messages_widget, area, &mut list_state); } -fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) { +fn draw_input( + frame: &mut Frame, + app: &ChatApp, + 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(frame: &mut Frame, app: &ChatApp, frame.set_cursor_position((cursor_x, area.y + 1)); } -fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) { +fn draw_status( + frame: &mut Frame, + app: &ChatApp, + 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(frame: &mut Frame, app: &ChatApp } /// Handle keyboard events. -pub fn handle_events(app: &mut ChatApp) -> io::Result { +pub fn handle_events( + app: &mut ChatApp, +) -> io::Result { // 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()? diff --git a/bin/keypackage-registry/Cargo.toml b/bin/keypackage-registry/Cargo.toml new file mode 100644 index 0000000..b53161c --- /dev/null +++ b/bin/keypackage-registry/Cargo.toml @@ -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"] } diff --git a/bin/keypackage-registry/README.md b/bin/keypackage-registry/README.md new file mode 100644 index 0000000..a74f630 --- /dev/null +++ b/bin/keypackage-registry/README.md @@ -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 ` | `0.0.0.0:8080` | HTTP bind address | +| `--db ` | `keypackage-registry.db` | SQLite database path | +| `--max-per-identity ` | `5` | Bundles retained per `device_id` | +| `--retention-days ` | `30` | Drop bundles older than this | +| `--prune-interval-secs ` | `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. diff --git a/bin/keypackage-registry/src/handlers.rs b/bin/keypackage-registry/src/handlers.rs new file mode 100644 index 0000000..9da0d42 --- /dev/null +++ b/bin/keypackage-registry/src/handlers.rs @@ -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) -> Router { + Router::new() + .route("/v0/keypackage", post(submit)) + .route("/v0/keypackage/:device_id", get(fetch)) + .with_state(store) +} + +async fn submit( + State(store): State>, + Json(req): Json, +) -> Result { + // 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>, + Path(device_id): Path, +) -> Result, 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) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: msg.into(), + } + } + fn not_found(msg: impl Into) -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: msg.into(), + } + } + fn internal(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() + } +} diff --git a/bin/keypackage-registry/src/main.rs b/bin/keypackage-registry/src/main.rs new file mode 100644 index 0000000..fa1a8f3 --- /dev/null +++ b/bin/keypackage-registry/src/main.rs @@ -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"); +} diff --git a/bin/keypackage-registry/src/store.rs b/bin/keypackage-registry/src/store.rs new file mode 100644 index 0000000..c3c5a05 --- /dev/null +++ b/bin/keypackage-registry/src/store.rs @@ -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, +} + +#[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, + /// 64-byte Ed25519 signature over `payload`. Opaque to the server. + pub signature: Vec, +} + +impl Store { + pub fn open(path: &Path) -> Result { + // 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`. + pub fn latest(&self, device_id: &str) -> Result> { + 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>(0)?, + signature: r.get::<_, Vec>(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 +} diff --git a/core/conversations/src/account.rs b/core/conversations/src/account.rs index b59ada2..676925d 100644 --- a/core/conversations/src/account.rs +++ b/core/conversations/src/account.rs @@ -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) -> Self { + pub fn new_test(_display_name: impl Into) -> 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, } diff --git a/core/conversations/src/context.rs b/core/conversations/src/context.rs index 315303f..17b95f3 100644 --- a/core/conversations/src/context.rs +++ b/core/conversations/src/context.rs @@ -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, diff --git a/core/conversations/src/conversation/group_v1.rs b/core/conversations/src/conversation/group_v1.rs index 3b973b4..7068024 100644 --- a/core/conversations/src/conversation/group_v1.rs +++ b/core/conversations/src/conversation/group_v1.rs @@ -193,10 +193,15 @@ where } fn key_package_for_account(&self, ident: &AccountId) -> Result { + // 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()); diff --git a/core/conversations/src/inbox_v2.rs b/core/conversations/src/inbox_v2.rs index 9e41a9a..023c8c9 100644 --- a/core/conversations/src/inbox_v2.rs +++ b/core/conversations/src/inbox_v2.rs @@ -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) } diff --git a/core/conversations/src/service_traits.rs b/core/conversations/src/service_traits.rs index 391923d..bf25162 100644 --- a/core/conversations/src/service_traits.rs +++ b/core/conversations/src/service_traits.rs @@ -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) -> Result<(), Self::Error>; - fn retrieve(&self, identity: &AccountId) -> Result>, Self::Error>; + fn register( + &mut self, + identity: &dyn IdentityProvider, + key_bundle: Vec, + ) -> Result<(), Self::Error>; + fn retrieve(&self, device_id: &str) -> Result>, 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>, Self::Error>; + fn retrieve(&self, device_id: &str) -> Result>, Self::Error>; } impl KeyPackageProvider for T { type Error = T::Error; - fn retrieve(&self, identity: &AccountId) -> Result>, Self::Error> { - RegistrationService::retrieve(self, identity) + fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { + RegistrationService::retrieve(self, device_id) } } diff --git a/core/crypto/src/signatures.rs b/core/crypto/src/signatures.rs index 28eb6dd..ac5282b 100644 --- a/core/crypto/src/signatures.rs +++ b/core/crypto/src/signatures.rs @@ -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 { + ed25519_dalek::VerifyingKey::from_bytes(bytes) + .map(Self) + .map_err(|_| SignatureVerificationError {}) + } } impl From for Ed25519VerifyingKey { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index f63247b..5b66e4d 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -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 { - ctx: Context, +pub struct ChatClient { + ctx: Context, } -impl ChatClient { +// ── Default-registry constructors ──────────────────────────────────────────── + +impl ChatClient { /// Create an in-memory, ephemeral client. Identity is lost on drop. pub fn new(name: impl Into, delivery: D) -> Self { let registry = EphemeralRegistry::new(); @@ -38,6 +41,34 @@ impl ChatClient { let ctx = Context::new_from_store(name, delivery, registry, store)?; Ok(Self { ctx }) } +} + +// ── Caller-supplied registry + shared methods ──────────────────────────────── + +impl ChatClient +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, + config: StorageConfig, + delivery: D, + registry: R, + ) -> Result> { + 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 { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f5d7e4a..7fc92a5 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -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}; diff --git a/extensions/components/Cargo.toml b/extensions/components/Cargo.toml index 055fad2..fb65de6 100644 --- a/extensions/components/Cargo.toml +++ b/extensions/components/Cargo.toml @@ -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" diff --git a/extensions/components/src/contact_registry.rs b/extensions/components/src/contact_registry.rs index 2037cf6..d4fd627 100644 --- a/extensions/components/src/contact_registry.rs +++ b/extensions/components/src/contact_registry.rs @@ -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) -> Result<(), Self::Error> { + fn register( + &mut self, + identity: &dyn IdentityProvider, + key_bundle: Vec, + ) -> 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>, Self::Error> { - Ok(self - .registry - .lock() - .unwrap() - .get(identity.as_str()) - .cloned()) + fn retrieve(&self, device_id: &str) -> Result>, Self::Error> { + Ok(self.registry.lock().unwrap().get(device_id).cloned()) } } diff --git a/extensions/components/src/contact_registry/http.rs b/extensions/components/src/contact_registry/http.rs new file mode 100644 index 0000000..771fbe4 --- /dev/null +++ b/extensions/components/src/contact_registry/http.rs @@ -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) -> Self { + Self::with_timeout(base_url, Duration::from_secs(10)) + } + + pub fn with_timeout(base_url: impl Into, 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, + ) -> 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>, 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 { + 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"); + } +} diff --git a/extensions/components/src/lib.rs b/extensions/components/src/lib.rs index d55c0f7..a147b70 100644 --- a/extensions/components/src/lib.rs +++ b/extensions/components/src/lib.rs @@ -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::*;