From b2fd4f6ac1304669150aec7baf125734615b3e7f Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Thu, 11 Jun 2026 15:43:10 +0800 Subject: [PATCH] feat: http server for account and keypackages storage --- .dockerignore | 7 + .gitignore | 10 + Cargo.lock | 1185 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 ++ Dockerfile | 48 ++ README.md | 196 +++++++- src/handlers.rs | 245 ++++++++++ src/main.rs | 94 ++++ src/store.rs | 309 ++++++++++++ 9 files changed, 2120 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 src/handlers.rs create mode 100644 src/main.rs create mode 100644 src/store.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd87951 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +target +*.db +*.db-shm +*.db-wal +.git +.gitignore +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5604a2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Rust build artifacts +/target + +# Local SQLite databases created at runtime +*.db +*.db-shm +*.db-wal + +# Editor / OS noise +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c9b60a9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1185 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +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", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +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", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +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", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[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", +] + +[[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-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", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5a8bcb4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +# Standalone single-crate workspace: keeps this repo independent of any parent +# Cargo workspace it might be checked out beneath. +[workspace] + +[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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..16551e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +######################################## +# Build stage +######################################## +FROM rust:1-bookworm AS builder + +# rusqlite's `bundled-sqlcipher-vendored-openssl` feature compiles SQLCipher and +# a vendored OpenSSL from source: the C toolchain ships in the base image, but +# the OpenSSL build also needs perl + make. +RUN apt-get update \ + && apt-get install -y --no-install-recommends perl make \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Build dependencies first against a stub binary so the (slow) SQLCipher/OpenSSL +# compilation is cached and only re-runs when Cargo.toml/Cargo.lock change. +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src \ + && echo "fn main() {}" > src/main.rs \ + && cargo build --release --locked \ + && rm -rf src target/release/deps/keypackage_registry* target/release/keypackage-registry + +# Now build the real binary; dependency artifacts above are reused. +COPY src ./src +RUN cargo build --release --locked --bin keypackage-registry + +######################################## +# Runtime stage +######################################## +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/keypackage-registry /usr/local/bin/keypackage-registry + +# Matches the default --bind 0.0.0.0:8080. +EXPOSE 8080 + +# Persist the SQLite database on a volume rather than the container layer. +VOLUME ["/data"] +ENV RUST_LOG=info + +ENTRYPOINT ["keypackage-registry"] +CMD ["--bind", "0.0.0.0:8080", "--db", "/data/keypackage-registry.db"] diff --git a/README.md b/README.md index 5cf73d4..96f67fc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,197 @@ # Chat Store -For persistence of group chat users' key package. +Persistence for group-chat users' key packages — the **keypackage-registry** +HTTP service, extracted from [libchat](https://github.com/logos-messaging/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_id`** +mapping an Account to its set of device (LocalIdentity) public keys, so clients +can invite every LocalIdentity of an account. `account_id` is the hex-encoded +32-byte Ed25519 AccountAddress verifying key. See +[Account device-list endpoints](#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: + +```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 +./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 ` | `100` | 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`). + +## Docker + +```bash +# 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-store +``` + +The image runs the binary with `--bind 0.0.0.0:8080 --db /data/keypackage-registry.db` +by default; override the `CMD` to change flags, e.g.: + +```bash +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 +``` + +## 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. + +## Account device-list endpoints + +The account service stores **exactly one blob per `account_id`** mapping an +Account to its LocalIdentity device keys. Same trust model as keypackages: the +server verifies `signature` over `payload` under `account_id`'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) +> `payload` and replaces the stored bundle only when the incoming lamport is +> strictly higher, returning `409` otherwise. 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. + +```json +{ + "account_id": "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_id}` + +Returns the stored bundle for that account, or `404`: + +```json +{ + "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_id`'s key, then decode the device list. + +## Storage & retention + +Two SQLite tables: `keypackages` keyed by `device_id`, and `account_bundles` +(one row per `account_id`). 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 + +End-to-end check with the real `chat-cli` (which lives in the +[libchat](https://github.com/logos-messaging/libchat) repo) against a running +server: + +```bash +# 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. + +## 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. diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..01ad784 --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,245 @@ +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, StoredAccountBundle, StoredKeyPackageBundle}; + +#[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)) + .route("/v0/account", post(submit_account)) + .route("/v0/account/:account_id", get(fetch_account)) + .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, + &StoredKeyPackageBundle { + 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), + })) +} + +/// Request body for publishing a signed device-list bundle under an account. +/// +/// The `payload` is intentionally opaque to the server. Clients are expected +/// to encode a lamport-timestamped list of device (LocalIdentity) Ed25519 +/// public keys inside it so that consumers can detect stale bundles. The server +/// only verifies that `signature` is a valid Ed25519 signature over `payload` +/// made by the key identified by `account_id`. +#[derive(Debug, Deserialize)] +pub struct SubmitAccountRequest { + /// Hex of the 32-byte Ed25519 account (AccountAddress) verifying key. + /// Acts as both the storage key and the verification key. + pub account_id: String, + /// base64 of the opaque signed payload (lamport-ts + device pubkeys, etc.). + pub payload: String, + /// base64 of the 64-byte Ed25519 signature over `payload` made by the + /// account key. Proof-of-possession: only the account holder can publish. + pub signature: String, +} + +#[derive(Debug, Serialize)] +pub struct FetchAccountResponse { + /// base64 of the stored payload. + pub payload: String, + /// base64 of the 64-byte Ed25519 signature. + pub signature: String, + /// Unix timestamp (ms) of the last successful upsert. + pub updated_at: i64, +} + +/// `POST /v0/account` — upsert a signed device-list bundle for an account. +/// +/// The server verifies the Ed25519 signature and then stores exactly one blob +/// per `account_id`, replacing any previous value. Clients should re-publish +/// whenever they add or rotate LocalIdentities. +async fn submit_account( + State(store): State>, + Json(req): Json, +) -> Result { + let account_pubkey: [u8; 32] = hex::decode(&req.account_id) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| ApiError::bad("account_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(&account_pubkey) + .map_err(|_| ApiError::bad("account_id: not a valid ed25519 key"))?; + verifying_key + .verify_strict(&payload, &Signature::from_bytes(&signature)) + .map_err(|_| ApiError::bad("signature: verification failed"))?; + + // Read the bundle's lamport so the store can reject replays. Safe to trust: + // the signature over `payload` was just verified, so the lamport can't be + // forged without the account key. + let lamport = crate::store::payload_lamport(&payload) + .ok_or_else(|| ApiError::bad("payload: too short to contain a lamport header"))?; + + let applied = store + .upsert_account( + &req.account_id, + lamport, + &StoredAccountBundle { + payload, + signature: signature.to_vec(), + updated_at: 0, // filled in by store + }, + ) + .map_err(ApiError::internal)?; + if !applied { + return Err(ApiError::conflict( + "stale bundle: lamport is not newer than the stored one", + )); + } + Ok(StatusCode::NO_CONTENT) +} + +/// `GET /v0/account/:account_id` — fetch the device-list bundle for an account. +/// +/// Returns the latest published bundle so consumers can verify the +/// account signature and decode the list of LocalIdentity keys themselves. +async fn fetch_account( + State(store): State>, + Path(account_id): Path, +) -> Result, ApiError> { + let Some(bundle) = store.get_account(&account_id).map_err(ApiError::internal)? else { + return Err(ApiError::not_found("no account bundle for account_id")); + }; + Ok(Json(FetchAccountResponse { + payload: BASE64.encode(&bundle.payload), + signature: BASE64.encode(&bundle.signature), + updated_at: bundle.updated_at, + })) +} + +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 conflict(msg: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + 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/src/main.rs b/src/main.rs new file mode 100644 index 0000000..095ab5b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,94 @@ +//! 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 keypackage bundle +//! GET /v0/keypackage/{device_id} — fetch the latest stored keypackage bundle +//! POST /v0/account — upsert a signed account device-list bundle +//! GET /v0/account/{account_id} — fetch the account device-list 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_key_packages(max_per_id, retention) { + tracing::warn!("prune (keypackages) failed: {e}"); + } + if let Err(e) = prune_store.prune_accounts(retention) { + tracing::warn!("prune (accounts) 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/src/store.rs b/src/store.rs new file mode 100644 index 0000000..8775a0a --- /dev/null +++ b/src/store.rs @@ -0,0 +1,309 @@ +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 StoredKeyPackageBundle { + /// 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, +} + +/// A signed bundle associating an account with its set of device (LocalIdentity) +/// public keys. The server stores exactly one blob per `account_id`; a newer +/// bundle replaces the old one only when its lamport is strictly higher (see +/// [`Store::upsert_account`]). `payload` is otherwise opaque to the server: it +/// encodes a lamport-timestamped list of device pubkeys signed by the account +/// key so that consumers can verify the full device set. +#[derive(Debug, Clone)] +pub struct StoredAccountBundle { + /// The canonical signed payload, returned verbatim so consumers can verify + /// the account signature over the exact bytes. + pub payload: Vec, + /// 64-byte Ed25519 signature over `payload` made by the account key. + pub signature: Vec, + /// Unix timestamp (ms) of the last upsert, stored for pruning. + pub updated_at: i64, +} + +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) + ); + -- One row per account; newer upserts replace the existing row. + CREATE TABLE IF NOT EXISTS account_bundles ( + account_id TEXT NOT NULL PRIMARY KEY, + updated_at INTEGER NOT NULL, + payload BLOB NOT NULL, + signature BLOB NOT NULL + );", + )?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + pub fn insert(&self, device_id: &str, bundle: &StoredKeyPackageBundle) -> 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(StoredKeyPackageBundle { + payload: r.get::<_, Vec>(0)?, + signature: r.get::<_, Vec>(1)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + /// Upsert the signed device-list bundle for `account_id`. The server stores + /// exactly one blob per account. + /// + /// Anti-replay: `lamport` is the monotonic version read from `bundle.payload` + /// (already signature-verified by the handler, so a forged value can't slip + /// past — the signature wouldn't match). The stored bundle is replaced only + /// when `lamport` is strictly greater than the one currently on file. A + /// replayed older-but-still-valid bundle therefore can't downgrade the device + /// list, and `updated_at` (the retention clock) is only bumped on a real + /// update so a replay can't keep a stale bundle alive past retention. + /// + /// Returns `true` when the bundle was stored, `false` when it was rejected as + /// stale. The compare-and-swap runs under the connection lock so concurrent + /// publishes can't interleave a read with a write. The `updated_at` field of + /// `bundle` is ignored; the store stamps the row with the current time. + pub fn upsert_account( + &self, + account_id: &str, + lamport: u64, + bundle: &StoredAccountBundle, + ) -> Result { + let updated_at = now_ms() as i64; + let conn = self.conn.lock().unwrap(); + let existing_lamport = conn + .query_row( + "SELECT payload FROM account_bundles WHERE account_id = ?1", + params![account_id], + |r| r.get::<_, Vec>(0), + ) + .optional()? + .and_then(|payload| payload_lamport(&payload)); + if let Some(stored) = existing_lamport + && lamport <= stored + { + return Ok(false); + } + conn.execute( + "INSERT INTO account_bundles (account_id, updated_at, payload, signature) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(account_id) DO UPDATE SET + updated_at = excluded.updated_at, + payload = excluded.payload, + signature = excluded.signature", + params![account_id, updated_at, bundle.payload, bundle.signature], + )?; + Ok(true) + } + + /// Returns the stored bundle for `account_id`, or `None` if unknown. + pub fn get_account(&self, account_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let row = conn + .query_row( + "SELECT payload, signature, updated_at FROM account_bundles + WHERE account_id = ?1", + params![account_id], + |r| { + Ok(StoredAccountBundle { + payload: r.get::<_, Vec>(0)?, + signature: r.get::<_, Vec>(1)?, + updated_at: r.get::<_, i64>(2)?, + }) + }, + ) + .optional()?; + Ok(row) + } + + /// Drops account bundles that have not been refreshed within `retention`. + pub fn prune_accounts(&self, 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 account_bundles WHERE updated_at < ?1", + params![cutoff_ms], + )?; + Ok(()) + } + + /// 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_key_packages(&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(()) + } +} + +/// Domain-separation prefix on every account-device-bundle payload. Must stay in +/// sync with `account_directory::BUNDLE_DOMAIN` in the conversations crate; this +/// throwaway service deliberately has no libchat-core dependency, so the constant +/// is duplicated here rather than imported. +const BUNDLE_DOMAIN: &[u8] = b"libchat:account-device-bundle\0"; + +/// Extract the lamport version from a bundle payload without otherwise +/// interpreting it. The canonical layout (owned by the conversations crate's +/// `encode_bundle_payload`) is `domain | version:u8 | lamport:u64 LE | …`, so the +/// lamport sits in the 8 bytes right after the domain prefix and version byte. +/// Returns `None` when the domain prefix is absent or the payload is too short to +/// contain a header — the handler treats either as a malformed request. +pub fn payload_lamport(payload: &[u8]) -> Option { + payload + .strip_prefix(BUNDLE_DOMAIN)? + .get(1..9) + .map(|b| u64::from_le_bytes(b.try_into().expect("1..9 is 8 bytes"))) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal stand-in for a real bundle payload: the domain prefix plus the + /// header fields the server reads (`version:u8 | lamport:u64 LE`), no device + /// keys needed. + fn payload_with_lamport(lamport: u64) -> Vec { + let mut p = BUNDLE_DOMAIN.to_vec(); + p.push(1u8); // version + p.extend_from_slice(&lamport.to_le_bytes()); + p + } + + fn bundle(lamport: u64) -> StoredAccountBundle { + StoredAccountBundle { + payload: payload_with_lamport(lamport), + signature: vec![0u8; 64], + updated_at: 0, + } + } + + fn upsert(store: &Store, account: &str, lamport: u64) -> bool { + store + .upsert_account(account, lamport, &bundle(lamport)) + .unwrap() + } + + #[test] + fn rejects_replayed_or_stale_lamport() { + let store = Store::open(Path::new(":memory:")).unwrap(); + + // First publish is always accepted. + assert!(upsert(&store, "acct", 5)); + // A strictly higher lamport replaces it. + assert!(upsert(&store, "acct", 6)); + // Re-publishing the same lamport (a replay) is rejected. + assert!(!upsert(&store, "acct", 6)); + // An older lamport (a downgrade) is rejected. + assert!(!upsert(&store, "acct", 4)); + + // The stored bundle is still the newest one accepted. + let stored = store.get_account("acct").unwrap().unwrap(); + assert_eq!(payload_lamport(&stored.payload), Some(6)); + } + + #[test] + fn stale_publish_does_not_refresh_retention_clock() { + let store = Store::open(Path::new(":memory:")).unwrap(); + assert!(upsert(&store, "acct", 9)); + let after_first = store.get_account("acct").unwrap().unwrap().updated_at; + + // A rejected (stale) publish must not bump updated_at, so a replay can't + // keep a stale bundle alive past the retention window. + assert!(!upsert(&store, "acct", 9)); + let after_replay = store.get_account("acct").unwrap().unwrap().updated_at; + assert_eq!(after_first, after_replay); + } + + #[test] + fn payload_lamport_requires_domain_and_full_header() { + assert_eq!(payload_lamport(&payload_with_lamport(42)), Some(42)); + // Missing the domain prefix → unparseable. + assert_eq!(payload_lamport(&[1u8, 0, 0, 0, 0, 0, 0, 0, 0]), None); + // Has the domain but is too short for version + u64 → unparseable. + let mut short = BUNDLE_DOMAIN.to_vec(); + short.push(1u8); + assert_eq!(payload_lamport(&short), None); + } +}