diff --git a/Cargo.lock b/Cargo.lock index 17b5109..73b96b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "ahash" version = "0.8.12" @@ -24,6 +34,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -111,6 +171,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.2" @@ -144,6 +210,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -157,16 +233,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures", "rand_core", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "config" version = "0.15.27" @@ -249,7 +355,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core", ] [[package]] @@ -294,6 +402,37 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror", +] + [[package]] name = "deranged" version = "0.5.9" @@ -368,6 +507,29 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -392,7 +554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -401,6 +563,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "foldhash" version = "0.2.0" @@ -754,12 +922,64 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "json5" version = "1.3.1" @@ -829,7 +1049,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -844,7 +1064,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -859,6 +1079,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -887,6 +1113,31 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -949,6 +1200,18 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -966,13 +1229,27 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags", + "bitflags 2.13.2", "once_cell", "serde", "serde_derive", @@ -999,6 +1276,40 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1141,6 +1452,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1182,7 +1499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1333,7 +1650,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1347,6 +1664,16 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.30.0" @@ -1355,8 +1682,12 @@ checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", + "rustls", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -1529,6 +1860,8 @@ dependencies = [ "httparse", "log", "rand", + "rustls", + "rustls-pki-types", "sha1", "thiserror", ] @@ -1563,6 +1896,22 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1581,6 +1930,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" @@ -1593,6 +1948,32 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wallet-session-client" +version = "0.1.0" +dependencies = [ + "chacha20poly1305", + "ed25519-dalek", + "env_logger", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "log", + "rustls", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "thiserror", + "tokio", + "tokio-tungstenite", + "url", + "wallet-session-core", + "wallet-session-relay", + "x25519-dalek", + "zeroize", +] + [[package]] name = "wallet-session-core" version = "0.1.0" @@ -1650,6 +2031,24 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1678,6 +2077,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1687,6 +2095,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" @@ -1715,6 +2187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ "curve25519-dalek", + "getrandom 0.4.3", "rand_core", "zeroize", ] @@ -1799,6 +2272,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index f6c4007..fc2f3d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,7 @@ [workspace] resolver = "3" members = [ - "crates/core", - "crates/relay" + "crates/*", ] [workspace.package] diff --git a/README.md b/README.md index 3a3e9af..c088721 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Relay implementation of the Web-to-Wallet communications protocol, as described Currently repository contains these crates: - [core](./crates/core/) -- core types which are shared between client and relay; +- [client](./crates/client/) -- client implementation of the relay protocol; - [relay](./crates/relay/) -- relay server implementation. ## Relay deployment diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..742c457 --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "wallet-session-client" +version = "0.1.0" +edition = "2024" +description = "Client implementation of the wallet-session relay protocol" +license = "MIT" +repository = "https://github.com/BlockstreamResearch/wallet-session" + +[dependencies] +wallet-session-core = { path = "../core", version = "0.1.0" } + +tokio = { version = "1.53.1", features = [ + "rt-multi-thread", + "macros", + "net", + "sync", + "time", +] } +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] } +# tokio-tungstenite's rustls support does not pick a CryptoProvider. Enabling +# `ring` here gives rustls exactly one provider feature; `tls` installs it. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +futures-util = "0.3.34" +url = "2" +getrandom = "0.4" +sha2 = "0.11.0" +chacha20poly1305 = "0.11.0" +ed25519-dalek = "3.0.0" +x25519-dalek = { version = "3.0.0", features = ["getrandom"] } +hkdf = "0.13.0" + +serde.workspace = true +serde_json.workspace = true +serde_urlencoded = "0.7" +thiserror.workspace = true +log = "0.4" +zeroize = { version = "1", features = ["derive"] } + +[dev-dependencies] +wallet-session-relay = { path = "../relay" } +env_logger = "0.11" +tokio = { version = "1.53.1", features = ["test-util"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs new file mode 100644 index 0000000..79e733e --- /dev/null +++ b/crates/client/src/client.rs @@ -0,0 +1,1066 @@ +use std::{ + collections::VecDeque, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use serde_json::Value; +use tokio::sync::mpsc; +use wallet_session_core::{ + crypto::MessageCipher, + did::DidKey, + frame::{RelayFrame, RelayFrameBody}, + message::{Message, MessageId, MessageType, Topic}, + payload::{ + AckPayload, ApprovePayload, DisconnectPayload, Opaque, ProposalPayload, RejectPayload, + RequestPayload, ResponsePayload, + }, +}; + +use crate::{ + config::ClientConfig, + crypto::{Aad, CipherError, SealedPayload}, + error::{ClientError, Result}, + handshake::{EphemeralKeyPair, SessionKey, SessionTopic, decode_public_key}, + identity::{IdentityKey, unix_now}, + messages::{ClientMessage, RequestId}, + random, + relay::{INBOX_CAPACITY, Inbox, Transport}, + reliability::{ClientEvent, EVENT_CAPACITY, Reliability, requires_ack}, + replay::{self, Observation, ReplayMemory}, + session::{Role, Session, SessionState}, +}; + +/// Default session lifetime. +const SESSION_EXPIRY: Duration = Duration::from_secs(24 * 60 * 60); + +/// Maximum accepted clock skew between envelope `ts` and local time. +const MAX_TS_SKEW_SECS: u64 = 300; + +/// Maximum application messages buffered while waiting for `session_ready`. +const MAX_PENDING_MESSAGES: usize = 256; + +/// A terminal client state that no later operation can leave. +#[derive(Clone, Copy)] +enum Terminal { + Closed, + Transport(&'static str), +} + +impl Terminal { + fn error(self) -> ClientError { + match self { + Terminal::Closed => ClientError::Closed, + Terminal::Transport(reason) => ClientError::Transport(reason), + } + } +} + +/// Local side of the session handshake. +enum Handshake { + /// No handshake activity yet. + Idle, + + /// We sent a proposal and hold its ephemeral keypair. + Proposed { keypair: EphemeralKeyPair }, + + /// We received a proposal and know the peer's ephemeral public key. + PeerProposed { peer_public: [u8; 32] }, + + /// Session key established; traffic uses the session topic and key. + Established, +} + +/// One relay connection, its outbound reliability and its inbound replay +/// memory. +struct Link { + topic: Topic, + _transport: Transport, + reliability: Reliability, + shared_ready: Arc, + cipher: C, + replay: ReplayMemory, + ready: bool, + saw_disconnect: bool, +} + +impl Link +where + C: MessageCipher, +{ + fn new( + config: &ClientConfig, + identity: &IdentityKey, + role: Role, + topic: Topic, + cipher: C, + events: mpsc::Sender, + inbox: &mpsc::Sender, + ) -> Self { + let ready = Arc::new(AtomicBool::new(false)); + let transport = Transport::connect( + config.clone(), + identity.clone(), + role, + topic.clone(), + Arc::clone(&ready), + inbox.clone(), + ); + let reliability = Reliability::spawn( + transport.outbound(), + Arc::clone(&ready), + events, + config.ack_base_delay, + config.ack_max_attempts, + config.retransmit_spacing, + ); + + Self { + topic, + _transport: transport, + reliability, + shared_ready: ready, + cipher, + replay: ReplayMemory::new(Duration::from_secs(MAX_TS_SKEW_SECS), replay::CAPACITY), + ready: false, + saw_disconnect: false, + } + } +} + +/// A paired client. +/// +/// Inbound work, including acknowledgements, retransmissions and relay +/// lifecycle events, is driven by [`Client::recv`], [`Client::next_event`] and +/// [`Client::wait_session_ready`]. Applications must keep one of those futures +/// pending; [`Client::send`] and [`Client::poll_event`] do not drive the receive +/// path. Cancelling a receive future after it has dequeued a frame may lose +/// that frame, but the peer retransmits anything left unacknowledged. +/// +/// Counterpart, replay memory, and reliability state are owned by this +/// instance and are not persisted. Constructing a new client for the same +/// pairing key starts with an empty replay memory and no counterpart, so +/// long-lived applications should keep one client alive rather than recreate +/// it from the pairing key. Restart recovery requires persisting and restoring +/// that state, which this crate does not implement. +pub struct Client { + config: ClientConfig, + identity: IdentityKey, + inbox: mpsc::Receiver, + inbox_tx: mpsc::Sender, + events: mpsc::Receiver, + events_tx: mpsc::Sender, + pairing: Option>, + session_link: Option>, + session: Session, + handshake: Handshake, + did: DidKey, + pending: VecDeque, + pairing_approval: Option, + terminal: Option, +} + +impl Client +where + C: MessageCipher, +{ + /// Connect to the relay for the pairing topic. + /// + /// The cipher must provide a fresh cipher for the derived session key so + /// the session starts with a new nonce prefix and counter. + pub async fn connect( + config: ClientConfig, + identity: IdentityKey, + role: Role, + topic: Topic, + cipher: C, + ) -> Result { + config.validate()?; + let did = identity.did(); + let (inbox_tx, inbox) = mpsc::channel(INBOX_CAPACITY); + let (events_tx, events) = mpsc::channel(EVENT_CAPACITY); + let pairing = Link::new( + &config, + &identity, + role, + topic.clone(), + cipher, + events_tx.clone(), + &inbox_tx, + ); + let session = Session::new(topic, role); + + Ok(Self { + config, + identity, + inbox, + inbox_tx, + events, + events_tx, + pairing: Some(pairing), + session_link: None, + session, + handshake: Handshake::Idle, + did, + pending: VecDeque::new(), + pairing_approval: None, + terminal: None, + }) + } + + pub fn session(&self) -> &Session { + &self.session + } + + /// Whether the relay session on the active topic is ready. + pub fn is_ready(&self) -> bool { + self.active_link().is_some_and(|link| link.ready) + } + + fn active_link(&self) -> Option<&Link> { + self.session_link.as_ref().or(self.pairing.as_ref()) + } + + fn terminal_error(&self) -> Option { + self.terminal.map(Terminal::error) + } + + /// Stop all traffic and release both relay links. + fn close(&mut self, terminal: Terminal) { + self.terminal = Some(terminal); + if let Some(link) = self.pairing.as_mut() { + link.ready = false; + link.shared_ready.store(false, Ordering::Relaxed); + } + if let Some(link) = self.session_link.as_mut() { + link.ready = false; + link.shared_ready.store(false, Ordering::Relaxed); + } + self.pairing = None; + self.session_link = None; + } + + fn active_topic(&self) -> &Topic { + &self.session.topic + } + + fn link_mut(&mut self, topic: &Topic) -> Option<&mut Link> { + if self + .pairing + .as_ref() + .is_some_and(|link| link.topic == *topic) + { + return self.pairing.as_mut(); + } + self.session_link + .as_mut() + .filter(|link| link.topic == *topic) + } + + /// Apply one transport lifecycle item, returning a relay frame if present. + fn handle_inbox_item(&mut self, item: Inbox) -> Result> { + match item { + Inbox::Frame(frame) => { + if matches!(&frame.body, RelayFrameBody::SessionReady) { + self.set_ready(&frame.topic); + } + Ok(Some(frame)) + } + Inbox::Connected(topic) => { + self.set_connected(&topic, true); + Ok(None) + } + Inbox::Ready(topic) => { + self.set_ready(&topic); + Ok(None) + } + Inbox::Disconnected(topic) => { + self.set_connected(&topic, false); + Ok(None) + } + Inbox::Closed { topic, reason } => { + let active = topic == *self.active_topic(); + if let Some(link) = self.link_mut(&topic) { + link.reliability.fail_all(); + } + self.set_connected(&topic, false); + if active { + self.close(Terminal::Transport(reason)); + return Err(ClientError::Transport(reason)); + } + log::debug!("relay link closed for inactive topic {}", topic.log_tag()); + Ok(None) + } + Inbox::Retire(topic) => { + if self + .pairing + .as_ref() + .is_some_and(|link| link.topic == topic) + { + log::debug!("retiring pairing link {}", topic.log_tag()); + self.retire_pairing(); + } + Ok(None) + } + } + } + + /// Await the next relay frame, folding in transport lifecycle events. + async fn next_frame(&mut self) -> Result { + loop { + let item = self.inbox.recv().await.ok_or(ClientError::Closed)?; + if let Some(frame) = self.handle_inbox_item(item)? { + return Ok(frame); + } + } + } + + /// Process one relay frame's body. + async fn handle_frame( + &mut self, + frame: RelayFrame, + deliver: bool, + ) -> Result> { + if matches!(&frame.body, RelayFrameBody::Message { .. }) { + return self.handle_message_frame(frame, deliver).await; + } + + match frame.body { + RelayFrameBody::SessionReady => Ok(None), + RelayFrameBody::PeerDisconnected => { + if frame.topic == *self.active_topic() { + self.mark_not_ready(&frame.topic); + self.session.state = SessionState::Disconnected; + return Err(ClientError::PeerDisconnected); + } + Ok(None) + } + RelayFrameBody::Message { .. } => unreachable!("handled above"), + } + } + + fn set_connected(&mut self, topic: &Topic, connected: bool) { + let active = *topic == *self.active_topic(); + let Some(link) = self.link_mut(topic) else { + return; + }; + + // A socket is not ready until the relay reports `session_ready`. + link.ready = false; + link.shared_ready.store(false, Ordering::Relaxed); + + if connected { + if link.saw_disconnect { + link.saw_disconnect = false; + if active { + link.reliability.emit(ClientEvent::Reconnected); + } + } + } else if !link.saw_disconnect { + link.saw_disconnect = true; + if active { + link.reliability.emit(ClientEvent::Reconnecting); + } + } + } + + fn set_ready(&mut self, topic: &Topic) { + let Some(link) = self.link_mut(topic) else { + return; + }; + link.ready = true; + link.shared_ready.store(true, Ordering::Relaxed); + } + + fn mark_not_ready(&mut self, topic: &Topic) { + let Some(link) = self.link_mut(topic) else { + return; + }; + link.ready = false; + link.shared_ready.store(false, Ordering::Relaxed); + } + + /// Wait until the relay reports the active topic ready. + /// + /// Before the handshake this waits on the pairing topic. After the + /// handshake it waits on the derived session topic. Messages received + /// while waiting are queued for the next [`Client::recv`]. + pub async fn wait_session_ready(&mut self) -> Result<()> { + if let Some(error) = self.terminal_error() { + return Err(error); + } + while !self.is_ready() { + let frame = self.next_frame().await?; + // Consume acknowledgements and retransmissions that arrive while + // waiting. Application messages are queued for the next `recv` + // while there is room; otherwise they are left unacknowledged so + // the peer retries later. + let deliver = self.pending.len() < MAX_PENDING_MESSAGES; + if let Some(message) = self.handle_frame(frame, deliver).await? { + self.pending.push_back(message); + } + } + if matches!(self.handshake, Handshake::Established) { + self.session.state = SessionState::Active; + } + Ok(()) + } + + /// Seal and send a message, returning its envelope id. + /// + /// Handshake messages use [`Client::propose`], [`Client::approve`] and + /// [`Client::reject`] and stay on the pairing topic. Application messages + /// are refused until the handshake is established, then use the derived + /// session topic. + pub async fn send(&mut self, message: &ClientMessage) -> Result { + if let Some(error) = self.terminal_error() { + return Err(error); + } + let topic = match message { + ClientMessage::Proposal(_) | ClientMessage::Approve(_) | ClientMessage::Reject(_) => { + if matches!(self.handshake, Handshake::Established) { + return Err(ClientError::UnexpectedHandshake); + } + if !self.pairing.as_ref().is_some_and(|link| link.ready) { + return Err(ClientError::NotReady); + } + self.session.pairing_topic.clone() + } + _ => { + self.ensure_session_ready()?; + self.active_topic().clone() + } + }; + self.send_on(&topic, message).await + } + + /// Whether application messages may be sent. + fn ensure_session_ready(&self) -> Result<()> { + if !matches!(self.handshake, Handshake::Established) { + return Err(ClientError::SessionNotActive); + } + if !self.active_link().is_some_and(|link| link.ready) { + return Err(ClientError::NotReady); + } + Ok(()) + } + + async fn send_on(&mut self, topic: &Topic, message: &ClientMessage) -> Result { + let id = random::id()?; + self.send_with_id_on(topic, id.clone(), message).await?; + Ok(id) + } + + /// Seal and send a message with a caller-chosen envelope id. + async fn send_with_id_on( + &mut self, + topic: &Topic, + id: MessageId, + message: &ClientMessage, + ) -> Result<()> { + let did = self.did.clone(); + let role = self.session.role; + let kind = message.message_type(); + let plaintext = message.to_bytes()?; + let ts = unix_now(); + let aad = Aad::new(&id, kind, topic, &did, ts, role).to_bytes(); + + let link = self.link_mut(topic).ok_or(ClientError::Closed)?; + let sealed = link.cipher.seal(&aad, &plaintext)?; + let wire = Message::new(kind, did, ts, sealed.nonce, sealed.data); + let frame = RelayFrame { + v: 1, + id, + topic: topic.clone(), + body: RelayFrameBody::Message { + payload: serde_json::to_string(&wire)?, + }, + }; + link.reliability.dispatch(frame, requires_ack(kind), ts) + } + + /// Receive the next application message, processing acknowledgements and + /// lifecycle traffic while waiting. + pub async fn recv(&mut self) -> Result { + if let Some(error) = self.terminal_error() { + return Err(error); + } + if let Some(message) = self.pending.pop_front() { + return Ok(message); + } + + loop { + let frame = self.next_frame().await?; + if let Some(message) = self.handle_frame(frame, true).await? { + return Ok(message); + } + } + } + + /// Process one `message` frame. Returns the message to deliver, or `None` + /// for acknowledgements, retransmissions and envelopes for inactive + /// handshake state. + async fn handle_message_frame( + &mut self, + frame: RelayFrame, + deliver: bool, + ) -> Result> { + let RelayFrame { + id, topic, body, .. + } = frame; + let RelayFrameBody::Message { payload } = body else { + return Ok(None); + }; + + if self.link_mut(&topic).is_none() { + log::warn!("dropping frame for unknown topic {}", topic.log_tag()); + return Ok(None); + } + + let wire: Message = match serde_json::from_str(&payload) { + Ok(wire) => wire, + Err(error) => { + log::warn!( + "dropping malformed envelope on topic {}: {error}", + topic.log_tag() + ); + return Ok(None); + } + }; + + if wire.from == self.did { + log::warn!("dropping frame from own identity {}", wire.from); + return Ok(None); + } + if let Some(counterpart) = &self.session.peer_did { + if wire.from != *counterpart { + log::warn!( + "dropping frame from {}; established counterpart is {}", + wire.from, + counterpart + ); + return Ok(None); + } + } else if topic != self.session.pairing_topic { + log::warn!("dropping session-topic frame before the counterpart is established"); + return Ok(None); + } + + if wire.version != Message::VERSION { + log::warn!("dropping frame with unsupported version {}", wire.version); + return Ok(None); + } + let now = unix_now(); + if now.abs_diff(wire.ts) > MAX_TS_SKEW_SECS { + log::warn!("dropping frame with stale timestamp ts={}", wire.ts); + return Ok(None); + } + let stale = self + .link_mut(&topic) + .expect("topic was checked") + .replay + .is_stale(wire.ts); + if stale { + log::warn!("dropping frame with discarded timestamp ts={}", wire.ts); + return Ok(None); + } + + let role = self.session.role; + let aad = Aad::new( + &id, + wire.kind, + &topic, + &wire.from, + wire.ts, + role.counterpart(), + ) + .to_bytes(); + let sealed = SealedPayload { + nonce: wire.nonce.clone(), + data: wire.data.clone(), + }; + let bytes = match self + .link_mut(&topic) + .expect("topic was checked") + .cipher + .open(&aad, &sealed) + { + Ok(bytes) => bytes, + Err(error) => { + log::warn!( + "dropping unauthenticated envelope on topic {}: {error}", + topic.log_tag() + ); + return Ok(None); + } + }; + + // The pairing key is for the handshake only. Application kinds on the + // pairing topic are dropped even after the session is established, so + // a leaked pairing key cannot bypass the session key. Conversely, the + // session topic carries application traffic only. + let allowed = if topic == self.session.pairing_topic { + wire.kind.is_handshake() || wire.kind == MessageType::Ack + } else { + wire.kind.is_application() + }; + if !allowed { + log::warn!( + "dropping {} on topic {}", + wire.kind.as_str(), + topic.log_tag() + ); + return Ok(None); + } + + // While `wait_session_ready` has no room to queue an application + // message, drop it without recording or acknowledging it, so the peer + // retransmits it later instead of the application silently losing it. + if !deliver + && matches!( + wire.kind, + MessageType::Request + | MessageType::Response + | MessageType::Disconnect + | MessageType::SessionDelete + ) + { + log::warn!("dropping application message: pending queue is full"); + return Ok(None); + } + + // The counterpart is the sender of the first envelope that passes the + // version, topic, identity, timestamp and authentication checks on the + // pairing topic. It stays the counterpart for the session topic and + // across reconnects. + if topic == self.session.pairing_topic && self.session.peer_did.is_none() { + self.session.peer_did = Some(wire.from.clone()); + log::debug!("established counterpart {}", wire.from); + } + + let observation = self + .link_mut(&topic) + .expect("topic was checked") + .replay + .classify(&id, &wire, now); + match observation { + Observation::Retransmission => { + if requires_ack(wire.kind) { + self.send_ack(&id, &topic).await?; + } + log::debug!("re-acknowledged retransmission {id}"); + return Ok(None); + } + Observation::Conflict => { + return Err(ClientError::ProtocolViolation( + "envelope reuses a frame id or nonce with different content", + )); + } + Observation::Full => { + log::warn!("dropping envelope: replay memory is full"); + return Ok(None); + } + Observation::New => {} + } + + let message = match ClientMessage::from_bytes(&bytes) { + Ok(message) => message, + Err(error) => { + log::warn!( + "dropping malformed plaintext on topic {}: {error}", + topic.log_tag() + ); + return Ok(None); + } + }; + if message.message_type() != wire.kind { + log::warn!( + "dropping envelope: plaintext type does not match kind {}", + wire.kind.as_str() + ); + return Ok(None); + } + + if let ClientMessage::Response(response) = &message + && !response.is_valid() + { + log::warn!("dropping response without exactly one of response or error"); + return Ok(None); + } + + if matches!( + &message, + ClientMessage::Disconnect(_) | ClientMessage::SessionDelete(_) + ) { + self.session.state = SessionState::Disconnected; + } + + // acks are consumed by reliability + if let ClientMessage::Ack(ack) = &message { + let ack_id = ack.ack_id.clone(); + let retires_pairing = topic == self.session.pairing_topic + && self.pairing_approval.as_ref() == Some(&ack_id); + let link = self.link_mut(&topic).expect("topic was checked"); + link.replay.record(&id, &wire); + link.reliability.mark_delivered(&ack_id); + if retires_pairing { + self.retire_pairing(); + } + return Ok(None); + } + + // Once established, the pairing key may no longer create new handshake + // state. Retransmissions of the handshake were re-acked above. + if matches!(self.handshake, Handshake::Established) + && matches!( + &message, + ClientMessage::Proposal(_) | ClientMessage::Approve(_) | ClientMessage::Reject(_) + ) + { + log::warn!("dropping handshake message after establishment"); + return Ok(None); + } + + // An `approve` establishes the session while `observe` runs. Record it + // before that so the pairing replay memory keeps the envelope. + let rekeys = matches!(&message, ClientMessage::Approve(_)); + if rekeys { + let link = self.link_mut(&topic).expect("topic was checked"); + link.replay.record(&id, &wire); + } + + if topic == self.session.pairing_topic + && let Err(error) = self.observe(&message) + { + if let Some(link) = self.link_mut(&topic) { + link.replay.forget(&id); + } + return Err(error); + } + + if requires_ack(message.message_type()) { + self.send_ack(&id, &topic).await?; + } + + if !rekeys { + let link = self.link_mut(&topic).expect("topic was checked"); + link.replay.record(&id, &wire); + } + + Ok(Some(message)) + } + + /// Acknowledge a received envelope by id, on the topic it arrived on. + async fn send_ack(&mut self, ack_id: &MessageId, topic: &Topic) -> Result<()> { + let result = self + .send_on( + topic, + &ClientMessage::Ack(AckPayload { + ack_id: ack_id.clone(), + }), + ) + .await; + match result { + Ok(_) => Ok(()), + Err(ClientError::Busy) => { + // The peer retransmits unacknowledged messages; dropping this + // ack under local backpressure is recoverable. + log::warn!("dropping ack: outbound queue is full"); + Ok(()) + } + Err(error) => Err(error), + } + } + + /// Non-blocking read of the next reliability event, if any. + /// + /// This does not process inbound frames. Call [`Client::recv`] or + /// [`Client::next_event`] to drive acknowledgements and delivery. + pub fn poll_event(&mut self) -> Option { + let event = self.events.try_recv().ok()?; + if matches!( + &event, + ClientEvent::Failed { id, .. } if self.pairing_approval.as_ref() == Some(id) + ) { + self.retire_pairing(); + } + Some(event) + } + + /// Await the next reliability event while processing inbound frames. + /// + /// Use this instead of polling [`Client::poll_event`] when acknowledgements + /// or lifecycle traffic may arrive while the application waits. + pub async fn next_event(&mut self) -> Result { + if let Some(error) = self.terminal_error() { + return Err(error); + } + loop { + if let Ok(event) = self.events.try_recv() { + if matches!( + &event, + ClientEvent::Failed { id, .. } if self.pairing_approval.as_ref() == Some(id) + ) { + self.retire_pairing(); + } + return Ok(event); + } + + tokio::select! { + item = self.inbox.recv() => { + let item = item.ok_or(ClientError::Closed)?; + if let Some(frame) = self.handle_inbox_item(item)? { + let deliver = self.pending.len() < MAX_PENDING_MESSAGES; + if let Some(message) = self.handle_frame(frame, deliver).await? { + self.pending.push_back(message); + } + } + } + event = self.events.recv() => { + let event = event.ok_or(ClientError::Closed)?; + if matches!( + &event, + ClientEvent::Failed { id, .. } if self.pairing_approval.as_ref() == Some(id) + ) { + self.retire_pairing(); + } + return Ok(event); + } + } + } + } + + /// Drop the pairing connection once the handshake no longer needs it. + fn retire_pairing(&mut self) { + let Some(mut link) = self.pairing.take() else { + return; + }; + log::debug!("retiring pairing link {}", link.topic.log_tag()); + link.reliability.fail_all(); + } + + /// Update handshake state from an inbound pairing message. + fn observe(&mut self, message: &ClientMessage) -> Result<()> { + match message { + ClientMessage::Proposal(proposal) => { + if !matches!(self.handshake, Handshake::Idle) { + return Err(ClientError::UnexpectedHandshake); + } + let peer_public = decode_public_key(&proposal.ephemeral_public_key)?; + self.handshake = Handshake::PeerProposed { peer_public }; + self.session.state = SessionState::ProposalReceived; + } + ClientMessage::Approve(approve) => { + if !matches!(self.handshake, Handshake::Proposed { .. }) { + return Err(ClientError::UnexpectedHandshake); + } + let peer_public = decode_public_key(&approve.ephemeral_public_key)?; + let Handshake::Proposed { keypair } = + std::mem::replace(&mut self.handshake, Handshake::Idle) + else { + unreachable!("state checked above"); + }; + let (key, topic) = keypair.derive(&peer_public)?; + self.establish_session(key, topic)?; + } + ClientMessage::Reject(_) => { + self.session.state = SessionState::Rejected; + } + _ => {} + } + Ok(()) + } + + /// Start the session-topic connection and keep the pairing link alive + /// until its handshake acknowledgements have had their retry window. + fn establish_session(&mut self, key: SessionKey, topic: SessionTopic) -> Result<()> { + if self.session_link.is_some() { + return Err(ClientError::UnexpectedHandshake); + } + + let session_topic = topic.as_topic().clone(); + let cipher = self + .pairing + .as_ref() + .ok_or(ClientError::Closed)? + .cipher + .for_key(key.as_bytes())?; + let link = Link::new( + &self.config, + &self.identity, + self.session.role, + session_topic.clone(), + cipher, + self.events_tx.clone(), + &self.inbox_tx, + ); + + self.session_link = Some(link); + self.session.topic = session_topic; + self.handshake = Handshake::Established; + self.session.state = SessionState::Active; + self.schedule_pairing_retirement(); + Ok(()) + } + + /// Retire the pairing connection after the handshake ack retry window. + fn schedule_pairing_retirement(&self) { + let window = self.pairing_retirement_window(); + let inbox = self.inbox_tx.clone(); + let topic = self.session.pairing_topic.clone(); + tokio::spawn(async move { + tokio::time::sleep(window).await; + let _ = inbox.send(Inbox::Retire(topic)).await; + }); + } + + /// Upper bound on how long handshake retransmissions can still arrive. + fn pairing_retirement_window(&self) -> Duration { + let attempts = self.config.ack_max_attempts.min(31) as u32; + let factor = 2u32.saturating_pow(attempts.saturating_add(1)); + self.config + .ack_base_delay + .saturating_mul(factor.saturating_sub(1)) + .saturating_add(Duration::from_secs(1)) + .max(Duration::from_secs(1)) + } + + /// Send a `proposal` carrying ephemeral public key. + pub async fn propose(&mut self, proposal: Value) -> Result<()> { + if let Some(error) = self.terminal_error() { + return Err(error); + } + if !matches!(self.handshake, Handshake::Idle) { + return Err(ClientError::UnexpectedHandshake); + } + if !self.pairing.as_ref().is_some_and(|link| link.ready) { + return Err(ClientError::NotReady); + } + let keypair = EphemeralKeyPair::generate(); + let payload = ProposalPayload { + ephemeral_public_key: keypair.public_key_encoded(), + session_expiry: session_expiry(), + proposal: Opaque(serde_json::to_vec(&proposal)?), + }; + let topic = self.session.pairing_topic.clone(); + self.send_on(&topic, &ClientMessage::Proposal(payload)) + .await?; + self.handshake = Handshake::Proposed { keypair }; + self.session.state = SessionState::ProposalSent; + Ok(()) + } + + /// Send an `approve` carrying ephemeral public key and switch to the + /// session topic and key. + pub async fn approve(&mut self, approval: Value) -> Result<()> { + if let Some(error) = self.terminal_error() { + return Err(error); + } + let Handshake::PeerProposed { peer_public } = self.handshake else { + return Err(ClientError::UnexpectedHandshake); + }; + if !self.pairing.as_ref().is_some_and(|link| link.ready) { + return Err(ClientError::NotReady); + } + let keypair = EphemeralKeyPair::generate(); + let payload = ApprovePayload { + ephemeral_public_key: keypair.public_key_encoded(), + approval: Opaque(serde_json::to_vec(&approval)?), + }; + let (key, topic) = keypair.derive(&peer_public)?; + let pairing_topic = self.session.pairing_topic.clone(); + let approve_id = self + .send_on(&pairing_topic, &ClientMessage::Approve(payload)) + .await?; + self.pairing_approval = Some(approve_id); + self.establish_session(key, topic)?; + Ok(()) + } + + /// Send a `reject` and move to `Rejected`. + pub async fn reject(&mut self, code: u32, message: impl Into) -> Result<()> { + self.send(&ClientMessage::Reject(RejectPayload { + code, + message: message.into(), + })) + .await?; + self.session.state = SessionState::Rejected; + Ok(()) + } + + /// Send a `request` and return its correlation id. + /// + /// The JSON value is encoded into the protocol's opaque `request` field. + pub async fn request(&mut self, request: Value) -> Result { + if let Some(error) = self.terminal_error() { + return Err(error); + } + self.ensure_session_ready()?; + let id = random::id()?; + let request_id = id.to_string(); + let payload = RequestPayload { + request_id: request_id.clone(), + request: opaque(request)?, + }; + let topic = self.active_topic().clone(); + self.send_with_id_on(&topic, id, &ClientMessage::Request(payload)) + .await?; + Ok(request_id) + } + + /// Send a `response` (or error) for a request. + /// + /// Each JSON value is encoded into its opaque `response` or `error` field. + pub async fn respond( + &mut self, + request_id: impl Into, + response: Option, + error: Option, + ) -> Result<()> { + let payload = ResponsePayload { + request_id: request_id.into(), + response: response.map(opaque).transpose()?, + error: error.map(opaque).transpose()?, + }; + if !payload.is_valid() { + return Err(ClientError::ProtocolViolation( + "response must carry exactly one of response or error", + )); + } + self.send(&ClientMessage::Response(payload)).await?; + Ok(()) + } + + /// Acknowledge a received message. + pub async fn ack(&mut self, ack_id: MessageId) -> Result<()> { + self.send(&ClientMessage::Ack(AckPayload { ack_id })) + .await?; + Ok(()) + } + + /// Gracefully disconnect. + pub async fn disconnect(&mut self, reason: impl Into, code: u32) -> Result<()> { + self.send(&ClientMessage::Disconnect(DisconnectPayload { + reason: reason.into(), + code, + message: String::new(), + })) + .await?; + self.session.state = SessionState::Disconnected; + self.close(Terminal::Closed); + Ok(()) + } +} + +/// Encode a JSON application value as the protocol's opaque payload. +fn opaque(value: Value) -> Result { + Ok(Opaque(serde_json::to_vec(&value)?)) +} + +/// Session expiry timestamp for a fresh proposal. +fn session_expiry() -> u64 { + unix_now().saturating_add(SESSION_EXPIRY.as_secs()) +} diff --git a/crates/client/src/config.rs b/crates/client/src/config.rs new file mode 100644 index 0000000..01cfe2b --- /dev/null +++ b/crates/client/src/config.rs @@ -0,0 +1,268 @@ +use std::time::Duration; + +use url::Url; + +use crate::error::ClientError; + +/// Client configuration. +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// Relay WebSocket endpoint used to connect and advertised in pairing URIs. + pub relay_url: String, + + /// Audience (`aud`) expected by the relay in the connection token. + pub audience: String, + + /// Optional registration id sent to the relay as an attribution label. + pub registration_id: Option, + + /// Allow `ws://` relay URLs. Without this, only `wss://` is accepted. + pub allow_insecure_ws: bool, + + /// Lifetime of the signed connection token. + pub token_ttl: Duration, + + /// Maximum time without an inbound frame before the relay is considered dead. + pub idle_timeout: Duration, + + /// Maximum time to establish the WebSocket connection. + pub connect_timeout: Duration, + + /// Maximum time for a single WebSocket write. + pub write_timeout: Duration, + + /// Maximum time to send a close frame and close the socket. + pub close_timeout: Duration, + + /// Base delay before the first retransmission of an un-ACKed message. + pub ack_base_delay: Duration, + + /// Maximum retransmissions before a message is reported failed. + pub ack_max_attempts: usize, + + /// Minimum spacing between retransmissions. + pub retransmit_spacing: Duration, + + /// Interval between client keepalive pings. + pub ping_interval: Duration, + + /// How long to wait for a pong before treating the connection as dead. + pub pong_timeout: Duration, + + /// Initial delay before the first reconnect attempt. + pub reconnect_initial_delay: Duration, + + /// Maximum reconnect backoff delay. + pub reconnect_max_delay: Duration, + + /// Maximum reconnect attempts before giving up. + pub reconnect_max_attempts: usize, +} + +impl ClientConfig { + /// Build a config whose token audience is the same as the relay URL. + pub fn new(relay_url: impl Into) -> Self { + let relay_url = relay_url.into(); + Self { + audience: relay_url.clone(), + relay_url, + registration_id: None, + allow_insecure_ws: false, + token_ttl: Duration::from_secs(300), + idle_timeout: Duration::from_secs(60), + connect_timeout: Duration::from_secs(10), + write_timeout: Duration::from_secs(10), + close_timeout: Duration::from_secs(2), + ack_base_delay: Duration::from_secs(2), + ack_max_attempts: 5, + retransmit_spacing: Duration::from_millis(100), + ping_interval: Duration::from_secs(30), + pong_timeout: Duration::from_secs(10), + reconnect_initial_delay: Duration::from_millis(250), + reconnect_max_delay: Duration::from_secs(5), + reconnect_max_attempts: 5, + } + } + + /// Set the registration id sent to the relay. + pub fn with_registration_id(mut self, registration_id: impl Into) -> Self { + self.registration_id = Some(registration_id.into()); + self + } + + /// Allow an insecure `ws://` relay URL. + pub fn with_insecure_ws(mut self) -> Self { + self.allow_insecure_ws = true; + self + } + + /// Validate the configuration before starting a client. + pub fn validate(&self) -> Result<(), ClientError> { + let url = Url::parse(&self.relay_url) + .map_err(|_| ClientError::InvalidConfig("invalid relay URL"))?; + match url.scheme() { + "wss" => {} + "ws" if self.allow_insecure_ws => {} + "ws" => { + return Err(ClientError::InvalidConfig( + "ws:// relay URL requires explicit opt-in", + )); + } + _ => { + return Err(ClientError::InvalidConfig( + "relay URL must use ws:// or wss://", + )); + } + } + + for (timeout, name) in [ + (self.idle_timeout, "idle_timeout"), + (self.connect_timeout, "connect_timeout"), + (self.write_timeout, "write_timeout"), + (self.close_timeout, "close_timeout"), + (self.ping_interval, "ping_interval"), + (self.pong_timeout, "pong_timeout"), + (self.retransmit_spacing, "retransmit_spacing"), + ] { + if timeout.is_zero() { + return Err(ClientError::InvalidConfig(name)); + } + } + + Ok(()) + } + + /// Set the audience expected by the relay, when it differs from `relay_url`. + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = audience.into(); + self + } + + /// Set the connection token lifetime. + pub fn with_token_ttl(mut self, token_ttl: Duration) -> Self { + self.token_ttl = token_ttl; + self + } + + /// Set the inbound idle timeout. + pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self { + self.idle_timeout = idle_timeout; + self + } + + /// Set the WebSocket connect timeout. + pub fn with_connect_timeout(mut self, connect_timeout: Duration) -> Self { + self.connect_timeout = connect_timeout; + self + } + + /// Set the WebSocket write timeout. + pub fn with_write_timeout(mut self, write_timeout: Duration) -> Self { + self.write_timeout = write_timeout; + self + } + + /// Set the WebSocket close timeout. + pub fn with_close_timeout(mut self, close_timeout: Duration) -> Self { + self.close_timeout = close_timeout; + self + } + + /// Set the base retransmission delay. + pub fn with_ack_base_delay(mut self, ack_base_delay: Duration) -> Self { + self.ack_base_delay = ack_base_delay; + self + } + + /// Set the maximum retransmission attempts. + pub fn with_ack_max_attempts(mut self, ack_max_attempts: usize) -> Self { + self.ack_max_attempts = ack_max_attempts; + self + } + + /// Set the minimum spacing between retransmissions. + pub fn with_retransmit_spacing(mut self, retransmit_spacing: Duration) -> Self { + self.retransmit_spacing = retransmit_spacing; + self + } + + /// Set the keepalive ping interval. + pub fn with_ping_interval(mut self, ping_interval: Duration) -> Self { + self.ping_interval = ping_interval; + self + } + + /// Set the pong deadline. + pub fn with_pong_timeout(mut self, pong_timeout: Duration) -> Self { + self.pong_timeout = pong_timeout; + self + } + + /// Set the initial reconnect delay. + pub fn with_reconnect_initial_delay(mut self, delay: Duration) -> Self { + self.reconnect_initial_delay = delay; + self + } + + /// Set the maximum reconnect backoff delay. + pub fn with_reconnect_max_delay(mut self, delay: Duration) -> Self { + self.reconnect_max_delay = delay; + self + } + + /// Set the maximum reconnect attempts. + pub fn with_reconnect_max_attempts(mut self, attempts: usize) -> Self { + self.reconnect_max_attempts = attempts; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_insecure_ws_without_opt_in() { + let config = ClientConfig::new("ws://127.0.0.1:8080"); + assert!(matches!( + config.validate(), + Err(ClientError::InvalidConfig( + "ws:// relay URL requires explicit opt-in" + )) + )); + assert!(config.with_insecure_ws().validate().is_ok()); + } + + #[test] + fn requires_websocket_urls() { + assert!(matches!( + ClientConfig::new("https://relay.example.com").validate(), + Err(ClientError::InvalidConfig( + "relay URL must use ws:// or wss://" + )) + )); + assert!( + ClientConfig::new("wss://relay.example.com") + .validate() + .is_ok() + ); + } + + #[test] + fn rejects_zero_transport_timeouts() { + for config in [ + ClientConfig::new("wss://relay.example.com").with_idle_timeout(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_connect_timeout(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_write_timeout(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_close_timeout(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_ping_interval(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_pong_timeout(Duration::ZERO), + ClientConfig::new("wss://relay.example.com").with_retransmit_spacing(Duration::ZERO), + ] { + assert!(matches!( + config.validate(), + Err(ClientError::InvalidConfig(_)) + )); + } + } +} diff --git a/crates/client/src/crypto.rs b/crates/client/src/crypto.rs new file mode 100644 index 0000000..957dbf6 --- /dev/null +++ b/crates/client/src/crypto.rs @@ -0,0 +1,426 @@ +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use wallet_session_core::{ + connection::Role, + crypto::MessageCipher, + did::DidKey, + message::{Ciphertext, Message, MessageId, MessageType, Nonce, Topic}, +}; +use zeroize::Zeroizing; + +/// Symmetric key length +pub const KEY_LEN: usize = 32; + +/// Random per-sender nonce prefix length +pub const SENDER_PREFIX_LEN: usize = 16; + +/// AEAD nonce length: sender prefix plus counter +pub const NONCE_LEN: usize = 24; + +/// AEAD associated data bound to the plaintext envelope header. +/// +/// Each field is a UTF-8 string prefixed by its length as a four-byte +/// big-endian integer: +/// +/// `AAD = LP(version) || LP(id) || LP(kind) || LP(topic) || LP(from) || LP(ts) || LP(role)` +/// +/// The sender uses its own role; the receiver rebuilds the AAD with its +/// counterpart's role, so an envelope returned to its sender does not +/// authenticate. +/// +/// TODO: verify `from` against the expected peer identity once it is known. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aad<'a> { + /// Protocol version + pub version: u8, + + /// Envelope id + pub id: &'a MessageId, + + /// Message type + pub kind: MessageType, + + /// Subscription topic + pub topic: &'a Topic, + + /// Sender identity + pub from: &'a DidKey, + + /// Envelope timestamp + pub ts: u64, + + /// Sender role on the topic + pub role: Role, +} + +impl<'a> Aad<'a> { + /// Build associated data with the current envelope version. + pub fn new( + id: &'a MessageId, + kind: MessageType, + topic: &'a Topic, + from: &'a DidKey, + ts: u64, + role: Role, + ) -> Self { + Self { + version: Message::VERSION, + id, + kind, + topic, + from, + ts, + role, + } + } + + /// Serialize the associated data as length-prefixed UTF-8 bytes. + pub fn to_bytes(&self) -> Vec { + let version = self.version.to_string(); + let id = self.id.to_string(); + let topic = self.topic.to_string(); + let from = self.from.to_string(); + let ts = self.ts.to_string(); + let fields = [ + version.as_str(), + id.as_str(), + self.kind.as_str(), + topic.as_str(), + from.as_str(), + ts.as_str(), + self.role.as_str(), + ]; + + let mut bytes = Vec::new(); + for field in fields { + let len = u32::try_from(field.len()).expect("AAD field length fits u32"); + bytes.extend_from_slice(&len.to_be_bytes()); + bytes.extend_from_slice(field.as_bytes()); + } + bytes + } +} + +/// Cipher output for a single message: nonce plus ciphertext. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SealedPayload { + /// AEAD nonce + pub nonce: Nonce, + + /// Ciphertext with tag + pub data: Ciphertext, +} + +/// Encryption and decryption failures. +#[derive(Debug, Error)] +pub enum CipherError { + /// Encryption failed. + #[error("encryption failed")] + Encrypt, + + /// Decryption failed (bad key, tampered data, or AAD mismatch). + #[error("decryption failed")] + Decrypt, + + /// A nonce had the wrong length. + #[error("invalid nonce length: {0}")] + NonceLength(usize), + + /// The send counter is exhausted. + #[error("nonce counter exhausted")] + CounterExhausted, + + /// A random source failed. + #[error("random source failure: {0}")] + Random(#[from] getrandom::Error), +} + +/// XChaCha20-Poly1305 provider +pub struct XChaCha20Poly1305Cipher { + prefix: [u8; SENDER_PREFIX_LEN], + counter: u64, + inner: XChaCha20Poly1305, +} + +impl XChaCha20Poly1305Cipher { + /// Build a cipher with a random sender prefix. + pub fn new(key: [u8; KEY_LEN]) -> Result { + let mut prefix = [0u8; SENDER_PREFIX_LEN]; + getrandom::fill(&mut prefix)?; + Self::from_prefix(key, prefix) + } + + fn from_prefix( + key: [u8; KEY_LEN], + prefix: [u8; SENDER_PREFIX_LEN], + ) -> Result { + let key = Zeroizing::new(key); + Ok(Self { + prefix, + counter: 0, + inner: XChaCha20Poly1305::new_from_slice(key.as_ref()) + .map_err(|_| CipherError::Encrypt)?, + }) + } + + /// Build a cipher with a fixed sender prefix. Test-only. + #[cfg(test)] + fn with_prefix( + key: [u8; KEY_LEN], + prefix: [u8; SENDER_PREFIX_LEN], + ) -> Result { + Self::from_prefix(key, prefix) + } + + fn nonce(&self) -> [u8; NONCE_LEN] { + let mut nonce = [0u8; NONCE_LEN]; + nonce[..SENDER_PREFIX_LEN].copy_from_slice(&self.prefix); + nonce[SENDER_PREFIX_LEN..].copy_from_slice(&self.counter.to_be_bytes()); + nonce + } +} + +impl MessageCipher for XChaCha20Poly1305Cipher { + type Sealed = SealedPayload; + type Error = CipherError; + fn seal(&mut self, aad: &[u8], plaintext: &[u8]) -> Result { + let next_counter = self + .counter + .checked_add(1) + .ok_or(CipherError::CounterExhausted)?; + let nonce = self.nonce(); + let data = self + .inner + .encrypt( + &XNonce::from(nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| CipherError::Encrypt)?; + self.counter = next_counter; + + let nonce = Nonce::new(nonce.to_vec()).map_err(|_| CipherError::NonceLength(NONCE_LEN))?; + Ok(SealedPayload { + nonce, + data: Ciphertext::new(data), + }) + } + + fn open(&self, aad: &[u8], sealed: &SealedPayload) -> Result, CipherError> { + let nonce = sealed.nonce.as_bytes(); + let nonce: [u8; NONCE_LEN] = nonce + .try_into() + .map_err(|_| CipherError::NonceLength(nonce.len()))?; + + self.inner + .decrypt( + &XNonce::from(nonce), + Payload { + msg: sealed.data.as_bytes(), + aad, + }, + ) + .map_err(|_| CipherError::Decrypt) + } + + fn for_key(&self, key: &[u8; KEY_LEN]) -> Result { + Self::new(*key) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + type TestResult = Result<(), Box>; + + fn topic() -> Topic { + Topic::new([1u8; 32]).expect("topic") + } + + fn did(seed: u8) -> DidKey { + DidKey::from_ed25519_bytes([seed; 32]) + } + + fn message_id() -> MessageId { + MessageId::from_bytes([3u8; MessageId::LEN]) + } + + fn aad(kind: MessageType, topic: &Topic) -> Vec { + Aad::new( + &message_id(), + kind, + topic, + &did(7), + 1_700_000_000, + Role::Proposer, + ) + .to_bytes() + } + + #[test] + fn aad_is_length_prefixed_and_includes_ts_and_role() { + let topic = topic(); + let from = did(7); + let aad = Aad::new( + &message_id(), + MessageType::Request, + &topic, + &from, + 1_700_000_000, + Role::Proposer, + ) + .to_bytes(); + + let fields = [ + Message::VERSION.to_string(), + message_id().to_string(), + "request".to_owned(), + topic.to_string(), + from.to_string(), + "1700000000".to_owned(), + "proposer".to_owned(), + ]; + let mut expected = Vec::new(); + for field in fields { + expected.extend_from_slice(&(field.len() as u32).to_be_bytes()); + expected.extend_from_slice(field.as_bytes()); + } + + assert_eq!(aad, expected); + } + + #[test] + fn aad_binds_timestamp_and_role() { + let topic = topic(); + let from = did(7); + let aad = |ts, role| { + Aad::new(&message_id(), MessageType::Request, &topic, &from, ts, role).to_bytes() + }; + + assert_ne!( + aad(1_700_000_000, Role::Proposer), + aad(1_700_000_001, Role::Proposer) + ); + assert_ne!( + aad(1_700_000_000, Role::Proposer), + aad(1_700_000_000, Role::Responder) + ); + } + + #[test] + fn round_trips() -> TestResult { + let topic = topic(); + let aad = aad(MessageType::Request, &topic); + let mut cipher = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + + let sealed = cipher.seal(&aad, b"hello")?; + assert_eq!(cipher.open(&aad, &sealed)?, b"hello"); + Ok(()) + } + + #[test] + fn rejects_tampered_aad() -> TestResult { + let topic = topic(); + let mut cipher = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + let sealed = cipher.seal(&aad(MessageType::Request, &topic), b"hello")?; + + let other = aad(MessageType::Response, &topic); + assert!(matches!( + cipher.open(&other, &sealed), + Err(CipherError::Decrypt) + )); + Ok(()) + } + + #[test] + fn rejects_tampered_from() -> TestResult { + let topic = topic(); + let mut cipher = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + + let sealed = cipher.seal( + &Aad::new( + &message_id(), + MessageType::Request, + &topic, + &did(1), + 1_700_000_000, + Role::Proposer, + ) + .to_bytes(), + b"hello", + )?; + let tampered = Aad::new( + &message_id(), + MessageType::Request, + &topic, + &did(2), + 1_700_000_000, + Role::Proposer, + ) + .to_bytes(); + + assert!(matches!( + cipher.open(&tampered, &sealed), + Err(CipherError::Decrypt) + )); + Ok(()) + } + + #[test] + fn nonce_advances_per_message() -> TestResult { + let topic = topic(); + let aad = aad(MessageType::Request, &topic); + let mut cipher = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + + let first = cipher.seal(&aad, b"a")?; + let second = cipher.seal(&aad, b"a")?; + assert_ne!(first.nonce, second.nonce); + Ok(()) + } + + #[test] + fn session_cipher_starts_with_fresh_nonce_state() -> TestResult { + let pairing = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + let session = pairing.for_key(&[8u8; 32])?; + + assert_ne!(pairing.prefix, session.prefix); + assert_eq!(session.counter, 0); + Ok(()) + } + + #[test] + fn session_cipher_does_not_open_with_the_pairing_key() -> TestResult { + let topic = topic(); + let aad = aad(MessageType::Request, &topic); + let pairing = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + let mut session = pairing.for_key(&[8u8; 32])?; + + let sealed = session.seal(&aad, b"hello")?; + assert!(matches!( + pairing.open(&aad, &sealed), + Err(CipherError::Decrypt) + )); + Ok(()) + } + + #[test] + fn counter_exhaustion_fails_closed() -> TestResult { + let topic = topic(); + let aad = aad(MessageType::Request, &topic); + let mut cipher = XChaCha20Poly1305Cipher::with_prefix([9u8; 32], [3u8; 16])?; + cipher.counter = u64::MAX; + + assert!(matches!( + cipher.seal(&aad, b"hello"), + Err(CipherError::CounterExhausted) + )); + Ok(()) + } +} diff --git a/crates/client/src/error.rs b/crates/client/src/error.rs new file mode 100644 index 0000000..825df9b --- /dev/null +++ b/crates/client/src/error.rs @@ -0,0 +1,64 @@ +use thiserror::Error; + +/// Client-side failures. +#[derive(Debug, Error)] +pub enum ClientError { + #[error("websocket error: {0}")] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("encoding error: {0}")] + Codec(#[from] wallet_session_core::error::Error), + + #[error("cipher error: {0}")] + Cipher(#[from] crate::crypto::CipherError), + + #[error("handshake error: {0}")] + Handshake(#[from] crate::handshake::HandshakeError), + + #[error("invalid pairing uri: {0}")] + PairingUri(String), + + #[error("invalid url: {0}")] + Url(#[from] url::ParseError), + + #[error("invalid query parameters: {0}")] + Query(#[from] serde_urlencoded::ser::Error), + + #[error("relay connection closed")] + Closed, + + #[error("relay transport failed: {0}")] + Transport(&'static str), + + #[error("invalid client configuration: {0}")] + InvalidConfig(&'static str), + + #[error("client outbound queue is full")] + Busy, + + #[error("relay connection timed out")] + Timeout, + + #[error("peer disconnected")] + PeerDisconnected, + + #[error("unexpected handshake message")] + UnexpectedHandshake, + + #[error("protocol violation: {0}")] + ProtocolViolation(&'static str), + + #[error("random source failure: {0}")] + Random(#[from] getrandom::Error), + + #[error("session is not active")] + SessionNotActive, + + #[error("relay session is not ready")] + NotReady, +} + +pub type Result = std::result::Result; diff --git a/crates/client/src/handshake.rs b/crates/client/src/handshake.rs new file mode 100644 index 0000000..3bb7ace --- /dev/null +++ b/crates/client/src/handshake.rs @@ -0,0 +1,157 @@ +use std::fmt; + +use hkdf::Hkdf; +use sha2::Sha256; +use thiserror::Error; +use wallet_session_core::{codec, message::Topic}; +use x25519_dalek::{EphemeralSecret, PublicKey}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; + +/// HKDF info label for the session symmetric key. +const SESSION_KEY_INFO: &[u8] = b"wallet-session/v1/session-key"; + +/// HKDF info label for the session topic. +const SESSION_TOPIC_INFO: &[u8] = b"wallet-session/v1/session-topic"; + +/// Symmetric session key derived by both peers. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct SessionKey([u8; 32]); + +impl SessionKey { + /// Borrow the raw key bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl fmt::Debug for SessionKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SessionKey(..)") + } +} + +/// Session topic derived by both peers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionTopic(Topic); + +impl SessionTopic { + /// Borrow the topic. + pub fn as_topic(&self) -> &Topic { + &self.0 + } +} + +/// A one-time X25519 keypair for the session handshake. +pub struct EphemeralKeyPair { + secret: EphemeralSecret, +} + +impl EphemeralKeyPair { + /// Generate a fresh ephemeral keypair. + pub fn generate() -> Self { + Self { + secret: EphemeralSecret::random(), + } + } + + /// Raw public key bytes. + pub fn public_key(&self) -> [u8; 32] { + PublicKey::from(&self.secret).to_bytes() + } + + /// Base64url-encoded public key for the handshake payload. + pub fn public_key_encoded(&self) -> String { + codec::encode(&self.public_key()) + } + + /// Derive the session key and topic from the peer's public key. + /// + /// Consumes the keypair: ephemeral secrets are used once. + pub fn derive( + self, + peer_public: &[u8; 32], + ) -> Result<(SessionKey, SessionTopic), HandshakeError> { + let shared = self.secret.diffie_hellman(&PublicKey::from(*peer_public)); + let shared = Zeroizing::new(shared.to_bytes()); + if shared.iter().all(|byte| *byte == 0) { + return Err(HandshakeError::WeakKey); + } + + let hkdf = Hkdf::::new(None, shared.as_ref()); + + let mut key = Zeroizing::new([0u8; 32]); + hkdf.expand(SESSION_KEY_INFO, key.as_mut()) + .map_err(|_| HandshakeError::Derive)?; + + let mut topic = Zeroizing::new([0u8; 32]); + hkdf.expand(SESSION_TOPIC_INFO, topic.as_mut()) + .map_err(|_| HandshakeError::Derive)?; + let topic = Topic::new(topic.to_vec())?; + + Ok((SessionKey(*key), SessionTopic(topic))) + } +} + +/// Decode a base64url public key from a handshake payload. +pub fn decode_public_key(encoded: &str) -> Result<[u8; 32], HandshakeError> { + let bytes = codec::decode(encoded)?; + bytes.try_into().map_err(|_| HandshakeError::PeerKey) +} + +/// Handshake and key-agreement failures. +#[derive(Debug, Error)] +pub enum HandshakeError { + /// The peer's public key was not a valid 32-byte base64url value. + #[error("invalid peer public key")] + PeerKey, + + /// The derived shared secret was all zero (low-order public key). + #[error("weak peer public key")] + WeakKey, + + /// HKDF expansion failed. + #[error("key derivation failed")] + Derive, + + /// The derived session topic was invalid. + #[error("invalid session topic: {0}")] + Topic(#[from] wallet_session_core::error::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peers_derive_the_same_session() { + let alice = EphemeralKeyPair::generate(); + let bob = EphemeralKeyPair::generate(); + let alice_public = alice.public_key(); + let bob_public = bob.public_key(); + + let (alice_key, alice_topic) = alice.derive(&bob_public).expect("alice derive"); + let (bob_key, bob_topic) = bob.derive(&alice_public).expect("bob derive"); + + assert_eq!(alice_key, bob_key); + assert_eq!(alice_topic, bob_topic); + } + + #[test] + fn distinct_pairs_derive_distinct_keys() { + let alice = EphemeralKeyPair::generate(); + let bob = EphemeralKeyPair::generate(); + let mallory = EphemeralKeyPair::generate(); + + let (_, alice_topic) = alice.derive(&bob.public_key()).expect("derive"); + let (_, other_topic) = mallory.derive(&bob.public_key()).expect("derive"); + assert_ne!(alice_topic, other_topic); + } + + #[test] + fn decodes_public_key() { + let pair = EphemeralKeyPair::generate(); + let decoded = decode_public_key(&pair.public_key_encoded()).expect("decode"); + assert_eq!(decoded, pair.public_key()); + assert!(decode_public_key("nope").is_err()); + } +} diff --git a/crates/client/src/identity.rs b/crates/client/src/identity.rs new file mode 100644 index 0000000..869a5c8 --- /dev/null +++ b/crates/client/src/identity.rs @@ -0,0 +1,107 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use ed25519_dalek::{Signer, SigningKey}; +use wallet_session_core::{auth::RelayAuthToken, codec, did::DidKey}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::{error::Result, random}; + +/// Long-term identity seed, persisted across reconnects and restarts. +#[derive(Clone, Zeroize, ZeroizeOnDrop)] +pub struct Seed([u8; 32]); + +impl Seed { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Generate a fresh random seed. + pub fn random() -> Result { + Ok(Self(random::bytes()?)) + } + + /// Borrow the raw seed bytes for persistence. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Ed25519 identity used to sign connection tokens. +#[derive(Clone)] +pub struct IdentityKey(SigningKey); + +// `SigningKey` zeroizes its secret key material when dropped. +impl ZeroizeOnDrop for IdentityKey {} + +impl IdentityKey { + /// Derive the identity key from a persisted seed. + pub fn from_seed(seed: &Seed) -> Self { + Self(SigningKey::from_bytes(seed.as_bytes())) + } + + /// Generate a fresh identity key from a random seed. + pub fn random() -> Result { + Ok(Self::from_seed(&Seed::random()?)) + } + + /// `did:key` identifier derived from the identity public key. + pub fn did(&self) -> DidKey { + DidKey::from_ed25519_bytes(self.0.verifying_key().to_bytes()) + } + + /// Sign a connection token for the given relay session and audience. + pub fn connection_token( + &self, + session_id: &str, + audience: &str, + ttl: Duration, + ) -> Result { + let now = unix_now(); + let token = RelayAuthToken { + iss: self.did(), + sub: session_id.to_owned(), + aud: audience.to_owned(), + iat: now, + exp: now.saturating_add(ttl.as_secs()), + }; + + let header = codec::encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); + let payload = codec::encode(&serde_json::to_vec(&token)?); + let signing_input = format!("{header}.{payload}"); + let signature = self.0.sign(signing_input.as_bytes()); + let signature = codec::encode(&signature.to_bytes()); + + Ok(format!("{signing_input}.{signature}")) + } +} + +/// Current Unix time in seconds, saturating at 0 before the epoch. +pub fn unix_now() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_secs(), + Err(_) => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn did_is_stable_for_a_seed() { + let seed = Seed::from_bytes([7u8; 32]); + let first = IdentityKey::from_seed(&seed).did(); + let second = IdentityKey::from_seed(&seed).did(); + assert_eq!(first, second); + assert!(first.to_string().starts_with("did:key:z")); + } + + #[test] + fn connection_token_has_three_segments() { + let identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + let token = identity + .connection_token("session", "wss://relay", Duration::from_secs(60)) + .expect("token"); + assert_eq!(token.split('.').count(), 3); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs new file mode 100644 index 0000000..8c3e3c5 --- /dev/null +++ b/crates/client/src/lib.rs @@ -0,0 +1,42 @@ +//! Client for the wallet-session relay protocol. +//! +//! A [`Client`] owns one pairing topic and, after the handshake, one derived +//! session topic. Its counterpart, replay memory, and outstanding reliability +//! state are in memory for the lifetime of that client instance. Creating a +//! new client with the same pairing key starts with empty replay state and no +//! counterpart, so long-lived applications should reuse the existing client. +//! +//! Inbound work, including acknowledgements and relay lifecycle events, is +//! driven by [`Client::recv`], [`Client::next_event`], and +//! [`Client::wait_session_ready`]. Applications should keep one of those +//! futures pending while the session is active. + +mod client; +mod config; +mod error; +mod identity; +mod messages; +mod pairing; +mod random; +mod relay; +mod reliability; +mod replay; +mod session; +mod tls; + +pub mod crypto; +pub mod handshake; + +pub use client::Client; +pub use config::ClientConfig; +pub use crypto::XChaCha20Poly1305Cipher; +pub use error::{ClientError, Result}; +pub use identity::{IdentityKey, Seed}; +pub use messages::{ClientMessage, RequestId}; +pub use pairing::{PairingKey, PairingUri}; +pub use reliability::ClientEvent; +pub use session::{Role, Session, SessionState}; +pub use wallet_session_core::payload::{ + AckPayload, ApprovePayload, DisconnectPayload, Opaque, ProposalPayload, RejectPayload, + RequestPayload, ResponsePayload, SessionDeletePayload, +}; diff --git a/crates/client/src/messages.rs b/crates/client/src/messages.rs new file mode 100644 index 0000000..cc18e10 --- /dev/null +++ b/crates/client/src/messages.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; +use wallet_session_core::{ + message::MessageType, + payload::{ + AckPayload, ApprovePayload, DisconnectPayload, ProposalPayload, RejectPayload, + RequestPayload, ResponsePayload, SessionDeletePayload, + }, +}; + +use crate::error::Result; + +/// Caller-assigned id correlating a request with its response. +pub type RequestId = String; + +/// Plaintext application message exchanged over the relay. +/// +/// The variant bodies are the payload types from `wallet_session_core`, so the +/// wire representation is the one defined by the protocol. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + /// Propose a session. + Proposal(ProposalPayload), + + /// Approve a session. + Approve(ApprovePayload), + + /// Reject a proposal. + Reject(RejectPayload), + + /// Application request. + Request(RequestPayload), + + /// Application response to a request. + Response(ResponsePayload), + + /// Acknowledge receipt of a message. + Ack(AckPayload), + + /// Graceful teardown. + Disconnect(DisconnectPayload), + + /// Notify the peer that the session was deleted. + SessionDelete(SessionDeletePayload), +} + +impl ClientMessage { + /// Message type used as the plaintext envelope tag. + pub fn message_type(&self) -> MessageType { + match self { + ClientMessage::Proposal(_) => MessageType::Proposal, + ClientMessage::Approve(_) => MessageType::Approve, + ClientMessage::Reject(_) => MessageType::Reject, + ClientMessage::Request(_) => MessageType::Request, + ClientMessage::Response(_) => MessageType::Response, + ClientMessage::Ack(_) => MessageType::Ack, + ClientMessage::Disconnect(_) => MessageType::Disconnect, + ClientMessage::SessionDelete(_) => MessageType::SessionDelete, + } + } + + pub fn to_bytes(&self) -> Result> { + Ok(serde_json::to_vec(self)?) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + Ok(serde_json::from_slice(bytes)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wallet_session_core::payload::Opaque; + + #[test] + fn request_round_trips() { + let message = ClientMessage::Request(RequestPayload { + request_id: "req-1".to_owned(), + request: Opaque(serde_json::to_vec(&json!({ "method": "get" })).expect("json")), + }); + let bytes = message.to_bytes().expect("bytes"); + assert_eq!(ClientMessage::from_bytes(&bytes).expect("parsed"), message); + assert_eq!(message.message_type(), MessageType::Request); + } + + #[test] + fn response_error_variant_round_trips() { + let message = ClientMessage::Response(ResponsePayload { + request_id: "req-1".to_owned(), + response: None, + error: Some(Opaque( + serde_json::to_vec(&json!({ "code": 4001 })).expect("json"), + )), + }); + let bytes = message.to_bytes().expect("bytes"); + assert_eq!(ClientMessage::from_bytes(&bytes).expect("parsed"), message); + } + + #[test] + fn ack_uses_the_documented_field_name() { + let ack_id = wallet_session_core::message::MessageId::from_bytes([1u8; 16]); + let message = ClientMessage::Ack(AckPayload { + ack_id: ack_id.clone(), + }); + + assert_eq!( + serde_json::to_value(&message).expect("json"), + json!({ "type": "ack", "ackId": ack_id.to_string() }) + ); + assert_eq!( + ClientMessage::from_bytes(format!(r#"{{"type":"ack","ackId":"{ack_id}"}}"#).as_bytes()) + .expect("parsed"), + message + ); + } + + #[test] + fn request_uses_the_documented_field_names() { + let message = ClientMessage::Request(RequestPayload { + request_id: "req-1".to_owned(), + request: Opaque(vec![1, 2, 3]), + }); + let value = serde_json::to_value(&message).expect("json"); + + assert_eq!( + value, + json!({ "type": "request", "requestId": "req-1", "request": "AQID" }) + ); + assert!(value.get("request_id").is_none()); + } + + #[test] + fn response_uses_the_documented_field_names() { + let message = ClientMessage::Response(ResponsePayload { + request_id: "req-1".to_owned(), + response: Some(Opaque(vec![1, 2, 3])), + error: None, + }); + let value = serde_json::to_value(&message).expect("json"); + + assert_eq!(value["type"], "response"); + assert_eq!(value["requestId"], "req-1"); + assert_eq!(value["response"], "AQID"); + assert!(value.get("request_id").is_none()); + assert!(value.get("error").is_none()); + + let message = ClientMessage::Response(ResponsePayload { + request_id: "req-1".to_owned(), + response: None, + error: Some(Opaque(vec![4, 5])), + }); + let value = serde_json::to_value(&message).expect("json"); + assert_eq!(value["error"], "BAU"); + assert!(value.get("response").is_none()); + } +} diff --git a/crates/client/src/pairing.rs b/crates/client/src/pairing.rs new file mode 100644 index 0000000..3c9d5cf --- /dev/null +++ b/crates/client/src/pairing.rs @@ -0,0 +1,170 @@ +use std::fmt; + +use sha2::{Digest, Sha256}; +use url::{Url, form_urlencoded}; +use wallet_session_core::{codec, message::Topic}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::{error::ClientError, error::Result, random}; + +const CONNECTION_SCHEME: &str = "bsconnect"; + +/// 32-byte symmetric pairing key shared out of band +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct PairingKey([u8; 32]); + +impl PairingKey { + /// Generate a fresh random pairing key. + pub fn random() -> Result { + Ok(Self(random::bytes()?)) + } + + /// Wrap raw pairing key bytes. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrow the raw key bytes. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Pairing topic derived from the key + pub fn topic(&self) -> Result { + let digest = Sha256::digest(self.0); + Ok(Topic::new(digest.to_vec())?) + } + + /// Base64url representation used in the pairing URI + pub fn to_base64(&self) -> String { + codec::encode(&self.0) + } + + /// Parse a base64url pairing key. + pub fn from_base64(encoded: &str) -> Result { + let bytes = codec::decode(encoded)?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| ClientError::PairingUri("bad pairing key length".to_owned()))?; + Ok(Self(bytes)) + } +} + +impl fmt::Debug for PairingKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("PairingKey(..)") + } +} + +/// Pairing URI +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingUri { + /// Symmetric pairing key. + pub key: PairingKey, + + /// Relay endpoint to connect to. + pub relay: String, +} + +impl PairingUri { + /// Build a pairing URI for a key and relay endpoint. + pub fn new(key: PairingKey, relay: impl Into) -> Self { + Self { + key, + relay: relay.into(), + } + } + + pub fn parse(uri: &str) -> Result { + let url = Url::parse(uri).map_err(|_| ClientError::PairingUri("invalid URI".to_owned()))?; + if url.scheme() != CONNECTION_SCHEME || url.host_str() != Some("pairing") { + return Err(ClientError::PairingUri("not a pairing URI".to_owned())); + } + + let mut key = None; + let mut relay = None; + for (name, value) in url.query_pairs() { + match name.as_ref() { + "key" => key = Some(PairingKey::from_base64(&value)?), + "relay" => relay = Some(value.into_owned()), + _ => {} + } + } + + Ok(Self { + key: key.ok_or_else(|| ClientError::PairingUri("missing pairing key".to_owned()))?, + relay: relay + .ok_or_else(|| ClientError::PairingUri("missing relay endpoint".to_owned()))?, + }) + } +} + +impl fmt::Display for PairingUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("key", &self.key.to_base64()) + .append_pair("relay", &self.relay) + .finish(); + write!(f, "{CONNECTION_SCHEME}://pairing?{query}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn topic_is_sha256_of_key() { + let key = PairingKey::from_bytes([3u8; 32]); + let expected = Topic::new(Sha256::digest([3u8; 32]).to_vec()).expect("topic"); + assert_eq!(key.topic().expect("topic"), expected); + } + + #[test] + fn pairing_uri_round_trips() { + let uri = PairingUri::new( + PairingKey::from_bytes([9u8; 32]), + "wss://relay.example.com/", + ); + let rendered = uri.to_string(); + assert_eq!(PairingUri::parse(&rendered).expect("parsed"), uri); + } + + #[test] + fn rejects_unknown_scheme() { + assert!(PairingUri::parse("https://example.com").is_err()); + } + + #[test] + fn errors_do_not_echo_pairing_secrets_or_uris() { + let key = codec::encode(&[9u8; 32]); + let uri = format!("https://example.com/?key={key}&relay=wss://relay.example.com"); + let error = PairingUri::parse(&uri).expect_err("wrong scheme"); + let text = error.to_string(); + assert!(!text.contains(&key), "{text}"); + assert!(!text.contains("relay.example.com"), "{text}"); + + let short = codec::encode(&[1u8; 31]); + let error = PairingKey::from_base64(&short).expect_err("short key"); + let text = error.to_string(); + assert!(!text.contains(&short), "{text}"); + } + + #[test] + fn missing_fields_have_fixed_errors() { + let error = PairingUri::parse("bsconnect://pairing?relay=wss://relay.example.com") + .expect_err("missing key"); + assert_eq!( + error.to_string(), + "invalid pairing uri: missing pairing key" + ); + + let key = codec::encode(&[0u8; 32]); + let error = PairingUri::parse(&format!("bsconnect://pairing?key={key}")) + .expect_err("missing relay"); + assert_eq!( + error.to_string(), + "invalid pairing uri: missing relay endpoint" + ); + } +} diff --git a/crates/client/src/random.rs b/crates/client/src/random.rs new file mode 100644 index 0000000..b244f28 --- /dev/null +++ b/crates/client/src/random.rs @@ -0,0 +1,13 @@ +use wallet_session_core::message::MessageId; + +use crate::error::Result; + +pub(crate) fn bytes() -> Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes)?; + Ok(bytes) +} + +pub(crate) fn id() -> Result { + Ok(MessageId::from_bytes(bytes()?)) +} diff --git a/crates/client/src/relay.rs b/crates/client/src/relay.rs new file mode 100644 index 0000000..02b5e9e --- /dev/null +++ b/crates/client/src/relay.rs @@ -0,0 +1,570 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use futures_util::{Sink, SinkExt, StreamExt}; +use tokio::{ + net::TcpStream, + sync::{mpsc, watch}, + task::JoinHandle, + time::{Instant, MissedTickBehavior, interval_at, sleep_until}, +}; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async_with_config, + tungstenite::{ + Bytes, Error as TungsteniteError, Message, + protocol::{CloseFrame, WebSocketConfig, frame::coding::CloseCode}, + }, +}; +use wallet_session_core::{ + connection::{ConnectionQuery, Role}, + frame::{RelayFrame, RelayFrameBody}, + message::Topic, +}; + +use crate::{config::ClientConfig, error::Result, identity::IdentityKey, random}; + +/// Client-side relay WebSocket stream. +pub(crate) type RelayStream = WebSocketStream>; + +/// Frames buffered between the transport task and the client. +pub(crate) const INBOX_CAPACITY: usize = 64; + +/// Frames buffered between the client and the transport task. +pub(crate) const OUTBOUND_CAPACITY: usize = 64; + +/// Maximum relay frame accepted from the relay, matching the relay default. +const MAX_RELAY_FRAME_BYTES: usize = 256 * 1024; + +/// Maximum buffered WebSocket write bytes. +const MAX_WRITE_BUFFER_BYTES: usize = 2 * MAX_RELAY_FRAME_BYTES; + +/// Everything the connection task can put on the wire. +pub(crate) enum Outbound { + /// A relay frame to serialize and send. + Frame(RelayFrame), +} + +/// Everything a connection task reports to the client. +pub(crate) enum Inbox { + /// A relay frame received from the relay. + Frame(RelayFrame), + + /// The socket for `topic` is established (initial or after reconnect). + Connected(Topic), + + /// The relay reported `session_ready` for `topic`. + Ready(Topic), + + /// The connection for `topic` was lost; a reconnect is starting. + Disconnected(Topic), + + /// Reconnect attempts for `topic` were exhausted or permanently rejected. + Closed { topic: Topic, reason: &'static str }, + + /// The client no longer needs the pairing connection for `topic`. + Retire(Topic), +} + +/// Why one connection attempt ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectionEnd { + /// The client is shutting down. + Shutdown, + + /// Retry after the configured backoff. + Reconnect { reason: &'static str }, + + /// Retry immediately, for example after a token-expiry close. + ReconnectNow { reason: &'static str }, + + /// Do not retry. + Fatal { reason: &'static str }, +} + +struct ConnectionResult { + end: ConnectionEnd, + saw_session_ready: bool, +} + +/// A reconnecting relay connection for one topic and role. +/// +/// A background task owns the WebSocket lifecycle and exposes a stable +/// outbound channel, so the client and reliability layer never observe a +/// socket swap. Inbound events go to the shared channel owned by the client. +pub(crate) struct Transport { + outbound: mpsc::Sender, + shutdown: watch::Sender, + _task: JoinHandle<()>, +} + +impl Drop for Transport { + fn drop(&mut self) { + let _ = self.shutdown.send(true); + } +} + +impl Transport { + /// Start (asynchronously) a connection to the relay for `topic` as `role`. + pub(crate) fn connect( + config: ClientConfig, + identity: IdentityKey, + role: Role, + topic: Topic, + ready: Arc, + inbox: mpsc::Sender, + ) -> Self { + let (outbound, outbound_rx) = mpsc::channel(OUTBOUND_CAPACITY); + let (shutdown, shutdown_rx) = watch::channel(false); + let _task = tokio::spawn(connection_task( + config, + identity, + role, + topic.clone(), + outbound_rx, + ready, + inbox, + shutdown_rx, + )); + + Self { + outbound, + shutdown, + _task, + } + } + + /// Cloneable handle to the outbound writer. + pub(crate) fn outbound(&self) -> mpsc::Sender { + self.outbound.clone() + } +} + +#[allow(clippy::too_many_arguments)] +async fn connection_task( + config: ClientConfig, + identity: IdentityKey, + role: Role, + topic: Topic, + mut outbound: mpsc::Receiver, + ready: Arc, + inbox: mpsc::Sender, + mut shutdown: watch::Receiver, +) { + let mut retries = 0usize; + let mut delay = config.reconnect_initial_delay; + + loop { + ready.store(false, Ordering::Relaxed); + let connected = tokio::select! { + biased; + result = connect_with_timeout(&config, &identity, role, &topic) => result, + _ = shutdown.changed() => return, + }; + + let end = match connected { + Ok(stream) => { + let _ = inbox.send(Inbox::Connected(topic.clone())).await; + let result = run_connection( + stream, + &mut outbound, + &ready, + &inbox, + &mut shutdown, + &config, + ) + .await; + ready.store(false, Ordering::Relaxed); + if result.saw_session_ready { + retries = 0; + delay = config.reconnect_initial_delay; + } + result.end + } + Err(error) => { + let end = classify_connect_error(&error); + if let ConnectionEnd::Reconnect { reason } | ConnectionEnd::Fatal { reason } = end { + log::debug!("relay connect failed: {reason}"); + } + end + } + }; + + match end { + ConnectionEnd::Shutdown => return, + ConnectionEnd::Fatal { reason } => { + let _ = inbox.send(Inbox::Closed { topic, reason }).await; + return; + } + ConnectionEnd::ReconnectNow { reason } => { + if retries >= config.reconnect_max_attempts { + let _ = inbox.send(Inbox::Closed { topic, reason }).await; + return; + } + retries += 1; + let _ = inbox.send(Inbox::Disconnected(topic.clone())).await; + } + ConnectionEnd::Reconnect { reason } => { + let _ = inbox.send(Inbox::Disconnected(topic.clone())).await; + if retries >= config.reconnect_max_attempts { + let _ = inbox.send(Inbox::Closed { topic, reason }).await; + return; + } + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = shutdown.changed() => return, + } + retries += 1; + delay = delay.saturating_mul(2).min(config.reconnect_max_delay); + } + } + } +} + +async fn connect_with_timeout( + config: &ClientConfig, + identity: &IdentityKey, + role: Role, + topic: &Topic, +) -> Result { + tokio::time::timeout( + config.connect_timeout, + connect_once(config, identity, role, topic), + ) + .await + .map_err(|_| crate::error::ClientError::Timeout)? +} + +fn classify_connect_error(error: &crate::error::ClientError) -> ConnectionEnd { + use crate::error::ClientError; + + match error { + ClientError::InvalidConfig(_) => ConnectionEnd::Fatal { + reason: "invalid client configuration", + }, + ClientError::Url(_) => ConnectionEnd::Fatal { + reason: "invalid relay URL", + }, + ClientError::Query(_) => ConnectionEnd::Fatal { + reason: "invalid connection parameters", + }, + ClientError::Random(_) => ConnectionEnd::Fatal { + reason: "random source failure", + }, + ClientError::WebSocket(TungsteniteError::Http(response)) + if response.status().is_client_error() => + { + ConnectionEnd::Fatal { + reason: "relay rejected connection", + } + } + ClientError::Timeout => ConnectionEnd::Reconnect { + reason: "relay connect timed out", + }, + _ => ConnectionEnd::Reconnect { + reason: "relay connection failed", + }, + } +} + +async fn connect_once( + config: &ClientConfig, + identity: &IdentityKey, + role: Role, + topic: &Topic, +) -> Result { + crate::tls::ensure_crypto_provider(); + + let session_id = random::id()?.to_string(); + let token = identity.connection_token(&session_id, &config.audience, config.token_ttl)?; + + let query = ConnectionQuery { + auth: token, + role, + topic: topic.clone(), + registration_id: config.registration_id.clone(), + }; + let query = serde_urlencoded::to_string(&query)?; + + let mut url = url::Url::parse(&config.relay_url)?; + url.set_query(Some(&query)); + + log::debug!( + "connecting to relay host={} topic={}", + url.host_str().unwrap_or("?"), + topic.log_tag() + ); + let (stream, _response) = + connect_async_with_config(url.as_str(), Some(websocket_config()), false).await?; + Ok(stream) +} + +/// WebSocket limits that bound what a relay can make the client buffer. +fn websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_message_size(Some(MAX_RELAY_FRAME_BYTES)) + .max_frame_size(Some(MAX_RELAY_FRAME_BYTES)) + .max_write_buffer_size(MAX_WRITE_BUFFER_BYTES) +} + +/// Drive one WebSocket session. +async fn run_connection( + stream: RelayStream, + outbound: &mut mpsc::Receiver, + ready: &AtomicBool, + inbox: &mpsc::Sender, + shutdown: &mut watch::Receiver, + config: &ClientConfig, +) -> ConnectionResult { + let (mut sink, mut source) = stream.split(); + + let mut ping = interval_at(Instant::now() + config.ping_interval, config.ping_interval); + ping.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut awaiting_pong = false; + let mut pong_deadline = Instant::now(); + let mut idle_deadline = Instant::now() + config.idle_timeout; + let mut saw_session_ready = false; + + let end = loop { + tokio::select! { + biased; + outbound_item = outbound.recv() => match outbound_item { + Some(Outbound::Frame(frame)) => { + let Ok(text) = serde_json::to_string(&frame) else { + break ConnectionEnd::Reconnect { reason: "invalid outbound frame" }; + }; + log::trace!( + "client -> topic={} bytes={}", + frame.topic.log_tag(), + text.len() + ); + if !send_with_timeout(&mut sink, Message::text(text), config.write_timeout).await { + break ConnectionEnd::Reconnect { reason: "relay write timed out" }; + } + } + None => break ConnectionEnd::Shutdown, + }, + message = source.next() => match message { + Some(Ok(Message::Text(text))) => { + idle_deadline = Instant::now() + config.idle_timeout; + match serde_json::from_str::(&text) { + Ok(frame) => { + log::trace!( + "client <- topic={} bytes={}", + frame.topic.log_tag(), + text.len() + ); + if matches!(&frame.body, RelayFrameBody::SessionReady) + && !saw_session_ready + { + saw_session_ready = true; + ready.store(true, Ordering::Relaxed); + if inbox.send(Inbox::Ready(frame.topic.clone())).await.is_err() { + break ConnectionEnd::Shutdown; + } + } + if inbox.send(Inbox::Frame(frame)).await.is_err() { + break ConnectionEnd::Shutdown; + } + } + Err(error) => log::warn!("invalid relay frame: {error}"), + } + } + Some(Ok(Message::Ping(payload))) => { + idle_deadline = Instant::now() + config.idle_timeout; + if !send_with_timeout( + &mut sink, + Message::Pong(payload), + config.write_timeout, + ) + .await + { + break ConnectionEnd::Reconnect { reason: "relay write timed out" }; + } + } + Some(Ok(Message::Pong(_))) => { + idle_deadline = Instant::now() + config.idle_timeout; + awaiting_pong = false; + } + Some(Ok(Message::Close(frame))) => break classify_close(frame), + Some(Ok(Message::Binary(_) | Message::Frame(_))) => { + idle_deadline = Instant::now() + config.idle_timeout; + } + Some(Err(error)) => { + log::debug!("relay read error: {error}"); + break ConnectionEnd::Reconnect { reason: "relay connection lost" }; + } + None => break ConnectionEnd::Reconnect { reason: "relay connection closed" }, + }, + _ = ping.tick(), if !awaiting_pong => { + awaiting_pong = true; + pong_deadline = Instant::now() + config.pong_timeout; + if !send_with_timeout(&mut sink, Message::Ping(Bytes::new()), config.write_timeout).await { + break ConnectionEnd::Reconnect { reason: "relay write timed out" }; + } + } + _ = sleep_until(pong_deadline), if awaiting_pong => { + log::debug!("relay pong timed out"); + break ConnectionEnd::Reconnect { reason: "relay pong timed out" }; + } + _ = sleep_until(idle_deadline) => { + log::debug!("relay idle timeout"); + break ConnectionEnd::Reconnect { reason: "relay idle timeout" }; + } + _ = shutdown.changed() => break ConnectionEnd::Shutdown, + } + }; + + if matches!(end, ConnectionEnd::Shutdown) { + close_socket(&mut sink, config.close_timeout).await; + } + + ConnectionResult { + end, + saw_session_ready, + } +} + +fn classify_close(frame: Option) -> ConnectionEnd { + match frame { + Some(frame) if frame.code == CloseCode::Policy => match frame.reason.as_str() { + "token expired" => ConnectionEnd::ReconnectNow { + reason: "token expired", + }, + "role already taken" => ConnectionEnd::Fatal { + reason: "role already taken", + }, + _ => ConnectionEnd::Fatal { + reason: "relay policy rejection", + }, + }, + _ => ConnectionEnd::Reconnect { + reason: "relay closed connection", + }, + } +} + +async fn send_with_timeout(sink: &mut S, message: Message, timeout: Duration) -> bool +where + S: Sink + Unpin, +{ + matches!( + tokio::time::timeout(timeout, sink.send(message)).await, + Ok(Ok(())) + ) +} + +async fn close_socket(sink: &mut S, timeout: Duration) +where + S: Sink + Unpin, +{ + let close = async { + let frame = CloseFrame { + code: CloseCode::Normal, + reason: "client shutdown".into(), + }; + let _ = sink.send(Message::Close(Some(frame))).await; + let _ = sink.close().await; + }; + let _ = tokio::time::timeout(timeout, close).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_limits_match_the_relay_default() { + let config = websocket_config(); + assert_eq!(config.max_message_size, Some(MAX_RELAY_FRAME_BYTES)); + assert_eq!(config.max_frame_size, Some(MAX_RELAY_FRAME_BYTES)); + assert_eq!(config.max_write_buffer_size, MAX_WRITE_BUFFER_BYTES); + } + + struct PendingSink; + + impl Sink for PendingSink { + type Error = std::io::Error; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn start_send( + self: std::pin::Pin<&mut Self>, + _: Message, + ) -> std::result::Result<(), Self::Error> { + unreachable!() + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + + #[tokio::test(start_paused = true)] + async fn write_timeout_fails_a_stalled_sink() { + let mut sink = PendingSink; + assert!( + !send_with_timeout(&mut sink, Message::text("frame"), Duration::from_secs(1)).await + ); + } + + #[test] + fn classifies_policy_closes() { + let close = |code: CloseCode, reason: &str| { + Some(CloseFrame { + code, + reason: reason.into(), + }) + }; + + assert_eq!( + classify_close(close(CloseCode::Policy, "token expired")), + ConnectionEnd::ReconnectNow { + reason: "token expired" + } + ); + assert_eq!( + classify_close(close(CloseCode::Policy, "role already taken")), + ConnectionEnd::Fatal { + reason: "role already taken" + } + ); + assert_eq!( + classify_close(close(CloseCode::Policy, "something else")), + ConnectionEnd::Fatal { + reason: "relay policy rejection" + } + ); + assert_eq!( + classify_close(close(CloseCode::Normal, "")), + ConnectionEnd::Reconnect { + reason: "relay closed connection" + } + ); + assert_eq!( + classify_close(None), + ConnectionEnd::Reconnect { + reason: "relay closed connection" + } + ); + } +} diff --git a/crates/client/src/reliability.rs b/crates/client/src/reliability.rs new file mode 100644 index 0000000..72bffd8 --- /dev/null +++ b/crates/client/src/reliability.rs @@ -0,0 +1,578 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use tokio::{ + sync::mpsc, + task::JoinHandle, + time::{Instant, interval}, +}; +use wallet_session_core::{ + frame::RelayFrame, + message::{MessageId, MessageType}, +}; + +use crate::{error::ClientError, identity::unix_now, relay::Outbound}; + +/// Events buffered between the reliability task and the client. +pub(crate) const EVENT_CAPACITY: usize = 256; + +/// Maximum messages kept for retransmission per link. +const PENDING_CAPACITY: usize = 256; + +/// Reliability events surfaced to the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientEvent { + /// A sent message was acknowledged by the peer. + Delivered { + /// Envelope id that was acknowledged. + id: MessageId, + }, + + /// A sent message exhausted its retransmissions. + Failed { + /// Envelope id that never got acknowledged. + id: MessageId, + + /// Retransmissions attempted. + attempts: usize, + }, + + /// The relay connection was lost and a reconnect is starting. + Reconnecting, + + /// The relay connection was re-established. + Reconnected, +} + +/// Whether a message type must be acknowledged. +pub(crate) fn requires_ack(kind: MessageType) -> bool { + matches!( + kind, + MessageType::Proposal + | MessageType::Approve + | MessageType::Reject + | MessageType::Request + | MessageType::Response + | MessageType::Disconnect + | MessageType::SessionDelete + ) +} + +struct Pending { + frame: RelayFrame, + attempts: usize, + next: Instant, + sequence: u64, + expires_at: u64, + sent: bool, +} + +/// Outbound reliability: ack tracking and retransmission. +pub(crate) struct Reliability { + outbox: mpsc::Sender, + pending: Arc>>, + ready: Arc, + events: mpsc::Sender, + base_delay: Duration, + next_sequence: u64, + _retry: JoinHandle<()>, +} + +impl Reliability { + pub(crate) fn spawn( + outbox: mpsc::Sender, + ready: Arc, + events: mpsc::Sender, + base_delay: Duration, + max_attempts: usize, + retransmit_spacing: Duration, + ) -> Self { + let pending = Arc::new(Mutex::new(HashMap::new())); + let _retry = tokio::spawn(retry_loop( + Arc::clone(&pending), + Arc::clone(&ready), + outbox.clone(), + events.clone(), + base_delay, + max_attempts, + retransmit_spacing, + )); + + Self { + outbox, + pending, + ready, + events, + base_delay, + next_sequence: 0, + _retry, + } + } + + /// Emit an event to the application. + pub(crate) fn emit(&self, event: ClientEvent) { + let _ = self.events.try_send(event); + } + + /// Enqueue a frame, tracking it for retransmission when `track` is set. + pub(crate) fn dispatch( + &mut self, + frame: RelayFrame, + track: bool, + ts: u64, + ) -> Result<(), ClientError> { + let id = frame.id.clone(); + let is_ready = self.ready.load(Ordering::Relaxed); + if track { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + let pending = Pending { + frame: frame.clone(), + attempts: 0, + next: Instant::now() + self.base_delay, + sequence, + expires_at: ts.saturating_add(300), + sent: is_ready, + }; + let mut pending_messages = self.pending.lock().expect("reliability lock"); + if !pending_messages.contains_key(&id) && pending_messages.len() >= PENDING_CAPACITY { + return Err(ClientError::Busy); + } + pending_messages.insert(id.clone(), pending); + } + + // While the relay session is not ready, tracked frames wait only in + // the pending map. Sending them now would duplicate the reconnect + // flush after the transport queue survives a disconnect. + if track && !is_ready { + return Ok(()); + } + + match self.outbox.try_send(Outbound::Frame(frame)) { + Ok(()) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => { + if track { + self.pending.lock().expect("reliability lock").remove(&id); + } + Err(ClientError::Busy) + } + Err(mpsc::error::TrySendError::Closed(_)) => { + if track { + self.pending.lock().expect("reliability lock").remove(&id); + } + Err(ClientError::Closed) + } + } + } + + /// Report and remove every outstanding tracked message. + pub(crate) fn fail_all(&mut self) { + let pending: Vec<(MessageId, Pending)> = self + .pending + .lock() + .expect("reliability lock") + .drain() + .collect(); + for (id, entry) in pending { + let _ = self.events.try_send(ClientEvent::Failed { + id, + attempts: entry.attempts, + }); + } + } + + /// Record an inbound ACK, reporting delivery. + pub(crate) fn mark_delivered(&mut self, ack_id: &MessageId) { + if self + .pending + .lock() + .expect("reliability lock") + .remove(ack_id) + .is_some() + { + let _ = self + .events + .try_send(ClientEvent::Delivered { id: ack_id.clone() }); + } + } +} + +impl Drop for Reliability { + fn drop(&mut self) { + // Stop the retry task so its outbox clone is released and the writer + // task can close the socket. + self._retry.abort(); + } +} + +async fn retry_loop( + pending: Arc>>, + ready: Arc, + outbox: mpsc::Sender, + events: mpsc::Sender, + base_delay: Duration, + max_attempts: usize, + retransmit_spacing: Duration, +) { + let mut ticker = interval(Duration::from_millis(50)); + let mut was_ready = ready.load(Ordering::Relaxed); + let mut next_send = Instant::now(); + + loop { + ticker.tick().await; + + let now = Instant::now(); + let now_unix = unix_now(); + let is_ready = ready.load(Ordering::Relaxed); + let mut resend = None; + let mut failed = Vec::new(); + + { + let mut pending = pending.lock().expect("reliability lock"); + + // An envelope past ts + 300 can no longer be accepted by the peer. + let expired: Vec = pending + .iter() + .filter(|(_, entry)| now_unix > entry.expires_at) + .map(|(id, _)| id.clone()) + .collect(); + for id in expired { + if let Some(entry) = pending.remove(&id) { + failed.push((id, entry.attempts)); + } + } + + if is_ready { + if !was_ready { + // A reconnect flush starts now, but is paced below. + for entry in pending.values_mut() { + entry.next = now; + } + next_send = now; + } else { + // Frames dispatched while offline still need their first + // write, even if the ready edge happened before this tick. + for entry in pending.values_mut().filter(|entry| !entry.sent) { + entry.next = now; + } + } + } + + if is_ready && now >= next_send { + let candidate = pending + .iter() + .filter(|(_, entry)| entry.next <= now) + .map(|(id, entry)| (id.clone(), entry.next, entry.sequence)) + .min_by_key(|(_, next, sequence)| (*next, *sequence)); + + if let Some((id, _, _)) = candidate { + let entry = pending.get_mut(&id).expect("candidate exists"); + if entry.attempts >= max_attempts { + if let Some(entry) = pending.remove(&id) { + failed.push((id, entry.attempts)); + } + } else { + entry.attempts += 1; + entry.next = now + backoff(base_delay, entry.attempts); + entry.sent = true; + resend = Some(entry.frame.clone()); + } + } + } + } + + was_ready = is_ready; + + if let Some(frame) = resend { + if outbox.send(Outbound::Frame(frame)).await.is_err() { + return; + } + next_send = now + retransmit_spacing; + } + + for (id, attempts) in failed { + let _ = events.try_send(ClientEvent::Failed { id, attempts }); + } + } +} + +fn backoff(base: Duration, attempts: usize) -> Duration { + base * 2u32.pow(attempts.min(31) as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + use wallet_session_core::{frame::RelayFrameBody, message::Topic}; + + fn spawn_reliability( + outbox: mpsc::Sender, + ready: Arc, + base_delay: Duration, + max_attempts: usize, + retransmit_spacing: Duration, + ) -> (Reliability, mpsc::Receiver) { + let (events, events_rx) = mpsc::channel(EVENT_CAPACITY); + let reliability = Reliability::spawn( + outbox, + ready, + events, + base_delay, + max_attempts, + retransmit_spacing, + ); + (reliability, events_rx) + } + + /// A distinct, valid message id for a test label. + fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) + } + + fn frame(id: &str) -> RelayFrame { + RelayFrame { + v: 1, + id: message_id(id), + topic: Topic::new([0u8; 32]).expect("topic"), + body: RelayFrameBody::Message { + payload: "payload".to_owned(), + }, + } + } + + #[test] + fn ack_classification() { + assert!(requires_ack(MessageType::Proposal)); + assert!(requires_ack(MessageType::Request)); + assert!(requires_ack(MessageType::Response)); + assert!(!requires_ack(MessageType::Ack)); + } + + fn ready(value: bool) -> Arc { + Arc::new(AtomicBool::new(value)) + } + + #[tokio::test] + async fn reports_a_full_outbound_queue() { + let (outbox, _outbound) = mpsc::channel(1); + let (mut reliability, _events) = spawn_reliability( + outbox, + ready(true), + Duration::from_secs(1), + 5, + Duration::from_millis(100), + ); + + reliability + .dispatch(frame("a"), true, unix_now()) + .expect("first dispatch"); + assert!(matches!( + reliability.dispatch(frame("b"), true, unix_now()), + Err(ClientError::Busy) + )); + assert_eq!( + reliability.pending.lock().expect("reliability lock").len(), + 1, + "a refused frame must not stay pending" + ); + } + + #[tokio::test] + async fn delivers_when_acked() { + let (outbox, mut outbound) = mpsc::channel(16); + let (mut reliability, mut events) = spawn_reliability( + outbox, + ready(true), + Duration::from_secs(1), + 5, + Duration::from_millis(100), + ); + + reliability + .dispatch(frame("a"), true, unix_now()) + .expect("dispatch"); + assert!(matches!(outbound.try_recv(), Ok(Outbound::Frame(_)))); + + reliability.mark_delivered(&message_id("a")); + assert_eq!( + events.try_recv().expect("event"), + ClientEvent::Delivered { + id: message_id("a") + } + ); + } + + #[tokio::test(start_paused = true)] + async fn retries_then_fails() { + let (outbox, mut outbound) = mpsc::channel(16); + let (mut reliability, mut events) = spawn_reliability( + outbox, + ready(true), + Duration::from_secs(1), + 2, + Duration::from_millis(100), + ); + + reliability + .dispatch(frame("a"), true, unix_now()) + .expect("dispatch"); + assert!(matches!(outbound.try_recv(), Ok(Outbound::Frame(_)))); + + // Let the retry task run through the backoff schedule and give up. + for _ in 0..200 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + + let mut resends = 0; + while let Ok(Outbound::Frame(_)) = outbound.try_recv() { + resends += 1; + } + assert!( + resends >= 2, + "expected at least two retransmissions, got {resends}" + ); + + let mut failed = None; + while let Ok(event) = events.try_recv() { + if let ClientEvent::Failed { id, attempts } = event { + failed = Some((id, attempts)); + } + } + assert_eq!(failed, Some((message_id("a"), 2))); + } + + #[tokio::test(start_paused = true)] + async fn pauses_while_disconnected_and_flushes_on_reconnect() { + let (outbox, mut outbound) = mpsc::channel(16); + let ready = ready(false); + let (mut reliability, _events) = spawn_reliability( + outbox, + Arc::clone(&ready), + Duration::from_secs(1), + 3, + Duration::from_millis(100), + ); + + reliability + .dispatch(frame("a"), true, unix_now()) + .expect("dispatch"); + assert!( + outbound.try_recv().is_err(), + "a frame dispatched while not ready must not be queued" + ); + + for _ in 0..20 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + assert!( + outbound.try_recv().is_err(), + "no send expected while disconnected" + ); + + ready.store(true, Ordering::Relaxed); + for _ in 0..5 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + assert!( + matches!(outbound.try_recv(), Ok(Outbound::Frame(_))), + "expected a flush after readiness" + ); + } + + #[tokio::test(start_paused = true)] + async fn flushes_pending_frames_in_order_and_with_spacing() { + let (outbox, mut outbound) = mpsc::channel(16); + let ready = ready(false); + let (mut reliability, _events) = spawn_reliability( + outbox, + Arc::clone(&ready), + Duration::from_secs(60), + 5, + Duration::from_millis(100), + ); + + for id in ["a", "b", "c"] { + reliability + .dispatch(frame(id), true, unix_now()) + .expect("dispatch"); + } + ready.store(true, Ordering::Relaxed); + + let mut sent = Vec::new(); + for _ in 0..20 { + tokio::time::advance(Duration::from_millis(50)).await; + tokio::task::yield_now().await; + while let Ok(Outbound::Frame(frame)) = outbound.try_recv() { + sent.push(frame.id); + } + } + assert_eq!(sent, [message_id("a"), message_id("b"), message_id("c")]); + } + + #[tokio::test(start_paused = true)] + async fn expired_frames_fail_without_sending() { + let (outbox, mut outbound) = mpsc::channel(16); + let (mut reliability, mut events) = spawn_reliability( + outbox, + ready(false), + Duration::from_secs(1), + 5, + Duration::from_millis(100), + ); + + reliability + .dispatch(frame("old"), true, unix_now().saturating_sub(301)) + .expect("dispatch"); + for _ in 0..3 { + tokio::time::advance(Duration::from_millis(100)).await; + tokio::task::yield_now().await; + } + + assert!(outbound.try_recv().is_err()); + assert!(matches!( + events.try_recv(), + Ok(ClientEvent::Failed { id, .. }) if id == message_id("old") + )); + } + + #[tokio::test] + async fn fail_all_reports_every_pending_frame() { + let (outbox, _outbound) = mpsc::channel(16); + let (mut reliability, mut events) = spawn_reliability( + outbox, + ready(false), + Duration::from_secs(1), + 5, + Duration::from_millis(100), + ); + + for id in ["a", "b"] { + reliability + .dispatch(frame(id), true, unix_now()) + .expect("dispatch"); + } + reliability.fail_all(); + + let mut failed = Vec::new(); + while let Ok(event) = events.try_recv() { + if let ClientEvent::Failed { id, .. } = event { + failed.push(id); + } + } + failed.sort_by_key(|id| id.to_string()); + assert_eq!(failed, [message_id("a"), message_id("b")]); + assert!(reliability.pending.lock().expect("lock").is_empty()); + } +} diff --git a/crates/client/src/replay.rs b/crates/client/src/replay.rs new file mode 100644 index 0000000..b8b4341 --- /dev/null +++ b/crates/client/src/replay.rs @@ -0,0 +1,309 @@ +//! Per-key replay memory for authenticated inbound envelopes. + +use std::collections::HashMap; +use std::time::Duration; + +use wallet_session_core::message::{Message, MessageId}; + +/// Maximum records kept per key. +pub(crate) const CAPACITY: usize = 8192; + +/// How a received envelope relates to the replay memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Observation { + /// A new envelope: record it and deliver it. + New, + + /// The identical envelope was already accepted: acknowledge it again, + /// but do not deliver it again. + Retransmission, + + /// An id or nonce was reused with different content. + Conflict, + + /// A new envelope cannot be recorded without exceeding the memory cap. + Full, +} + +struct Record { + digest: [u8; 32], + nonce: Vec, + ts: u64, + expires_at: u64, +} + +/// Replay memory for one key. +/// +/// A record is kept until the local clock passes the envelope's `ts` plus the +/// accepted clock-skew window. When a record expires, its `ts` is remembered, +/// so a clock that steps backward cannot make the discarded envelope +/// acceptable again. +pub(crate) struct ReplayMemory { + retention_secs: u64, + capacity: usize, + by_id: HashMap, + by_nonce: HashMap, MessageId>, + largest_discarded_ts: Option, +} + +impl ReplayMemory { + /// Build an empty replay memory with the given retention window and cap. + pub(crate) fn new(retention: Duration, capacity: usize) -> Self { + Self { + retention_secs: retention.as_secs(), + capacity, + by_id: HashMap::new(), + by_nonce: HashMap::new(), + largest_discarded_ts: None, + } + } + + /// Whether `ts` is at or below a timestamp already discarded from the + /// replay memory. + pub(crate) fn is_stale(&self, ts: u64) -> bool { + self.largest_discarded_ts + .is_some_and(|largest| ts <= largest) + } + + /// Classify an authenticated envelope against the replay memory. + /// + /// `now` is the local Unix time, after the caller has checked `message.ts` + /// against the clock-skew window. A [`Observation::New`] envelope is only + /// remembered once the caller has delivered or acknowledged it, with + /// [`ReplayMemory::record`]. + pub(crate) fn classify(&mut self, id: &MessageId, message: &Message, now: u64) -> Observation { + self.prune(now); + + if let Some(record) = self.by_id.get(id) { + return if record.digest == message.digest() { + Observation::Retransmission + } else { + log::debug!("replay conflict: frame {id} reused with different content"); + Observation::Conflict + }; + } + + if let Some(owner) = self.by_nonce.get(message.nonce.as_bytes()) + && owner != id + { + log::debug!( + "replay conflict: nonce {} used by frame {owner} and frame {id} \ + (kind={:?}, from={}, ts={})", + message.nonce, + message.kind, + message.from, + message.ts + ); + return Observation::Conflict; + } + + if self.by_id.len() >= self.capacity { + return Observation::Full; + } + + Observation::New + } + + /// Remember an envelope that was classified as [`Observation::New`]. + pub(crate) fn record(&mut self, id: &MessageId, message: &Message) { + let nonce = message.nonce.as_bytes().to_vec(); + self.by_nonce.insert(nonce.clone(), id.clone()); + self.by_id.insert( + id.clone(), + Record { + digest: message.digest(), + nonce, + ts: message.ts, + expires_at: message.ts.saturating_add(self.retention_secs), + }, + ); + } + + /// Forget an envelope that was recorded but then rejected before delivery. + pub(crate) fn forget(&mut self, id: &MessageId) { + if let Some(record) = self.by_id.remove(id) { + self.by_nonce.remove(&record.nonce); + } + } + + /// Drop records whose expiry has passed and remember their timestamps. + fn prune(&mut self, now: u64) { + let expired: Vec = self + .by_id + .iter() + .filter(|(_, record)| record.expires_at < now) + .map(|(id, _)| id.clone()) + .collect(); + + for id in expired { + if let Some(record) = self.by_id.remove(&id) { + self.by_nonce.remove(&record.nonce); + self.largest_discarded_ts = Some( + self.largest_discarded_ts + .map_or(record.ts, |largest| largest.max(record.ts)), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wallet_session_core::{ + did::DidKey, + message::{Ciphertext, MessageType, Nonce}, + }; + + const TEST_CAPACITY: usize = 8; + + /// A distinct, valid message id for a test label. + fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) + } + + fn message_with_nonce(ts: u64, nonce: [u8; 24]) -> Message { + Message::new( + MessageType::Request, + DidKey::from_ed25519_bytes([1u8; 32]), + ts, + Nonce::new(nonce.to_vec()).expect("nonce"), + Ciphertext::new(vec![3, 4]), + ) + } + + fn message(ts: u64) -> Message { + message_with_nonce(ts, [2u8; 24]) + } + + fn nonce_with_counter(counter: u64) -> [u8; 24] { + let mut nonce = [7u8; 24]; + nonce[16..].copy_from_slice(&counter.to_be_bytes()); + nonce + } + + /// Classify and record, as the client does after delivering a message. + fn observe( + memory: &mut ReplayMemory, + id: &MessageId, + message: &Message, + now: u64, + ) -> Observation { + let observation = memory.classify(id, message, now); + if observation == Observation::New { + memory.record(id, message); + } + observation + } + + #[test] + fn classifies_retransmissions_and_conflicts() { + let mut memory = ReplayMemory::new(Duration::from_secs(300), TEST_CAPACITY); + let original = message(1_000); + + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &original, 1_000), + Observation::New + ); + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &original, 1_000), + Observation::Retransmission + ); + + let mut modified = original.clone(); + modified.data = Ciphertext::new(vec![9]); + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &modified, 1_000), + Observation::Conflict + ); + + // The same nonce under a different frame id is also a conflict. + assert_eq!( + observe(&mut memory, &message_id("frame-2"), &original, 1_000), + Observation::Conflict + ); + } + + #[test] + fn accepts_unseen_nonces_regardless_of_counter_order() { + let mut memory = ReplayMemory::new(Duration::from_secs(300), TEST_CAPACITY); + let later = message_with_nonce(1_000, nonce_with_counter(1)); + let earlier = message_with_nonce(1_000, nonce_with_counter(0)); + + // A later counter is accepted first. An unseen earlier counter is not + // a replay of it; counters only have to be unique per key. + assert_eq!( + observe(&mut memory, &message_id("frame-later"), &later, 1_000), + Observation::New + ); + assert_eq!( + observe(&mut memory, &message_id("frame-earlier"), &earlier, 1_000), + Observation::New + ); + } + + #[test] + fn expires_after_the_envelope_window() { + let mut memory = ReplayMemory::new(Duration::from_secs(300), TEST_CAPACITY); + let original = message(1_000); + + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &original, 1_000), + Observation::New + ); + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &original, 1_300), + Observation::Retransmission + ); + + // Pruning at 1_301 discards the record and remembers its timestamp. + assert_eq!( + observe(&mut memory, &message_id("frame-1"), &original, 1_301), + Observation::New + ); + assert!(memory.is_stale(1_000)); + assert!(!memory.is_stale(1_001)); + } + + #[test] + fn rejects_new_records_when_full_until_expiry() { + let mut memory = ReplayMemory::new(Duration::from_secs(300), 2); + for counter in 0..2 { + let message = message_with_nonce(1_000, nonce_with_counter(counter)); + let id = message_id(&format!("frame-{counter}")); + assert_eq!(observe(&mut memory, &id, &message, 1_000), Observation::New); + } + + let third = message_with_nonce(1_000, nonce_with_counter(2)); + assert_eq!( + observe(&mut memory, &message_id("frame-2"), &third, 1_000), + Observation::Full + ); + assert_eq!(memory.by_id.len(), 2, "a full memory does not grow"); + + // Expired records free capacity for newer timestamps. + let fourth = message_with_nonce(1_001, nonce_with_counter(3)); + assert_eq!( + observe(&mut memory, &message_id("frame-3"), &fourth, 1_301), + Observation::New + ); + } + + #[test] + fn discarded_timestamp_outlives_the_record() { + let mut memory = ReplayMemory::new(Duration::from_secs(300), TEST_CAPACITY); + let original = message(1_000); + + observe(&mut memory, &message_id("frame-1"), &original, 1_000); + let mut later = message(1_001); + later.nonce = Nonce::new([3u8; 24]).expect("nonce"); + observe(&mut memory, &message_id("frame-2"), &later, 1_301); + + // The record is gone, but a copy replayed after a clock rollback is + // still rejected by the remembered discarded timestamp. + assert!(memory.is_stale(original.ts)); + } +} diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs new file mode 100644 index 0000000..d79382e --- /dev/null +++ b/crates/client/src/session.rs @@ -0,0 +1,62 @@ +use wallet_session_core::{did::DidKey, message::Topic}; + +pub use wallet_session_core::connection::Role; + +/// Lifecycle state of a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + /// Connected, waiting for `session_ready`. + Idle, + + /// Proposal sent, awaiting approval. + ProposalSent, + + /// Proposal received, awaiting a local decision. + ProposalReceived, + + /// Session established. + Active, + + /// Proposal rejected. + Rejected, + + /// Session torn down. + Disconnected, +} + +/// A single paired session. +#[derive(Debug, Clone)] +pub struct Session { + /// Topic used for the initial handshake. + pub pairing_topic: Topic, + + /// Topic currently carrying application traffic. + pub topic: Topic, + + /// Local role. + pub role: Role, + + /// `did:key` of the peer, once known. + pub peer_did: Option, + + /// Current lifecycle state. + pub state: SessionState, +} + +impl Session { + /// Start a session on `topic` for the given role. + pub fn new(topic: Topic, role: Role) -> Self { + Self { + pairing_topic: topic.clone(), + topic, + role, + peer_did: None, + state: SessionState::Idle, + } + } + + /// Whether application messages may be exchanged. + pub fn is_active(&self) -> bool { + self.state == SessionState::Active + } +} diff --git a/crates/client/src/tls.rs b/crates/client/src/tls.rs new file mode 100644 index 0000000..0e056da --- /dev/null +++ b/crates/client/src/tls.rs @@ -0,0 +1,31 @@ +//! TLS provider setup for `wss://` relay connections. +//! +//! `tokio-tungstenite` enables rustls without selecting a crypto provider. +//! Enabling the `ring` feature on our direct rustls dependency gives rustls +//! exactly one provider feature, and this module installs it before the first +//! connection. If the embedding application already installed a provider, that +//! choice is kept. + +use std::sync::Once; + +static INSTALL: Once = Once::new(); + +/// Install the default rustls provider once per process. +pub(crate) fn ensure_crypto_provider() { + INSTALL.call_once(|| { + // An application may have installed its own provider already; in that + // case rustls returns it as an error and we leave it in place. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installs_a_process_default() { + ensure_crypto_provider(); + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + } +} diff --git a/crates/client/tests/e2e.rs b/crates/client/tests/e2e.rs new file mode 100644 index 0000000..50c32b0 --- /dev/null +++ b/crates/client/tests/e2e.rs @@ -0,0 +1,171 @@ +use std::{sync::Arc, time::Duration}; + +use serde_json::json; +use tokio::net::TcpListener; +use wallet_session_client::{ + Client, ClientConfig, ClientError, ClientEvent, ClientMessage, IdentityKey, PairingKey, Role, + Seed, SessionState, XChaCha20Poly1305Cipher, +}; +use wallet_session_relay::{ + config::NativeRelayTransportConfig, + limits::{Limiter, RelayLimitsConfig}, + registry::Registry, + server::serve, +}; + +const AUDIENCE: &str = "wss://relay.example.com"; + +type TestResult = Result<(), Box>; + +#[tokio::test] +async fn sealed_request_response() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run()).await??; + Ok(()) +} + +/// Request/response over the relay with real XChaCha20-Poly1305 encryption. +async fn run() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let key = *pairing.as_bytes(); + + let proposer_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_registration_id("reg-1"); + let responder_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE); + + let mut proposer = Client::connect( + proposer_config, + IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])), + Role::Proposer, + topic.clone(), + XChaCha20Poly1305Cipher::new(key)?, + ) + .await?; + let mut responder = Client::connect( + responder_config, + IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])), + Role::Responder, + topic.clone(), + XChaCha20Poly1305Cipher::new(key)?, + ) + .await?; + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + } + other => panic!("expected proposal, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + assert_ne!(proposer.session().topic, topic); + assert_ne!(responder.session().topic, topic); + assert_eq!(proposer.session().pairing_topic, topic); + assert_eq!(responder.session().pairing_topic, topic); + + assert!(matches!( + responder + .respond("invalid", Some(json!({})), Some(json!({}))) + .await, + Err(ClientError::ProtocolViolation(_)) + )); + assert!(matches!( + responder.respond("invalid", None, None).await, + Err(ClientError::ProtocolViolation(_)) + )); + + let request_id = proposer.request(json!({ "method": "get_status" })).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => { + assert_eq!(payload.request_id, request_id); + let request: serde_json::Value = serde_json::from_slice(&payload.request.0)?; + assert_eq!(request, json!({ "method": "get_status" })); + } + other => panic!("expected request, got {other:?}"), + } + + responder + .respond(request_id.clone(), Some(json!({ "result": "ok" })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => { + assert_eq!(payload.request_id, request_id); + let response = payload.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response, json!({ "result": "ok" })); + } + other => panic!("expected response, got {other:?}"), + } + + // The proposer's request was acknowledged by the responder. + let mut delivered = Vec::new(); + while let Some(ClientEvent::Delivered { id }) = proposer.poll_event() { + delivered.push(id); + } + assert!( + delivered.iter().any(|id| id.to_string() == request_id), + "expected a Delivered event for {request_id}, got {delivered:?}" + ); + + // A second request has no response. `next_event` must process the inbound + // ack itself, without the application calling `recv`. + let second = proposer + .request(json!({ "method": "wait_for_ack" })) + .await?; + match responder.recv().await? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, second), + other => panic!("expected the second request, got {other:?}"), + } + let event = tokio::time::timeout(Duration::from_secs(2), proposer.next_event()).await??; + assert!( + matches!(event, ClientEvent::Delivered { ref id } if id.to_string() == second), + "expected a Delivered event for {second}, got {event:?}" + ); + + // A local disconnect is terminal: later operations fail instead of hanging. + proposer.disconnect("done", 4000).await?; + match tokio::time::timeout(Duration::from_secs(2), responder.recv()).await?? { + ClientMessage::Disconnect(payload) => assert_eq!(payload.code, 4000), + other => panic!("expected disconnect, got {other:?}"), + } + assert_eq!(responder.session().state, SessionState::Disconnected); + assert!(matches!(proposer.recv().await, Err(ClientError::Closed))); + assert!(matches!( + proposer + .request(json!({ "method": "after_disconnect" })) + .await, + Err(ClientError::Closed) + )); + + Ok(()) +} diff --git a/crates/client/tests/live_relay.rs b/crates/client/tests/live_relay.rs new file mode 100644 index 0000000..9295d91 --- /dev/null +++ b/crates/client/tests/live_relay.rs @@ -0,0 +1,700 @@ +//! Opt-in tests against an already-deployed relay. +//! +//! `cargo test` never touches the network: every test in this file is +//! `#[ignore]`. Run them explicitly: +//! +//! ```text +//! WALLET_SESSION_RELAY_URL=wss://relay.example.com/ \ +//! WALLET_SESSION_RELAY_AUDIENCE=wss://relay.example.com \ +//! cargo test -p wallet-session-client --test live_relay -- --ignored --nocapture +//! ``` +//! +//! `WALLET_SESSION_RELAY_AUDIENCE` defaults to the relay URL. Set it +//! explicitly when the relay's configured audience differs from the URL used +//! to reach it (for example when a proxy rewrites the host). +//! +//! The tests share one deployment, so they run one at a time. They use random +//! identities and topics, so repeated runs do not collide with each other. + +use std::time::{Duration, Instant}; + +use futures_util::StreamExt; +use serde_json::json; +use tokio::{net::TcpStream, sync::Mutex, time::timeout}; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async, + tungstenite::{Message, protocol::frame::coding::CloseCode}, +}; +use url::Url; +use wallet_session_client::{ + Client, ClientConfig, ClientEvent, ClientMessage, IdentityKey, PairingKey, Role, Seed, + SessionState, XChaCha20Poly1305Cipher, +}; +use wallet_session_core::{connection::ConnectionQuery, message::Topic}; + +/// Connection token lifetime used by the live tests. It is longer than +/// `TEST_TIMEOUT` but shorter than the relay's ten-minute default cap, so the +/// tests still fit deployments that lower the cap. +const TOKEN_TTL: Duration = Duration::from_secs(240); + +/// Upper bound for one live test, including connect, reconnect and routing. +const TEST_TIMEOUT: Duration = Duration::from_secs(120); + +/// Request/response pairs sent by the long-session test. +const LONG_SESSION_MESSAGES: usize = 50; + +/// Pacing between pairs. Each pair produces one request frame from the +/// proposer and two frames from the responder (ack + response), so this stays +/// below the relay's default per-connection inbound message rate. +const LONG_SESSION_INTERVAL: Duration = Duration::from_millis(150); + +/// The long session needs more time than a single round trip. +const LONG_SESSION_TIMEOUT: Duration = Duration::from_secs(120); + +/// Serialize live tests: a small deployment may have per-source admission +/// limits that a parallel test run would trip. +static LIVE_LOCK: Mutex<()> = Mutex::const_new(()); + +type TestResult = Result<(), Box>; +type LiveClient = Client; +type RawStream = WebSocketStream>; + +/// A deployed relay and the audience its tokens must carry. +struct LiveRelay { + url: String, + audience: String, +} + +impl LiveRelay { + fn from_env() -> Self { + // Show the test's own progress lines and client warnings. Debug logs + // are opt-in through RUST_LOG because the client's connect debug line + // contains the connection token. + let _ = env_logger::Builder::from_env( + env_logger::Env::default() + .default_filter_or("live_relay=info,wallet_session_client=warn"), + ) + .is_test(true) + .try_init(); + + // Raw `connect_async` calls below bypass `Client::connect`, which is + // where the library normally installs the rustls provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let url = std::env::var("WALLET_SESSION_RELAY_URL").unwrap_or_else(|_| { + panic!( + "set WALLET_SESSION_RELAY_URL to the deployed relay, for example \ + wss://relay.example.com/ (live tests are ignored by default)" + ) + }); + let audience = + std::env::var("WALLET_SESSION_RELAY_AUDIENCE").unwrap_or_else(|_| url.clone()); + + eprintln!("live relay: url={url} audience={audience}"); + Self { url, audience } + } + + fn config(&self) -> ClientConfig { + ClientConfig::new(self.url.clone()) + .with_insecure_ws() + .with_audience(self.audience.clone()) + .with_token_ttl(TOKEN_TTL) + .with_idle_timeout(Duration::from_secs(30)) + .with_ack_base_delay(Duration::from_millis(250)) + .with_ack_max_attempts(8) + .with_ping_interval(Duration::from_secs(10)) + .with_pong_timeout(Duration::from_secs(10)) + .with_reconnect_initial_delay(Duration::from_millis(100)) + .with_reconnect_max_delay(Duration::from_millis(500)) + .with_reconnect_max_attempts(3) + } +} + +/// A connected proposer/responder pair plus the material needed to reconnect +/// the responder under the same identity. +struct Pair { + proposer: LiveClient, + responder: LiveClient, + topic: Topic, + key: [u8; 32], + responder_seed: Seed, +} + +async fn connect_pair(live: &LiveRelay) -> Result> { + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let key = *pairing.as_bytes(); + let responder_seed = Seed::random()?; + + let mut proposer = Client::connect( + live.config().with_registration_id("live-test"), + IdentityKey::random()?, + Role::Proposer, + topic.clone(), + XChaCha20Poly1305Cipher::new(key)?, + ) + .await?; + let mut responder = Client::connect( + live.config(), + IdentityKey::from_seed(&responder_seed), + Role::Responder, + topic.clone(), + XChaCha20Poly1305Cipher::new(key)?, + ) + .await?; + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + Ok(Pair { + proposer, + responder, + topic, + key, + responder_seed, + }) +} + +/// Complete the pairing handshake and wait for both session links. +async fn establish_session( + proposer: &mut LiveClient, + responder: &mut LiveClient, +) -> Result<(), Box> { + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + match responder.recv().await? { + ClientMessage::Proposal(_) => {} + other => panic!("expected proposal, got {other:?}"), + } + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + Ok(()) +} + +/// Build a handshake URL with the signed connection query. +fn url_with_query( + base: &str, + query: &ConnectionQuery, +) -> Result> { + let mut url = Url::parse(base)?; + url.set_query(Some(&serde_urlencoded::to_string(query)?)); + Ok(url.to_string()) +} + +/// Open a raw WebSocket connection, bypassing the client's reconnect logic so +/// handshake and close behavior can be asserted directly. +async fn connect_raw( + live: &LiveRelay, + identity: &IdentityKey, + role: Role, + topic: &Topic, +) -> Result> { + let token = identity.connection_token("live-test", &live.audience, TOKEN_TTL)?; + let query = ConnectionQuery { + auth: token, + role, + topic: topic.clone(), + registration_id: None, + }; + let url = url_with_query(&live.url, &query)?; + let (stream, response) = connect_async(url).await?; + + assert_eq!( + response.status().as_u16(), + 101, + "relay did not upgrade the WebSocket handshake" + ); + Ok(stream) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn full_session_round_trip() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(TEST_TIMEOUT, async { + let Pair { + mut proposer, + mut responder, + .. + } = connect_pair(&live).await?; + + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + } + other => panic!("expected proposal, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + let request_id = proposer.request(json!({ "method": "get_status" })).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => { + assert_eq!(payload.request_id, request_id); + let request: serde_json::Value = serde_json::from_slice(&payload.request.0)?; + assert_eq!(request, json!({ "method": "get_status" })); + } + other => panic!("expected request, got {other:?}"), + } + + responder + .respond(request_id.clone(), Some(json!({ "result": "ok" })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => { + assert_eq!(payload.request_id, request_id); + assert_eq!(payload.error, None); + let response = payload.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response, json!({ "result": "ok" })); + } + other => panic!("expected response, got {other:?}"), + } + + // The responder acknowledged the request before sending the response, + // so the delivery event is already queued when the response arrives. + let mut delivered = Vec::new(); + while let Some(event) = proposer.poll_event() { + if let ClientEvent::Delivered { id } = event { + delivered.push(id); + } + } + assert!( + delivered.iter().any(|id| id.to_string() == request_id), + "expected a Delivered event for {request_id}, got {delivered:?}" + ); + + Ok::<(), Box>(()) + }) + .await + .expect("live relay test timed out")?; + + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn long_session_with_100_messages() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(LONG_SESSION_TIMEOUT, async { + let Pair { + mut proposer, + mut responder, + .. + } = connect_pair(&live).await?; + + eprintln!("long session: proposing"); + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + eprintln!("long session: proposal received"); + } + other => panic!("expected proposal, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + eprintln!("long session: approved; starting {LONG_SESSION_MESSAGES} messages"); + + // A non-trivial payload also exercises the relay's byte accounting. + let payload = "x".repeat(512); + let started_at = Instant::now(); + let mut latencies = Vec::with_capacity(LONG_SESSION_MESSAGES); + + for seq in 1..=LONG_SESSION_MESSAGES { + let sent_at = Instant::now(); + let request_id = proposer + .request(json!({ + "seq": seq, + "method": "echo", + "data": payload.clone(), + })) + .await?; + eprintln!( + "[{seq:03}/{LONG_SESSION_MESSAGES}] proposer -> request id={request_id} bytes={}", + payload.len() + ); + + match responder.recv().await? { + ClientMessage::Request(message) => { + assert_eq!(message.request_id, request_id); + let request: serde_json::Value = serde_json::from_slice(&message.request.0)?; + assert_eq!(request["seq"].as_u64(), Some(seq as u64)); + assert_eq!(request["data"].as_str(), Some(payload.as_str())); + eprintln!( + "[{seq:03}/{LONG_SESSION_MESSAGES}] responder <- request id={}", + message.request_id + ); + } + other => panic!("expected request {seq}, got {other:?}"), + } + + responder + .respond( + request_id.clone(), + Some(json!({ + "seq": seq, + "ok": true, + "data": payload.clone(), + })), + None, + ) + .await?; + eprintln!("[{seq:03}/{LONG_SESSION_MESSAGES}] responder -> response id={request_id}"); + + match proposer.recv().await? { + ClientMessage::Response(message) => { + assert_eq!(message.request_id, request_id); + assert_eq!(message.error, None); + let response = message.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response["seq"].as_u64(), Some(seq as u64)); + assert_eq!(response["ok"], json!(true)); + assert_eq!(response["data"].as_str(), Some(payload.as_str())); + } + other => panic!("expected response {seq}, got {other:?}"), + } + + let mut delivered = false; + while let Some(event) = proposer.poll_event() { + if let ClientEvent::Delivered { id } = event + && id.to_string() == request_id + { + delivered = true; + } + } + assert!( + delivered, + "no Delivered event for request {seq} ({request_id})" + ); + + let latency = sent_at.elapsed(); + latencies.push(latency); + eprintln!( + "[{seq:03}/{LONG_SESSION_MESSAGES}] proposer <- response id={request_id} \ + delivered=true rtt={latency:?} elapsed={:?}", + started_at.elapsed() + ); + + tokio::time::sleep(LONG_SESSION_INTERVAL).await; + } + + let total = started_at.elapsed(); + let sum: Duration = latencies.iter().sum(); + let average = sum / LONG_SESSION_MESSAGES as u32; + let minimum = latencies.iter().min().copied().unwrap_or_default(); + let maximum = latencies.iter().max().copied().unwrap_or_default(); + eprintln!( + "long session complete: messages={LONG_SESSION_MESSAGES} total={total:?} \ + avg_rtt={average:?} min_rtt={minimum:?} max_rtt={maximum:?}" + ); + + Ok::<(), Box>(()) + }) + .await + .expect("long live relay test timed out")?; + + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn disconnect_is_delivered_and_acknowledged() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(TEST_TIMEOUT, async { + let Pair { + mut proposer, + mut responder, + .. + } = connect_pair(&live).await?; + establish_session(&mut proposer, &mut responder).await?; + + // Prove the session is active before tearing it down. + let request_id = proposer + .request(json!({ "method": "before_disconnect" })) + .await?; + match responder.recv().await? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, request_id), + other => panic!("expected request before disconnect, got {other:?}"), + } + responder + .respond(request_id.clone(), Some(json!({ "ok": true })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => assert_eq!(payload.request_id, request_id), + other => panic!("expected response before disconnect, got {other:?}"), + } + + // Drain delivery events from the request above; the only tracked + // message left after this point is the disconnect. + let mut request_delivered = false; + while let Some(event) = proposer.poll_event() { + if let ClientEvent::Delivered { id } = event + && id.to_string() == request_id + { + request_delivered = true; + } + } + assert!( + request_delivered, + "pre-disconnect request was not acknowledged" + ); + eprintln!("disconnect test: session active"); + + let proposer_reason = "user closed the pairing"; + let proposer_code = 4000; + proposer.disconnect(proposer_reason, proposer_code).await?; + assert_eq!(proposer.session().state, SessionState::Disconnected); + eprintln!( + "disconnect test: proposer -> disconnect reason={proposer_reason:?} \ + code={proposer_code}" + ); + + match responder.recv().await? { + ClientMessage::Disconnect(payload) => { + assert_eq!(payload.reason, proposer_reason); + assert_eq!(payload.code, proposer_code); + assert!( + payload.message.is_empty(), + "unexpected message: {:?}", + payload.message + ); + eprintln!( + "disconnect test: responder <- disconnect reason={:?} code={}", + payload.reason, payload.code + ); + } + other => panic!("expected disconnect from proposer, got {other:?}"), + } + + // The responder acknowledges the teardown with its own disconnect. + let responder_reason = "wallet closed the pairing"; + let responder_code = 4001; + responder + .disconnect(responder_reason, responder_code) + .await?; + assert_eq!(responder.session().state, SessionState::Disconnected); + eprintln!( + "disconnect test: responder -> disconnect reason={responder_reason:?} \ + code={responder_code}" + ); + + // This recv processes the ack for the proposer's disconnect before + // returning the responder's disconnect frame. + match proposer.recv().await? { + ClientMessage::Disconnect(payload) => { + assert_eq!(payload.reason, responder_reason); + assert_eq!(payload.code, responder_code); + assert!( + payload.message.is_empty(), + "unexpected message: {:?}", + payload.message + ); + eprintln!( + "disconnect test: proposer <- disconnect reason={:?} code={}", + payload.reason, payload.code + ); + } + other => panic!("expected disconnect from responder, got {other:?}"), + } + + let mut delivered = false; + while let Some(event) = proposer.poll_event() { + if let ClientEvent::Delivered { .. } = event { + delivered = true; + } + } + assert!( + delivered, + "proposer did not receive a Delivered event for its disconnect" + ); + eprintln!("disconnect test: proposer disconnect acknowledged"); + + Ok::<(), Box>(()) + }) + .await + .expect("disconnect live relay test timed out")?; + + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn reconnect_resumes_buffered_frames() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(TEST_TIMEOUT, async { + let Pair { + mut proposer, + responder, + topic, + key, + responder_seed, + } = connect_pair(&live).await?; + + // Drop the responder without a disconnect message and give the relay + // time to reserve its slot before the proposer sends. + drop(responder); + tokio::time::sleep(Duration::from_millis(300)).await; + + // The proposal is buffered for the reserved responder slot. + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + + // The same identity reconnects within the deployment's grace window. + let mut responder = Client::connect( + live.config(), + IdentityKey::from_seed(&responder_seed), + Role::Responder, + topic.clone(), + XChaCha20Poly1305Cipher::new(key)?, + ) + .await?; + responder.wait_session_ready().await?; + + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + } + other => panic!("expected buffered proposal after reconnect, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + let request_id = proposer.request(json!({ "method": "get_status" })).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => { + assert_eq!(payload.request_id, request_id); + let request: serde_json::Value = serde_json::from_slice(&payload.request.0)?; + assert_eq!(request, json!({ "method": "get_status" })); + } + other => panic!("expected request after reconnect, got {other:?}"), + } + + responder + .respond(request_id.clone(), Some(json!({ "result": "ok" })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => { + assert_eq!(payload.request_id, request_id); + let response = payload.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response, json!({ "result": "ok" })); + } + other => panic!("expected response, got {other:?}"), + } + + Ok::<(), Box>(()) + }) + .await + .expect("live relay test timed out")?; + + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn wrong_audience_is_rejected() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(TEST_TIMEOUT, async { + let identity = IdentityKey::random()?; + let topic = PairingKey::random()?.topic()?; + let token = + identity.connection_token("live-test", "wss://wrong-audience.invalid", TOKEN_TTL)?; + let query = ConnectionQuery { + auth: token, + role: Role::Proposer, + topic, + registration_id: None, + }; + let url = url_with_query(&live.url, &query)?; + + let error = connect_async(url) + .await + .expect_err("relay accepted a token minted for the wrong audience"); + match error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + assert_eq!( + response.status().as_u16(), + 400, + "relay must answer a bad token with 400 Bad Request" + ); + } + other => panic!("expected HTTP 400 for a wrong-audience token, got {other:?}"), + } + + Ok::<(), Box>(()) + }) + .await + .expect("live relay test timed out")?; + + Ok(()) +} + +#[tokio::test] +#[ignore = "requires a deployed relay; set WALLET_SESSION_RELAY_URL"] +async fn role_slot_is_exclusive() -> TestResult { + let _guard = LIVE_LOCK.lock().await; + let live = LiveRelay::from_env(); + + timeout(TEST_TIMEOUT, async { + let topic = PairingKey::random()?.topic()?; + + // Occupy the proposer slot with one identity. + let _first = connect_raw(&live, &IdentityKey::random()?, Role::Proposer, &topic).await?; + tokio::time::sleep(Duration::from_millis(300)).await; + + // A different identity must not be able to take the same slot. + let mut second = + connect_raw(&live, &IdentityKey::random()?, Role::Proposer, &topic).await?; + let message = timeout(Duration::from_secs(5), second.next()) + .await + .expect("timed out waiting for the role rejection") + .expect("relay closed without a close frame")?; + + match message { + Message::Close(Some(frame)) => { + assert_eq!(frame.code, CloseCode::Policy); + assert_eq!(frame.reason.as_str(), "role already taken"); + } + other => panic!("expected a policy close for the taken role, got {other:?}"), + } + + Ok::<(), Box>(()) + }) + .await + .expect("live relay test timed out")?; + + Ok(()) +} diff --git a/crates/client/tests/plaintext.rs b/crates/client/tests/plaintext.rs new file mode 100644 index 0000000..274e121 --- /dev/null +++ b/crates/client/tests/plaintext.rs @@ -0,0 +1,1255 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message as WsMessage, +}; +use wallet_session_client::{ + AckPayload, ApprovePayload, Client, ClientConfig, ClientError, ClientEvent, ClientMessage, + IdentityKey, Opaque, PairingKey, ProposalPayload, RequestPayload, ResponsePayload, Role, Seed, + SessionState, + handshake::{EphemeralKeyPair, decode_public_key}, +}; +use wallet_session_relay::{ + config::NativeRelayTransportConfig, + limits::{Limiter, RateLimit, RelayLimitsConfig}, + registry::Registry, + server::serve, +}; + +use wallet_session_client::crypto::{CipherError, NONCE_LEN, SENDER_PREFIX_LEN, SealedPayload}; +use wallet_session_core::{ + connection::ConnectionQuery, + crypto::MessageCipher, + did::DidKey, + frame::{RelayFrame, RelayFrameBody}, + message::{Ciphertext, Message as Envelope, MessageId, MessageType, Nonce, Topic}, +}; + +const AUDIENCE: &str = "wss://relay.example.com"; + +type TestResult = Result<(), Box>; + +/// Process-unique prefix so two test clients don't share a nonce space. +static PREFIX_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Data the test cipher refuses to open, standing in for a bad AEAD tag. +const TAMPERED: &[u8] = b"plaintext-tampered"; + +/// Test-only passthrough cipher: no encryption, but a valid nonce sequence. +#[derive(Debug, Clone)] +pub struct PlaintextCipher { + prefix: [u8; SENDER_PREFIX_LEN], + counter: u64, +} + +impl PlaintextCipher { + /// Build a plaintext cipher with a fresh sender prefix. + pub fn new() -> Self { + let sequence = PREFIX_SEQ.fetch_add(1, Ordering::Relaxed); + let mut prefix = [0u8; SENDER_PREFIX_LEN]; + prefix[SENDER_PREFIX_LEN - 8..].copy_from_slice(&sequence.to_be_bytes()); + Self { prefix, counter: 0 } + } + + fn nonce(&self) -> Nonce { + let mut nonce = [0u8; NONCE_LEN]; + nonce[..SENDER_PREFIX_LEN].copy_from_slice(&self.prefix); + nonce[SENDER_PREFIX_LEN..].copy_from_slice(&self.counter.to_be_bytes()); + Nonce::new(nonce.to_vec()).expect("nonce length") + } +} + +impl Default for PlaintextCipher { + fn default() -> Self { + Self::new() + } +} + +impl MessageCipher for PlaintextCipher { + type Sealed = SealedPayload; + type Error = CipherError; + + fn seal(&mut self, _aad: &[u8], plaintext: &[u8]) -> Result { + let nonce = self.nonce(); + self.counter = self.counter.wrapping_add(1); + Ok(SealedPayload { + nonce, + data: Ciphertext::new(plaintext.to_vec()), + }) + } + + fn open(&self, _aad: &[u8], sealed: &SealedPayload) -> Result, CipherError> { + if sealed.data.as_bytes() == TAMPERED { + return Err(CipherError::Decrypt); + } + Ok(sealed.data.as_bytes().to_vec()) + } + + fn for_key(&self, _key: &[u8; 32]) -> Result { + Ok(Self::new()) + } +} + +#[tokio::test] +async fn plaintext_request_response() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run()).await??; + Ok(()) +} + +async fn run() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + + let proposer_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_registration_id("reg-1"); + let responder_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE); + + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + + let mut proposer = Client::connect( + proposer_config, + proposer_identity, + Role::Proposer, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let mut responder = Client::connect( + responder_config, + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + // Relay readiness is not session establishment. + assert_eq!(proposer.session().state, SessionState::Idle); + assert_eq!(responder.session().state, SessionState::Idle); + + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + } + other => panic!("expected proposal, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + assert_ne!(proposer.session().topic, topic); + assert_ne!(responder.session().topic, topic); + assert_eq!(proposer.session().pairing_topic, topic); + assert_eq!(responder.session().pairing_topic, topic); + + let request_id = proposer.request(json!({ "method": "get_status" })).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => { + assert_eq!(payload.request_id, request_id); + let request: serde_json::Value = serde_json::from_slice(&payload.request.0)?; + assert_eq!(request, json!({ "method": "get_status" })); + } + other => panic!("expected request, got {other:?}"), + } + + responder + .respond(request_id.clone(), Some(json!({ "result": "ok" })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => { + assert_eq!(payload.request_id, request_id); + assert_eq!(payload.error, None); + let response = payload.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response, json!({ "result": "ok" })); + } + other => panic!("expected response, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn responder_reconnects_within_grace_and_resumes() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_reconnect()).await??; + Ok(()) +} + +/// The responder drops abruptly, the proposer keeps going (its proposal is +/// buffered), and the same identity reconnects within the grace window. The +/// buffered proposal is redelivered and the handshake then completes normally. +async fn run_reconnect() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE).with_session_grace(Duration::from_secs(2)), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + + let proposer_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_registration_id("reg-1"); + let responder_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE); + let responder_seed = Seed::from_bytes([2u8; 32]); + + let mut proposer = Client::connect( + proposer_config, + IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])), + Role::Proposer, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let mut responder = Client::connect( + responder_config.clone(), + IdentityKey::from_seed(&responder_seed), + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + // The responder drops abruptly, without a disconnect message. + drop(responder); + // Let the relay observe the drop and reserve the responder's slot. + tokio::time::sleep(Duration::from_millis(300)).await; + + // Sent while the responder is away: the relay buffers it for redelivery. + proposer + .propose(json!({ "capabilities": ["read"] })) + .await?; + + // The same identity reconnects within the grace window. + let mut responder = Client::connect( + responder_config, + IdentityKey::from_seed(&responder_seed), + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + + responder.wait_session_ready().await?; + match responder.recv().await? { + ClientMessage::Proposal(payload) => { + let proposal: serde_json::Value = serde_json::from_slice(&payload.proposal.0)?; + assert_eq!(proposal, json!({ "capabilities": ["read"] })); + } + other => panic!("expected buffered proposal after reconnect, got {other:?}"), + } + + responder.approve(json!({ "ok": true })).await?; + assert!(matches!(proposer.recv().await?, ClientMessage::Approve(_))); + proposer.wait_session_ready().await?; + responder.wait_session_ready().await?; + + let request_id = proposer.request(json!({ "method": "get_status" })).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, request_id), + other => panic!("expected request after reconnect, got {other:?}"), + } + + responder + .respond(request_id.clone(), Some(json!({ "result": "ok" })), None) + .await?; + match proposer.recv().await? { + ClientMessage::Response(payload) => { + assert_eq!(payload.request_id, request_id); + let response = payload.response.expect("response body"); + let response: serde_json::Value = serde_json::from_slice(&response.0)?; + assert_eq!(response, json!({ "result": "ok" })); + } + other => panic!("expected response, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn survivor_is_notified_when_peer_does_not_reconnect() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_expiry()).await??; + Ok(()) +} + +/// The responder drops and does not reconnect, so after the grace window the +/// relay tells the survivor, whose `recv` fails with `PeerDisconnected`. +async fn run_expiry() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE).with_session_grace(Duration::from_millis(200)), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + + let proposer_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_registration_id("reg-1"); + let responder_config = ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE); + + let mut proposer = Client::connect( + proposer_config, + IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])), + Role::Proposer, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let responder = Client::connect( + responder_config, + IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])), + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + + proposer.wait_session_ready().await?; + + // The responder drops and never reconnects. + drop(responder); + + let outcome = proposer.recv().await; + assert!( + matches!(outcome, Err(ClientError::PeerDisconnected)), + "expected PeerDisconnected, got {outcome:?}" + ); + assert_eq!(proposer.session().state, SessionState::Disconnected); + assert!( + !proposer.is_ready(), + "peer disconnection must clear readiness" + ); + + Ok(()) +} + +type RawStream = WebSocketStream>; + +/// Connect to the relay as a raw peer, bypassing `Client`. +async fn connect_raw( + relay_url: &str, + identity: &IdentityKey, + role: Role, + topic: &Topic, +) -> Result> { + let token = identity.connection_token("raw-test", AUDIENCE, Duration::from_secs(60))?; + let query = ConnectionQuery { + auth: token, + role, + topic: topic.clone(), + registration_id: None, + }; + let mut url = url::Url::parse(relay_url)?; + url.set_query(Some(&serde_urlencoded::to_string(&query)?)); + let (stream, _) = connect_async(url.as_str()).await?; + Ok(stream) +} + +/// Read the next relay frame, ignoring WebSocket control frames. +async fn read_raw_frame(stream: &mut RawStream) -> Result> { + loop { + match stream.next().await { + Some(Ok(WsMessage::Text(text))) => return Ok(serde_json::from_str(&text)?), + Some(Ok(WsMessage::Ping(_) | WsMessage::Pong(_))) => continue, + Some(Ok(WsMessage::Close(frame))) => { + return Err(std::io::Error::other(format!( + "relay closed the connection: {frame:?}" + )) + .into()); + } + Some(Ok(_)) => continue, + Some(Err(error)) => return Err(error.into()), + None => return Err(std::io::Error::other("relay stream ended").into()), + } + } +} + +/// Read and decrypt the next application message from the raw peer. +async fn read_raw_message( + stream: &mut RawStream, + cipher: &PlaintextCipher, +) -> Result> { + let frame = read_raw_frame(stream).await?; + let RelayFrameBody::Message { payload } = frame.body else { + return Err(std::io::Error::other("expected a message frame").into()); + }; + let wire: Envelope = serde_json::from_str(&payload)?; + let bytes = cipher.open( + &[], + &SealedPayload { + nonce: wire.nonce, + data: wire.data, + }, + )?; + Ok(ClientMessage::from_bytes(&bytes)?) +} + +/// A distinct, valid message id for a test label. +fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) +} + +fn client_frame( + topic: &Topic, + from: DidKey, + id: &str, + message: &ClientMessage, + cipher: &mut PlaintextCipher, +) -> Result> { + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + let plaintext = message.to_bytes()?; + let sealed = cipher.seal(&[], &plaintext)?; + let wire = Envelope::new( + message.message_type(), + from, + ts.as_secs(), + sealed.nonce, + sealed.data, + ); + Ok(RelayFrame { + v: 1, + id: message_id(id), + topic: topic.clone(), + body: RelayFrameBody::Message { + payload: serde_json::to_string(&wire)?, + }, + }) +} + +/// Build a frame carrying arbitrary envelope data, bypassing a real cipher. +fn envelope_frame( + topic: &Topic, + from: DidKey, + id: &str, + kind: MessageType, + nonce: [u8; 24], + data: &[u8], +) -> Result> { + let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; + let wire = Envelope::new( + kind, + from, + ts.as_secs(), + Nonce::new(nonce.to_vec())?, + Ciphertext::new(data.to_vec()), + ); + Ok(RelayFrame { + v: 1, + id: message_id(id), + topic: topic.clone(), + body: RelayFrameBody::Message { + payload: serde_json::to_string(&wire)?, + }, + }) +} + +fn request_frame( + topic: &Topic, + from: DidKey, + id: &str, + request_id: &str, + cipher: &mut PlaintextCipher, +) -> Result> { + client_frame( + topic, + from, + id, + &ClientMessage::Request(RequestPayload { + request_id: request_id.to_owned(), + request: Opaque(serde_json::to_vec(&json!({ "method": "get_status" }))?), + }), + cipher, + ) +} + +/// Read frames until the relay sends `session_ready`. +async fn wait_raw_session_ready(stream: &mut RawStream) -> Result<(), Box> { + loop { + let frame = read_raw_frame(stream).await?; + if matches!(frame.body, RelayFrameBody::SessionReady) { + return Ok(()); + } + } +} + +/// A raw proposer connected to both the pairing and session topics. +struct RawSession { + pairing: RawStream, + pairing_topic: Topic, + pairing_cipher: PlaintextCipher, + approve: RelayFrame, + session: RawStream, + cipher: PlaintextCipher, + topic: Topic, +} + +/// Drive a `Client` responder through a handshake with a raw proposer, then +/// return a session-topic connection for the raw proposer. +async fn establish_raw_session( + relay_url: &str, + pairing_topic: &Topic, + responder: &mut Client, + proposer_identity: &IdentityKey, +) -> Result> { + let mut pairing = + connect_raw(relay_url, proposer_identity, Role::Proposer, pairing_topic).await?; + responder.wait_session_ready().await?; + wait_raw_session_ready(&mut pairing).await?; + + let keypair = EphemeralKeyPair::generate(); + let mut cipher = PlaintextCipher::new(); + let proposal = ClientMessage::Proposal(ProposalPayload { + ephemeral_public_key: keypair.public_key_encoded(), + session_expiry: 0, + proposal: Opaque(serde_json::to_vec(&json!({ "test": true }))?), + }); + let frame = client_frame( + pairing_topic, + proposer_identity.did(), + "raw-proposal", + &proposal, + &mut cipher, + )?; + pairing + .send(WsMessage::text(serde_json::to_string(&frame)?)) + .await?; + + match responder.recv().await? { + ClientMessage::Proposal(_) => {} + other => return Err(format!("expected proposal, got {other:?}").into()), + } + responder.approve(json!({ "test": true })).await?; + + let (approve_id, approve, approve_frame) = loop { + let frame = read_raw_frame(&mut pairing).await?; + let approve_frame = frame.clone(); + let RelayFrameBody::Message { payload } = frame.body else { + continue; + }; + let wire: Envelope = serde_json::from_str(&payload)?; + if wire.kind != MessageType::Approve { + continue; + } + let bytes = cipher.open( + &[], + &SealedPayload { + nonce: wire.nonce, + data: wire.data, + }, + )?; + break (frame.id, ClientMessage::from_bytes(&bytes)?, approve_frame); + }; + let ClientMessage::Approve(approve) = approve else { + return Err("expected approve payload".into()); + }; + let peer_public = decode_public_key(&approve.ephemeral_public_key)?; + let (_key, session_topic) = keypair.derive(&peer_public)?; + let session_topic = session_topic.as_topic().clone(); + + let ack = ClientMessage::Ack(AckPayload { + ack_id: approve_id.clone(), + }); + let ack_frame = client_frame( + pairing_topic, + proposer_identity.did(), + "raw-approve-ack", + &ack, + &mut cipher, + )?; + pairing + .send(WsMessage::text(serde_json::to_string(&ack_frame)?)) + .await?; + + // Wait until the responder has consumed the ack, which also retires the + // pairing link. Returning after session_ready alone races with that ack. + loop { + match responder.next_event().await? { + ClientEvent::Delivered { id } if id == approve_id => break, + ClientEvent::Failed { id, .. } if id == approve_id => { + return Err("approve was not acknowledged".into()); + } + _ => {} + } + } + + let mut session = + connect_raw(relay_url, proposer_identity, Role::Proposer, &session_topic).await?; + wait_raw_session_ready(&mut session).await?; + responder.wait_session_ready().await?; + + Ok(RawSession { + pairing, + pairing_topic: pairing_topic.clone(), + pairing_cipher: cipher, + approve: approve_frame, + session, + cipher: PlaintextCipher::new(), + topic: session_topic, + }) +} + +/// Drive `recv` and the raw stream together, requiring neither a delivered +/// message nor a relay frame (such as an ack) before the timeout. +async fn expect_no_delivery_or_ack( + responder: &mut Client, + raw: &mut RawStream, +) -> TestResult { + let mut receiving = Box::pin(responder.recv()); + let mut incoming = Box::pin(read_raw_frame(raw)); + tokio::select! { + result = &mut receiving => panic!("unexpected delivery: {result:?}"), + result = &mut incoming => panic!("unexpected relay frame: {result:?}"), + _ = tokio::time::sleep(Duration::from_millis(300)) => {} + } + Ok(()) +} + +#[tokio::test] +async fn retransmission_is_reacknowledged() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_retransmission()).await??; + Ok(()) +} + +/// An identical retransmission must not be delivered twice, but must be +/// acknowledged again. +async fn run_retransmission() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE), + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let RawSession { + mut session, + mut cipher, + topic: session_topic, + .. + } = establish_raw_session(&relay_url, &topic, &mut responder, &proposer_identity).await?; + + let request_id = "raw-request-1"; + let frame = request_frame( + &session_topic, + proposer_identity.did(), + "raw-frame-1", + request_id, + &mut cipher, + )?; + let text = serde_json::to_string(&frame)?; + + session.send(WsMessage::text(text.clone())).await?; + match responder.recv().await? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, request_id), + other => panic!("expected request, got {other:?}"), + } + match read_raw_message(&mut session, &cipher).await? { + ClientMessage::Ack(payload) => assert_eq!(payload.ack_id, frame.id), + other => panic!("expected ack, got {other:?}"), + } + + // Retransmit the identical envelope. It must not reach the application + // again, but the responder must send a fresh ack. + session.send(WsMessage::text(text)).await?; + let receiving = tokio::spawn(async move { responder.recv().await }); + let ack = tokio::time::timeout( + Duration::from_secs(2), + read_raw_message(&mut session, &cipher), + ) + .await??; + match ack { + ClientMessage::Ack(payload) => assert_eq!(payload.ack_id, frame.id), + other => panic!("expected re-ack, got {other:?}"), + } + assert!( + !receiving.is_finished(), + "the duplicate must not be delivered to the application again" + ); + receiving.abort(); + + Ok(()) +} + +#[tokio::test] +async fn pairing_topic_rejects_application_traffic() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_pairing_topic_guard()).await??; + Ok(()) +} + +/// Application traffic must not use the pairing key before or after the +/// handshake, and the session topic must keep working. +async fn run_pairing_topic_guard() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_ack_base_delay(Duration::from_secs(60)), + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + + assert!(matches!( + responder.request(json!({ "method": "too_early" })).await, + Err(ClientError::SessionNotActive) + )); + + let mut raw = + establish_raw_session(&relay_url, &topic, &mut responder, &proposer_identity).await?; + + // Once the approve has been acknowledged, the pairing link is retired: + // even an otherwise valid handshake retransmission is no longer re-acked. + let _ = raw + .pairing + .send(WsMessage::text(serde_json::to_string(&raw.approve)?)) + .await; + match tokio::time::timeout(Duration::from_millis(300), read_raw_frame(&mut raw.pairing)).await { + Err(_) => {} + Ok(Err(_)) => {} + Ok(Ok(frame)) => { + panic!("the retired pairing link must not acknowledge handshake traffic, got {frame:?}") + } + } + + // The pairing key still exists locally, but an application envelope on the + // pairing topic is rejected even though the session is established. + let pairing_request = request_frame( + &raw.pairing_topic, + proposer_identity.did(), + "pairing-app-frame", + "pairing-app-request", + &mut raw.pairing_cipher, + )?; + raw.pairing + .send(WsMessage::text(serde_json::to_string(&pairing_request)?)) + .await?; + expect_no_delivery_or_ack(&mut responder, &mut raw.session).await?; + + // The session topic remains usable. + let session_request = request_frame( + &raw.topic, + proposer_identity.did(), + "session-frame", + "session-request", + &mut raw.cipher, + )?; + raw.session + .send(WsMessage::text(serde_json::to_string(&session_request)?)) + .await?; + match responder.recv().await? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, "session-request"), + other => panic!("expected session request, got {other:?}"), + } + match read_raw_message(&mut raw.session, &raw.cipher).await? { + ClientMessage::Ack(payload) => assert_eq!(payload.ack_id, message_id("session-frame")), + other => panic!("expected session ack, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn unexpected_approve_preserves_the_handshake() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_unexpected_approve()).await??; + Ok(()) +} + +/// A bad `approve` in the wrong handshake state must not discard the peer's +/// proposal, so the real approval can still complete. +async fn run_unexpected_approve() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE), + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let mut raw = connect_raw(&relay_url, &proposer_identity, Role::Proposer, &topic).await?; + responder.wait_session_ready().await?; + wait_raw_session_ready(&mut raw).await?; + + let mut cipher = PlaintextCipher::new(); + let keypair = EphemeralKeyPair::generate(); + let proposal = ClientMessage::Proposal(ProposalPayload { + ephemeral_public_key: keypair.public_key_encoded(), + session_expiry: 0, + proposal: Opaque(serde_json::to_vec(&json!({ "test": true }))?), + }); + let proposal = client_frame( + &topic, + proposer_identity.did(), + "unexpected-proposal", + &proposal, + &mut cipher, + )?; + raw.send(WsMessage::text(serde_json::to_string(&proposal)?)) + .await?; + assert!(matches!( + responder.recv().await?, + ClientMessage::Proposal(_) + )); + + let bad = ClientMessage::Approve(ApprovePayload { + ephemeral_public_key: EphemeralKeyPair::generate().public_key_encoded(), + approval: Opaque(serde_json::to_vec(&json!({ "test": true }))?), + }); + let bad = client_frame( + &topic, + proposer_identity.did(), + "unexpected-approve", + &bad, + &mut cipher, + )?; + raw.send(WsMessage::text(serde_json::to_string(&bad)?)) + .await?; + assert!( + matches!( + responder.recv().await, + Err(ClientError::UnexpectedHandshake) + ), + "an approve in the wrong state must be rejected" + ); + + // The original PeerProposed state is still intact. + responder.approve(json!({ "ok": true })).await?; + + Ok(()) +} + +#[tokio::test] +async fn oversized_relay_frame_is_rejected() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_oversized_frame()).await??; + Ok(()) +} + +/// A relay that forwards a frame above the client's WebSocket limit must cause +/// a transport failure, not an application delivery. +async fn run_oversized_frame() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig { + inbound_byte_rate: RateLimit { + burst: 1024 * 1024, + interval: Duration::from_secs(1), + }, + ..RelayLimitsConfig::default() + })), + NativeRelayTransportConfig::new(AUDIENCE).with_max_incoming_frame_bytes(512 * 1024), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE), + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let mut raw = + establish_raw_session(&relay_url, &topic, &mut responder, &proposer_identity).await?; + + // Two base64 layers (the opaque payload and the envelope ciphertext) put + // this above 256 KiB on the wire while staying inside the relay's default + // byte budget. + let request = ClientMessage::Request(RequestPayload { + request_id: "oversized".to_owned(), + request: Opaque(vec![0u8; 200 * 1024]), + }); + let frame = client_frame( + &raw.topic, + proposer_identity.did(), + "oversized-frame", + &request, + &mut raw.cipher, + )?; + let text = serde_json::to_string(&frame)?; + assert!(text.len() > 256 * 1024); + raw.session.send(WsMessage::text(text)).await?; + + if let Ok(Ok(message)) = + tokio::time::timeout(Duration::from_millis(500), responder.recv()).await + { + panic!("oversized frame was delivered: {message:?}"); + } + + Ok(()) +} + +#[tokio::test] +async fn recoverable_bad_frames_are_dropped() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_recoverable_bad_frames()).await??; + Ok(()) +} + +/// A malformed envelope, a failed AEAD open, and plaintext that is not a client +/// message must not end the receive loop. The next valid message is delivered. +async fn run_recoverable_bad_frames() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE), + responder_identity, + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let mut raw = + establish_raw_session(&relay_url, &topic, &mut responder, &proposer_identity).await?; + + let malformed = RelayFrame { + v: 1, + id: message_id("malformed-envelope"), + topic: raw.topic.clone(), + body: RelayFrameBody::Message { + payload: "not json".to_owned(), + }, + }; + raw.session + .send(WsMessage::text(serde_json::to_string(&malformed)?)) + .await?; + + let bad_plaintext = envelope_frame( + &raw.topic, + proposer_identity.did(), + "bad-plaintext", + MessageType::Request, + [11u8; 24], + b"not a client message", + )?; + raw.session + .send(WsMessage::text(serde_json::to_string(&bad_plaintext)?)) + .await?; + + let bad_tag = envelope_frame( + &raw.topic, + proposer_identity.did(), + "bad-tag", + MessageType::Request, + [12u8; 24], + TAMPERED, + )?; + raw.session + .send(WsMessage::text(serde_json::to_string(&bad_tag)?)) + .await?; + + let invalid_response = ClientMessage::Response(ResponsePayload { + request_id: "invalid".to_owned(), + response: Some(Opaque(Vec::new())), + error: Some(Opaque(Vec::new())), + }); + let invalid_response = client_frame( + &raw.topic, + proposer_identity.did(), + "invalid-response", + &invalid_response, + &mut raw.cipher, + )?; + raw.session + .send(WsMessage::text(serde_json::to_string(&invalid_response)?)) + .await?; + + let valid = request_frame( + &raw.topic, + proposer_identity.did(), + "valid-after-bad", + "valid-request", + &mut raw.cipher, + )?; + raw.session + .send(WsMessage::text(serde_json::to_string(&valid)?)) + .await?; + + match tokio::time::timeout(Duration::from_secs(2), responder.recv()).await?? { + ClientMessage::Request(payload) => assert_eq!(payload.request_id, "valid-request"), + other => panic!("expected the valid request, got {other:?}"), + } + match read_raw_message(&mut raw.session, &raw.cipher).await? { + ClientMessage::Ack(payload) => assert_eq!(payload.ack_id, message_id("valid-after-bad")), + other => panic!("expected an ack for the valid request, got {other:?}"), + } + + Ok(()) +} + +#[tokio::test] +async fn own_and_foreign_identities_are_rejected() -> TestResult { + tokio::time::timeout(Duration::from_secs(10), run_identities()).await??; + Ok(()) +} + +/// Frames from the receiver's own identity, and from an identity other than +/// the established counterpart, must not be delivered or acknowledged. +async fn run_identities() -> TestResult { + let _ = env_logger::builder() + .is_test(true) + .parse_default_env() + .try_init(); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + tokio::spawn(serve( + listener, + Arc::new(Registry::new()), + Arc::new(Limiter::new(RelayLimitsConfig::default())), + NativeRelayTransportConfig::new(AUDIENCE), + )); + + let relay_url = format!("ws://{addr}"); + let pairing = PairingKey::random()?; + let topic = pairing.topic()?; + + let responder_identity = IdentityKey::from_seed(&Seed::from_bytes([2u8; 32])); + let proposer_identity = IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])); + let foreign_identity = IdentityKey::from_seed(&Seed::from_bytes([9u8; 32])); + + let mut responder = Client::connect( + ClientConfig::new(&relay_url) + .with_insecure_ws() + .with_audience(AUDIENCE), + responder_identity.clone(), + Role::Responder, + topic.clone(), + PlaintextCipher::new(), + ) + .await?; + let RawSession { + mut session, + mut cipher, + topic: session_topic, + .. + } = establish_raw_session(&relay_url, &topic, &mut responder, &proposer_identity).await?; + + assert_eq!(responder.session().peer_did, Some(proposer_identity.did())); + assert_eq!(responder.session().state, SessionState::Active); + + // The receiver's own identity is rejected. + let own = request_frame( + &session_topic, + responder_identity.did(), + "own-frame", + "own-request", + &mut cipher, + )?; + session + .send(WsMessage::text(serde_json::to_string(&own)?)) + .await?; + expect_no_delivery_or_ack(&mut responder, &mut session).await?; + + // A different sender is rejected because the counterpart is pinned to the + // proposer that completed the handshake. + let foreign = request_frame( + &session_topic, + foreign_identity.did(), + "foreign-frame", + "foreign-request", + &mut cipher, + )?; + session + .send(WsMessage::text(serde_json::to_string(&foreign)?)) + .await?; + expect_no_delivery_or_ack(&mut responder, &mut session).await?; + assert_eq!(responder.session().peer_did, Some(proposer_identity.did())); + + // A later frame from the counterpart proves the rejected frames were + // skipped rather than merely still queued. + let after = request_frame( + &session_topic, + proposer_identity.did(), + "after-foreign-frame", + "after-foreign-request", + &mut cipher, + )?; + session + .send(WsMessage::text(serde_json::to_string(&after)?)) + .await?; + match responder.recv().await? { + ClientMessage::Request(payload) => { + assert_eq!(payload.request_id, "after-foreign-request"); + } + other => panic!("expected the post-foreign request, got {other:?}"), + } + match read_raw_message(&mut session, &cipher).await? { + ClientMessage::Ack(payload) => { + assert_eq!(payload.ack_id, message_id("after-foreign-frame")); + } + other => panic!("expected ack for the post-foreign request, got {other:?}"), + } + + Ok(()) +} diff --git a/crates/client/tests/transport.rs b/crates/client/tests/transport.rs new file mode 100644 index 0000000..12ba3e4 --- /dev/null +++ b/crates/client/tests/transport.rs @@ -0,0 +1,313 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio::{ + net::{TcpListener, TcpStream}, + sync::oneshot, + time::timeout, +}; +use tokio_tungstenite::{ + accept_async, + tungstenite::{ + Bytes, Message, + protocol::{CloseFrame, frame::coding::CloseCode}, + }, +}; +use wallet_session_client::{ + Client, ClientConfig, ClientError, IdentityKey, Role, Seed, XChaCha20Poly1305Cipher, +}; +use wallet_session_core::message::Topic; + +const AUDIENCE: &str = "ws://test"; + +type TestResult = Result<(), Box>; +type TestClient = Client; + +fn config(addr: std::net::SocketAddr) -> ClientConfig { + ClientConfig::new(format!("ws://{addr}")) + .with_insecure_ws() + .with_audience(AUDIENCE) + .with_connect_timeout(Duration::from_secs(1)) + .with_write_timeout(Duration::from_secs(1)) + .with_close_timeout(Duration::from_secs(1)) + .with_ping_interval(Duration::from_secs(30)) + .with_pong_timeout(Duration::from_secs(5)) +} + +async fn connect( + _addr: std::net::SocketAddr, + config: ClientConfig, +) -> Result> { + Ok(Client::connect( + config, + IdentityKey::from_seed(&Seed::from_bytes([1u8; 32])), + Role::Proposer, + Topic::new([9u8; 32])?, + XChaCha20Poly1305Cipher::new([7u8; 32])?, + ) + .await?) +} + +async fn accept(listener: &TcpListener) -> std::io::Result { + let (stream, _) = listener.accept().await?; + Ok(stream) +} + +#[tokio::test] +async fn policy_close_is_terminal_and_surfaced() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let accepts = Arc::new(AtomicUsize::new(0)); + let server = { + let accepts = Arc::clone(&accepts); + tokio::spawn(async move { + loop { + let Ok(stream) = accept(&listener).await else { + return; + }; + accepts.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + let Ok(mut socket) = accept_async(stream).await else { + return; + }; + let _ = socket + .close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "role already taken".into(), + })) + .await; + }); + } + }) + }; + + let mut client = connect(addr, config(addr)).await?; + let error = timeout(Duration::from_secs(2), client.recv()) + .await? + .expect_err("unexpected message"); + assert!( + matches!(error, ClientError::Transport("role already taken")), + "unexpected error: {error:?}" + ); + let again = timeout(Duration::from_millis(100), client.recv()) + .await? + .expect_err("the terminal state must not hang"); + assert!( + matches!(again, ClientError::Transport("role already taken")), + "unexpected repeated error: {again:?}" + ); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + accepts.load(Ordering::Relaxed), + 1, + "a policy rejection must not reconnect" + ); + server.abort(); + Ok(()) +} + +#[tokio::test] +async fn token_expiry_reconnects_without_backoff() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let accepts = Arc::new(AtomicUsize::new(0)); + let server = { + let accepts = Arc::clone(&accepts); + tokio::spawn(async move { + loop { + let Ok(stream) = accept(&listener).await else { + return; + }; + let accepted = accepts.fetch_add(1, Ordering::Relaxed) + 1; + tokio::spawn(async move { + let Ok(mut socket) = accept_async(stream).await else { + return; + }; + if accepted == 1 { + let _ = socket + .close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "token expired".into(), + })) + .await; + } else { + // Keep the reconnected socket open for the assertion. + let _ = socket.next().await; + } + }); + } + }) + }; + + let _client = connect( + addr, + config(addr).with_reconnect_initial_delay(Duration::from_secs(5)), + ) + .await?; + + let started = Instant::now(); + timeout(Duration::from_secs(1), async { + while accepts.load(Ordering::Relaxed) < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("token expiry did not reconnect immediately"); + assert!( + started.elapsed() < Duration::from_secs(1), + "token expiry must bypass the reconnect backoff" + ); + server.abort(); + Ok(()) +} + +#[tokio::test] +async fn reconnect_attempts_are_exactly_bounded() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let accepts = Arc::new(AtomicUsize::new(0)); + let server = { + let accepts = Arc::clone(&accepts); + tokio::spawn(async move { + loop { + let Ok(stream) = accept(&listener).await else { + return; + }; + accepts.fetch_add(1, Ordering::Relaxed); + tokio::spawn(async move { + let Ok(mut socket) = accept_async(stream).await else { + return; + }; + let _ = socket + .close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "token expired".into(), + })) + .await; + }); + } + }) + }; + + let mut client = connect(addr, config(addr).with_reconnect_max_attempts(2)).await?; + let error = timeout(Duration::from_secs(1), client.recv()) + .await? + .expect_err("unexpected message"); + assert!( + matches!(error, ClientError::Transport("token expired")), + "unexpected error: {error:?}" + ); + assert_eq!( + accepts.load(Ordering::Relaxed), + 3, + "one initial connection plus exactly two retries" + ); + server.abort(); + Ok(()) +} + +#[tokio::test] +async fn shutdown_sends_a_normal_close_frame() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (connected_tx, connected_rx) = oneshot::channel(); + let (close_tx, close_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + let stream = accept(&listener).await.expect("accepted"); + let mut socket = accept_async(stream).await.expect("websocket"); + socket + .send(Message::Ping(Bytes::new())) + .await + .expect("ping"); + let mut connected_tx = Some(connected_tx); + while let Some(Ok(message)) = socket.next().await { + match message { + Message::Pong(_) => { + if let Some(tx) = connected_tx.take() { + let _ = tx.send(()); + } + } + Message::Close(Some(frame)) => { + let _ = close_tx.send(frame); + return; + } + _ => {} + } + } + }); + + let client = connect(addr, config(addr)).await?; + timeout(Duration::from_secs(1), connected_rx).await??; + drop(client); + + let close = timeout(Duration::from_secs(1), close_rx).await??; + assert_eq!(close.code, CloseCode::Normal); + server.abort(); + Ok(()) +} + +#[tokio::test] +async fn sends_before_readiness_are_rejected() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server = tokio::spawn(async move { + let stream = accept(&listener).await.expect("accepted"); + let mut socket = accept_async(stream).await.expect("websocket"); + while socket.next().await.is_some() {} + }); + + let mut client = connect(addr, config(addr)).await?; + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!( + matches!( + client.propose(json!({ "test": true })).await, + Err(ClientError::NotReady) + ), + "handshake sends require pairing readiness" + ); + assert!(matches!( + client.request(json!({ "method": "too_early" })).await, + Err(ClientError::SessionNotActive) + )); + + drop(client); + server.abort(); + Ok(()) +} + +#[tokio::test] +async fn connect_timeout_is_surfaced_and_stops() -> TestResult { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let server = tokio::spawn(async move { + let stream = accept(&listener).await.expect("accepted"); + let _stream = stream; + std::future::pending::<()>().await; + }); + + let mut client = connect( + addr, + config(addr) + .with_connect_timeout(Duration::from_millis(100)) + .with_reconnect_max_attempts(0), + ) + .await?; + let error = timeout(Duration::from_secs(2), client.recv()) + .await? + .expect_err("unexpected message"); + assert!( + matches!(error, ClientError::Transport("relay connect timed out")), + "unexpected error: {error:?}" + ); + server.abort(); + Ok(()) +} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 588864e..09760bb 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -12,6 +12,11 @@ pub trait MessageCipher: Send { /// Decrypt a received sealed representation. fn open(&self, aad: &[u8], sealed: &Self::Sealed) -> Result, Self::Error>; - /// Switch to a new 32-byte symmetric key. - fn rekey(&mut self, key: &[u8; 32]); + /// Create a fresh cipher for a new 32-byte key. + /// + /// Implementations must start with fresh nonce state: the sender prefix + /// must be regenerated and the counter must start at zero. + fn for_key(&self, key: &[u8; 32]) -> Result + where + Self: Sized; } diff --git a/crates/core/src/frame.rs b/crates/core/src/frame.rs index 9577fd5..ab59143 100644 --- a/crates/core/src/frame.rs +++ b/crates/core/src/frame.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::message::Topic; +use crate::message::{MessageId, Topic}; /// Plaintext control frame exchanged over the relay. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -8,8 +8,8 @@ pub struct RelayFrame { /// Protocol version. pub v: u8, - /// 16 random bytes, base64url. - pub id: String, + /// Message id. + pub id: MessageId, /// Frame topic. pub topic: Topic, diff --git a/crates/core/src/message.rs b/crates/core/src/message.rs index e460d26..d85ac33 100644 --- a/crates/core/src/message.rs +++ b/crates/core/src/message.rs @@ -42,6 +42,23 @@ impl Message { data, } } + + /// Cryptographic digest of all authenticated envelope fields. + pub fn digest(&self) -> [u8; 32] { + fn field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + } + + let mut hasher = Sha256::new(); + hasher.update([self.version]); + field(&mut hasher, self.kind.as_str().as_bytes()); + field(&mut hasher, &self.from.to_ed25519_bytes()); + field(&mut hasher, &self.ts.to_be_bytes()); + field(&mut hasher, self.nonce.as_bytes()); + field(&mut hasher, self.data.as_bytes()); + hasher.finalize().into() + } } /// Plaintext frame for the relay. @@ -51,8 +68,8 @@ pub struct ControlFrame { #[serde(rename = "v")] version: u8, - /// 16 random bytes, base64url. - id: String, + /// Message id. + id: MessageId, /// Message type. #[serde(rename = "type")] @@ -65,10 +82,10 @@ pub struct ControlFrame { impl ControlFrame { pub const VERSION: u8 = 1; - pub fn new(id: impl Into, type_: MessageType, topic: Topic) -> Self { + pub fn new(id: MessageId, type_: MessageType, topic: Topic) -> Self { Self { version: Self::VERSION, - id: id.into(), + id, type_, topic, } @@ -108,6 +125,26 @@ impl MessageType { MessageType::SessionReady => "session_ready", } } + + /// Whether this kind belongs to the pairing handshake. + pub fn is_handshake(&self) -> bool { + matches!( + self, + MessageType::Proposal | MessageType::Approve | MessageType::Reject + ) + } + + /// Whether this kind is application traffic carried on the session topic. + pub fn is_application(&self) -> bool { + matches!( + self, + MessageType::Request + | MessageType::Response + | MessageType::Ack + | MessageType::Disconnect + | MessageType::SessionDelete + ) + } } impl From<&Payload> for MessageType { @@ -134,6 +171,58 @@ impl From for MessageType { } } +/// 16-byte message id, base64url. +#[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct MessageId(#[serde(with = "crate::codec::base64url")] Vec); + +impl<'de> Deserialize<'de> for MessageId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let encoded = String::deserialize(deserializer)?; + encoded + .parse::() + .map_err(serde::de::Error::custom) + } +} + +impl MessageId { + /// Number of bytes in a message id. + pub const LEN: usize = 16; + + /// Build a message id from raw bytes, rejecting the wrong length. + pub fn new(bytes: impl Into>) -> Result { + let bytes = bytes.into(); + if bytes.len() != Self::LEN { + return Err(Error::InvalidLength { + expected: Self::LEN, + actual: bytes.len(), + }); + } + Ok(Self(bytes)) + } + + /// Build a message id from a full-length byte array. + pub fn from_bytes(bytes: [u8; Self::LEN]) -> Self { + Self(bytes.to_vec()) + } +} + +impl fmt::Display for MessageId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&codec::encode(&self.0)) + } +} + +impl FromStr for MessageId { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::new(codec::decode(s)?) + } +} + /// 32-byte topic, base64url. #[derive(Serialize, Debug, Clone, PartialEq, Eq, Hash)] pub struct Topic(#[serde(with = "crate::codec::base64url")] Vec); @@ -303,6 +392,55 @@ impl FromStr for Ciphertext { mod tests { use super::*; + #[test] + fn message_id_accepts_16_bytes() { + assert!(MessageId::new([0u8; 16]).is_ok()); + } + + #[test] + fn message_id_rejects_wrong_length() { + assert!(matches!( + MessageId::new([0u8; 15]), + Err(Error::InvalidLength { + expected: 16, + actual: 15 + }) + )); + } + + #[test] + fn message_id_base64url_round_trip() { + let id = MessageId::new([7u8; 16]).unwrap(); + assert_eq!(id.to_string().parse::().unwrap(), id); + } + + #[test] + fn message_id_deserialization_enforces_the_16_byte_invariant() { + let id = MessageId::new([7u8; 16]).unwrap(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + + for bad in [ + String::new(), + codec::encode(&[7u8; 15]), + codec::encode(&[7u8; 17]), + ] { + let json = format!("\"{bad}\""); + assert!( + serde_json::from_str::(&json).is_err(), + "{json} must be rejected" + ); + } + } + + #[test] + fn message_id_serializes_as_plain_string() { + let id = MessageId::new([7u8; 16]).unwrap(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, format!("\"{id}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + } + #[test] fn topic_accepts_32_bytes() { assert!(Topic::new([0u8; 32]).is_ok()); diff --git a/crates/core/src/payload.rs b/crates/core/src/payload.rs index c065d89..c6fd6ca 100644 --- a/crates/core/src/payload.rs +++ b/crates/core/src/payload.rs @@ -1,9 +1,19 @@ +use std::fmt; + use serde::{Deserialize, Serialize}; +use crate::message::MessageId; + /// Opaque application data carried inside a payload shape. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Opaque(#[serde(with = "crate::codec::base64url")] pub Vec); +impl fmt::Debug for Opaque { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Opaque(..)") + } +} + /// Body of a `proposal` message. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -30,7 +40,7 @@ pub struct ApprovePayload { } /// Body of a `reject` message. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RejectPayload { /// Reason code for the rejection pub code: u32, @@ -40,7 +50,7 @@ pub struct RejectPayload { } /// Body of a `request` message. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RequestPayload { /// Caller-assigned identifier used to correlate the eventual `response` @@ -52,32 +62,41 @@ pub struct RequestPayload { } /// Body of a `response` message. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResponsePayload { /// Identifier of the `request` being answered. pub request_id: String, /// Opaque application result; present on success, absent on error. + #[serde(skip_serializing_if = "Option::is_none")] pub response: Option, /// Failure description; present on error, absent on success. + #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } +impl ResponsePayload { + /// Whether exactly one of `response` and `error` is present. + pub fn is_valid(&self) -> bool { + self.response.is_some() != self.error.is_some() + } +} + /// Body of an `ack` message. /// /// Confirms receipt of an ack-required message, driving the retransmission /// logic. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AckPayload { /// The envelope `id` of the message being acknowledged. - pub ack_id: String, + pub ack_id: MessageId, } /// Body of a `disconnect` message. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DisconnectPayload { /// Short reason label for the disconnect. pub reason: String, @@ -90,7 +109,7 @@ pub struct DisconnectPayload { } /// Body of a `session_delete` message. -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionDeletePayload { /// Machine-readable close code. pub code: u32, @@ -148,6 +167,50 @@ mod tests { type TestResult = Result<(), Box>; + #[test] + fn response_serializes_exactly_one_alternative() -> TestResult { + let success = ResponsePayload { + request_id: "req-1".to_owned(), + response: Some(Opaque(vec![1, 2, 3])), + error: None, + }; + let value = serde_json::to_value(&success)?; + assert_eq!(value.get("response"), Some(&serde_json::json!("AQID"))); + assert!(value.get("error").is_none()); + assert!(success.is_valid()); + + let failure = ResponsePayload { + request_id: "req-1".to_owned(), + response: None, + error: Some(Opaque(vec![4, 5])), + }; + let value = serde_json::to_value(&failure)?; + assert!(value.get("response").is_none()); + assert_eq!(value.get("error"), Some(&serde_json::json!("BAU"))); + assert!(failure.is_valid()); + + let neither = ResponsePayload { + request_id: "req-1".to_owned(), + response: None, + error: None, + }; + assert!(!neither.is_valid()); + + let both = ResponsePayload { + request_id: "req-1".to_owned(), + response: Some(Opaque(Vec::new())), + error: Some(Opaque(Vec::new())), + }; + assert!(!both.is_valid()); + Ok(()) + } + + #[test] + fn opaque_debug_redacts_application_data() { + let opaque = Opaque(vec![1, 2, 3, 4]); + assert_eq!(format!("{opaque:?}"), "Opaque(..)"); + } + #[test] fn bodies_with_the_same_shape_are_distinguished() -> TestResult { let rejected: Payload = diff --git a/crates/relay/src/connection.rs b/crates/relay/src/connection.rs index 133b74b..e5a5bec 100644 --- a/crates/relay/src/connection.rs +++ b/crates/relay/src/connection.rs @@ -17,7 +17,7 @@ use tokio_tungstenite::{ protocol::{CloseFrame, frame::coding::CloseCode}, }, }; -use wallet_session_core::{codec, connection::ConnectionQuery}; +use wallet_session_core::connection::ConnectionQuery; use crate::{ auth::{Identity, IdentityError, unix_now}, @@ -30,6 +30,7 @@ use crate::{ use wallet_session_core::{ did::DidKey, frame::{RelayFrame, RelayFrameBody}, + message::MessageId, }; /// Server-side relay WebSocket stream, read through the connection's wire @@ -99,10 +100,10 @@ fn reject_response() -> ErrorResponse { response } -pub(crate) fn random_id() -> Result { - let mut bytes = [0u8; 16]; +pub(crate) fn random_id() -> Result { + let mut bytes = [0u8; MessageId::LEN]; getrandom::fill(&mut bytes)?; - Ok(codec::encode(&bytes)) + Ok(MessageId::from_bytes(bytes)) } /// The client behind an accepted handshake. @@ -982,6 +983,14 @@ mod tests { Topic::new([1u8; 32]) } + /// A distinct, valid message id for a test label. + fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) + } + fn parse(query: &str) -> Result<(ConnectionQuery, Identity), ConnectionError> { parse_connection( &uri(query).map_err(|_| ConnectionError::HandshakeFailed)?, @@ -1176,7 +1185,7 @@ mod tests { } /// Ids of the frames a test reader accepted. - type Accepted = Arc>>; + type Accepted = Arc>>; /// A reader that takes `per_message` to accept each message, and the ids /// of the frames it accepted. @@ -1235,7 +1244,7 @@ mod tests { ) -> Result> { let frame = RelayFrame { v: RELAY_FRAME_VERSION, - id: id.to_owned(), + id: message_id(id), topic: topic()?, body: RelayFrameBody::Message { payload: "ciphertext".to_owned(), @@ -1267,7 +1276,10 @@ mod tests { window.contains(&elapsed), "the writer must stop once a frame has waited the queue timeout, not {elapsed:?}" ); - assert_eq!(*accepted.lock().expect("accepted ids"), ["m0", "m1", "m2"]); + assert_eq!( + *accepted.lock().expect("accepted ids"), + [message_id("m0"), message_id("m1"), message_id("m2")] + ); drop(tx); assert_eq!(budget.held(), 0, "frames left behind give their bytes back"); @@ -1296,7 +1308,12 @@ mod tests { ); assert_eq!( *accepted.lock().expect("accepted ids"), - ["m0", "m1", "m2", "m3"] + [ + message_id("m0"), + message_id("m1"), + message_id("m2"), + message_id("m3") + ] ); assert_eq!(budget.held(), 0, "written frames give their bytes back"); @@ -1351,7 +1368,7 @@ mod tests { }; let ready = RelayFrame { v: RELAY_FRAME_VERSION, - id: "ready".to_owned(), + id: message_id("ready"), topic: topic()?, body: RelayFrameBody::SessionReady, }; diff --git a/crates/relay/src/health.rs b/crates/relay/src/health.rs index c9ffc0f..a3a3455 100644 --- a/crates/relay/src/health.rs +++ b/crates/relay/src/health.rs @@ -112,7 +112,7 @@ mod tests { use wallet_session_core::{ did::DidKey, frame::{RelayFrame, RelayFrameBody}, - message::Topic, + message::{MessageId, Topic}, }; use super::*; @@ -185,7 +185,7 @@ mod tests { permit, &RelayFrame { v: 1, - id: "ready".to_owned(), + id: MessageId::from_bytes([0u8; MessageId::LEN]), topic: topic.clone(), body: RelayFrameBody::SessionReady, }, diff --git a/crates/relay/src/registry.rs b/crates/relay/src/registry.rs index 1f08f7e..ec6ac28 100644 --- a/crates/relay/src/registry.rs +++ b/crates/relay/src/registry.rs @@ -175,6 +175,7 @@ mod tests { use tokio::sync::mpsc; use crate::limits::{Limiter, OutboundBudget, RelayLimitsConfig}; + use wallet_session_core::message::MessageId; /// A permit for `identity`, from a limiter that is never shared: these /// tests do not exercise the per-identity cap. @@ -219,7 +220,7 @@ mod tests { fn notification(topic: &Topic) -> RelayFrame { RelayFrame { v: 1, - id: "gone".to_owned(), + id: MessageId::from_bytes([1u8; MessageId::LEN]), topic: topic.clone(), body: wallet_session_core::frame::RelayFrameBody::PeerDisconnected, } @@ -228,7 +229,7 @@ mod tests { fn ready(topic: &Topic) -> RelayFrame { RelayFrame { v: 1, - id: "ready".to_owned(), + id: MessageId::from_bytes([2u8; MessageId::LEN]), topic: topic.clone(), body: wallet_session_core::frame::RelayFrameBody::SessionReady, } diff --git a/crates/relay/src/server.rs b/crates/relay/src/server.rs index 0dd7e68..8b2b420 100644 --- a/crates/relay/src/server.rs +++ b/crates/relay/src/server.rs @@ -205,7 +205,7 @@ mod tests { }, }; use wallet_session_core::frame::{RelayFrame, RelayFrameBody}; - use wallet_session_core::message::Topic; + use wallet_session_core::message::{MessageId, Topic}; const AUDIENCE: &str = "wss://relay.example.com"; @@ -229,6 +229,14 @@ mod tests { Topic::new([9u8; 32]) } + /// A distinct, valid message id for a test label. + fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) + } + async fn connect( addr: SocketAddr, role: &str, @@ -296,7 +304,7 @@ mod tests { let message = RelayFrame { v: RELAY_FRAME_VERSION, - id: "m1".to_owned(), + id: message_id("m1"), topic: topic.clone(), body: RelayFrameBody::Message { payload: "hello".to_owned(), @@ -419,11 +427,17 @@ mod tests { /// can wait for the relay to finish tearing that connection down. async fn serve_tracked( config: NativeRelayTransportConfig, + ) -> Result<(SocketAddr, Handlers), Box> { + serve_tracked_with_limiter(config, limiter()).await + } + + async fn serve_tracked_with_limiter( + config: NativeRelayTransportConfig, + limiter: Arc, ) -> Result<(SocketAddr, Handlers), Box> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?; let registry = Arc::new(Registry::new()); - let limiter = limiter(); let (handlers_tx, handlers) = mpsc::unbounded_channel(); tokio::spawn(async move { while let Ok((stream, _)) = listener.accept().await { @@ -520,7 +534,7 @@ mod tests { let message = RelayFrame { v: RELAY_FRAME_VERSION, - id: "buffered".to_owned(), + id: message_id("buffered"), topic: topic.clone(), body: RelayFrameBody::Message { payload: "queued".to_owned(), @@ -618,7 +632,7 @@ mod tests { fn message(topic: &Topic, id: &str, payload: &str) -> RelayFrame { RelayFrame { v: RELAY_FRAME_VERSION, - id: id.to_owned(), + id: message_id(id), topic: topic.clone(), body: RelayFrameBody::Message { payload: payload.to_owned(), @@ -737,7 +751,7 @@ mod tests { } assert_eq!( redelivered, - ["m2", "m3"], + [message_id("m2"), message_id("m3")], "the oldest buffered frame makes room for the newest" ); Ok(()) @@ -1276,7 +1290,7 @@ mod tests { // Clients must not forge the relay-only session_ready frame. let rogue = RelayFrame { v: RELAY_FRAME_VERSION, - id: "rogue".to_owned(), + id: message_id("rogue"), topic: topic.clone(), body: RelayFrameBody::SessionReady, }; @@ -1349,7 +1363,7 @@ mod tests { let forged = RelayFrame { v: RELAY_FRAME_VERSION, - id: "forged".to_owned(), + id: message_id("forged"), topic: topic.clone(), body: RelayFrameBody::PeerDisconnected, }; @@ -1449,7 +1463,7 @@ mod tests { let rogue = RelayFrame { v: 2, - id: "rogue".to_owned(), + id: message_id("rogue"), topic: topic.clone(), body: RelayFrameBody::Message { payload: "future".to_owned(), @@ -2011,7 +2025,10 @@ mod tests { { delivered.push(frame.id); } - assert_eq!(delivered, ["m0", "m1", "m2"]); + assert_eq!( + delivered, + [message_id("m0"), message_id("m1"), message_id("m2")] + ); Ok(()) } @@ -2252,12 +2269,13 @@ mod tests { ..ping_limits() })); let config = NativeRelayTransportConfig::new(AUDIENCE); - let addr = serve_with_limiter(config, Arc::clone(&limiter)).await?; + let (addr, mut handlers) = serve_tracked_with_limiter(config, Arc::clone(&limiter)).await?; // Every ping after the first is dropped, and once the drops pass the // consecutive-drop limit the sender is disconnected. Sends may fail // after that. let mut hog = connect(addr, "proposer", &topic()?, None).await?; + let handler = handlers.recv().await.ok_or("relay stopped accepting")?; let _ = send_pings(&mut hog, MAX_CONSECUTIVE_RATE_DROPS + 8, PING_PAYLOAD).await; let limit = u64::from(PING_PAYLOAD) * u64::from(MAX_CONSECUTIVE_RATE_DROPS); let dropped = wait_for_count(|| limiter.dropped_share_bytes(), limit + 1).await; @@ -2266,7 +2284,7 @@ mod tests { "the relay must process enough dropped pings to reach the disconnect threshold" ); assert!( - expect_closed_within(&mut hog, Duration::from_secs(10)).await, + timeout(SYNC_TIMEOUT, handler).await??.is_err(), "an identity flooding control frames over its share must be disconnected" ); assert_eq!(limiter.dropped_global_bytes(), 0); @@ -2609,7 +2627,7 @@ mod tests { .await?; // The message is whole, so every fragment was read, in order. - assert_eq!(read_frame(&mut responder).await?.id, "f0"); + assert_eq!(read_frame(&mut responder).await?.id, message_id("f0")); // What the burst did not cover takes about 2.7 seconds. let elapsed = started.elapsed(); assert!( @@ -2619,7 +2637,7 @@ mod tests { // The sender is still connected, and its next frame goes through. send(&mut proposer, &message(&topic, "f1", "still-connected")).await?; - assert_eq!(read_frame(&mut responder).await?.id, "f1"); + assert_eq!(read_frame(&mut responder).await?.id, message_id("f1")); Ok(()) } diff --git a/crates/relay/src/session.rs b/crates/relay/src/session.rs index 8014173..fbfd90c 100644 --- a/crates/relay/src/session.rs +++ b/crates/relay/src/session.rs @@ -651,7 +651,7 @@ mod tests { use crate::connection::RELAY_FRAME_VERSION; use crate::limits::{Limiter, RelayLimitsConfig}; use wallet_session_core::frame::RelayFrameBody; - use wallet_session_core::message::Topic; + use wallet_session_core::message::{MessageId, Topic}; type TestResult = Result<(), Box>; @@ -670,6 +670,14 @@ mod tests { Topic::new([1u8; 32]) } + /// A distinct, valid message id for a test label. + fn message_id(label: &str) -> MessageId { + let mut bytes = [0u8; MessageId::LEN]; + let label = &label.as_bytes()[..label.len().min(MessageId::LEN)]; + bytes[..label.len()].copy_from_slice(label); + MessageId::from_bytes(bytes) + } + fn peer(seed: u8) -> (PeerHandle, mpsc::Receiver) { peer_with(seed, PEER_QUEUE, PEER_BUDGET) } @@ -693,7 +701,7 @@ mod tests { } /// Ids of the frames waiting in a queue, taking them out as a writer would. - fn take_ids(rx: &mut mpsc::Receiver) -> Vec { + fn take_ids(rx: &mut mpsc::Receiver) -> Vec { let mut ids = Vec::new(); while let Ok(queued) = rx.try_recv() { ids.push(queued.frame.id); @@ -717,7 +725,7 @@ mod tests { fn frame(topic: &Topic) -> RelayFrame { RelayFrame { v: RELAY_FRAME_VERSION, - id: "frame-id".to_owned(), + id: message_id("frame-id"), topic: topic.clone(), body: RelayFrameBody::Message { payload: "ciphertext".to_owned(), @@ -728,7 +736,7 @@ mod tests { fn ready(topic: &Topic) -> RelayFrame { RelayFrame { v: RELAY_FRAME_VERSION, - id: "ready".to_owned(), + id: message_id("ready"), topic: topic.clone(), body: RelayFrameBody::SessionReady, } @@ -738,7 +746,7 @@ mod tests { fn notification() -> RelayFrame { RelayFrame { v: RELAY_FRAME_VERSION, - id: "gone".to_owned(), + id: message_id("gone"), topic: topic().expect("topic"), body: RelayFrameBody::PeerDisconnected, } @@ -746,7 +754,7 @@ mod tests { fn frame_with_id(topic: &Topic, id: &str) -> RelayFrame { RelayFrame { - id: id.to_owned(), + id: message_id(id), ..frame(topic) } } @@ -783,7 +791,7 @@ mod tests { role: Role, tx: &mpsc::Sender, rx: &mut mpsc::Receiver, - ) -> Result, SessionError> { + ) -> Result, SessionError> { let mut ids = Vec::new(); loop { let drain = session.drain_backlog(role, tx)?; @@ -932,7 +940,7 @@ mod tests { // frames to the disconnected peer are buffered, not dropped session.route(Role::Proposer, frame(&topic()?), MAX_BUFFERED, BUFFER_TTL)?; - assert_eq!(responder_rx.try_recv()?.frame.id, "ready"); + assert_eq!(responder_rx.try_recv()?.frame.id, message_id("ready")); assert!(responder_rx.try_recv().is_err()); // same identity reconnects within grace and receives session_ready, @@ -943,7 +951,7 @@ mod tests { assert!(matches!(registration, Registration::BothReady)); assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "frame-id"] + [message_id("ready"), message_id("frame-id")] ); Ok(()) } @@ -985,7 +993,7 @@ mod tests { let ids = received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?; assert_eq!( ids, - ["ready", "frame-id"], + [message_id("ready"), message_id("frame-id")], "only the unexpired frame is redelivered" ); Ok(()) @@ -1008,7 +1016,14 @@ mod tests { let tx = responder.tx.clone(); register(&session, Role::Responder, responder)?; let ids = received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?; - assert_eq!(ids, ["ready", "frame-id", "frame-id"]); + assert_eq!( + ids, + [ + message_id("ready"), + message_id("frame-id"), + message_id("frame-id") + ] + ); Ok(()) } @@ -1039,7 +1054,7 @@ mod tests { register(&session, Role::Proposer, proposer)?; register(&session, Role::Responder, responder)?; disconnect_current(&session, Role::Responder, Duration::from_secs(1))?; - assert_eq!(proposer_rx.try_recv()?.frame.id, "ready"); + assert_eq!(proposer_rx.try_recv()?.frame.id, message_id("ready")); tokio::time::advance(Duration::from_secs(2)).await; let notice = notification(); @@ -1104,8 +1119,8 @@ mod tests { BUFFER_TTL, )?; - assert_eq!(responder_rx.try_recv()?.frame.id, "ready"); - assert_eq!(responder_rx.try_recv()?.frame.id, "m1"); + assert_eq!(responder_rx.try_recv()?.frame.id, message_id("ready")); + assert_eq!(responder_rx.try_recv()?.frame.id, message_id("m1")); Ok(()) } @@ -1144,7 +1159,13 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "old1", "old2", "new1", "new2"] + [ + message_id("ready"), + message_id("old1"), + message_id("old2"), + message_id("new1"), + message_id("new2") + ] ); // Once drained, frames go straight to the queue again. @@ -1154,7 +1175,7 @@ mod tests { MAX_BUFFERED, BUFFER_TTL, )?; - assert_eq!(responder_rx.try_recv()?.frame.id, "live"); + assert_eq!(responder_rx.try_recv()?.frame.id, message_id("live")); Ok(()) } @@ -1196,8 +1217,8 @@ mod tests { let mut ids = take_ids(&mut responder_rx); ids.extend(received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?); - let mut expected = vec!["ready".to_owned()]; - expected.extend((0..total).map(|i| format!("m{i:02}"))); + let mut expected = vec![message_id("ready")]; + expected.extend((0..total).map(|i| message_id(&format!("m{i:02}")))); assert_eq!(ids, expected); Ok(()) } @@ -1225,12 +1246,12 @@ mod tests { // The stale connection finally gets to run. assert_eq!( received_ids(&session, Role::Responder, &first_tx, &mut first_rx).await?, - ["ready"], + [message_id("ready")], "a stale connection must not consume the new connection's backlog" ); assert_eq!( received_ids(&session, Role::Responder, &second_tx, &mut second_rx).await?, - ["ready", "frame-id"] + [message_id("ready"), message_id("frame-id")] ); Ok(()) } @@ -1242,7 +1263,7 @@ mod tests { let (old, mut old_rx) = peer(2); register(&session, Role::Proposer, proposer)?; register(&session, Role::Responder, old)?; - assert_eq!(proposer_rx.try_recv()?.frame.id, "ready"); + assert_eq!(proposer_rx.try_recv()?.frame.id, message_id("ready")); // Same identity, new connection, while the old one is still registered. let (new, mut new_rx) = peer(2); @@ -1253,7 +1274,7 @@ mod tests { )); // The counterpart sees the peer come back. - assert_eq!(proposer_rx.try_recv()?.frame.id, "ready"); + assert_eq!(proposer_rx.try_recv()?.frame.id, message_id("ready")); // Routing now reaches the new connection only. session.route( @@ -1264,7 +1285,7 @@ mod tests { )?; assert_eq!( received_ids(&session, Role::Responder, &new_tx, &mut new_rx).await?, - ["ready", "m1"] + [message_id("ready"), message_id("m1")] ); // The replaced connection's queue is closed once its old frames drain. @@ -1297,8 +1318,8 @@ mod tests { MAX_BUFFERED, BUFFER_TTL, )?; - assert_eq!(owner_rx.try_recv()?.frame.id, "ready"); - assert_eq!(owner_rx.try_recv()?.frame.id, "m1"); + assert_eq!(owner_rx.try_recv()?.frame.id, message_id("ready")); + assert_eq!(owner_rx.try_recv()?.frame.id, message_id("m1")); Ok(()) } @@ -1327,7 +1348,7 @@ mod tests { )?; assert_eq!( received_ids(&session, Role::Responder, &new_tx, &mut new_rx).await?, - ["ready", "m1"] + [message_id("ready"), message_id("m1")] ); // And an expiry the old teardown might have scheduled is a no-op. @@ -1369,7 +1390,7 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &second_tx, &mut second_rx).await?, - ["ready", "old", "new"], + [message_id("ready"), message_id("old"), message_id("new")], "frames not yet handed to the replaced connection must not be lost" ); Ok(()) @@ -1491,7 +1512,7 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &other_tx, &mut other_rx).await?, - ["ready"], + [message_id("ready")], "frames buffered for the previous owner must not reach another identity" ); Ok(()) @@ -1515,7 +1536,7 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &tx, &mut reconnect_rx).await?, - ["ready", "frame-id"], + [message_id("ready"), message_id("frame-id")], "the owner gets its frames back after the reservation expired" ); Ok(()) @@ -1552,7 +1573,7 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready"], + [message_id("ready")], "a frame reclaimed before its TTL must not be delivered after it" ); Ok(()) @@ -1602,7 +1623,7 @@ mod tests { tokio::time::advance(Duration::from_secs(3)).await; assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "late"], + [message_id("ready"), message_id("late")], "at t=9 only the frame queued at t=6 is still deliverable" ); Ok(()) @@ -1632,7 +1653,7 @@ mod tests { assert_eq!( received_ids(&session, Role::Responder, &second_tx, &mut second_rx).await?, - ["ready"], + [message_id("ready")], "an inherited frame must not outlive its TTL" ); Ok(()) @@ -1711,7 +1732,7 @@ mod tests { )); assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["gone", "ready"], + [message_id("gone"), message_id("ready")], "frames from an expired counterpart must not follow peer_disconnected" ); Ok(()) @@ -1745,7 +1766,7 @@ mod tests { )); assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready"], + [message_id("ready")], "frames the previous proposer sent must not reach the responder as the new one's" ); Ok(()) @@ -1754,7 +1775,7 @@ mod tests { #[test] fn encoded_len_is_the_text_the_writer_sends() -> TestResult { let frame = RelayFrame { - id: "quote \" backslash \\ newline \n control \u{1}".to_owned(), + id: message_id("quote \" backslash \\ newline \n control \u{1}"), body: RelayFrameBody::Message { payload: "é \u{7f} \u{1f} \u{1F600}".to_owned(), }, @@ -1782,12 +1803,15 @@ mod tests { route_id(&session, id)?; } assert_eq!(budget.held(), 2 * one, "session_ready is not charged"); - assert_eq!(take_ids(&mut responder_rx), ["ready", "m1", "m2"]); + assert_eq!( + take_ids(&mut responder_rx), + [message_id("ready"), message_id("m1"), message_id("m2")] + ); // Written frames give their bytes back. assert_eq!(budget.held(), 0); route_id(&session, "m4")?; - assert_eq!(take_ids(&mut responder_rx), ["m4"]); + assert_eq!(take_ids(&mut responder_rx), [message_id("m4")]); Ok(()) } @@ -1812,7 +1836,12 @@ mod tests { assert_eq!( take_ids(&mut responder_rx), - ["ready", "m1", "gone", "ready"], + [ + message_id("ready"), + message_id("m1"), + message_id("gone"), + message_id("ready") + ], "control frames bypass the byte budget" ); Ok(()) @@ -1840,7 +1869,13 @@ mod tests { assert_eq!(budget.held(), 4 * one); assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m1", "m3", "m4", "m5"] + [ + message_id("ready"), + message_id("m1"), + message_id("m3"), + message_id("m4"), + message_id("m5") + ] ); assert_eq!(budget.held(), 0, "written frames give their bytes back"); Ok(()) @@ -1868,7 +1903,7 @@ mod tests { session.route(Role::Proposer, large, MAX_BUFFERED, BUFFER_TTL)?; assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m1", "m2"] + [message_id("ready"), message_id("m1"), message_id("m2")] ); Ok(()) } @@ -1900,7 +1935,12 @@ mod tests { session.route(Role::Proposer, large_frame(&topic, 2 * one), 2, BUFFER_TTL)?; assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m1", "m2", "m3"] + [ + message_id("ready"), + message_id("m1"), + message_id("m2"), + message_id("m3") + ] ); Ok(()) } @@ -1928,7 +1968,7 @@ mod tests { register(&session, Role::Responder, responder)?; assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m1", "m2"] + [message_id("ready"), message_id("m1"), message_id("m2")] ); Ok(()) } @@ -1945,7 +1985,7 @@ mod tests { assert_eq!(drain.delivered, 1); // The writer takes session_ready, which frees a queue slot but no bytes. - assert_eq!(responder_rx.try_recv()?.frame.id, "ready"); + assert_eq!(responder_rx.try_recv()?.frame.id, message_id("ready")); // Room for m3 means dropping m2, the whole backlog. A frame left on an // empty backlog would never be drained, so m3 goes to the queue. @@ -1958,7 +1998,10 @@ mod tests { finished: true, } ); - assert_eq!(take_ids(&mut responder_rx), ["m1", "m3"]); + assert_eq!( + take_ids(&mut responder_rx), + [message_id("m1"), message_id("m3")] + ); Ok(()) } @@ -1991,7 +2034,7 @@ mod tests { assert_eq!(budget.held(), 2 * one, "moved to the new budget"); assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m2", "m3"] + [message_id("ready"), message_id("m2"), message_id("m3")] ); Ok(()) } @@ -2007,7 +2050,7 @@ mod tests { register(&session, Role::Responder, responder)?; assert_eq!( received_ids(&session, Role::Responder, &tx, &mut responder_rx).await?, - ["ready", "m2", "m3"] + [message_id("ready"), message_id("m2"), message_id("m3")] ); Ok(()) } @@ -2035,7 +2078,7 @@ mod tests { assert_eq!(second_budget.held(), 2 * one); assert_eq!( received_ids(&session, Role::Responder, &second_tx, &mut second_rx).await?, - ["ready", "m2", "m3"] + [message_id("ready"), message_id("m2"), message_id("m3")] ); Ok(()) } @@ -2091,7 +2134,7 @@ mod tests { register(&session, Role::Responder, second)?; assert_eq!( received_ids(&session, Role::Responder, &second_tx, &mut second_rx).await?, - ["ready", "late"], + [message_id("ready"), message_id("late")], "the backlog survives the connection, with the expiry it was buffered with" ); Ok(()) @@ -2120,7 +2163,7 @@ mod tests { register(&session, Role::Responder, other)?; assert_eq!( received_ids(&session, Role::Responder, &other_tx, &mut other_rx).await?, - ["ready"], + [message_id("ready")], "frames kept for the previous owner must not reach another identity" ); assert_eq!( diff --git a/docs/spec.md b/docs/spec.md index 91c1c60..050b3ef 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -170,6 +170,8 @@ Session establishment flow: 6. proposer derives the same session key; 7. both parties continues exchanging messages by the new session key, using the same session. +The Relay binds each connection to one topic. After deriving the session topic, each peer opens a new connection to it. The pairing connection remains available for handshake acknowledgements and retransmissions, because those messages belong to the pairing topic and key; application traffic uses the session topic. + # Part II. Relay Protocol @@ -277,7 +279,7 @@ Payload shapes: - `disconnect`: `{ reason, code, message }` - `session_delete`: `{ code, message }` -`proposal` and `approval` are opaque application data. +`proposal`, `approval`, `request`, `response`, and `error` are opaque application data, encoded as base64url strings. ## 13. Reliability