diff --git a/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json b/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json new file mode 100644 index 0000000..ce02bb9 --- /dev/null +++ b/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n c.id,\n c.chat_type,\n CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END AS partner_user_id,\n (\n SELECT u.username\n FROM users u\n WHERE u.id = CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_username,\n (\n SELECT u.federated_address\n FROM users u\n WHERE u.id = CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_federated_address,\n c.name,\n c.last_message_id,\n c.updated_at\n FROM chats c\n WHERE\n (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b)))\n OR (c.chat_type = 'group' AND c.id IN (\n SELECT chat_id FROM chat_members WHERE user_id = $1\n ))\n ORDER BY c.updated_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "chat_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "partner_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "partner_username", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "partner_federated_address", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "last_message_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + null, + null, + null, + true, + true, + true + ] + }, + "hash": "0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a" +} diff --git a/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json b/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json deleted file mode 100644 index 6527807..0000000 --- a/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT \n c.id,\n c.chat_type,\n CASE \n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END AS partner_user_id,\n (\n SELECT u.username \n FROM users u\n WHERE u.id = CASE \n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_username,\n c.name,\n c.last_message_id,\n c.updated_at\n FROM chats c\n WHERE \n (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b)))\n OR (c.chat_type = 'group' AND c.id IN (\n SELECT chat_id FROM chat_members WHERE user_id = $1\n ))\n ORDER BY c.updated_at DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "chat_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "partner_user_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "partner_username", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "last_message_id", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - null, - null, - true, - true, - true - ] - }, - "hash": "a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d" -} diff --git a/Cargo.lock b/Cargo.lock index c47ce1c..694e7db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-trait" @@ -64,17 +64,39 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "axum-macros", "base64", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -87,8 +109,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -104,19 +125,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -125,9 +144,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -148,15 +167,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -170,11 +189,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -184,20 +212,28 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.44" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -212,16 +248,35 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", ] [[package]] @@ -239,11 +294,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -264,20 +325,29 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-queue" @@ -316,6 +386,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -323,9 +402,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -345,9 +424,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -355,16 +434,16 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -375,12 +454,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -398,6 +488,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" version = "0.16.9" @@ -405,7 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -433,7 +529,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -455,7 +551,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -468,15 +564,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -515,12 +602,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "ff" version = "0.13.1" @@ -539,9 +620,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" @@ -554,33 +635,12 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -590,11 +650,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -602,15 +668,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -630,27 +696,27 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -658,7 +724,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -675,9 +740,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -695,11 +760,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "group" version = "0.13.0" @@ -711,25 +789,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "h2" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -743,9 +802,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -783,7 +842,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -797,12 +856,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -846,16 +904,19 @@ name = "hushnet-backend" version = "0.0.1-alpha" dependencies = [ "anyhow", + "async-trait", "axum", "base64", "chrono", "dotenvy", "ed25519-dalek", + "hex", "jsonwebtoken", "reqwest", "rsa", "serde", "serde_json", + "sha2 0.11.0", "sqlx", "tokio", "tracing", @@ -863,24 +924,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -888,47 +956,28 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -938,18 +987,16 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -971,12 +1018,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -984,9 +1032,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -997,9 +1045,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1011,15 +1059,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1031,15 +1079,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1050,6 +1098,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1073,25 +1127,27 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1099,39 +1155,95 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "10.1.0" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", "ed25519-dalek", - "getrandom 0.2.16", + "getrandom 0.2.17", "hmac", "js-sys", "p256", "p384", "pem", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "signature", "simple_asn1", ] @@ -1145,27 +1257,34 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1178,17 +1297,11 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1201,9 +1314,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1222,9 +1335,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -1233,14 +1346,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1250,32 +1363,15 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1297,25 +1393,25 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1349,53 +1445,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "openssl" -version = "0.10.74" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.110" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "p256" @@ -1406,7 +1464,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1418,7 +1476,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1445,9 +1503,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1477,15 +1535,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -1510,15 +1562,21 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1538,6 +1596,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1549,9 +1617,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1570,7 +1638,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1578,20 +1646,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1613,9 +1682,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1626,11 +1695,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1639,12 +1714,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1664,7 +1739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1673,14 +1748,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -1694,11 +1769,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1707,43 +1791,37 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -1752,7 +1830,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1773,7 +1850,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1781,12 +1858,12 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -1801,9 +1878,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -1815,48 +1892,75 @@ dependencies = [ ] [[package]] -name = "rustix" -version = "1.1.2" +name = "rustls" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] -name = "rustls" -version = "0.23.35" +name = "rustls-native-certs" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "once_cell", - "ring", + "openssl-probe", "rustls-pki-types", - "rustls-webpki", - "subtle", + "schannel", + "security-framework", +] + +[[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-pki-types" -version = "1.13.0" +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" -dependencies = [ - "web-time", - "zeroize", -] +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1870,15 +1974,24 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -1905,9 +2018,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -1918,9 +2031,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -1928,9 +2041,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1964,15 +2077,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2005,8 +2118,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2016,8 +2129,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2037,10 +2161,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2050,27 +2175,27 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2083,12 +2208,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2149,9 +2274,9 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2187,7 +2312,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2210,7 +2335,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2227,15 +2352,15 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2267,14 +2392,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2300,7 +2425,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2331,9 +2456,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2360,40 +2485,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2405,11 +2496,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2425,9 +2516,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2445,30 +2536,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2476,9 +2567,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2486,9 +2577,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2501,9 +2592,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2518,25 +2609,15 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2549,9 +2630,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2560,9 +2641,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -2570,24 +2651,11 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tokio-util" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -2601,9 +2669,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -2631,9 +2699,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -2643,9 +2711,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2654,9 +2722,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2675,9 +2743,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -2699,27 +2767,25 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.5", + "rand 0.9.4", "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror 2.0.18", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-bidi" @@ -2729,9 +2795,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -2748,6 +2814,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2756,9 +2828,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2766,12 +2838,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2780,13 +2846,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -2808,6 +2874,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2825,11 +2901,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -2840,9 +2925,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -2853,22 +2938,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2876,9 +2958,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -2889,18 +2971,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2917,10 +3033,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.4" +name = "webpki-root-certs" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -2935,6 +3051,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2943,9 +3068,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -2970,63 +3095,37 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] -name = "windows-strings" -version = "0.5.1" +name = "windows-sys" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-link 0.2.1", + "windows-targets 0.42.2", ] [[package]] @@ -3062,7 +3161,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3102,7 +3216,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3113,6 +3227,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3131,6 +3251,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3149,6 +3275,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3179,6 +3311,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3197,6 +3335,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3215,6 +3359,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3233,6 +3383,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3253,21 +3409,109 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3276,9 +3520,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3288,18 +3532,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -3308,18 +3552,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3335,9 +3579,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3346,9 +3590,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3357,11 +3601,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[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 index a587991..a1bf52c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] -axum = { version = "0.7", features = ["ws", "macros"] } +axum = { version = "0.8", features = ["ws", "macros"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "full"] } tracing = "0.1" @@ -17,11 +17,14 @@ serde_json = "1.0" dotenvy = "0.15" anyhow = "1.0" sqlx = { version = "0.8.6", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono"] } -uuid = {version = "1.18.1", features = ["serde", "v4"]} -chrono = {version = "0.4.42", features = ["serde"]} +uuid = {version = "1.23.1", features = ["serde", "v4"]} +chrono = {version = "0.4.44", features = ["serde"]} ed25519-dalek = { version = "2.2.0", features = ["rand_core", "serde"] } -jsonwebtoken = {version = "10.0.0", features = ["rust_crypto"]} +jsonwebtoken = {version = "10.3.0", features = ["rust_crypto"]} base64 = "0.22.1" -rsa = "0.9.8" -reqwest = { version = "0.12.24", features = ["json", "rustls-tls"] } +rsa = "0.9.10" +reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] } +async-trait = "0.1" +sha2 = "0.11" +hex = "0.4" # TODO : Monitor the RSA and ed25519-dalek crates for updates and security patches. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a416b44..8c3321e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ WORKDIR /app COPY --from=builder /app/target/release/hushnet-backend /app/hushnet-backend # Change ownership -RUN chown -R hushnet:hushnet /app +RUN chown -R hushnet:hushnet /app && mkdir -p /app/.hushnet && chown hushnet:hushnet /app/.hushnet # Switch to non-root user USER hushnet diff --git a/docker-compose.yml b/docker-compose.yml index 8142498..9c18cb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,6 @@ services: # PostgreSQL Database postgres: image: postgres:17-alpine - container_name: hushnet-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} @@ -28,7 +27,6 @@ services: build: context: . dockerfile: Dockerfile - container_name: hushnet-backend restart: unless-stopped environment: DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-dev}@postgres:5432/${POSTGRES_DB:-e2ee} @@ -37,17 +35,25 @@ services: SERVER_HOST: ${SERVER_HOST:-0.0.0.0} SERVER_PORT: ${SERVER_PORT:-8080} RUST_BACKTRACE: "full" + NODE_NAME: ${NODE_NAME:-node-eu1} + NODE_HOST: ${NODE_HOST:-host.docker.internal} + NODE_API_URL: ${NODE_API_URL:-http://host.docker.internal:8080} + REGISTRY_URL: ${REGISTRY_URL:-https://registry.hushnet.net/} + CONTACT_EMAIL: ${CONTACT_EMAIL:-ops@hushnet.net} + REGISTER_TO_REGISTRY: ${REGISTER_TO_REGISTRY:-true} env_file: - .env + volumes: + - node_keys:/app/.hushnet ports: - - "${BACKEND_PORT:-8080}:8080" + - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" depends_on: postgres: condition: service_healthy networks: - hushnet healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/"] + test: ["CMD", "curl", "-f", "http://localhost:${SERVER_PORT:-8080}/"] interval: 30s timeout: 3s retries: 3 @@ -56,6 +62,8 @@ services: volumes: postgres_data: driver: local + node_keys: + driver: local networks: hushnet: diff --git a/docs/API.md b/docs/API.md index 3fc731b..2a67124 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,6 +14,7 @@ Complete API documentation for HushNet Backend. - [Chat Endpoints](#chat-endpoints) - [Message Endpoints](#message-endpoints) - [WebSocket Endpoints](#websocket-endpoints) +- [Federation — Inter-Node Messaging](#federation--inter-node-messaging) - [Error Responses](#error-responses) --- @@ -609,6 +610,537 @@ const ws = new WebSocket('ws://127.0.0.1:8080/ws?user_id='); --- +--- + +## Federation — Inter-Node Messaging + +Federation allows users registered on different HushNet nodes to exchange messages. The cryptographic layer (X3DH, Double Ratchet) is unchanged — clients remain responsible for all key agreement and encryption. Servers route opaque ciphertexts; no node can read message content. + +--- + +### Architecture Overview + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ HushNet Network │ +│ │ +│ ┌──────────────────┐ S2S (Ed25519) ┌───────────────┐ │ +│ │ Node A │◄────────────────────────────► Node B │ │ +│ │ │ │ │ │ +│ │ Alice (local) │ │ Bob (local) │ │ +│ │ Bob (shadow)◄──┼──────────────────────────────┼─────────────┘ │ +│ └──────────────────┘ └───────────────┘ │ +│ ▲ ▲ │ +│ │ client API client API │ │ +│ │ │ │ +│ Client A Client B │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +**Invariants:** +- Clients always talk to their home node only. +- Nodes forward already-encrypted payloads. No node sees plaintext. +- Private keys never leave the client device. +- Double Ratchet session state never touches a server. + +--- + +### Federated User Addressing + +A federated address uniquely identifies a user across all nodes: + +``` +alice@node-a.hushnet.net +bob@node-b.hushnet.net +``` + +Format: `{username}@{node_host}` + +The node host is the `NODE_HOST` environment variable set at startup. It is registered at the central registry (`registry.hushnet.net`) alongside the node's Ed25519 public key and API base URL. + +**Routing rule (applied server-side on every relevant request):** +- If `node_host == this node` → local delivery (existing path, unchanged). +- If `node_host != this node` → federated path (S2S forwarding). + +--- + +### Node-to-Node Authentication (S2S) + +Every outbound S2S request from Node A carries four headers. Node B verifies them in sequence before executing the handler. + +| Header | Value | +|--------|-------| +| `X-Node-ID` | Sender's canonical node identifier (`node-a.hushnet.net`) | +| `X-Timestamp` | Unix seconds as a decimal string | +| `X-Nonce` | 16 random bytes, base64-encoded | +| `X-Node-Signature` | Ed25519 signature, base64-encoded | + +**Canonical string signed (fields joined by `\n`, UTF-8):** + +``` +{HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +``` + +Only the path portion of the URL is signed (no scheme, no host), so the canonical string is stable regardless of which domain name the caller used. + +**Verification sequence on Node B:** + +```mermaid +flowchart TD + A[Inbound S2S request] --> B{"abs(now - timestamp) <= 60s?"} + B -- no --> R1[401 timestamp out of window] + B -- yes --> C{node_id in federation_nodes?} + C -- no --> D[GET registry/nodes/node_id] + D --> E{registry responds 200?} + E -- no --> R2[401 peer not found in registry] + E -- yes --> F[upsert federation_nodes] + F --> G + C -- yes --> G{is_blocked = false?} + G -- no --> R3[403 node is blocked] + G -- yes --> H[verify Ed25519 signature] + H --> I{valid?} + I -- no --> R4[401 invalid node signature] + I -- yes --> J[INSERT used_node_nonces ON CONFLICT DO NOTHING] + J --> K{rows_affected = 1?} + K -- no --> R5[401 replayed nonce] + K -- yes --> L[execute handler] +``` + +**Public key discovery:** On first contact from an unknown peer, Node B fetches `GET {registry_url}/api/registry/nodes/{node_id}` and caches the result in `federation_nodes`. Subsequent requests use the cache (no registry call). + +**Anti-replay:** The `(node_id, nonce)` pair is stored in `used_node_nonces` immediately after signature verification. Nonces are unique per request; the 60-second timestamp window bounds how long they need to be retained. The outbox worker purges entries older than 5 minutes. + +--- + +### Flow 1 — Federated Prekey Lookup + +Before initiating X3DH with a remote user, Client A must obtain Bob's prekey bundle from Node B. Node A acts as a transparent proxy; it does not cache the bundle because OTPKs are one-time-use. + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + + CA->>NA: GET /users/federated/bob@node-b.hushnet.net/keys + Note over NA: AuthenticatedDevice (Alice's headers) + NA->>NA: resolve node-b in federation_nodes
(registry fallback if unknown) + NA->>NB: GET /s2s/users/bob/keys
X-Node-ID / X-Timestamp / X-Nonce / X-Node-Signature + Note over NB: AuthenticatedNode verifies signature + NB->>NB: look up local user "bob"
fetch device bundles
consume one OTPK per device + NB-->>NA: 200 DeviceBundle[] + NA-->>CA: 200 DeviceBundle[] + + Note over CA: X3DH key agreement performed locally
shared secret never leaves client +``` + +--- + +### Flow 2 — Cross-Node X3DH Session Initiation + +Client A performs X3DH locally using Bob's prekeys, then sends the encrypted init envelope to Node A. Node A forwards it to Node B synchronously (no outbox — session inits are small and need to be prompt). + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + participant CB as Client B + + CA->>NA: POST /sessions
{ recipient_user_address: "bob@node-b.hushnet.net",
sessions_init: [...] } + Note over NA: AuthenticatedDevice + NA->>NA: parse address → username=bob, node=node-b + NA->>NA: resolve Node B (DB cache / registry) + NA->>NB: POST /s2s/sessions
{ from_federated_address: "alice@node-a",
from_device_id, from_identity_pubkey,
to_user: "bob", sessions_init: [...] } + Note over NB: AuthenticatedNode + NB->>NB: upsert shadow user alice@node-a
upsert shadow device (alice_device_id) + NB->>NB: INSERT pending_sessions
(pg trigger fires) + NB-->>NA: 200 { status: "ok" } + NA-->>CA: 202 Accepted { status: "forwarded" } + NB-->>CB: WebSocket event
{ type: "pending_session", ... } + Note over CB: fetches GET /sessions/pending
confirms session locally (unchanged flow) +``` + +--- + +### Flow 3 — Cross-Node Message Delivery + +Client A sends an already-encrypted message to Node A. Node A writes to the outbox for durability, then immediately attempts delivery to Node B in a background task. The client receives 202 Accepted before the S2S call completes. + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + participant CB as Client B + + CA->>NA: POST /messages
{ to_user_address: "bob@node-b.hushnet.net",
logical_msg_id, payloads: [...] } + Note over NA: AuthenticatedDevice + NA->>NA: resolve Node B + NA->>NA: serialize S2sMessagePayload + NA->>NA: INSERT federation_outbox (durable) + NA-->>CA: 202 Accepted { status: "queued" } + + NA-)NB: POST /s2s/messages (background task)
{ logical_msg_id, from_federated_address,
from_device_id, from_identity_pubkey,
to_user: "bob", payloads: [...] } + Note over NB: AuthenticatedNode + NB->>NB: resolve local user "bob" (404 if not found) + NB->>NB: upsert shadow user + device for Alice + NB->>NB: get_or_create_direct_chat(shadow_alice, bob) + NB->>NB: INSERT messages per device payload
ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING + NB->>NB: pg trigger fires → pg_notify + NB-->>NA: 200 S2sAck { status: "delivered" | "duplicate" } + NA->>NA: UPDATE federation_outbox SET status='delivered' + NB-->>CB: WebSocket event { type: "message", ... } + Note over CB: fetches GET /messages/pending (unchanged) +``` + +**Outbox retry schedule:** + +| Attempt | Delay before next retry | +|---------|------------------------| +| 1 | 10 s | +| 2 | 20 s | +| 3 | 40 s | +| 4 | 80 s | +| 5 | 160 s | +| 6 | 320 s | +| 7 | 640 s | +| 8 | 1 280 s | +| 9 | 2 560 s | +| 10 | — (marked `failed`) | + +--- + +### Shadow Records + +When Node B receives a message from `alice@node-a`, it needs valid rows in `users` and `devices` to satisfy the foreign-key constraints on the `messages` table. Two shadow records are upserted automatically: + +**Shadow user** (`users.home_node_id IS NOT NULL`): +- `username` = `"alice"` (local part of the federated address) +- `federated_address` = `"alice@node-a.hushnet.net"` (unique key for upsert) +- `home_node_id` → FK to `federation_nodes` + +**Shadow device** (`devices` with empty prekey fields): +- `id` = device UUID from Node A (reused; UUID collision probability negligible) +- `identity_pubkey` = Alice's Ed25519 IK (included in every S2S payload) +- `prekey_pubkey`, `signed_prekey_pub`, `signed_prekey_sig` = `""` (never queried) +- `one_time_prekeys` = `[]` (never queried) + +Shadow records are never returned by client-facing endpoints (`GET /users`, `GET /users/:id/keys`, etc.) because those queries filter `WHERE home_node_id IS NULL`. + +--- + +### New Client-Facing Endpoint + +#### GET `/users/federated/{address}/keys` + +Fetch the prekey bundle for a remote user. Node A proxies the request to the user's home node. + +**Authentication:** Required (standard `AuthenticatedDevice` headers) + +**Path parameter:** + +| Name | Type | Description | +|------|------|-------------| +| `address` | string | Full federated address: `bob@node-b.hushnet.net` | + +**Response:** `200 OK` — same structure as local `GET /users/:id/keys` + +```json +[ + { + "device_id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890", + "identity_pubkey": "base64...", + "signed_prekey_pub": "base64...", + "signed_prekey_sig": "base64...", + "one_time_prekeys": ["base64_otpk_1"] + } +] +``` + +**Errors:** + +| Status | Condition | +|--------|-----------| +| `400` | Address missing `@` separator | +| `403` | Target node is blocked | +| `404` | Remote user not found on target node | +| `502` | Target node returned an error | +| `503` | Registry unreachable and node unknown | + +--- + +### Extended Client Endpoints + +Two existing endpoints accept an additional optional field for cross-node delivery. Clients that do not send the new field continue to work without modification. + +#### POST `/messages` — new optional field + +```json +{ + "chat_id": "uuid", + "logical_msg_id": "string", + "to_user_id": "uuid", + "to_user_address": "bob@node-b.hushnet.net", + "payloads": [ + { + "to_device_id": "uuid", + "header": { "...": "..." }, + "ciphertext": "base64..." + } + ] +} +``` + +When `to_user_address` is present and its node portion differs from this node's `NODE_HOST`, the message is forwarded via S2S. `to_user_id` is still required for schema compatibility but is ignored in the federated path. + +**Response when federated:** `202 Accepted` +```json +{ "status": "queued" } +``` + +#### POST `/sessions` — new optional field + +```json +{ + "recipient_user_id": "uuid", + "recipient_user_address": "bob@node-b.hushnet.net", + "sessions_init": [ { "...": "..." } ] +} +``` + +**Response when federated:** `202 Accepted` +```json +{ "status": "forwarded" } +``` + +--- + +### S2S Endpoints (Node-to-Node Only) + +These endpoints are consumed exclusively by peer nodes. Client applications must never call them directly. + +#### GET `/s2s/info` + +Return this node's public identity. No authentication required — this is the bootstrap endpoint that lets an unknown peer fetch the public key before the registry has been consulted. + +**Response:** `200 OK` + +```json +{ + "node_id": "node-a.hushnet.net", + "api_url": "https://node-a.hushnet.net/api", + "public_key_b64": "base64_ed25519_verifying_key", + "protocol_version": "0.0.1" +} +``` + +> **Security note:** The returned key should be cross-checked against the central registry before being trusted. A MITM that intercepts this call could substitute their own key if the channel is not TLS-protected. + +--- + +#### GET `/s2s/users/{username}/devices` + +Return the full device list for a local user. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Response:** `200 OK` — array of `Devices` records (same structure as `GET /users/:id/devices`) + +**Errors:** `404` if user does not exist or is a shadow record. + +--- + +#### GET `/s2s/users/{username}/keys` + +Return the prekey bundle for a local user, consuming one OTPK per device. Semantics are identical to the local `GET /users/:id/keys`. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Response:** `200 OK` — `DeviceBundle[]` + +**Errors:** `404` if user does not exist or is a shadow record. + +--- + +#### POST `/s2s/sessions` + +Accept a forwarded X3DH session initiation. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "from_federated_address": "alice@node-a.hushnet.net", + "from_device_id": "uuid", + "from_identity_pubkey": "base64...", + "to_user": "bob", + "sessions_init": [ + { + "recipient_device_id": "uuid", + "ephemeral_pubkey": "base64...", + "sender_prekey_pub": "base64...", + "otpk_used": "true", + "ciphertext": "base64..." + } + ] +} +``` + +**Response:** `200 OK` +```json +{ "status": "ok" } +``` + +--- + +#### POST `/s2s/messages` + +Accept forwarded encrypted message payloads for a local recipient. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "logical_msg_id": "string", + "from_federated_address": "alice@node-a.hushnet.net", + "from_device_id": "uuid", + "from_identity_pubkey": "base64...", + "to_user": "bob", + "payloads": [ + { + "to_device_id": "uuid", + "header": { "dh": "base64...", "pn": 0, "n": 1 }, + "ciphertext": "base64..." + } + ] +} +``` + +**Response:** `200 OK` + +```json +{ + "logical_msg_id": "string", + "status": "delivered" +} +``` + +`status` is `"duplicate"` if all payloads were already present in the database (idempotent retry from the sender's outbox). The sender must treat both `"delivered"` and `"duplicate"` as success and stop retrying. + +**Errors:** + +| Status | Condition | +|--------|-----------| +| `404` | Recipient username not found or is a shadow record | +| `500` | DB error during shadow upsert or message insert | + +--- + +#### POST `/s2s/ack` + +Delivery acknowledgment sent from Node B back to Node A. Advisory: Node A's outbox worker already marks entries delivered when it receives a `200` from `POST /s2s/messages`. This endpoint is for explicit acks sent by Node B after delayed processing. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "logical_msg_id": "string", + "status": "delivered" +} +``` + +**Response:** `200 OK` +```json +{ "status": "ack received" } +``` + +--- + +### Failure Handling Reference + +| Failure | Node A behavior | Node B behavior | +|---------|----------------|-----------------| +| Node B unreachable | 202 to client; outbox retries with backoff | — | +| Node B returns 404 for recipient | outbox entry marked `failed` immediately; no retry | 404 response | +| All OTPKs depleted on Node B | bundle returned with empty `one_time_prekeys`; client proceeds with SPK-only X3DH | `one_time_prekeys: []` in response | +| Duplicate message delivery (outbox retry) | outbox marked `delivered` on any 200 | `INSERT ... ON CONFLICT DO NOTHING`; returns `status: "duplicate"` | +| Delayed or missing ack | outbox resends after TTL; Node B's idempotent insert prevents double storage | — | +| Invalid S2S signature | — | 401; sender logs and does not retry same payload | +| Blocked peer node | 403 returned on any S2S request | — | +| Registry unreachable at auth time | — | 503; request rejected; sender may retry later | + +--- + +### Database Schema Additions + +The federation layer adds three tables and two columns to the existing schema. All changes are additive; no existing table is altered destructively. + +```sql +-- Peer node registry +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Anti-replay nonce store (TTL: 5 minutes) +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Outbound delivery queue with retry state +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, -- verbatim S2S request body + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Users: federation columns +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id), + -- NULL = local user; non-NULL = shadow record for a remote user + ADD COLUMN federated_address TEXT UNIQUE; + -- "alice@node-a.hushnet.net"; populated for all users after migration + +-- Messages: deduplication constraint +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); +``` + +--- + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NODE_HOST` | `node-unknown.hushnet.net` | This node's canonical identifier (used as `node_id` in S2S auth) | +| `NODE_API_URL` | `https://{NODE_HOST}/api` | Base API URL announced to peers | +| `REGISTRY_URL` | `https://registry.hushnet.net` | Central registry for peer node discovery | +| `REGISTER_TO_REGISTRY` | `false` | Set to `true` to register at startup | + +--- + ## Error Responses ### Standard Error Format diff --git a/sql_models/federation.sql b/sql_models/federation.sql new file mode 100644 index 0000000..7264893 --- /dev/null +++ b/sql_models/federation.sql @@ -0,0 +1,138 @@ +-- ============================================================================= +-- Migration: inter-node federation support +-- +-- Run this after sql_models/seed.sql. Every change here is purely additive: +-- no existing column is dropped or renamed, no existing constraint is altered. +-- +-- The three new tables (federation_nodes, used_node_nonces, federation_outbox) +-- and the two new columns on users (home_node_id, federated_address) are the +-- only schema deltas required to support cross-node message routing. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Peer node registry +-- +-- One row per known peer. Rows are created lazily: the first time this node +-- receives an S2S request from an unknown peer, it fetches that peer's record +-- from the central registry and inserts it here. +-- +-- public_key_b64 is the Ed25519 verifying key used to authenticate every +-- inbound S2S request from that peer. It must match what the peer registered +-- at the central registry (registry.hushnet.net). +-- +-- is_blocked allows an operator to stop accepting traffic from a specific peer +-- without removing the row (which would just re-create it on the next contact). +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, -- "https://node-a.hushnet.net/api" + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key, base64 + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Anti-replay nonce store +-- +-- Every accepted S2S request carries a random 16-byte nonce (base64-encoded). +-- The pair (node_id, nonce) is stored here immediately after signature +-- verification to prevent exact-replay attacks within the timestamp acceptance +-- window (currently 60 s). +-- +-- Rows older than 5 minutes can be safely deleted; the outbox worker runs a +-- periodic purge via: +-- DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes' +-- ----------------------------------------------------------------------------- +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Supports cheap TTL-based cleanup without a sequential scan. +CREATE INDEX idx_used_node_nonces_used_at ON used_node_nonces (used_at); + +-- ----------------------------------------------------------------------------- +-- Outbound delivery queue (federation outbox) +-- +-- Every logical message addressed to a remote node is written here before any +-- network call is made. The outbox worker reads pending entries, attempts +-- delivery to the target node, and transitions entries to 'delivered' or +-- 'failed'. +-- +-- payload is the verbatim JSON body of the POST /s2s/messages request that +-- will be sent to the target node. Storing it here means retries require no +-- additional DB reads to reconstruct the request. +-- +-- Backoff schedule implemented by the worker (seconds): +-- 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600 (cap) +-- After 10 failed attempts the entry is marked 'failed' and abandoned. +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, -- destination node_id + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partial index: only pending entries participate in the outbox work loop. +CREATE INDEX idx_federation_outbox_work + ON federation_outbox (target_node_id, next_attempt) + WHERE status = 'pending'; + +-- ----------------------------------------------------------------------------- +-- Users: federation columns +-- +-- home_node_id NULL → the user is local to this node; their devices are +-- authoritative here and they can log in normally. +-- +-- home_node_id SET → shadow record for a remote user whose real account lives +-- on another node. Created automatically the first time +-- this node receives a message from that user. Shadow +-- users cannot register devices or log in here; they +-- exist only to satisfy foreign-key constraints on the +-- messages and pending_sessions tables. +-- +-- federated_address is globally unique across all nodes: +-- "alice@node-a.hushnet.net" +-- It is the canonical identifier for cross-node addressing. Username alone is +-- not globally unique since each node has its own namespace. +-- ----------------------------------------------------------------------------- +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id) ON DELETE SET NULL, + ADD COLUMN federated_address TEXT UNIQUE; + +-- After running this migration, populate federated_address for every existing +-- local user by substituting the actual NODE_HOST value: +-- +-- UPDATE users +-- SET federated_address = username || '@' +-- WHERE home_node_id IS NULL AND federated_address IS NULL; +-- +-- This can be run as a separate step; the column is nullable so existing rows +-- are not broken before the backfill runs. + +-- ----------------------------------------------------------------------------- +-- Messages: deduplication constraint +-- +-- A given (logical_msg_id, to_device_id) pair must appear at most once in the +-- messages table. This is already implied by correct client behavior (one +-- logical message fan-out produces exactly one row per recipient device), but +-- the unique constraint makes idempotent S2S delivery safe: the receiving node +-- can INSERT ... ON CONFLICT DO NOTHING and check rows_affected to distinguish +-- a fresh delivery from a duplicate. +-- +-- If existing data violates this constraint (which it should not under correct +-- operation), the migration will fail and duplicates must be resolved first. +-- ----------------------------------------------------------------------------- +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); diff --git a/sql_models/seed.sql b/sql_models/seed.sql index 0e9ecdc..3e84e78 100644 --- a/sql_models/seed.sql +++ b/sql_models/seed.sql @@ -312,3 +312,142 @@ CREATE TRIGGER pending_sessions_notify_trigger AFTER INSERT ON pending_sessions FOR EACH ROW EXECUTE FUNCTION notify_new_pending_session(); + +-- ============================================================================= +-- Migration: inter-node federation support +-- +-- Run this after sql_models/seed.sql. Every change here is purely additive: +-- no existing column is dropped or renamed, no existing constraint is altered. +-- +-- The three new tables (federation_nodes, used_node_nonces, federation_outbox) +-- and the two new columns on users (home_node_id, federated_address) are the +-- only schema deltas required to support cross-node message routing. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Peer node registry +-- +-- One row per known peer. Rows are created lazily: the first time this node +-- receives an S2S request from an unknown peer, it fetches that peer's record +-- from the central registry and inserts it here. +-- +-- public_key_b64 is the Ed25519 verifying key used to authenticate every +-- inbound S2S request from that peer. It must match what the peer registered +-- at the central registry (registry.hushnet.net). +-- +-- is_blocked allows an operator to stop accepting traffic from a specific peer +-- without removing the row (which would just re-create it on the next contact). +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, -- "https://node-a.hushnet.net/api" + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key, base64 + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Anti-replay nonce store +-- +-- Every accepted S2S request carries a random 16-byte nonce (base64-encoded). +-- The pair (node_id, nonce) is stored here immediately after signature +-- verification to prevent exact-replay attacks within the timestamp acceptance +-- window (currently 60 s). +-- +-- Rows older than 5 minutes can be safely deleted; the outbox worker runs a +-- periodic purge via: +-- DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes' +-- ----------------------------------------------------------------------------- +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Supports cheap TTL-based cleanup without a sequential scan. +CREATE INDEX idx_used_node_nonces_used_at ON used_node_nonces (used_at); + +-- ----------------------------------------------------------------------------- +-- Outbound delivery queue (federation outbox) +-- +-- Every logical message addressed to a remote node is written here before any +-- network call is made. The outbox worker reads pending entries, attempts +-- delivery to the target node, and transitions entries to 'delivered' or +-- 'failed'. +-- +-- payload is the verbatim JSON body of the POST /s2s/messages request that +-- will be sent to the target node. Storing it here means retries require no +-- additional DB reads to reconstruct the request. +-- +-- Backoff schedule implemented by the worker (seconds): +-- 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600 (cap) +-- After 10 failed attempts the entry is marked 'failed' and abandoned. +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, -- destination node_id + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partial index: only pending entries participate in the outbox work loop. +CREATE INDEX idx_federation_outbox_work + ON federation_outbox (target_node_id, next_attempt) + WHERE status = 'pending'; + +-- ----------------------------------------------------------------------------- +-- Users: federation columns +-- +-- home_node_id NULL → the user is local to this node; their devices are +-- authoritative here and they can log in normally. +-- +-- home_node_id SET → shadow record for a remote user whose real account lives +-- on another node. Created automatically the first time +-- this node receives a message from that user. Shadow +-- users cannot register devices or log in here; they +-- exist only to satisfy foreign-key constraints on the +-- messages and pending_sessions tables. +-- +-- federated_address is globally unique across all nodes: +-- "alice@node-a.hushnet.net" +-- It is the canonical identifier for cross-node addressing. Username alone is +-- not globally unique since each node has its own namespace. +-- ----------------------------------------------------------------------------- +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id) ON DELETE SET NULL, + ADD COLUMN federated_address TEXT UNIQUE; + +-- After running this migration, populate federated_address for every existing +-- local user by substituting the actual NODE_HOST value: +-- +-- UPDATE users +-- SET federated_address = username || '@' +-- WHERE home_node_id IS NULL AND federated_address IS NULL; +-- +-- This can be run as a separate step; the column is nullable so existing rows +-- are not broken before the backfill runs. + +-- ----------------------------------------------------------------------------- +-- Messages: deduplication constraint +-- +-- A given (logical_msg_id, to_device_id) pair must appear at most once in the +-- messages table. This is already implied by correct client behavior (one +-- logical message fan-out produces exactly one row per recipient device), but +-- the unique constraint makes idempotent S2S delivery safe: the receiving node +-- can INSERT ... ON CONFLICT DO NOTHING and check rows_affected to distinguish +-- a fresh delivery from a duplicate. +-- +-- If existing data violates this constraint (which it should not under correct +-- operation), the migration will fail and duplicates must be resolved first. +-- ----------------------------------------------------------------------------- +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); diff --git a/src/app_state.rs b/src/app_state.rs index 78c343c..40381f6 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,7 +1,24 @@ +use std::sync::Arc; + use sqlx::PgPool; +use crate::utils::node_keys::NodeKeys; + #[derive(Clone)] pub struct AppState { pub pool: PgPool, pub jwt_secret: String, + /// This node's Ed25519 keypair, used to sign outbound S2S requests. + pub node_keys: Arc, + /// Canonical identifier for this node (e.g. "node-a.hushnet.net"). + /// Matches the node_id registered at the central registry. + pub this_node_id: String, + /// Base API URL for this node (e.g. "https://node-a.hushnet.net/api"). + /// Included in GET /s2s/info responses so peers know where to send requests. + pub this_api_url: String, + /// Central registry URL used for peer node discovery. + pub registry_url: String, + /// Shared HTTP client for outbound requests (registry lookups + S2S calls). + /// reqwest::Client is Clone and internally reference-counted. + pub http_client: reqwest::Client, } diff --git a/src/controllers/federation_controller.rs b/src/controllers/federation_controller.rs new file mode 100644 index 0000000..30f600f --- /dev/null +++ b/src/controllers/federation_controller.rs @@ -0,0 +1,546 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; +use tracing::{debug, error, info, warn}; + +use crate::{ + app_state::AppState, + federation::client::FederationClient, + middlewares::node_auth::AuthenticatedNode, + models::federation::{ + NodeInfo, S2sAck, S2sMessagePayload, S2sSessionPayload, + }, + repository::{device_repository, federation_repository, message_repository, session_repository}, +}; + +// ─── GET /s2s/info ─────────────────────────────────────────────────────────── + +pub async fn node_info(State(state): State) -> impl IntoResponse { + info!(node_id = %state.this_node_id, "GET /s2s/info"); + let info = NodeInfo { + node_id: state.this_node_id.clone(), + api_url: state.this_api_url.clone(), + public_key_b64: state.node_keys.public_b64.clone(), + protocol_version: "0.0.2", + }; + (StatusCode::OK, Json(info)) +} + +// ─── GET /s2s/users/:username/devices ──────────────────────────────────────── + +pub async fn get_user_devices( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Path(username): Path, +) -> impl IntoResponse { + info!(peer = %peer.node_id, %username, "GET /s2s/users/:username/devices"); + + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, &username).await { + Ok(Some(id)) => { + debug!(%username, %id, "local user found"); + id + } + Ok(None) => { + warn!(%username, "user not found or is a shadow record"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(%username, err = %e, "db error resolving user"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + match device_repository::get_devices_by_user_id(&state.pool, &user_id).await { + Ok(devices) => { + debug!(%username, count = devices.len(), "returning devices"); + (StatusCode::OK, Json(devices)).into_response() + } + Err(e) => { + error!(%username, err = %e, "db error fetching devices"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + } +} + +// ─── GET /s2s/users/:username/keys ─────────────────────────────────────────── + +pub async fn get_user_keys( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Path(username): Path, +) -> impl IntoResponse { + info!(peer = %peer.node_id, %username, "GET /s2s/users/:username/keys"); + + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, &username).await { + Ok(Some(id)) => { + debug!(%username, %id, "local user found for key fetch"); + id + } + Ok(None) => { + warn!(%username, "user not found or is a shadow record (key fetch)"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(%username, err = %e, "db error resolving user for key fetch"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + match device_repository::get_device_bundle(&state.pool, &user_id).await { + Ok(bundle) => { + debug!(%username, devices = bundle.len(), "returning key bundle"); + (StatusCode::OK, Json(bundle)).into_response() + } + Err(e) => { + error!(%username, err = %e, "db error fetching key bundle"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + } +} + +// ─── POST /s2s/sessions ────────────────────────────────────────────────────── + +pub async fn receive_session( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Json(payload): Json, +) -> impl IntoResponse { + info!( + peer = %peer.node_id, + from = %payload.from_federated_address, + to = %payload.to_user, + sessions = payload.sessions_init.len(), + "POST /s2s/sessions" + ); + + let sender_username = payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"); + + let sender_local_id = match federation_repository::upsert_shadow_user( + &state.pool, + sender_username, + &payload.from_federated_address, + peer.id, + ) + .await + { + Ok(id) => { + debug!(federated = %payload.from_federated_address, local_id = %id, "shadow user upserted"); + id + } + Err(e) => { + error!(err = %e, "shadow user upsert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if let Err(e) = federation_repository::upsert_shadow_device( + &state.pool, + payload.from_device_id, + sender_local_id, + &payload.from_identity_pubkey, + ) + .await + { + error!(device_id = %payload.from_device_id, err = %e, "shadow device upsert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + + for init in &payload.sessions_init { + debug!(recipient_device = %init.recipient_device_id, "inserting pending session"); + if let Err(e) = session_repository::create_pending_session( + &state.pool, + &payload.from_device_id, + &init.recipient_device_id, + &init.ephemeral_pubkey, + &init.sender_prekey_pub, + &init.otpk_used, + &init.ciphertext, + ) + .await + { + error!(recipient_device = %init.recipient_device_id, err = %e, "pending session insert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to store pending session"})), + ) + .into_response(); + } + } + + info!(from = %payload.from_federated_address, to = %payload.to_user, "sessions stored ok"); + (StatusCode::OK, Json(json!({"status": "ok"}))).into_response() +} + +// ─── POST /s2s/messages ────────────────────────────────────────────────────── + +pub async fn receive_messages( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Json(payload): Json, +) -> impl IntoResponse { + info!( + peer = %peer.node_id, + logical_id = %payload.logical_msg_id, + from = %payload.from_federated_address, + to_user = %payload.to_user, + device_count = payload.payloads.len(), + "POST /s2s/messages" + ); + + let recipient_id = match federation_repository::get_local_user_id_by_username( + &state.pool, + &payload.to_user, + ) + .await + { + Ok(Some(id)) => { + debug!(username = %payload.to_user, local_id = %id, "recipient resolved"); + id + } + Ok(None) => { + warn!(username = %payload.to_user, "recipient not found or is a shadow record"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "recipient not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(username = %payload.to_user, err = %e, "db error resolving recipient"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + let sender_username = payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"); + + let sender_local_id = match federation_repository::upsert_shadow_user( + &state.pool, + sender_username, + &payload.from_federated_address, + peer.id, + ) + .await + { + Ok(id) => { + debug!(federated = %payload.from_federated_address, local_id = %id, "shadow user upserted"); + id + } + Err(e) => { + error!(err = %e, "shadow user upsert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if let Err(e) = federation_repository::upsert_shadow_device( + &state.pool, + payload.from_device_id, + sender_local_id, + &payload.from_identity_pubkey, + ) + .await + { + error!(device_id = %payload.from_device_id, err = %e, "shadow device upsert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + + let chat_id = match federation_repository::get_or_create_direct_chat( + &state.pool, + sender_local_id, + recipient_id, + ) + .await + { + Ok(id) => { + debug!(chat_id = %id, "chat resolved"); + id + } + Err(e) => { + error!(err = %e, "get_or_create_direct_chat failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + let mut any_new = false; + for dev in &payload.payloads { + debug!(to_device = %dev.to_device_id, "inserting device payload"); + match message_repository::insert_federated_message( + &state.pool, + &payload.logical_msg_id, + chat_id, + sender_local_id, + payload.from_device_id, + recipient_id, + dev.to_device_id, + &dev.header, + &dev.ciphertext, + ) + .await + { + Ok(true) => { + debug!(to_device = %dev.to_device_id, "message inserted"); + any_new = true; + } + Ok(false) => { + debug!(to_device = %dev.to_device_id, "duplicate, skipped"); + } + Err(e) => { + error!(to_device = %dev.to_device_id, err = %e, "message insert failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + } + } + + let status = if any_new { "delivered" } else { "duplicate" }; + info!(logical_id = %payload.logical_msg_id, %status, "messages processed"); + + let ack = S2sAck { + logical_msg_id: payload.logical_msg_id, + status: status.into(), + }; + (StatusCode::OK, Json(ack)).into_response() +} + +// ─── POST /s2s/ack ─────────────────────────────────────────────────────────── + +pub async fn receive_ack( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Json(ack): Json, +) -> impl IntoResponse { + info!(peer = %peer.node_id, logical_id = %ack.logical_msg_id, status = %ack.status, "POST /s2s/ack"); + + if let Err(e) = + federation_repository::mark_outbox_delivered_by_logical_id(&state.pool, &ack.logical_msg_id) + .await + { + error!(logical_id = %ack.logical_msg_id, err = %e, "ack db update failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({"status": "ack received"}))).into_response() +} + +// ─── GET /users/federated/:address/keys ────────────────────────────────────── + +pub async fn federated_keys( + State(state): State, + crate::middlewares::auth::AuthenticatedDevice(_device): crate::middlewares::auth::AuthenticatedDevice, + Path((username, node_id)): Path<(String, String)>, +) -> impl IntoResponse { + info!(%username, %node_id, "GET /users/federated/:username/:node_id/keys"); + let (username, node_id) = (username.as_str(), node_id.as_str()); + + // Local shortcut: address points to this node. + if node_id == state.this_node_id { + debug!(%username, "address is local, serving directly"); + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, username) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + warn!(%username, "local user not found"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found"})), + ) + .into_response(); + } + Err(e) => { + error!(%username, err = %e, "db error on local key fetch"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + return match device_repository::get_device_bundle(&state.pool, &user_id).await { + Ok(bundle) => { + debug!(%username, devices = bundle.len(), "local bundle returned"); + (StatusCode::OK, Json(bundle)).into_response() + } + Err(e) => { + error!(%username, err = %e, "db error fetching local bundle"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + }; + } + + // Remote: resolve the target node. + debug!(%node_id, "resolving remote node"); + let node = match federation_repository::get_federation_node(&state.pool, node_id).await { + Ok(Some(n)) => { + debug!(%node_id, api_url = %n.api_url, "node found in local cache"); + n + } + Ok(None) => { + info!(%node_id, "node not in cache, querying registry"); + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + debug!(registry_url = %url, "registry lookup"); + match state.http_client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(body) => { + let api_url = body["api_url"].as_str().unwrap_or(""); + let pubkey = body["public_key_b64"].as_str().unwrap_or(""); + debug!(%node_id, %api_url, "registry returned node info"); + match federation_repository::upsert_federation_node( + &state.pool, node_id, api_url, pubkey, + ) + .await + { + Ok(n) => n, + Err(e) => { + error!(%node_id, err = %e, "failed to cache node from registry"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + } + } + Err(e) => { + error!(%node_id, err = %e, "malformed registry response"); + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "malformed registry response"})), + ) + .into_response(); + } + } + } + Ok(resp) => { + warn!(%node_id, status = %resp.status(), "registry returned non-200"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "target node not found in registry"})), + ) + .into_response(); + } + Err(e) => { + error!(%node_id, err = %e, "registry request failed"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "registry unreachable"})), + ) + .into_response(); + } + } + } + Err(e) => { + error!(%node_id, err = %e, "db error looking up federation node"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if node.is_blocked { + warn!(%node_id, "node is blocked"); + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "target node is blocked"})), + ) + .into_response(); + } + + info!(%node_id, api_url = %node.api_url, %username, "proxying key fetch to remote node"); + + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + + match fed_client.fetch_peer_keys(&node.api_url, username).await { + Ok(bundle) => { + info!(%node_id, %username, devices = bundle.len(), "remote key fetch succeeded"); + (StatusCode::OK, Json(bundle)).into_response() + } + Err(e) => { + error!(%node_id, %username, err = %e, "remote key fetch failed"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("peer returned error: {e}")})), + ) + .into_response() + } + } +} diff --git a/src/controllers/messages_controller.rs b/src/controllers/messages_controller.rs index 8b7a9e6..cf4ce71 100644 --- a/src/controllers/messages_controller.rs +++ b/src/controllers/messages_controller.rs @@ -1,8 +1,15 @@ use crate::{ app_state::AppState, + federation::{client::FederationClient, parse_federated_address}, middlewares::auth::AuthenticatedDevice, - models::message::OutgoingMessage, - repository::message_repository::{fetch_pending_messages, insert_message}, + models::{ + federation::{S2sDevicePayload, S2sMessagePayload}, + message::OutgoingMessage, + }, + repository::{ + federation_repository, message_repository::{fetch_pending_messages, insert_message}, + user_repository, + }, }; use axum::{ extract::{Json, State}, @@ -17,28 +24,150 @@ pub async fn send_message( AuthenticatedDevice(device): AuthenticatedDevice, Json(msg): Json, ) -> impl IntoResponse { - // Find user_id of sender let from_user_id: Uuid = device.user_id; + // ── Federated path ──────────────────────────────────────────────────────── + // When to_user_address is present and points to a different node, bypass + // local delivery entirely and queue the message for S2S forwarding. + if let Some(ref addr) = msg.to_user_address { + if let Some((username, node_id)) = parse_federated_address(addr) { + if node_id != state.this_node_id { + return handle_federated_message( + &state, + &device, + &msg, + from_user_id, + username, + node_id, + ) + .await; + } + } + } + + // ── Local delivery (existing path) ──────────────────────────────────────── match insert_message(&state.pool, device.id, from_user_id, msg).await { - Ok(()) => ( - StatusCode::OK, - Json(json!({ - "success": "true" - })), - ) - .into_response(), + Ok(()) => (StatusCode::OK, Json(json!({"success": "true"}))).into_response(), Err(e) => { - eprintln!("Error when inserting message {}", e); + eprintln!("Error inserting message: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Internal server error"})), + Json(json!({"error": "internal server error"})), ) .into_response() } } } +/// Build and queue a cross-node message for delivery to `username@node_id`. +/// +/// Steps: +/// 1. Look up sender's username (needed for from_federated_address). +/// 2. Resolve target node from DB cache or central registry. +/// 3. Serialize the S2S payload and write to federation_outbox (durable). +/// 4. Spawn a task for immediate delivery; if it fails, the outbox worker +/// will retry on its next poll cycle. +/// 5. Return 202 Accepted — the client does not wait for Node B to respond. +async fn handle_federated_message( + state: &AppState, + device: &crate::models::device::Devices, + msg: &OutgoingMessage, + from_user_id: Uuid, + to_username: &str, + target_node_id: &str, +) -> axum::response::Response { + // Look up sender's username for the federated address. + let sender_username = match user_repository::find_user_by_id(&state.pool, &from_user_id).await + { + Ok(Some(u)) => u.username, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "cannot resolve sender identity"})), + ) + .into_response() + } + }; + + // Resolve the target node (DB → registry). + let node = match resolve_node(state, target_node_id).await { + Ok(n) => n, + Err(resp) => return resp, + }; + + let s2s_payload = S2sMessagePayload { + logical_msg_id: msg.logical_msg_id.clone(), + from_federated_address: format!("{}@{}", sender_username, state.this_node_id), + from_device_id: device.id, + from_identity_pubkey: device.identity_pubkey.clone(), + to_user: to_username.to_string(), + payloads: msg + .payloads + .iter() + .map(|p| S2sDevicePayload { + to_device_id: p.to_device_id, + header: p.header.clone(), + ciphertext: p.ciphertext.clone(), + }) + .collect(), + }; + + let payload_json = match serde_json::to_value(&s2s_payload) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to serialize S2S payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Write to outbox for durability before attempting delivery. + let outbox_id = match federation_repository::enqueue_outbox( + &state.pool, + target_node_id, + &msg.logical_msg_id, + &payload_json, + ) + .await + { + Ok(id) => id, + Err(e) => { + eprintln!("Failed to enqueue outbox entry: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Spawn immediate delivery attempt; failures are handled by the outbox worker. + let pool = state.pool.clone(); + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + let api_url = node.api_url.clone(); + + tokio::spawn(async move { + match fed_client.forward_messages(&api_url, &s2s_payload).await { + Ok(_) => { + let _ = federation_repository::mark_outbox_delivered(&pool, outbox_id).await; + } + Err(e) => { + eprintln!("[federated send] immediate delivery failed, will retry: {e}"); + // Outbox worker schedules the next attempt automatically. + } + } + }); + + (StatusCode::ACCEPTED, Json(json!({"status": "queued"}))).into_response() +} + pub async fn get_pending_messages( State(state): State, AuthenticatedDevice(device): AuthenticatedDevice, @@ -46,12 +175,78 @@ pub async fn get_pending_messages( match fetch_pending_messages(&state.pool, AuthenticatedDevice(device)).await { Ok(messages) => (StatusCode::OK, Json(messages)).into_response(), Err(e) => { - eprintln!("Error when fetching pending messages {}", e); + eprintln!("Error fetching pending messages: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Internal server error"})), + Json(json!({"error": "internal server error"})), ) .into_response() } } } + +// ── Shared helper ───────────────────────────────────────────────────────────── + +/// Look up a FederationNode by node_id, falling back to the central registry +/// if the node is not yet cached locally. +pub(crate) async fn resolve_node( + state: &AppState, + node_id: &str, +) -> Result { + if let Ok(Some(n)) = federation_repository::get_federation_node(&state.pool, node_id).await { + if n.is_blocked { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "target node is blocked"})), + ) + .into_response()); + } + return Ok(n); + } + + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + let resp = match state.http_client.get(&url).send().await { + Ok(r) => r, + Err(_) => { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "registry unreachable"})), + ) + .into_response()) + } + }; + + if !resp.status().is_success() { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "target node not found in registry"})), + ) + .into_response()); + } + + let body = match resp.json::().await { + Ok(b) => b, + Err(_) => { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "malformed registry response"})), + ) + .into_response()) + } + }; + + let api_url = body["api_url"].as_str().unwrap_or(""); + let pubkey = body["public_key_b64"].as_str().unwrap_or(""); + match federation_repository::upsert_federation_node(&state.pool, node_id, api_url, pubkey).await + { + Ok(n) => Ok(n), + Err(e) => { + eprintln!("Failed to upsert federation node: {e}"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response()) + } + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 9a4fcde..29481e6 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,5 +1,6 @@ pub mod chats_controller; pub mod device_controller; +pub mod federation_controller; pub mod messages_controller; pub mod root_controller; pub mod session_controller; diff --git a/src/controllers/session_controller.rs b/src/controllers/session_controller.rs index b73410b..8f42458 100644 --- a/src/controllers/session_controller.rs +++ b/src/controllers/session_controller.rs @@ -4,10 +4,14 @@ use serde_json::json; use uuid::Uuid; use crate::app_state::AppState; +use crate::federation::{client::FederationClient, parse_federated_address}; use crate::middlewares::auth::AuthenticatedDevice; -use crate::repository::session_repository; +use crate::models::federation::{S2sSessionInit, S2sSessionPayload}; +use crate::repository::{session_repository, user_repository}; -#[derive(Debug, serde::Deserialize)] +use super::messages_controller::resolve_node; + +#[derive(Debug, Deserialize)] pub struct SessionInit { pub recipient_device_id: Uuid, pub ephemeral_pubkey: String, @@ -16,9 +20,13 @@ pub struct SessionInit { pub ciphertext: String, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize)] pub struct CreateSessionBody { pub recipient_user_id: Uuid, + /// Optional federated address for cross-node session initiation. + /// Format: "username@node-host" (e.g. "bob@node-b.hushnet.net"). + #[serde(default)] + pub recipient_user_address: Option, pub sessions_init: Vec, } @@ -34,6 +42,18 @@ pub async fn create_session( AuthenticatedDevice(sender): AuthenticatedDevice, Json(payload): Json, ) -> Result { + // ── Federated path ──────────────────────────────────────────────────────── + if let Some(ref addr) = payload.recipient_user_address { + if let Some((username, node_id)) = parse_federated_address(addr) { + if node_id != state.this_node_id { + return handle_federated_session(&state, &sender, &payload, username, node_id) + .await + .map_err(|_| (StatusCode::BAD_GATEWAY, "failed to forward session")); + } + } + } + + // ── Local path (unchanged) ──────────────────────────────────────────────── if sender.user_id == payload.recipient_user_id { return Err((StatusCode::BAD_REQUEST, "Cannot create session with self")); } @@ -57,8 +77,7 @@ pub async fn create_session( ) .await .map_err(|e| { - // Print the underlying database error for debugging before mapping to a generic HTTP error - eprintln!("Failed to insert pending session: {:#?}", e); + eprintln!("Failed to insert pending session: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to insert pending session", @@ -70,7 +89,66 @@ pub async fn create_session( .await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to commit"))?; - Ok((StatusCode::CREATED, Json(json!({ "status": "ok" })))) + Ok((StatusCode::CREATED, Json(json!({ "status": "ok" }))).into_response()) +} + +/// Forward an X3DH session initiation to a peer node. +/// +/// Node A signs and POSTs to Node B's /s2s/sessions endpoint. Node B inserts +/// the pending session records and delivers a WebSocket notification to the +/// recipient client. Node A returns 202 Accepted immediately. +async fn handle_federated_session( + state: &AppState, + sender: &crate::models::device::Devices, + payload: &CreateSessionBody, + to_username: &str, + target_node_id: &str, +) -> Result { + let sender_username = + match user_repository::find_user_by_id(&state.pool, &sender.user_id).await { + Ok(Some(u)) => u.username, + _ => return Err(()), + }; + + let node = match resolve_node(state, target_node_id).await { + Ok(n) => n, + Err(resp) => return Ok(resp), + }; + + let s2s_payload = S2sSessionPayload { + from_federated_address: format!("{}@{}", sender_username, state.this_node_id), + from_device_id: sender.id, + from_identity_pubkey: sender.identity_pubkey.clone(), + to_user: to_username.to_string(), + sessions_init: payload + .sessions_init + .iter() + .map(|i| S2sSessionInit { + recipient_device_id: i.recipient_device_id, + ephemeral_pubkey: i.ephemeral_pubkey.clone(), + sender_prekey_pub: i.sender_prekey_pub.clone(), + otpk_used: i.otpk_used.clone(), + ciphertext: i.ciphertext.clone(), + }) + .collect(), + }; + + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + + if let Err(e) = fed_client.forward_session(&node.api_url, &s2s_payload).await { + eprintln!("[federated session] forward failed: {e}"); + return Ok(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "failed to reach target node"})), + ) + .into_response()); + } + + Ok((StatusCode::ACCEPTED, Json(json!({"status": "forwarded"}))).into_response()) } pub async fn get_pending_sessions_handler( @@ -124,7 +202,7 @@ pub async fn confirm_session( ) .await .map_err(|e| { - eprintln!("Error {:#?}", e); + eprintln!("Error: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to get or create chat", @@ -139,12 +217,13 @@ pub async fn confirm_session( ) .await .map_err(|e| { - eprintln!("Error {:#?}", e); + eprintln!("Error: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to insert session", ) })?; + session_repository::delete_pending_session(&state.pool, &payload.pending_session_id) .await .map_err(|_| { diff --git a/src/federation/client.rs b/src/federation/client.rs new file mode 100644 index 0000000..9e4d52d --- /dev/null +++ b/src/federation/client.rs @@ -0,0 +1,185 @@ +// src/federation/client.rs +// +// Authenticated HTTP client for outbound S2S requests. +// +// Every outbound request is signed with this node's Ed25519 private key so +// that the receiving node can verify the sender's identity against the public +// key stored in the central registry. +// +// Canonical string signed (UTF-8, fields separated by "\n"): +// +// {HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +// +// The path component is extracted from the full URL by stripping the scheme +// and authority, making it consistent with what the receiver reconstructs from +// the incoming request URI. Only the path+query portion is signed, not the +// host, so that node API URLs can change without invalidating the signing logic. +// +// Four headers carry the authentication material: +// +// X-Node-ID — this node's canonical identifier +// X-Timestamp — Unix seconds (string) +// X-Nonce — 16 random bytes, base64-encoded +// X-Node-Signature — Ed25519(canonical), base64-encoded + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use ed25519_dalek::Signer; +use reqwest::Client; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + models::{ + device::DeviceBundle, + federation::{S2sAck, S2sMessagePayload, S2sSessionPayload}, + }, + utils::node_keys::NodeKeys, +}; + +/// HTTP client for outbound S2S communication. +/// +/// Clone is cheap: both `http` (reqwest::Client) and `node_keys` (Arc) are +/// reference-counted internally. +#[derive(Clone)] +pub struct FederationClient { + pub http: Client, + node_keys: Arc, + pub this_node_id: String, +} + +impl FederationClient { + pub fn new(http: Client, node_keys: Arc, this_node_id: String) -> Self { + Self { + http, + node_keys, + this_node_id, + } + } + + /// Fetch the prekey bundle for `username` from a peer node. + /// + /// The returned Vec has one DeviceBundle per device registered for that + /// user on the remote node. One-time prekeys are consumed by the remote + /// node on fetch (same semantics as the local GET /users/:id/keys endpoint). + pub async fn fetch_peer_keys( + &self, + api_url: &str, + username: &str, + ) -> Result> { + let url = format!("{api_url}/s2s/users/{username}/keys"); + self.signed_get(&url) + .await? + .error_for_status() + .context("peer returned error for key fetch")? + .json::>() + .await + .context("invalid key bundle in peer response") + } + + /// Forward an X3DH session initiation to the peer that hosts the recipient. + pub async fn forward_session(&self, api_url: &str, payload: &S2sSessionPayload) -> Result<()> { + self.signed_post(api_url, "/s2s/sessions", payload) + .await? + .error_for_status() + .context("peer rejected session forward")?; + Ok(()) + } + + /// Forward a batch of device-specific ciphertexts to the peer. + /// Returns the S2sAck the receiving node sends back. + pub async fn forward_messages( + &self, + api_url: &str, + payload: &S2sMessagePayload, + ) -> Result { + self.signed_post(api_url, "/s2s/messages", payload) + .await? + .error_for_status() + .context("peer rejected message forward")? + .json::() + .await + .context("invalid ack in peer response") + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + async fn signed_get(&self, url: &str) -> Result { + let path = url_path(url); + let (ts, nonce, sig) = self.sign("GET", path)?; + self.http + .get(url) + .header("X-Node-ID", &self.this_node_id) + .header("X-Timestamp", &ts) + .header("X-Nonce", &nonce) + .header("X-Node-Signature", &sig) + .send() + .await + .context("S2S GET request failed") + } + + async fn signed_post( + &self, + api_url: &str, + path: &str, + body: &T, + ) -> Result { + let (ts, nonce, sig) = self.sign("POST", path)?; + let url = format!("{api_url}{path}"); + self.http + .post(&url) + .header("Content-Type", "application/json") + .header("X-Node-ID", &self.this_node_id) + .header("X-Timestamp", &ts) + .header("X-Nonce", &nonce) + .header("X-Node-Signature", &sig) + .json(body) + .send() + .await + .context("S2S POST request failed") + } + + /// Build the canonical string and sign it with this node's private key. + /// + /// canonical = "{method}\n{path}\n{ts}\n{nonce}" + fn sign(&self, method: &str, path: &str) -> Result<(String, String, String)> { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + + let nonce = { + use ed25519_dalek::ed25519::signature::rand_core::{OsRng, RngCore}; + let mut buf = [0u8; 16]; + OsRng.fill_bytes(&mut buf); + B64.encode(buf) + }; + + let canonical = format!("{method}\n{path}\n{ts}\n{nonce}"); + let signing_key = self.node_keys.signing_key()?; + let signature = signing_key.sign(canonical.as_bytes()); + let sig_b64 = B64.encode(signature.to_bytes()); + + Ok((ts, nonce, sig_b64)) + } +} + +/// Extract the path+query portion from a full URL. +/// +/// "https://node-a.hushnet.net/api/s2s/messages?x=1" → "/api/s2s/messages?x=1" +/// +/// This is what the receiving node reconstructs from the incoming request URI, +/// so both sides of the signature use the same string. +fn url_path(url: &str) -> &str { + if let Some(pos) = url.find("://") { + let after_scheme = &url[pos + 3..]; + if let Some(slash) = after_scheme.find('/') { + return &after_scheme[slash..]; + } + return "/"; + } + url +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs new file mode 100644 index 0000000..82be538 --- /dev/null +++ b/src/federation/mod.rs @@ -0,0 +1,39 @@ +pub mod client; +pub mod outbox; + +/// Parse a federated user address into its local and node components. +/// +/// "alice@node-a.hushnet.net" → ("alice", "node-a.hushnet.net") +/// +/// Uses rfind('@') so that a username containing '@' (unlikely but possible) +/// is tolerated: the rightmost '@' is taken as the domain separator. +/// Returns None if the address contains no '@'. +pub fn parse_federated_address(addr: &str) -> Option<(&str, &str)> { + let at = addr.rfind('@')?; + Some((&addr[..at], &addr[at + 1..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_normal_address() { + let (user, node) = parse_federated_address("alice@node-a.hushnet.net").unwrap(); + assert_eq!(user, "alice"); + assert_eq!(node, "node-a.hushnet.net"); + } + + #[test] + fn parse_missing_at() { + assert!(parse_federated_address("alice").is_none()); + } + + #[test] + fn parse_rightmost_at() { + // degenerate case: username itself contains '@' + let (user, node) = parse_federated_address("a@b@node-a.hushnet.net").unwrap(); + assert_eq!(user, "a@b"); + assert_eq!(node, "node-a.hushnet.net"); + } +} diff --git a/src/federation/outbox.rs b/src/federation/outbox.rs new file mode 100644 index 0000000..aea89cd --- /dev/null +++ b/src/federation/outbox.rs @@ -0,0 +1,169 @@ +// src/federation/outbox.rs +// +// Background worker that delivers queued outbound S2S messages. +// +// The outbox provides durability for cross-node message delivery: when Node A +// forwards a message to Node B, it first writes the request body to the +// federation_outbox table, then attempts immediate delivery in a spawned task. +// If that attempt fails (Node B is unreachable, times out, etc.), the outbox +// worker picks up the entry on its next poll cycle and retries with exponential +// backoff. +// +// This decouples the client-facing POST /messages response from the S2S +// network call: Node A returns 202 Accepted to the client as soon as the +// entry is written to the outbox, regardless of Node B's availability. +// +// Backoff schedule (seconds): +// attempt 0 → immediate (spawned task at request time) +// attempt 1 → 10 s +// attempt 2 → 20 s +// attempt 3 → 40 s +// ... +// attempt 12+ → 3600 s (1 hour, cap) +// +// After MAX_ATTEMPTS the entry is marked 'failed'. A separate mechanism +// (not implemented here) could push a delivery-failure event to the +// originating client's WebSocket connection. + +use std::sync::Arc; +use std::time::Duration; + +use sqlx::PgPool; +use tokio::time; +use tracing::{debug, error, info, warn}; + +use crate::{ + models::federation::S2sMessagePayload, + repository::federation_repository, + utils::node_keys::NodeKeys, +}; + +use super::client::FederationClient; + +const POLL_INTERVAL: Duration = Duration::from_secs(10); +const MAX_ATTEMPTS: i32 = 10; + +/// Long-running task: poll the outbox and retry failed deliveries. +/// +/// Spawn this once at startup: +/// ```rust +/// tokio::spawn(federation::outbox::run(pool, node_keys, node_id, http)); +/// ``` +pub async fn run( + pool: PgPool, + node_keys: Arc, + this_node_id: String, + http_client: reqwest::Client, +) { + let mut interval = time::interval(POLL_INTERVAL); + // Delay mode: if a tick is missed (the previous iteration took longer than + // POLL_INTERVAL), skip the missed ticks rather than bursting. + interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay); + + loop { + interval.tick().await; + + // Housekeeping: purge nonces older than 5 minutes. + if let Err(e) = federation_repository::purge_expired_nonces(&pool).await { + warn!(err = %e, "outbox: nonce purge failed"); + } + + let entries = match federation_repository::fetch_due_outbox_entries(&pool).await { + Ok(v) => v, + Err(e) => { + error!(err = %e, "outbox: db error fetching due entries"); + continue; + } + }; + + if !entries.is_empty() { + info!(count = entries.len(), "outbox: processing due entries"); + } + + for entry in entries { + let pool = pool.clone(); + let client = FederationClient::new( + http_client.clone(), + node_keys.clone(), + this_node_id.clone(), + ); + + tokio::spawn(async move { + let payload: S2sMessagePayload = match serde_json::from_value(entry.payload) { + Ok(p) => p, + Err(e) => { + error!(entry_id = %entry.id, err = %e, "outbox: cannot deserialize entry, marking failed"); + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + MAX_ATTEMPTS, + MAX_ATTEMPTS, + ) + .await; + return; + } + }; + + let node = + match federation_repository::get_federation_node(&pool, &entry.target_node_id) + .await + { + Ok(Some(n)) => n, + Ok(None) => { + warn!( + target_node = %entry.target_node_id, + entry_id = %entry.id, + "outbox: unknown target node" + ); + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + entry.attempt_count + 1, + MAX_ATTEMPTS, + ) + .await; + return; + } + Err(e) => { + error!(err = %e, "outbox: db error looking up node"); + return; + } + }; + + debug!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + attempt = entry.attempt_count + 1, + "outbox: attempting delivery" + ); + + match client.forward_messages(&node.api_url, &payload).await { + Ok(_) => { + info!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + "outbox: delivery succeeded" + ); + let _ = federation_repository::mark_outbox_delivered(&pool, entry.id).await; + } + Err(e) => { + warn!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + attempt = entry.attempt_count + 1, + err = %e, + "outbox: delivery failed" + ); + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + entry.attempt_count + 1, + MAX_ATTEMPTS, + ) + .await; + } + } + }); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0d91333..74525e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod controllers; +mod federation; mod middlewares; mod models; mod repository; @@ -7,6 +8,7 @@ mod services; use axum::{Extension, Router}; use sqlx::PgPool; use std::net::SocketAddr; +use std::sync::Arc; use tokio::sync::broadcast; mod app_state; mod realtime; @@ -25,31 +27,57 @@ use registry::register::register_with_registry; async fn main() -> Result<(), anyhow::Error> { tracing_subscriber::fmt::init(); dotenvy::dotenv().ok(); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".into()); let server_port = env::var("SERVER_PORT").unwrap_or_else(|_| "8080".into()); - let pool: sqlx::Pool = PgPool::connect(&database_url).await?; - let jwt_secret = std::env::var("JWT_SECRET").unwrap(); + let jwt_secret = env::var("JWT_SECRET").unwrap(); + let registry_url = env::var("REGISTRY_URL").unwrap_or_else(|_| "https://registry.hushnet.net".into()); + let node_host = + env::var("NODE_HOST").unwrap_or_else(|_| "node-unknown.hushnet.net".into()); + let node_api_url = + env::var("NODE_API_URL").unwrap_or_else(|_| format!("https://{node_host}/api")); + + let pool: sqlx::Pool = PgPool::connect(&database_url).await?; let keys = NodeKeys::load_or_generate()?; println!("Public key (base64): {}", keys.public_b64); + if env::var("REGISTER_TO_REGISTRY") .unwrap_or_else(|_| "false".into()) .to_lowercase() == "true" { - println!("Registering with registry at {}", registry_url); + println!("Registering with registry at {registry_url}"); register_with_registry(®istry_url).await?; } + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + let state: AppState = AppState { pool: pool.clone(), jwt_secret, + node_keys: Arc::new(keys), + this_node_id: node_host.clone(), + this_api_url: node_api_url, + registry_url: registry_url.clone(), + http_client: http_client.clone(), }; + let (tx, _rx) = broadcast::channel::(100); tokio::spawn(start_pg_listeners(pool.clone(), tx.clone())); + // Outbox worker: retries failed cross-node message deliveries. + tokio::spawn(federation::outbox::run( + pool.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + http_client, + )); + let app = Router::new() .merge(routes::users::routes().with_state(state.clone())) .merge(routes::devices::routes().with_state(state.clone())) @@ -57,11 +85,11 @@ async fn main() -> Result<(), anyhow::Error> { .merge(routes::sessions::routes().with_state(state.clone())) .merge(routes::chats::routes().with_state(state.clone())) .merge(routes::messages::routes().with_state(state.clone())) + .merge(routes::federation::routes().with_state(state.clone())) .merge(routes::websocket::routes()) .layer(Extension(tx)); let addr = SocketAddr::new(server_host.parse().unwrap(), server_port.parse().unwrap()); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); Ok(()) diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 988d777..eeeca50 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -1,7 +1,6 @@ // src/middlewares/auth.rs use crate::{app_state::AppState, models::device::Devices, repository::device_repository}; use axum::{ - async_trait, extract::FromRequestParts, http::{request::Parts, StatusCode}, }; @@ -10,7 +9,6 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; pub struct AuthenticatedDevice(pub Devices); -#[async_trait] impl FromRequestParts for AuthenticatedDevice { type Rejection = (StatusCode, String); diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 0e4a05d..b55c3a3 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod node_auth; diff --git a/src/middlewares/node_auth.rs b/src/middlewares/node_auth.rs new file mode 100644 index 0000000..acbc6a6 --- /dev/null +++ b/src/middlewares/node_auth.rs @@ -0,0 +1,184 @@ +// src/middlewares/node_auth.rs +// +// Authenticates inbound S2S requests from peer nodes. +// +// Every request to a /s2s/* endpoint (except /s2s/info) must carry four +// headers that together prove the request was sent by the node that owns the +// private key registered at the central registry: +// +// X-Node-ID — canonical node identifier ("node-a.hushnet.net") +// X-Timestamp — Unix seconds as a decimal string +// X-Nonce — random 16-byte value, base64-encoded +// X-Node-Signature — Ed25519 signature, base64-encoded +// +// Canonical string (UTF-8, signed verbatim, fields separated by "\n"): +// +// {HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +// +// The path component is the request URI path only (no scheme or host), so +// that the canonical string is independent of which domain name the caller +// used to reach this node. +// +// Verification sequence +// --------------------- +// 1. Reject if |now − timestamp| > 60 s. +// 2. Look up the peer's FederationNode record (DB cache → registry fallback). +// 3. Reject if the node is flagged is_blocked. +// 4. Verify the Ed25519 signature over the canonical string. +// 5. Atomically claim the (node_id, nonce) pair in used_node_nonces; reject +// if the pair was already present (replay attack). +// +// On success the FederationNode record is inserted into request Extensions so +// that handlers can access it with `Extension`. + +use crate::{ + app_state::AppState, + models::federation::FederationNode, + repository::federation_repository, +}; +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +/// Extractor that validates the four S2S authentication headers and returns the +/// authenticated peer's FederationNode record on success. +/// +/// Usage in a handler: +/// ``` +/// pub async fn my_handler( +/// AuthenticatedNode(peer): AuthenticatedNode, +/// ... +/// ) -> impl IntoResponse { ... } +/// ``` +pub struct AuthenticatedNode(pub FederationNode); + +impl FromRequestParts for AuthenticatedNode { + type Rejection = (StatusCode, String); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let node_id = header_str(&parts.headers, "X-Node-ID")?; + let ts_str = header_str(&parts.headers, "X-Timestamp")?; + let nonce = header_str(&parts.headers, "X-Nonce")?; + let sig_b64 = header_str(&parts.headers, "X-Node-Signature")?; + + // ── 1. timestamp check ─────────────────────────────────────────────── + let now = chrono::Utc::now().timestamp(); + let ts: i64 = ts_str + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "X-Timestamp must be an integer".into()))?; + if (now - ts).abs() > 60 { + return Err((StatusCode::UNAUTHORIZED, "timestamp outside 60-second window".into())); + } + + // ── 2. peer public key lookup (DB cache → registry fallback) ───────── + let node = resolve_peer(state, &node_id).await?; + + // ── 3. blocked check ───────────────────────────────────────────────── + if node.is_blocked { + return Err((StatusCode::FORBIDDEN, "node is blocked".into())); + } + + // ── 4. signature verification ──────────────────────────────────────── + let path = parts + .uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let canonical = format!("{}\n{}\n{}\n{}", parts.method.as_str(), path, ts_str, nonce); + + let sig_bytes: [u8; 64] = B64 + .decode(&sig_b64) + .map_err(|_| (StatusCode::BAD_REQUEST, "bad signature base64".into()))? + .try_into() + .map_err(|_| (StatusCode::BAD_REQUEST, "signature must be 64 bytes".into()))?; + let sig = Signature::from_bytes(&sig_bytes); + + let vk_bytes: [u8; 32] = B64 + .decode(&node.public_key_b64) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bad cached peer pubkey".into()))? + .try_into() + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "peer pubkey must be 32 bytes".into(), + ) + })?; + let vk = VerifyingKey::from_bytes(&vk_bytes) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "invalid peer pubkey".into()))?; + + vk.verify(canonical.as_bytes(), &sig) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid node signature".into()))?; + + // ── 5. nonce claim (replay prevention) ─────────────────────────────── + let fresh = federation_repository::claim_nonce(&state.pool, &node_id, &nonce) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))?; + if !fresh { + return Err((StatusCode::UNAUTHORIZED, "replayed nonce".into())); + } + + Ok(AuthenticatedNode(node)) + } +} + +fn header_str( + headers: &axum::http::HeaderMap, + name: &'static str, +) -> Result { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, format!("missing header: {name}"))) +} + +/// Look up a peer's FederationNode, falling back to the central registry if the +/// node is not yet cached locally. +/// +/// On a successful registry fetch, the node record is upserted into +/// federation_nodes so subsequent requests use the local cache. +async fn resolve_peer( + state: &AppState, + node_id: &str, +) -> Result { + if let Some(node) = federation_repository::get_federation_node(&state.pool, node_id) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))? + { + return Ok(node); + } + + // Cache miss: ask the central registry. + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + let resp = state + .http_client + .get(&url) + .send() + .await + .map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "registry unreachable".into()))? + .error_for_status() + .map_err(|_| (StatusCode::UNAUTHORIZED, "peer node not found in registry".into()))? + .json::() + .await + .map_err(|_| (StatusCode::BAD_GATEWAY, "malformed registry response".into()))?; + + let api_url = resp["api_url"] + .as_str() + .ok_or((StatusCode::BAD_GATEWAY, "registry response missing api_url".into()))?; + let pubkey = resp["public_key_b64"] + .as_str() + .ok_or((StatusCode::BAD_GATEWAY, "registry response missing public_key_b64".into()))?; + + let node = + federation_repository::upsert_federation_node(&state.pool, node_id, api_url, pubkey) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))?; + + Ok(node) +} diff --git a/src/models/chat.rs b/src/models/chat.rs index b7c6003..e75f437 100644 --- a/src/models/chat.rs +++ b/src/models/chat.rs @@ -22,6 +22,7 @@ pub struct ChatView { pub chat_type: Option, pub partner_user_id: Option, pub partner_username: Option, + pub partner_federated_address: Option, pub name: Option, pub last_message_id: Option, pub updated_at: Option, diff --git a/src/models/device.rs b/src/models/device.rs index 988f4e8..3114e78 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -33,6 +33,7 @@ pub struct OneTimePrekeys { pub struct DeviceBundle { pub device_id: Uuid, pub identity_pubkey: String, + pub prekey_pubkey: String, pub signed_prekey_pub: String, pub signed_prekey_sig: String, pub one_time_prekeys: Vec, diff --git a/src/models/federation.rs b/src/models/federation.rs new file mode 100644 index 0000000..126f015 --- /dev/null +++ b/src/models/federation.rs @@ -0,0 +1,124 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +// ─── Peer node record ──────────────────────────────────────────────────────── + +/// A peer node as stored in the federation_nodes table. +/// Rows are created lazily on first contact (via registry lookup) and cached. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FederationNode { + pub id: Uuid, + /// Canonical host-based identifier: "node-a.hushnet.net" + pub node_id: String, + /// Base API URL the S2S client uses for outbound requests. + pub api_url: String, + /// Ed25519 verifying key (base64) for authenticating inbound S2S requests. + pub public_key_b64: String, + pub last_seen: Option>, + pub is_blocked: bool, + pub created_at: DateTime, +} + +// ─── Outbox entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FederationOutboxEntry { + pub id: Uuid, + pub target_node_id: String, + pub logical_msg_id: String, + /// Verbatim JSON body to POST to /s2s/messages on the target node. + pub payload: Value, + pub attempt_count: i32, + pub last_attempt: Option>, + pub next_attempt: DateTime, + /// "pending" | "delivered" | "failed" + pub status: String, + pub created_at: DateTime, +} + +// ─── S2S wire types ────────────────────────────────────────────────────────── + +/// Body of POST /s2s/messages. +/// +/// Sent by Node A to Node B to deliver one logical message to a local user. +/// Each entry in `payloads` is encrypted specifically for one recipient device; +/// Node B stores each as an independent row in the messages table. +/// +/// `from_identity_pubkey` is included so Node B can upsert the shadow device +/// row (devices table) without requiring a round-trip back to Node A. Shadow +/// devices need a valid identity_pubkey but no actual prekey material. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sMessagePayload { + /// Shared across all device fanouts of this message. Used for idempotent + /// delivery: Node B rejects duplicates keyed on (logical_msg_id, to_device_id). + pub logical_msg_id: String, + /// "alice@node-a.hushnet.net" — used to upsert the shadow user on Node B. + pub from_federated_address: String, + /// UUID of the sending device, authoritative on Node A. + pub from_device_id: Uuid, + /// Ed25519 identity public key of the sending device (base64). + pub from_identity_pubkey: String, + /// Local username of the recipient on Node B. + pub to_user: String, + pub payloads: Vec, +} + +/// One ciphertext destined for a single recipient device. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sDevicePayload { + pub to_device_id: Uuid, + pub header: Value, + pub ciphertext: String, +} + +/// Body of POST /s2s/sessions. +/// +/// Sent by Node A to Node B to forward an X3DH session initiation. +/// Node B inserts the data into pending_sessions so the local recipient +/// sees it via GET /sessions/pending or the WebSocket stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sSessionPayload { + pub from_federated_address: String, + pub from_device_id: Uuid, + pub from_identity_pubkey: String, + /// Local username of the recipient on Node B. + pub to_user: String, + pub sessions_init: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sSessionInit { + pub recipient_device_id: Uuid, + pub ephemeral_pubkey: String, + pub sender_prekey_pub: String, + pub otpk_used: String, + pub ciphertext: String, +} + +/// Body of POST /s2s/ack (Node B → Node A). +/// +/// Advisory: the outbox worker already marks entries delivered when it receives +/// a 2xx from forward_messages, so this ack is redundant in the happy path. +/// It exists as an explicit signal for cases where Node B wants to proactively +/// confirm delivery without waiting for Node A to poll. +#[derive(Debug, Serialize, Deserialize)] +pub struct S2sAck { + pub logical_msg_id: String, + /// "delivered" | "duplicate" + pub status: String, +} + +/// Response body for GET /s2s/info. +/// +/// Used by peers during bootstrapping to obtain this node's public key before +/// the registry has been consulted. The caller must still verify the returned +/// key against the registry to prevent a MITM from substituting its own key. +#[derive(Debug, Serialize, Deserialize)] +pub struct NodeInfo { + pub node_id: String, + pub api_url: String, + pub public_key_b64: String, + pub protocol_version: &'static str, +} diff --git a/src/models/message.rs b/src/models/message.rs index 2a09baf..995c349 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -29,11 +29,21 @@ pub struct OutgoingMessagePayload { } /// Represents the logical message (fan-out over multiple recipient devices). +/// +/// For local delivery, set `to_user_id`. +/// For cross-node delivery, also set `to_user_address` ("bob@node-b.hushnet.net"). +/// When `to_user_address` points to a remote node, `to_user_id` is ignored by +/// the server and the message is forwarded via S2S. Existing clients that do +/// not send `to_user_address` continue to work unchanged. #[derive(Debug, Deserialize)] pub struct OutgoingMessage { pub chat_id: Uuid, pub logical_msg_id: String, pub to_user_id: Uuid, + /// Optional federated address for cross-node delivery. + /// Format: "username@node-host" (e.g. "bob@node-b.hushnet.net"). + #[serde(default)] + pub to_user_address: Option, pub payloads: Vec, } diff --git a/src/models/mod.rs b/src/models/mod.rs index ad38a72..f168a06 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod chat; pub mod device; pub mod enrollment_token; +pub mod federation; pub mod message; pub mod realtime; pub mod session; diff --git a/src/realtime/listener.rs b/src/realtime/listener.rs index 515ed4f..ab21015 100644 --- a/src/realtime/listener.rs +++ b/src/realtime/listener.rs @@ -1,6 +1,7 @@ use serde_json::Value; use sqlx::{postgres::PgListener, PgPool}; use tokio::sync::broadcast; +use tracing::{error, info, warn}; use crate::models::realtime::RealtimeEvent; @@ -16,14 +17,46 @@ pub async fn start_pg_listeners(pool: PgPool, tx: broadcast::Sender(notif.payload()) { - if let Some(event_type) = payload.get("type").and_then(|v| v.as_str()) { - let event = RealtimeEvent { - event_type: event_type.to_string(), - payload, - }; - let _ = tx.send(event); + info!("PG listener started, watching 4 channels"); + + loop { + match listener.recv().await { + Ok(notif) => { + let channel = notif.channel(); + match serde_json::from_str::(notif.payload()) { + Ok(payload) => { + let event_type = payload + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let user_id = payload + .get("user_id") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + + info!( + %channel, + %event_type, + %user_id, + subscribers = tx.receiver_count(), + "PG notify received, forwarding to broadcast" + ); + + let event = RealtimeEvent { + event_type: event_type.to_string(), + payload, + }; + if let Err(e) = tx.send(event) { + warn!(%channel, err = %e, "broadcast send failed (no subscribers?)"); + } + } + Err(e) => { + warn!(%channel, err = %e, "PG notify payload parse failed"); + } + } + } + Err(e) => { + error!(err = %e, "PG listener error"); } } } diff --git a/src/realtime/websocket.rs b/src/realtime/websocket.rs index 0a9ce12..539e241 100644 --- a/src/realtime/websocket.rs +++ b/src/realtime/websocket.rs @@ -8,8 +8,10 @@ use axum::{ }; use tokio::sync::broadcast; +use tracing::{info, warn}; use crate::models::realtime::RealtimeEvent; + pub async fn ws_route( Path(user_id): Path, ws: WebSocketUpgrade, @@ -25,24 +27,41 @@ async fn handle_socket( ) { let mut rx = tx.subscribe(); - println!("WS connected for user {}", user_id); + info!(%user_id, subscribers = tx.receiver_count(), "WS connected"); tokio::spawn(async move { - while let Ok(event) = rx.recv().await { - let payload_user = event - .payload - .get("user_id") - .and_then(|v| v.as_str()) - .unwrap_or_default(); + loop { + match rx.recv().await { + Ok(event) => { + let payload_user = event + .payload + .get("user_id") + .and_then(|v| v.as_str()) + .unwrap_or_default(); - if payload_user == user_id { - if let Ok(json) = serde_json::to_string(&event) { - if socket.send(Message::Text(json)).await.is_err() { - break; + if payload_user == user_id { + info!( + %user_id, + event_type = %event.event_type, + "WS dispatching event to client" + ); + if let Ok(json) = serde_json::to_string(&event) { + if socket.send(Message::Text(json.into())).await.is_err() { + info!(%user_id, "WS send failed, closing"); + break; + } + } } } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(%user_id, skipped = n, "WS broadcast lagged, events dropped"); + } + Err(broadcast::error::RecvError::Closed) => { + info!(%user_id, "WS broadcast channel closed"); + break; + } } } - println!("WS closed for user {}", user_id); + info!(%user_id, "WS disconnected"); }); } diff --git a/src/repository/chat_repository.rs b/src/repository/chat_repository.rs index 8dc8e65..8b207e1 100644 --- a/src/repository/chat_repository.rs +++ b/src/repository/chat_repository.rs @@ -22,26 +22,34 @@ pub async fn get_chats_for_device( let chats = sqlx::query_as!( ChatView, r#" - SELECT + SELECT c.id, c.chat_type, - CASE + CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END AS partner_user_id, ( - SELECT u.username + SELECT u.username FROM users u - WHERE u.id = CASE + WHERE u.id = CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END ) AS partner_username, + ( + SELECT u.federated_address + FROM users u + WHERE u.id = CASE + WHEN c.user_a = $1 THEN c.user_b + ELSE c.user_a + END + ) AS partner_federated_address, c.name, c.last_message_id, c.updated_at FROM chats c - WHERE + WHERE (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b))) OR (c.chat_type = 'group' AND c.id IN ( SELECT chat_id FROM chat_members WHERE user_id = $1 diff --git a/src/repository/device_repository.rs b/src/repository/device_repository.rs index 7a4f8c2..ef9d309 100644 --- a/src/repository/device_repository.rs +++ b/src/repository/device_repository.rs @@ -130,6 +130,7 @@ pub async fn get_device_bundle( bundles.push(DeviceBundle { device_id: row.id, identity_pubkey: row.identity_pubkey, + prekey_pubkey: row.prekey_pubkey, signed_prekey_pub: row.signed_prekey_pub, signed_prekey_sig: row.signed_prekey_sig, one_time_prekeys: otpks, diff --git a/src/repository/federation_repository.rs b/src/repository/federation_repository.rs new file mode 100644 index 0000000..6636f98 --- /dev/null +++ b/src/repository/federation_repository.rs @@ -0,0 +1,256 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::federation::{FederationNode, FederationOutboxEntry}; + +// Non-macro sqlx throughout: avoids compile-time DATABASE_URL requirement and +// the need to run `cargo sqlx prepare` every time a query changes. + +// ─── federation_nodes ──────────────────────────────────────────────────────── + +pub async fn upsert_federation_node( + pool: &PgPool, + node_id: &str, + api_url: &str, + public_key_b64: &str, +) -> Result { + sqlx::query_as::<_, FederationNode>( + r#" + INSERT INTO federation_nodes (node_id, api_url, public_key_b64) + VALUES ($1, $2, $3) + ON CONFLICT (node_id) DO UPDATE + SET api_url = EXCLUDED.api_url, + public_key_b64 = EXCLUDED.public_key_b64, + last_seen = NOW() + RETURNING id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at + "#, + ) + .bind(node_id) + .bind(api_url) + .bind(public_key_b64) + .fetch_one(pool) + .await +} + +pub async fn get_federation_node( + pool: &PgPool, + node_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, FederationNode>( + "SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at + FROM federation_nodes WHERE node_id = $1", + ) + .bind(node_id) + .fetch_optional(pool) + .await +} + +// ─── used_node_nonces ──────────────────────────────────────────────────────── + +/// Returns true if the nonce was fresh (not seen before), false on replay. +pub async fn claim_nonce( + pool: &PgPool, + node_id: &str, + nonce: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO used_node_nonces (nonce, node_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(nonce) + .bind(node_id) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +pub async fn purge_expired_nonces(pool: &PgPool) -> Result { + let result = sqlx::query( + "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'", + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +// ─── federation_outbox ─────────────────────────────────────────────────────── + +pub async fn enqueue_outbox( + pool: &PgPool, + target_node_id: &str, + logical_msg_id: &str, + payload: &serde_json::Value, +) -> Result { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload) + VALUES ($1, $2, $3) RETURNING id", + ) + .bind(target_node_id) + .bind(logical_msg_id) + .bind(payload) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn fetch_due_outbox_entries( + pool: &PgPool, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, FederationOutboxEntry>( + "SELECT id, target_node_id, logical_msg_id, payload, + attempt_count, last_attempt, next_attempt, status, created_at + FROM federation_outbox + WHERE status = 'pending' AND next_attempt <= NOW() + ORDER BY next_attempt ASC LIMIT 100", + ) + .fetch_all(pool) + .await +} + +pub async fn mark_outbox_delivered(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE id = $1", + ) + .bind(id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn mark_outbox_delivered_by_logical_id( + pool: &PgPool, + logical_msg_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() + WHERE logical_msg_id = $1 AND status = 'pending'", + ) + .bind(logical_msg_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Exponential backoff: 10s * 2^attempt, capped at 3600s. +/// Marks 'failed' after max_attempts. +pub async fn record_outbox_failure( + pool: &PgPool, + id: Uuid, + attempt_count: i32, + max_attempts: i32, +) -> Result<(), sqlx::Error> { + if attempt_count >= max_attempts { + sqlx::query( + "UPDATE federation_outbox + SET status = 'failed', last_attempt = NOW(), attempt_count = $2 + WHERE id = $1", + ) + .bind(id) + .bind(attempt_count) + .execute(pool) + .await?; + } else { + let backoff_secs = (10_i64 * (1_i64 << attempt_count.min(12))).min(3600); + sqlx::query( + "UPDATE federation_outbox + SET attempt_count = $2, + last_attempt = NOW(), + next_attempt = NOW() + ($3 || ' seconds')::interval + WHERE id = $1", + ) + .bind(id) + .bind(attempt_count) + .bind(backoff_secs.to_string()) + .execute(pool) + .await?; + } + Ok(()) +} + +// ─── Shadow user / device ──────────────────────────────────────────────────── + +pub async fn upsert_shadow_user( + pool: &PgPool, + username: &str, + federated_address: &str, + home_node_id: Uuid, +) -> Result { + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO users (username, federated_address, home_node_id) + VALUES ($1, $2, $3) + ON CONFLICT (federated_address) DO UPDATE SET username = EXCLUDED.username + RETURNING id", + ) + .bind(username) + .bind(federated_address) + .bind(home_node_id) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +pub async fn upsert_shadow_device( + pool: &PgPool, + device_id: Uuid, + user_id: Uuid, + identity_pubkey: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO devices (id, user_id, identity_pubkey, + prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys) + VALUES ($1, $2, $3, '', '', '', '[]'::jsonb) + ON CONFLICT (id) DO NOTHING", + ) + .bind(device_id) + .bind(user_id) + .bind(identity_pubkey) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn get_or_create_direct_chat( + pool: &PgPool, + user_x: Uuid, + user_y: Uuid, +) -> Result { + let (ua, ub) = if user_x < user_y { + (user_x, user_y) + } else { + (user_y, user_x) + }; + + if let Some(row) = sqlx::query_as::<_, (Uuid,)>( + "SELECT id FROM chats WHERE user_a = $1 AND user_b = $2 AND chat_type = 'direct'", + ) + .bind(ua) + .bind(ub) + .fetch_optional(pool) + .await? + { + return Ok(row.0); + } + + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO chats (user_a, user_b, chat_type) VALUES ($1, $2, 'direct') RETURNING id", + ) + .bind(ua) + .bind(ub) + .fetch_one(pool) + .await?; + Ok(row.0) +} + +/// Returns the UUID of a local (non-shadow) user by username. +/// Returns None if the user does not exist or is a shadow record. +pub async fn get_local_user_id_by_username( + pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, (Uuid,)>( + "SELECT id FROM users WHERE username = $1 AND home_node_id IS NULL", + ) + .bind(username) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.0)) +} diff --git a/src/repository/message_repository.rs b/src/repository/message_repository.rs index 7841ef1..88a6497 100644 --- a/src/repository/message_repository.rs +++ b/src/repository/message_repository.rs @@ -5,6 +5,7 @@ use crate::{ use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; + pub async fn insert_message( pool: &PgPool, from_device_id: Uuid, @@ -42,6 +43,49 @@ pub async fn insert_message( Ok(()) } +/// Insert a message that arrived via S2S forwarding from a peer node. +/// +/// Idempotent: if a row with the same (logical_msg_id, to_device_id) already +/// exists (duplicate delivery from outbox retry), the INSERT is skipped and +/// the function returns Ok(false). Returns Ok(true) when a new row is created. +/// +/// The unique constraint `uniq_message_per_device` (added in federation.sql) +/// makes the ON CONFLICT clause safe without a preceding SELECT. +#[allow(clippy::too_many_arguments)] +pub async fn insert_federated_message( + pool: &PgPool, + logical_msg_id: &str, + chat_id: Uuid, + from_user_id: Uuid, + from_device_id: Uuid, + to_user_id: Uuid, + to_device_id: Uuid, + header: &Value, + ciphertext: &str, +) -> Result { + let result = sqlx::query( + "INSERT INTO messages ( + logical_msg_id, chat_id, + from_user_id, from_device_id, + to_user_id, to_device_id, + header, ciphertext + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING", + ) + .bind(logical_msg_id) + .bind(chat_id) + .bind(from_user_id) + .bind(from_device_id) + .bind(to_user_id) + .bind(to_device_id) + .bind(header) + .bind(ciphertext) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + pub async fn fetch_pending_messages( pool: &PgPool, AuthenticatedDevice(device): AuthenticatedDevice, diff --git a/src/repository/mod.rs b/src/repository/mod.rs index e848e7f..1e4ee80 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,6 +1,7 @@ pub mod chat_repository; pub mod device_repository; pub mod enrollment_token_repository; +pub mod federation_repository; pub mod keys_repository; pub mod message_repository; pub mod session_repository; diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 6210f02..fa2b35d 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -2,9 +2,11 @@ use crate::models::user::User; use sqlx::{PgPool, Result}; pub async fn get_all_users(pool: &PgPool) -> Result> { - let users = sqlx::query_as::<_, User>("SELECT * FROM users") - .fetch_all(pool) - .await?; + let users = sqlx::query_as::<_, User>( + "SELECT id, username, created_at FROM users WHERE home_node_id IS NULL", + ) + .fetch_all(pool) + .await?; Ok(users) } diff --git a/src/routes/federation.rs b/src/routes/federation.rs new file mode 100644 index 0000000..3fe788d --- /dev/null +++ b/src/routes/federation.rs @@ -0,0 +1,29 @@ +use axum::{ + routing::{get, post}, + Router, +}; + +use crate::{app_state::AppState, controllers::federation_controller}; + +pub fn routes() -> Router { + Router::new() + // ── Public ────────────────────────────────────────────────────────── + .route("/s2s/info", get(federation_controller::node_info)) + // ── S2S (node-to-node, AuthenticatedNode required inside handler) ─── + .route( + "/s2s/users/:username/devices", + get(federation_controller::get_user_devices), + ) + .route( + "/s2s/users/:username/keys", + get(federation_controller::get_user_keys), + ) + .route("/s2s/sessions", post(federation_controller::receive_session)) + .route("/s2s/messages", post(federation_controller::receive_messages)) + .route("/s2s/ack", post(federation_controller::receive_ack)) + // ── Client-facing federated proxy ──────────────────────────────────── + .route( + "/s2s/federated/:username/:node_id/keys", + get(federation_controller::federated_keys), + ) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 6ec01e9..d552cd1 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod chats; pub mod devices; +pub mod federation; pub mod messages; pub mod root; pub mod sessions;