From bddfb9c280fd0e74457a5b696b4329f4e9a44998 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 16 Feb 2026 09:46:33 +0100 Subject: [PATCH 1/7] Implementation of the pairing protocol, including examples. --- Cargo.lock | 1247 ++++++++++++++++++++++++++++++++- Cargo.toml | 16 +- examples/pairing-client.rs | 53 ++ examples/pairing-server.rs | 66 ++ src/connection.rs | 1 + src/lib.rs | 1 + src/pairing/client.rs | 318 +++++++++ src/pairing/mod.rs | 402 +++++++++++ src/pairing/server.rs | 484 +++++++++++++ src/pairing/transport.rs | 173 +++++ src/pairing/wire.rs | 275 ++++++++ testdata/gen_cert.sh | 48 ++ testdata/root.key | 28 + testdata/root.pem | 21 + testdata/test.local.chain.pem | 43 ++ testdata/test.local.crt | 22 + testdata/test.local.key | 28 + 17 files changed, 3219 insertions(+), 7 deletions(-) create mode 100644 examples/pairing-client.rs create mode 100644 examples/pairing-server.rs create mode 100644 src/pairing/client.rs create mode 100644 src/pairing/mod.rs create mode 100644 src/pairing/server.rs create mode 100644 src/pairing/transport.rs create mode 100644 src/pairing/wire.rs create mode 100755 testdata/gen_cert.sh create mode 100644 testdata/root.key create mode 100644 testdata/root.pem create mode 100644 testdata/test.local.chain.pem create mode 100644 testdata/test.local.crt create mode 100644 testdata/test.local.key diff --git a/Cargo.lock b/Cargo.lock index 5690aa4..669f4a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,12 +17,135 @@ dependencies = [ "libc", ] +[[package]] +name = "arc-swap" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + [[package]] name = "block-buffer" version = "0.10.4" @@ -76,15 +199,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.43" @@ -99,6 +236,45 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -172,14 +348,47 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -202,12 +411,52 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -269,8 +518,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -280,9 +531,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -302,6 +574,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -312,12 +593,105 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -342,24 +716,184 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -376,18 +910,42 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -414,6 +972,18 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -426,6 +996,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -454,6 +1033,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.43" @@ -508,6 +1143,46 @@ dependencies = [ "memchr", ] +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -522,12 +1197,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -535,21 +1218,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -561,23 +1285,38 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "s2energy" version = "0.3.0" dependencies = [ + "axum", + "axum-server", + "base64", "bon", "chrono", "eyre", "futures-util", + "hmac", "prettyplease", "quote", + "rand", "regress", + "reqwest", + "rustls", + "rustls-platform-verifier", "schemars", "semver", "serde", "serde_json", + "sha2", "syn", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-tungstenite", "tracing", @@ -585,6 +1324,24 @@ dependencies = [ "uuid", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -609,6 +1366,29 @@ dependencies = [ "syn", ] +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -673,6 +1453,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_tokenstream" version = "0.2.2" @@ -685,6 +1476,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -696,6 +1499,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -708,6 +1522,12 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "socket2" version = "0.6.1" @@ -718,6 +1538,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -741,13 +1567,74 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -761,6 +1648,31 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -772,9 +1684,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -801,12 +1725,72 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -832,6 +1816,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.28.0" @@ -847,7 +1837,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 2.0.18", "utf-8", ] @@ -883,7 +1873,7 @@ dependencies = [ "serde", "serde_json", "syn", - "thiserror", + "thiserror 2.0.18", "unicode-ident", ] @@ -916,12 +1906,30 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.19.0" @@ -940,6 +1948,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -968,6 +1995,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.108" @@ -1000,6 +2041,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -1018,6 +2088,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1059,6 +2138,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1077,6 +2167,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1104,6 +2203,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1137,6 +2251,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1149,6 +2269,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1161,6 +2287,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1185,6 +2317,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1197,6 +2335,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1209,6 +2353,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1221,6 +2371,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1239,6 +2395,35 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.33" @@ -1259,12 +2444,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index 0d10dd6..e69df8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ keywords = ["s2", "energy", "energy-management", "protocol", "automation"] [features] default = ["websockets-json", "dbus"] -websockets-json = ["dep:futures-util", "dep:tokio", "dep:tokio-tungstenite", "dep:serde_json"] +websockets-json = ["dep:futures-util", "dep:tokio-tungstenite", "dep:serde_json"] dbus = [] [dependencies] @@ -27,9 +27,18 @@ thiserror = "2.0.17" # feature=websockets-json futures-util = { version = "0.3.31", optional = true } -tokio = { version = "1.47.1", features = ["net"], optional = true } +tokio = { version = "1.47.1", features = ["net"] } tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"], optional = true } serde_json = { version = "1.0.145", optional = true } +rand = "0.9.2" +base64 = "0.22.1" +hmac = "0.12.1" +sha2 = "0.10.9" +reqwest = { version = "0.13.1", features = ["json"] } +axum = "0.8.8" + +rustls = "0.23.36" +rustls-platform-verifier = "0.6.2" [build-dependencies] prettyplease = "0.2.37" @@ -42,6 +51,7 @@ quote = "1.0.41" [dev-dependencies] eyre = "0.6.12" serde_json = "1.0.145" +axum-server = { version = "0.8.0", features = ["tls-rustls"] } # docs.rs-specific configuration [package.metadata.docs.rs] @@ -51,4 +61,4 @@ rustdoc-args = ["--cfg", "docsrs_s2energy"] # We can't use cfg(docsrs), because several dependencies have issues when passing # --cfg docsrs as a compiler arg. So here we define our own docsrs cfg. [lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs_s2energy)'] } \ No newline at end of file +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs_s2energy)'] } diff --git a/examples/pairing-client.rs b/examples/pairing-client.rs new file mode 100644 index 0000000..ca7775f --- /dev/null +++ b/examples/pairing-client.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use s2energy::pairing::{ + Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role, +}; + +const PAIRING_TOKEN: &[u8] = &[1, 2, 3]; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let config = EndpointConfig::builder( + S2NodeDescription { + id: S2NodeId(String::from("12121212")), + brand: String::from("super-reliable-corp"), + logo_uri: None, + type_: String::from("fancy"), + model_name: String::from("the best"), + user_defined_name: None, + role: S2Role::Rm, + }, + vec![MessageVersion("v1".into())], + ) + .with_connection_initiate_url("client.example.com".into()) + .build() + .unwrap(); + + let client = Client::new( + Arc::new(config), + ClientConfig { + additional_certificates: vec![], + pairing_deployment: Deployment::Lan, + }, + ) + .unwrap(); + + let pair_result = client + .pair( + PairingRemote { + url: "https://test.local:8005".into(), + id: S2NodeId(String::from("12121212")), + }, + PAIRING_TOKEN, + ) + .await + .unwrap(); + + match pair_result.role { + s2energy::pairing::PairingRole::CommunicationClient { initiate_url } => { + println!("Paired as client, url: {initiate_url}, token: {}", pair_result.token.0) + } + s2energy::pairing::PairingRole::CommunicationServer => println!("Paired as server, token: {}", pair_result.token.0), + } +} diff --git a/examples/pairing-server.rs b/examples/pairing-server.rs new file mode 100644 index 0000000..4f021fe --- /dev/null +++ b/examples/pairing-server.rs @@ -0,0 +1,66 @@ +use axum_server::tls_rustls::RustlsConfig; +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc}; + +use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, S2NodeDescription, S2NodeId, S2Role, Server, ServerConfig}; + +#[allow(unused)] +const PAIRING_TOKEN: &[u8] = &[1, 2, 3]; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let server = Server::new(ServerConfig { + root_certificate: Some( + CertificateDer::from_pem_file(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("root.pem")).unwrap(), + ), + }); + let config = EndpointConfig::builder( + S2NodeDescription { + id: S2NodeId(String::from("12121212")), + brand: String::from("super-reliable-corp"), + logo_uri: None, + type_: String::from("fancy"), + model_name: String::from("the best"), + user_defined_name: None, + role: S2Role::Cem, + }, + vec![MessageVersion("v1".into())], + ) + .with_connection_initiate_url("test.example.com".into()) + .build() + .unwrap(); + let app = server.get_router(); + + let addr = SocketAddr::from(([127, 0, 0, 1], 8005)); + + let rustls_config = RustlsConfig::from_pem_file( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("test.local.chain.pem"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("test.local.key"), + ) + .await + .unwrap(); + + tokio::spawn(async move { + println!("listening on http://{}", addr); + axum_server::bind_rustls(addr, rustls_config) + .serve(app.into_make_service()) + .await + .unwrap(); + }); + + let pairing = server + .pair_once(Arc::new(config.clone()), PairingToken(PAIRING_TOKEN.into())) + .unwrap() + .result() + .await + .unwrap(); + println!("token: {}", pairing.token.0); + + let mut repeated_pairing = server.pair_repeated(Arc::new(config), PairingToken(PAIRING_TOKEN.into())).unwrap(); + + while let Some(result) = repeated_pairing.next().await { + println!("token: {}", result.token.0); + } +} diff --git a/src/connection.rs b/src/connection.rs index 35d4037..d041062 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -13,6 +13,7 @@ use thiserror::Error; /// An error from the S2 connection. #[derive(Error, Debug)] +#[expect(clippy::large_enum_variant)] pub enum ConnectionError { /// An error from the underlying [`S2Transport`]. #[error("an error occurred in the underlying transport: {0}")] diff --git a/src/lib.rs b/src/lib.rs index 09e0317..5d82282 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,6 +94,7 @@ include!(concat!(env!("OUT_DIR"), "/generated.rs")); pub mod connection; +pub mod pairing; pub mod transport; #[cfg(test)] diff --git a/src/pairing/client.rs b/src/pairing/client.rs new file mode 100644 index 0000000..6d79ee7 --- /dev/null +++ b/src/pairing/client.rs @@ -0,0 +1,318 @@ +use std::sync::Arc; + +use reqwest::{StatusCode, Url}; +use rustls::pki_types::CertificateDer; + +use crate::pairing::transport::{HashProvider, hash_providing_https_client}; +use crate::pairing::{Pairing, PairingRole, SUPPORTED_PAIRING_VERSIONS}; + +use super::EndpointConfig; +use super::wire::*; +use super::{Error, Network, PairingResult}; + +/// Remote endpoint to pair with +pub struct PairingRemote { + /// URL at which the remote endpoint can be reached + pub url: String, + /// S2 node id of the remote endpoint. + pub id: S2NodeId, +} + +/// Configuration for pairing clients. +pub struct ClientConfig { + /// Additional roots of trust for TLS connections. Useful when testing during the development of WAN endpoints. + /// + /// When the remote is on the LAN, this is not used. + pub additional_certificates: Vec>, + /// Where the pairing is deployed. + pub pairing_deployment: Deployment, +} + +/// Client for S2 pairing transactions. +/// +/// Used as the client end of a pairing interaction. +pub struct Client { + config: Arc, + additional_certificates: Vec>, + pairing_deployment: Deployment, +} + +impl Client { + /// Create a new client for pairing on an endpoint with the given configuration. + pub fn new(config: Arc, client_config: ClientConfig) -> PairingResult { + Ok(Self { + config, + additional_certificates: client_config.additional_certificates, + pairing_deployment: client_config.pairing_deployment, + }) + } + + /// Pair with a given remote S2 node, using the provided token. + pub async fn pair(&self, remote: PairingRemote, pairing_token: &[u8]) -> PairingResult { + let url = Url::try_from(remote.url.as_str()).map_err(|_| Error::InvalidUrl)?; + + let (client, certhash) = if url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { + let (client, certhash) = hash_providing_https_client()?; + (client, Some(certhash)) + } else { + ( + reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|_| Error::TransportFailed)?, + None, + ) + }; + let pairing_version = negotiate_version(&client, url.clone()).await?; + + match pairing_version { + PairingVersion::V1 => { + V1Session::new(client, url, &self.config) + .pair(certhash, self.pairing_deployment, remote.id, pairing_token) + .await + } + } + } +} + +async fn negotiate_version(client: &reqwest::Client, url: Url) -> Result { + let response = client.get(url).send().await.map_err(|_| Error::TransportFailed)?; + let status = response.status(); + if status != StatusCode::OK { + return Err(Error::ProtocolError); + } + + let supported_versions = response.json::>().await.map_err(|_| Error::ProtocolError)?; + + for version in supported_versions.into_iter().filter_map(|v| v.try_into().ok()) { + if SUPPORTED_PAIRING_VERSIONS.contains(&version) { + return Ok(version); + } + } + + Err(Error::NoSupportedVersion) +} + +struct V1Session<'a> { + client: reqwest::Client, + base_url: Url, + config: &'a EndpointConfig, +} + +impl<'a> V1Session<'a> { + fn new(client: reqwest::Client, url: Url, config: &'a EndpointConfig) -> Self { + V1Session { + client, + base_url: url.join("v1/").unwrap(), + config, + } + } + + async fn pair( + self, + certhash: Option, + local_deployment: Deployment, + id: S2NodeId, + pairing_token: &[u8], + ) -> PairingResult { + let our_deployment = self.config.endpoint_description.deployment.unwrap_or(local_deployment); + let our_role = self.config.node_description.role; + + let network = if self.base_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { + if let Some(hash) = certhash.as_ref().and_then(HashProvider::hash) { + Network::Lan { + fingerprint: hash.try_into().unwrap(), + } + } else { + return Err(Error::ProtocolError); + } + } else { + Network::Wan + }; + + let client_hmac_challenge = HmacChallenge::new(&mut rand::rng()); + + let request_pairing_response = self.request_pairing(id, &client_hmac_challenge).await?; + let attempt_id = request_pairing_response.pairing_attempt_id; + let remote_deployment = request_pairing_response + .server_s2_endpoint_description + .deployment + .unwrap_or_else(|| network.as_deployment()); + let remote_role = request_pairing_response.server_s2_node_description.role; + + match request_pairing_response.selected_hmac_hashing_algorithm { + HmacHashingAlgorithm::Sha256 => { + let expected = client_hmac_challenge.sha256(&network, pairing_token); + + if expected != request_pairing_response.client_hmac_challenge_response { + let _ = self.finalize(&attempt_id, false).await; + return Err(Error::InvalidToken); + } + } + } + + let server_hmac_challenge_response = match request_pairing_response.selected_hmac_hashing_algorithm { + HmacHashingAlgorithm::Sha256 => request_pairing_response.server_hmac_challenge.sha256(&network, pairing_token), + }; + + enum CommunicationRole { + CommunicationServer { initiate_connection_url: String }, + CommunicationClient, + } + + let role = match (our_deployment, our_role, remote_deployment, remote_role) { + (_, S2Role::Rm, _, S2Role::Rm) | (_, S2Role::Cem, _, S2Role::Cem) => { + let _ = self.finalize(&attempt_id, false).await; + return Err(Error::RemoteOfSameType); + } + (Deployment::Lan, _, Deployment::Wan, _) => CommunicationRole::CommunicationClient, + // unwrap is okay here, as Deployment::Wan or S2Role::Cem locally means we will ALWAYS have a connection initiate url. + (Deployment::Wan, _, Deployment::Lan, _) | (_, S2Role::Cem, _, S2Role::Rm) => CommunicationRole::CommunicationServer { + initiate_connection_url: self.config.connection_initiate_url.as_ref().unwrap().into(), + }, + (_, S2Role::Rm, _, S2Role::Cem) => CommunicationRole::CommunicationClient, + }; + + let pairing = match role { + CommunicationRole::CommunicationServer { initiate_connection_url } => { + let access_token = AccessToken::new(&mut rand::rng()); + if let Err(e) = self + .post_connection_details( + &attempt_id, + server_hmac_challenge_response, + initiate_connection_url.clone(), + access_token.clone(), + ) + .await + { + let _ = self.finalize(&attempt_id, false).await; + return Err(e); + } + Pairing { + remote_endpoint_description: request_pairing_response.server_s2_endpoint_description, + remote_node_description: request_pairing_response.server_s2_node_description, + token: access_token, + role: PairingRole::CommunicationServer, + } + } + CommunicationRole::CommunicationClient => { + let connection_details = match self.get_connection_details(&attempt_id, server_hmac_challenge_response).await { + Ok(connection_details) => connection_details, + Err(e) => { + let _ = self.finalize(&attempt_id, false).await; + return Err(e); + } + }; + Pairing { + remote_endpoint_description: request_pairing_response.server_s2_endpoint_description, + remote_node_description: request_pairing_response.server_s2_node_description, + token: connection_details.access_token, + role: PairingRole::CommunicationClient { + initiate_url: connection_details.initiate_connection_url, + }, + } + } + }; + + self.finalize(&attempt_id, true).await?; + + Ok(pairing) + } + + async fn get_connection_details( + &self, + attempt_id: &PairingAttemptId, + server_hmac_challenge_response: HmacChallengeResponse, + ) -> PairingResult { + let request = RequestConnectionDetailsRequest { + server_hmac_challenge_response, + }; + let response = self + .client + .post(self.base_url.join("requestConnectionDetails").unwrap()) + .header(PairingAttemptId::HEADER_NAME, attempt_id.header_value()) + .json(&request) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + if response.status() != StatusCode::OK { + return Err(Error::ProtocolError); + } + let connection_details = response.json::().await.map_err(|_| Error::ProtocolError)?; + Ok(connection_details) + } + + async fn post_connection_details( + &self, + attempt_id: &PairingAttemptId, + server_hmac_challenge_response: HmacChallengeResponse, + initiate_connection_url: String, + access_token: AccessToken, + ) -> PairingResult<()> { + let request = PostConnectionDetailsRequest { + server_hmac_challenge_response, + connection_details: ConnectionDetails { + initiate_connection_url, + access_token, + }, + }; + let response = self + .client + .post(self.base_url.join("postConnectionDetails").unwrap()) + .header(PairingAttemptId::HEADER_NAME, attempt_id.header_value()) + .json(&request) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + if response.status() != StatusCode::NO_CONTENT { + return Err(Error::ProtocolError); + } + + Ok(()) + } + + async fn request_pairing(&self, id: S2NodeId, client_hmac_challenge: &HmacChallenge) -> PairingResult { + let request = RequestPairing { + node_description: self.config.node_description.clone(), + endpoint_description: self.config.endpoint_description.clone(), + id, + supported_protocols: self.config.supported_communication_protocols.clone(), + supported_versions: self.config.supported_message_versions.clone(), + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: client_hmac_challenge.clone(), + force_pairing: false, + }; + let response = self + .client + .post(self.base_url.join("requestPairing").unwrap()) + .json(&request) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + if response.status() != StatusCode::OK { + return Err(Error::ProtocolError); + } + let request_pairing_response = response.json::().await.map_err(|_| Error::ProtocolError)?; + Ok(request_pairing_response) + } + + async fn finalize(self, attempt_id: &PairingAttemptId, success: bool) -> PairingResult<()> { + let response = self + .client + .post(self.base_url.join("finalizePairing").unwrap()) + .header(PairingAttemptId::HEADER_NAME, attempt_id.header_value()) + .json(&success) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + if response.status() != StatusCode::NO_CONTENT { + return Err(Error::ProtocolError); + } + + Ok(()) + } +} diff --git a/src/pairing/mod.rs b/src/pairing/mod.rs new file mode 100644 index 0000000..2187199 --- /dev/null +++ b/src/pairing/mod.rs @@ -0,0 +1,402 @@ +//! Pairing logic for the S2 protocols. +//! +//! This module provides client and server implementations of the [S2 pairing protocol](https://docs.s2standard.org/docs/communication-layer/discovery-pairing-authentication/#the-pairing-process) +//! +//! # Endpoint configuration +//! +//! The main configuration struct [`EndpointConfig`] describes an S2 endpoint. It is constructed through +//! a builder pattern. For simple configuration, the builder can immediately be build: +//! ```rust +//! # use s2energy::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! let _config = EndpointConfig::builder(S2NodeDescription { +//! id: S2NodeId(String::from("12121212")), +//! brand: String::from("super-reliable-corp"), +//! logo_uri: None, +//! type_: String::from("fancy"), +//! model_name: String::from("the best"), +//! user_defined_name: None, +//! role: S2Role::Rm, +//! }, vec![MessageVersion("v1".into())]) +//! .build() +//! .unwrap(); +//! ``` +//! +//! Additional information can be added through methods on the builder. For example, we can add a connection initiate url through: +//! ```rust +//! # use s2energy::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! let _config = EndpointConfig::builder(S2NodeDescription { +//! id: S2NodeId(String::from("12121212")), +//! brand: String::from("super-reliable-corp"), +//! logo_uri: None, +//! type_: String::from("fancy"), +//! model_name: String::from("the best"), +//! user_defined_name: None, +//! role: S2Role::Rm, +//! }, vec![MessageVersion("v1".into())]) +//! .with_connection_initiate_url("https://example.com/".into()) +//! .build() +//! .unwrap(); +//! ``` +//! +//! # Client usage +//! +//! Given an endpoint configuration, a [`Client`] can be constructed which can be used to pair with a remote S2 node running a pairing +//! server. For this, you will also need to know the id of the node, and the URL on which its pairing server is reachable. +//! ```rust +//! # use std::sync::Arc; +//! # use s2energy::pairing::{Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role}; +//! # let config = EndpointConfig::builder(S2NodeDescription { +//! # id: S2NodeId(String::from("12121212")), +//! # brand: String::from("super-reliable-corp"), +//! # logo_uri: None, +//! # type_: String::from("fancy"), +//! # model_name: String::from("the best"), +//! # user_defined_name: None, +//! # role: S2Role::Rm, +//! # }, vec![MessageVersion("v1".into())]) +//! # .with_connection_initiate_url("https://example.com/".into()) +//! # .build() +//! # .unwrap(); +//! +//! let client = Client::new(Arc::new(config), ClientConfig { +//! pairing_deployment: Deployment::Lan, +//! additional_certificates: vec![], +//! }).unwrap(); +//! +//! let pairing_result = client.pair(PairingRemote { +//! url: "https://remote.example.com".into(), +//! id: S2NodeId(String::from("56565656")), +//! }, b"ABCDEF0123456"); +//! ``` +//! +//! # Server usage +//! +//! We can also setup a server to act as the HTTP server in a pairing exchange. The server then provides a router which we need to serve. Setting this up can look something like +//! ```rust +//! # use std::{path::PathBuf, net::SocketAddr}; +//! # use axum_server::tls_rustls::RustlsConfig; +//! # use s2energy::pairing::{Server, ServerConfig}; +//! # #[tokio::main(flavor = "current_thread")] +//! # async fn main() { +//! # let tls_config = RustlsConfig::from_pem_file( +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")) +//! # .join("testdata") +//! # .join("test.local.chain.pem"), +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("test.local.key"), +//! # ) +//! # .await +//! # .unwrap(); +//! # let addr = SocketAddr::from(([127, 0, 0, 1], 8005)); +//! let server = Server::new(ServerConfig { +//! root_certificate: None, +//! }); +//! tokio::spawn(async move { +//! axum_server::bind_rustls(addr, tls_config) +//! .serve(server.get_router().into_make_service()) +//! .await +//! .unwrap(); +//! }); +//! # } +//! ``` +//! After this setup, the server can be used to either start a single pairing session: +//! ```no_run +//! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; +//! # use axum_server::tls_rustls::RustlsConfig; +//! # use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # #[tokio::main(flavor = "current_thread")] +//! # async fn main() { +//! # let tls_config = RustlsConfig::from_pem_file( +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")) +//! # .join("testdata") +//! # .join("test.local.chain.pem"), +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("test.local.key"), +//! # ) +//! # .await +//! # .unwrap(); +//! # let addr = SocketAddr::from(([127, 0, 0, 1], 8005)); +//! # let server = Server::new(ServerConfig { +//! # root_certificate: None, +//! # }); +//! # let config = Arc::new(EndpointConfig::builder(S2NodeDescription { +//! # id: S2NodeId(String::from("12121212")), +//! # brand: String::from("super-reliable-corp"), +//! # logo_uri: None, +//! # type_: String::from("fancy"), +//! # model_name: String::from("the best"), +//! # user_defined_name: None, +//! # role: S2Role::Rm, +//! # }, vec![MessageVersion("v1".into())]) +//! # .with_connection_initiate_url("https://example.com/".into()) +//! # .build() +//! # .unwrap()); +//! let pairing_result = server.pair_once(config, PairingToken(b"ABCDEF0123456".as_slice().into())).unwrap().result().await; +//! # } +//! ``` +//! +//! Or to enable repeated pairing using the same fixed pairing token: +//! ```no_run +//! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; +//! # use axum_server::tls_rustls::RustlsConfig; +//! # use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # #[tokio::main(flavor = "current_thread")] +//! # async fn main() { +//! # let tls_config = RustlsConfig::from_pem_file( +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")) +//! # .join("testdata") +//! # .join("test.local.chain.pem"), +//! # PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("test.local.key"), +//! # ) +//! # .await +//! # .unwrap(); +//! # let addr = SocketAddr::from(([127, 0, 0, 1], 8005)); +//! # let server = Server::new(ServerConfig { +//! # root_certificate: None, +//! # }); +//! # let config = Arc::new(EndpointConfig::builder(S2NodeDescription { +//! # id: S2NodeId(String::from("12121212")), +//! # brand: String::from("super-reliable-corp"), +//! # logo_uri: None, +//! # type_: String::from("fancy"), +//! # model_name: String::from("the best"), +//! # user_defined_name: None, +//! # role: S2Role::Rm, +//! # }, vec![MessageVersion("v1".into())]) +//! # .with_connection_initiate_url("https://example.com/".into()) +//! # .build() +//! # .unwrap()); +//! let mut pairing_results = server.pair_repeated(config, PairingToken(b"ABCDEF0123456".as_slice().into())).unwrap(); +//! while let Some(pairing_result) = pairing_results.next().await { +//! /* do something with the pairing result */ +//! } +//! # } +//! ``` +//! +//! # Example applications +//! +//! A complete example of a pairing client and pairing server are present in the examples folder. These demonstrate also more completely +//! how a simple server setup can be done using the [`axum-server`](https://docs.rs/axum-server/0.8.0/axum_server/) crate. +#![warn(clippy::clone_on_copy)] +mod client; +mod server; +mod transport; +mod wire; + +use rand::Rng; + +use wire::{AccessToken, HmacChallenge, HmacChallengeResponse}; + +pub use client::{Client, ClientConfig, PairingRemote}; +pub use server::{PairingToken, PendingPairing, RepeatedPairing, Server, ServerConfig}; +pub use wire::{CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role}; + +use crate::pairing::wire::PairingVersion; + +const SUPPORTED_PAIRING_VERSIONS: &[PairingVersion] = &[PairingVersion::V1]; + +/// Full description of an S2 endpoint +#[derive(Debug, Clone)] +pub struct EndpointConfig { + node_description: S2NodeDescription, + endpoint_description: S2EndpointDescription, + supported_message_versions: Vec, + supported_communication_protocols: Vec, + connection_initiate_url: Option, +} + +impl EndpointConfig { + /// Description of the S2 node. + pub fn node_description(&self) -> &S2NodeDescription { + &self.node_description + } + + /// Description of the actual endpoint of the node. + pub fn endpoint_description(&self) -> &S2EndpointDescription { + &self.endpoint_description + } + + /// Message versions supported by this endpoint. + pub fn supported_message_versions(&self) -> &[MessageVersion] { + &self.supported_message_versions + } + + /// Communication protocols supported by this endpoint + pub fn supported_communication_protocols(&self) -> &[CommunicationProtocol] { + &self.supported_communication_protocols + } + + /// Connection initiate url used for this endpoint, if configured. + pub fn connection_initiate_url(&self) -> Option<&str> { + self.connection_initiate_url.as_deref() + } + + /// Create a builder for a new [`EndpointConfig`] + /// + /// All endpoint configurations must at least contain description of the node and supported message versions. Additional + /// properties can be configured through the builder. + pub fn builder(node_description: S2NodeDescription, supported_message_versions: Vec) -> ConfigBuilder { + ConfigBuilder { + node_description, + endpoint_description: S2EndpointDescription::default(), + supported_message_versions, + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + connection_initiate_url: None, + } + } +} + +/// Builder for an [`EndpointConfig`] +pub struct ConfigBuilder { + node_description: S2NodeDescription, + endpoint_description: S2EndpointDescription, + supported_message_versions: Vec, + supported_communication_protocols: Vec, + connection_initiate_url: Option, +} + +impl ConfigBuilder { + /// Set a url for initiating new connections. + /// + /// By default, this URL is not present. It is however required for CEM endpoints, or RM endpoints with a WAN deployment. + pub fn with_connection_initiate_url(mut self, connection_initiate_url: String) -> Self { + self.connection_initiate_url = Some(connection_initiate_url); + self + } + + /// Set the communication protocols supported by + pub fn with_supported_communication_protocols(mut self, communication_protocols: Vec) -> Self { + self.supported_communication_protocols = communication_protocols; + self + } + + /// Set the endpoint description explicitly. + /// + /// By default, all fields in the endpoint description are unset. Note that this replaces any previous endpoint descriptions passed. + pub fn with_endpoint_description(mut self, endpoint_description: S2EndpointDescription) -> Self { + self.endpoint_description = endpoint_description; + self + } + + /// Create the actual [`EndpointConfig`], validating that it is reasonable. + pub fn build(self) -> Result { + if (self.node_description.role == S2Role::Cem || self.endpoint_description.deployment == Some(Deployment::Wan)) + && self.connection_initiate_url.is_none() + { + return Err(ConfigError::MissingInitiateUrl); + } + Ok(EndpointConfig { + node_description: self.node_description, + endpoint_description: self.endpoint_description, + supported_message_versions: self.supported_message_versions, + supported_communication_protocols: self.supported_communication_protocols, + connection_initiate_url: self.connection_initiate_url, + }) + } +} + +/// Error for problems with inconsistent [`EndpointConfig`] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ConfigError { + /// The [`EndpointConfig`] doesn't have an `connection_initiate_url` even though it is needed for the configuration to make sense. + MissingInitiateUrl, +} + +/// Role for the communication protocol assigned to the node in the pairing process +pub enum PairingRole { + /// This node must initiate the connection protocol. + CommunicationClient { + /// URL to be used for initiating the connection. + initiate_url: String, + }, + /// This node gets contacted by the other node to initiate a connection. + CommunicationServer, +} + +/// The result of a pairing operation +/// +/// Describes the remote endpoint, and how communication between the nodes will happen. +pub struct Pairing { + /// Description of the remote S2 Node. + pub remote_node_description: S2NodeDescription, + /// Description of the remote S2 Endpoint. + pub remote_endpoint_description: S2EndpointDescription, + /// Token used during communication setup. + pub token: AccessToken, + /// Role this node has for initiating communication. + pub role: PairingRole, +} + +impl HmacChallenge { + pub fn new(rng: &mut impl Rng) -> Self { + Self(rng.random()) + } + + pub fn sha256(&self, network: &Network, pairing_token: &[u8]) -> HmacChallengeResponse { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let mut mac = Hmac::::new_from_slice(&self.0).expect("HMAC can take a key of any size"); + + match network { + Network::Wan => { + // R = HMAC(C, T) + mac.update(pairing_token); + } + Network::Lan { fingerprint } => { + // R = HMAC(C, T || F) + mac.update(pairing_token); + mac.update(fingerprint); + } + } + + HmacChallengeResponse(mac.finalize().into_bytes().into()) + } +} + +/// Error that occured during the pairing process. +#[derive(Debug, Clone)] +pub enum Error { + /// Invalid URL for remote + InvalidUrl, + /// Something went wrong in the transport layers + TransportFailed, + /// The remote reacted outside our expectations + ProtocolError, + /// No shared version with the remote. + NoSupportedVersion, + /// Session timed out. + Timeout, + /// Already a pending pairing session with that node id. + AlreadyPending, + /// Provided token was invalid. + InvalidToken, + /// The pairing session was cancelled. + Cancelled, + /// The remote is of the same type + RemoteOfSameType, + /// The configuration was invalid + InvalidConfig(ConfigError), +} + +impl From for Error { + fn from(value: ConfigError) -> Self { + Self::InvalidConfig(value) + } +} + +/// Convenience type for [`Result`] +pub type PairingResult = Result; + +#[derive(Debug)] +enum Network { + Wan, + Lan { fingerprint: [u8; 32] }, +} + +impl Network { + fn as_deployment(&self) -> Deployment { + match self { + Network::Wan => Deployment::Wan, + Network::Lan { .. } => Deployment::Lan, + } + } +} diff --git a/src/pairing/server.rs b/src/pairing/server.rs new file mode 100644 index 0000000..98c58c5 --- /dev/null +++ b/src/pairing/server.rs @@ -0,0 +1,484 @@ +#![allow(unused)] +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::State, + http::HeaderMap, + routing::{get, post}, +}; +use reqwest::StatusCode; +use rustls::pki_types::CertificateDer; +use sha2::Digest; +use tokio::time::Instant; + +use crate::pairing::{PairingRole, SUPPORTED_PAIRING_VERSIONS}; + +use super::{EndpointConfig, Error, Network, Pairing, PairingResult, S2EndpointDescription, S2NodeDescription, wire::*}; + +const PERMANENT_PAIRING_BUFFER_SIZE: usize = 1; + +/// Token known to both S2 nodes trying to pair. +/// +/// This token is used to validate the identity of the nodes. +pub struct PairingToken(pub Box<[u8]>); + +/// Server for handling S2 pairing transactions. +/// +/// Responsible for providing the HTTP endpoints needed for handling +pub struct Server { + state: AppState, +} + +/// Configuration for the S2 pairing server. +pub struct ServerConfig { + /// The root certificate of the server, if we are using a self-signed root. + /// Presence of this field indicates we are deployed on LAN. + pub root_certificate: Option>, +} + +/// A pending one-time pairing transaction. +pub struct PendingPairing { + receiver: tokio::sync::oneshot::Receiver>, +} + +impl PendingPairing { + /// Wait for the result of the pairing transaction. + pub async fn result(self) -> PairingResult { + self.receiver.await.unwrap_or(Err(Error::Timeout)) + } +} + +/// A repeated pairing with a fixed token, that can yield multiple pairings. +pub struct RepeatedPairing { + receiver: tokio::sync::mpsc::Receiver>, +} + +impl RepeatedPairing { + /// Wait for and return the next pairing result using the token associated with this repeated pairing. + pub async fn next(&mut self) -> Option { + loop { + if let Ok(pairing) = self.receiver.recv().await.transpose() { + break pairing; + } + } + } +} + +impl Server { + /// Create a new server using the given configuration. + pub fn new(server_config: ServerConfig) -> Self { + let state = AppStateInner { + network: server_config + .root_certificate + .map(|v| Network::Lan { + fingerprint: sha2::Sha256::digest(v).into(), + }) + .unwrap_or(Network::Wan), + permanent_pairings: Mutex::new(HashMap::new()), + open_pairings: Mutex::new(HashMap::new()), + attempts: Mutex::new(HashMap::default()), + }; + + Self { state: Arc::new(state) } + } + + /// Get an [`axum::Router`] handling the endpoints for the pairing protocol. + /// + /// Incomming http requests can be handled by this router through the [axum-server](https://docs.rs/axum-server/0.8.0/axum_server/) crate. + pub fn get_router(&self) -> axum::Router<()> { + Router::new() + .route("/", get(root)) + .nest("/v1", v1_router()) + .with_state(self.state.clone()) + } + + /// Start a one-time pairing session for the given endpoint using the given token. + pub fn pair_once(&self, config: Arc, pairing_token: PairingToken) -> Result { + if config.connection_initiate_url.is_none() { + return Err(Error::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + } + + let mut open_pairings = self.state.open_pairings.lock().unwrap(); + let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); + if open_pairings.contains_key(&config.node_description.id) || permanent_pairings.contains_key(&config.node_description.id) { + return Err(Error::AlreadyPending); + } + drop(permanent_pairings); + let (sender, receiver) = tokio::sync::oneshot::channel(); + open_pairings.insert( + config.node_description.id.clone(), + PairingRequest { + config, + sender: ResultSender::Oneshot(sender), + token: pairing_token, + }, + ); + Ok(PendingPairing { receiver }) + } + + /// Allow repeated pairing sessions for the given endpoing using the given token. + pub fn pair_repeated(&self, config: Arc, pairing_token: PairingToken) -> Result { + if config.connection_initiate_url.is_none() { + return Err(Error::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + } + + let mut open_pairings = self.state.open_pairings.lock().unwrap(); + let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); + if open_pairings.contains_key(&config.node_description.id) || permanent_pairings.contains_key(&config.node_description.id) { + return Err(Error::AlreadyPending); + } + drop(open_pairings); + let (sender, receiver) = tokio::sync::mpsc::channel(PERMANENT_PAIRING_BUFFER_SIZE); + permanent_pairings.insert( + config.node_description.id.clone(), + PermanentPairingRequest { + config, + sender, + token: pairing_token, + }, + ); + Ok(RepeatedPairing { receiver }) + } +} + +enum ResultSender { + Oneshot(tokio::sync::oneshot::Sender>), + Multi(tokio::sync::mpsc::Sender>), +} + +impl ResultSender { + async fn send(self, result: PairingResult) { + match self { + Self::Oneshot(sender) => { + let _ = sender.send(result); + } + Self::Multi(sender) => { + let _ = sender.send(result).await; + } + }; + } +} + +struct PermanentPairingRequest { + config: Arc, + sender: tokio::sync::mpsc::Sender>, + token: PairingToken, +} + +struct PairingRequest { + config: Arc, + sender: ResultSender, + token: PairingToken, +} + +struct InitialPairingState { + config: Arc, + sender: ResultSender, + challenge: HmacChallenge, + token: PairingToken, + remote_node_description: S2NodeDescription, + remote_endpoint_description: S2EndpointDescription, +} +struct CompletePairingState { + sender: ResultSender, + remote_node_description: S2NodeDescription, + remote_endpoint_description: S2EndpointDescription, + access_token: AccessToken, + role: PairingRole, +} + +enum PairingState { + Empty, + Initial(InitialPairingState), + Complete(CompletePairingState), +} + +struct ExpiringPairingState { + start_time: Instant, + state: PairingState, +} + +impl ExpiringPairingState { + fn get_state(&mut self) -> Option<&mut PairingState> { + if self.start_time.elapsed() > Duration::from_secs(15) { + None + } else { + Some(&mut self.state) + } + } + + fn into_state(self) -> Option { + if self.start_time.elapsed() > Duration::from_secs(15) { + None + } else { + Some(self.state) + } + } +} + +type AppState = Arc; + +struct AppStateInner { + // rng: ThreadRng, + network: Network, + permanent_pairings: Mutex>, + open_pairings: Mutex>, + attempts: Mutex>, +} + +async fn root() -> Json<&'static [PairingVersion]> { + Json(SUPPORTED_PAIRING_VERSIONS) +} + +fn v1_router() -> Router { + Router::new() + .route("/requestPairing", post(v1_request_pairing)) + .route("/requestConnectionDetails", post(v1_request_connection_details)) + .route("/postConnectionDetails", post(v1_post_connection_details)) + .route("/finalizePairing", post(v1_finalize_pairing)) +} + +async fn v1_request_pairing( + State(state): State, + Json(request_pairing): Json, +) -> Result, Json> { + if !request_pairing.supported_hashing_algorithms.contains(&HmacHashingAlgorithm::Sha256) { + return Err(PairingResponseErrorMessage::IncompatibleHMACHashingAlgorithms.into()); + } + + let mut rng = rand::rng(); + let server_hmac_challenge = HmacChallenge::new(&mut rng); + + let open_pairing = { + let mut open_pairings = state.open_pairings.lock().unwrap(); + if let Some((_, request)) = open_pairings.remove_entry(&request_pairing.id) { + request + } else { + drop(open_pairings); + let permanent_pairings = state.permanent_pairings.lock().unwrap(); + let entry = permanent_pairings + .get(&request_pairing.id) + .ok_or(PairingResponseErrorMessage::S2NodeNotFound)?; + PairingRequest { + config: entry.config.clone(), + sender: ResultSender::Multi(entry.sender.clone()), + token: PairingToken(entry.token.0.clone()), + } + } + }; + + if !request_pairing.force_pairing { + let mut communication_overlap = false; + for communication_protocol in &open_pairing.config.supported_communication_protocols { + if request_pairing.supported_protocols.contains(communication_protocol) { + communication_overlap = true; + break; + } + } + if !communication_overlap { + return Err(PairingResponseErrorMessage::IncompatibleCommunicationProtocols.into()); + } + let mut connection_overlap = false; + for connection_protocol in &open_pairing.config.supported_message_versions { + if request_pairing.supported_versions.contains(connection_protocol) { + connection_overlap = true; + break; + } + } + if !connection_overlap { + return Err(PairingResponseErrorMessage::IncompatibleS2MessageVersions.into()); + } + } + + let client_hmac_challenge_response = request_pairing.client_hmac_challenge.sha256(&state.network, &open_pairing.token.0); + + let pairing_attempt_id = { + let mut attempts = state.attempts.lock().unwrap(); + loop { + let id = PairingAttemptId::new(&mut rng); + if !attempts.contains_key(&id) { + attempts.insert( + id.clone(), + ExpiringPairingState { + start_time: Instant::now(), + state: PairingState::Initial(InitialPairingState { + config: open_pairing.config.clone(), + sender: open_pairing.sender, + challenge: server_hmac_challenge.clone(), + token: open_pairing.token, + remote_node_description: request_pairing.node_description, + remote_endpoint_description: request_pairing.endpoint_description, + }), + }, + ); + break id; + } + } + }; + + let resp = RequestPairingResponse { + pairing_attempt_id, + server_s2_node_description: open_pairing.config.node_description.clone(), + server_s2_endpoint_description: open_pairing.config.endpoint_description.clone(), + selected_hmac_hashing_algorithm: HmacHashingAlgorithm::Sha256, + client_hmac_challenge_response, + server_hmac_challenge, + }; + + Ok(Json(resp)) +} + +async fn v1_request_connection_details( + State(app_state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, StatusCode> { + let Some(pairing_attempt_id) = PairingAttemptId::from_headers(&headers) else { + return Err(StatusCode::UNAUTHORIZED); + }; + + // We do this with a closure to drop attempts before we run the future for sending results to the caller, if it is present. + let (result, future) = (|| { + let mut attempts = app_state.attempts.lock().unwrap(); + let Some(state) = attempts.get_mut(&pairing_attempt_id) else { + return (Err(StatusCode::UNAUTHORIZED), None); + }; + + if let Some(state_entry) = state.get_state() + && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) + { + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return (Err(StatusCode::FORBIDDEN), Some(state.sender.send(Err(Error::InvalidToken)))); + } + + let mut rng = rand::rng(); + let connection_details = ConnectionDetails { + initiate_connection_url: match &state.config.connection_initiate_url { + Some(url) => url.clone(), + None => return (Err(StatusCode::BAD_REQUEST), None), + }, + access_token: AccessToken::new(&mut rng), + }; + + *state_entry = PairingState::Complete(CompletePairingState { + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: connection_details.access_token.clone(), + role: PairingRole::CommunicationServer, + }); + + (Ok(Json(connection_details)), None) + } else { + attempts.remove(&pairing_attempt_id); + (Err(StatusCode::UNAUTHORIZED), None) + } + })(); + + if let Some(future) = future { + future.await; + } + + result +} + +async fn v1_post_connection_details( + State(app_state): State, + headers: HeaderMap, + Json(req): Json, +) -> StatusCode { + let Some(pairing_attempt_id) = PairingAttemptId::from_headers(&headers) else { + return StatusCode::UNAUTHORIZED; + }; + + // We do this with a closure to drop attempts before we run the future for sending results to the caller, if it is present. + let (result, future) = (|| { + let mut attempts: std::sync::MutexGuard<'_, HashMap> = app_state.attempts.lock().unwrap(); + let Some(state) = attempts.get_mut(&pairing_attempt_id) else { + return (StatusCode::UNAUTHORIZED, None); + }; + + if let Some(state_entry) = state.get_state() + && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) + { + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return (StatusCode::FORBIDDEN, Some(state.sender.send(Err(Error::InvalidToken)))); + } + + // Do better error handling here than unwrap + *state_entry = PairingState::Complete(CompletePairingState { + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: req.connection_details.access_token, + role: PairingRole::CommunicationClient { + initiate_url: req.connection_details.initiate_connection_url, + }, + }); + + (StatusCode::NO_CONTENT, None) + } else { + attempts.remove(&pairing_attempt_id); + (StatusCode::UNAUTHORIZED, None) + } + })(); + + if let Some(future) = future { + future.await; + } + + result +} + +async fn v1_finalize_pairing(State(state): State, headers: HeaderMap, Json(success): Json) -> StatusCode { + let Some(pairing_attempt_id) = PairingAttemptId::from_headers(&headers) else { + return StatusCode::UNAUTHORIZED; + }; + + let Some(state) = ({ + let mut attempts = state.attempts.lock().unwrap(); + attempts.remove(&pairing_attempt_id) + }) else { + return StatusCode::UNAUTHORIZED; + }; + + if let Some(state) = state.into_state() { + if success { + if let PairingState::Complete(state) = state { + state + .sender + .send(Ok(Pairing { + remote_endpoint_description: state.remote_endpoint_description, + remote_node_description: state.remote_node_description, + token: state.access_token, + role: state.role, + })) + .await; + + StatusCode::NO_CONTENT + } else { + StatusCode::BAD_REQUEST + } + } else { + match state { + PairingState::Empty => { /* should never happen, but fine to ignore */ } + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + sender.send(Err(Error::Cancelled)).await; + } + } + + StatusCode::NO_CONTENT + } + } else { + StatusCode::UNAUTHORIZED + } +} diff --git a/src/pairing/transport.rs b/src/pairing/transport.rs new file mode 100644 index 0000000..8704e01 --- /dev/null +++ b/src/pairing/transport.rs @@ -0,0 +1,173 @@ +use std::sync::{Arc, OnceLock}; + +use rustls::{ + RootCertStore, + client::{WebPkiServerVerifier, danger::ServerCertVerifier}, + pki_types::CertificateDer, +}; +use sha2::Digest; + +use super::{Error, PairingResult}; + +#[derive(Debug)] +struct HashingCertificateVerifier { + inner: rustls_platform_verifier::Verifier, + self_signed_state: Arc>, +} + +#[derive(Debug)] +struct SelfSignedState { + hash: CertificateHash, + verifier: SelfVerifier, +} + +#[derive(Debug)] +enum SelfVerifier { + WebPki(WebPkiServerVerifier), + None, +} + +impl ServerCertVerifier for SelfVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => { + web_pki_server_verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls12_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls13_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn supported_verify_schemes(&self) -> Vec { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.supported_verify_schemes(), + SelfVerifier::None => vec![], + } + } +} + +type CertificateHash = sha2::digest::generic_array::GenericArray::OutputSize>; + +impl ServerCertVerifier for HashingCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + match self + .inner + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + { + Ok(v) => Ok(v), + Err(_) => { + let state = self.self_signed_state.get_or_init(|| { + let fallback = CertificateDer::from_slice(&[]); + let root_cert = intermediates.last().unwrap_or(&fallback); + let hash = sha2::Sha256::digest(root_cert); + let mut root_store = RootCertStore::empty(); + // conciously ignore errors here, we just want to initialize + root_store.add(root_cert.clone()).ok(); + let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { + Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), + Err(_) => SelfVerifier::None, + }; + + SelfSignedState { hash, verifier } + }); + state + .verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +pub(crate) struct HashProvider { + state: Arc>, +} + +impl HashProvider { + pub(crate) fn hash(&self) -> Option<&[u8]> { + match self.state.get() { + Some(state) => Some(&state.hash), + None => None, + } + } +} + +pub(crate) fn hash_providing_https_client() -> PairingResult<(reqwest::Client, HashProvider)> { + let rustls_config_builder = rustls::ClientConfig::builder(); + let crypto_provider = rustls_config_builder.crypto_provider().clone(); + let self_signed_state = Arc::new(OnceLock::new()); + let state = self_signed_state.clone(); + let verifier = HashingCertificateVerifier { + inner: rustls_platform_verifier::Verifier::new(crypto_provider).map_err(|_| Error::TransportFailed)?, + self_signed_state, + }; + let client_config = rustls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + let client = reqwest::Client::builder() + .use_preconfigured_tls(client_config) + .build() + .map_err(|_| Error::TransportFailed)?; + + Ok((client, HashProvider { state })) +} diff --git a/src/pairing/wire.rs b/src/pairing/wire.rs new file mode 100644 index 0000000..9b47ba6 --- /dev/null +++ b/src/pairing/wire.rs @@ -0,0 +1,275 @@ +use axum::http::{HeaderMap, HeaderName, HeaderValue}; +use serde::*; +use thiserror::Error; + +#[derive(Error, Debug, Serialize, Deserialize)] +pub(crate) enum PairingResponseErrorMessage { + #[error("Invalid combination of roles")] + InvalidCombinationOfRoles, + #[error("Incompatible S2 message versions")] + IncompatibleS2MessageVersions, + #[error("Incompatible HMAC hashing algorithms")] + IncompatibleHMACHashingAlgorithms, + #[error("Incompatible communication protocols")] + IncompatibleCommunicationProtocols, + #[error("S2Node not found")] + S2NodeNotFound, + #[error("No S2Node provided")] + S2NodeNotProvided, + #[error("No valid pairingToken on PairingServer")] + InvalidPairingToken, + #[error("Parsing error")] + ParsingError, + #[error("Other")] + Other, +} + +#[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub(crate) enum PairingVersion { + V1, +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub(crate) enum WirePairingVersion { + V1, + #[serde(other)] + Other, +} + +impl TryFrom for PairingVersion { + type Error = (); + + fn try_from(value: WirePairingVersion) -> Result { + match value { + WirePairingVersion::V1 => Ok(PairingVersion::V1), + WirePairingVersion::Other => Err(()), + } + } +} + +/// Message schema version. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MessageVersion(pub String); + +#[derive(Serialize, Deserialize)] +pub(crate) struct RequestPairing { + #[serde(rename = "clientS2NodeDescription")] + pub node_description: S2NodeDescription, + #[serde(rename = "clientS2EndpointDescription")] + pub endpoint_description: S2EndpointDescription, + #[serde(rename = "pairingS2NodeId")] + pub id: S2NodeId, + #[serde(rename = "supportedCommunicationProtocols")] + pub supported_protocols: Vec, + /// The versions of the S2 JSON message schemas this S2Node implementation currently supports. + #[serde(rename = "supportedS2MessageVersions")] + pub supported_versions: Vec, + #[serde(rename = "supportedHmacHashingAlgorithms")] + #[serde(default)] + pub supported_hashing_algorithms: Vec, + #[serde(rename = "clientHmacChallenge")] + pub client_hmac_challenge: HmacChallenge, + /// Forces the server to attempt pairing, even though the S2 message versions are not compatible. In this case the S2Nodes won't be able to communicate after pairing, but this could later be solved through a software update on one or both of the S2Nodes. + #[serde(rename = "forcePairing")] + #[serde(default)] + pub force_pairing: bool, +} + +/// Information about the pairing endpoint of a S2 node +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S2EndpointDescription { + /// Name of the endpoint + #[serde(default)] + pub name: Option, + /// URI of a logo to be used for the endpoint in GUIs + #[serde(default)] + pub logo_uri: Option, + /// Type of deployment used by the endpoint (local or globally routable). + #[serde(default)] + pub deployment: Option, +} + +/// One-time access token for secure access to the S2 message communication channel. It must be renewed every time a client wants to access +/// the S2 message communication channel by calling the requestToken endpoint. This token is valid for one time login, with a maximum 5 +/// years, and should have a minimum length of 32 bytes. +#[derive(Serialize, Deserialize, Clone)] +pub struct AccessToken(pub String); + +impl AccessToken { + pub fn new(rng: &mut impl rand::Rng) -> Self { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + + let encoded = STANDARD.encode(bytes); + Self(encoded) + } +} + +/// Unique identifier of the S2 node +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct S2NodeId(pub String); + +/// Information about the S2 node +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S2NodeDescription { + /// Unique identifier of the node + pub id: S2NodeId, + /// Brandname used for the node + pub brand: String, + /// URI of a logo to be used for the node in GUIs + #[serde(default)] + pub logo_uri: Option, + /// The type of this node. + pub type_: String, + /// Model name of the device this node belongs to. + pub model_name: String, + /// A name for the device configured by the end user/owner. + #[serde(default)] + pub user_defined_name: Option, + /// The S2 role this device has (e.g. CEM or RM). + pub role: S2Role, +} + +/// Identifier of a protocol that can be used for communication of S2 messages between nodes, for example `"WebSocket"` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct CommunicationProtocol(pub String); + +/// Role within the S2 standard. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub enum S2Role { + /// Customer Energy Manager. + Cem, + /// Resource Manager. + Rm, +} + +/// Place of deployment for an S2 Node +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub enum Deployment { + /// On a WAN, reachable over the internet + Wan, + /// On the local network, only reachable near the place the device is located. + Lan, +} + +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum HmacHashingAlgorithm { + Sha256, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub(crate) struct HmacChallenge( + #[serde( + serialize_with = "base64_bytes::serialize", + deserialize_with = "base64_bytes::deserialize::<_, 32>" + )] + pub(crate) [u8; 32], +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub(crate) struct HmacChallengeResponse( + #[serde( + serialize_with = "base64_bytes::serialize", + deserialize_with = "base64_bytes::deserialize::<_, 32>" + )] + pub(crate) [u8; 32], +); + +/// An identifier that is generated by the server for each pairing attempt. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PairingAttemptId(String); + +impl PairingAttemptId { + pub const HEADER_NAME: &'static str = "authorization"; + + pub fn new(rng: &mut impl rand::Rng) -> Self { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + + let encoded = STANDARD.encode(bytes); + Self(encoded) + } + + pub fn header_name() -> HeaderName { + HeaderName::from_static(Self::HEADER_NAME) + } + + pub fn header_value(&self) -> HeaderValue { + HeaderValue::from_str(&self.0).expect("base64 makes a valid HeaderValue") + } + + pub fn from_headers(headers: &HeaderMap) -> Option { + let value = headers.get(Self::header_name())?; + Some(Self(value.to_str().ok()?.to_string())) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RequestPairingResponse { + pub pairing_attempt_id: PairingAttemptId, + pub server_s2_node_description: S2NodeDescription, + pub server_s2_endpoint_description: S2EndpointDescription, + pub selected_hmac_hashing_algorithm: HmacHashingAlgorithm, + pub client_hmac_challenge_response: HmacChallengeResponse, + pub server_hmac_challenge: HmacChallenge, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RequestConnectionDetailsRequest { + pub server_hmac_challenge_response: HmacChallengeResponse, +} + +/// Details the Connection client needs to set up an S2 session. +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConnectionDetails { + pub initiate_connection_url: String, + pub access_token: AccessToken, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PostConnectionDetailsRequest { + pub server_hmac_challenge_response: HmacChallengeResponse, + pub connection_details: ConnectionDetails, +} + +mod base64_bytes { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use serde::{Deserialize, Deserializer, Serializer, de}; + + pub(crate) fn serialize(value: &T, serializer: S) -> Result + where + S: Serializer, + T: AsRef<[u8]>, + { + let encoded = STANDARD.encode(value.as_ref()); + serializer.serialize_str(&encoded) + } + + pub(crate) fn deserialize<'de, D, const N: usize>(deserializer: D) -> Result<[u8; N], D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let decoded = STANDARD.decode(&s).map_err(de::Error::custom)?; + + decoded + .as_slice() + .try_into() + .map_err(|_| de::Error::custom(format!("expected {N} bytes after base64 decoding, got {}", decoded.len()))) + } +} diff --git a/testdata/gen_cert.sh b/testdata/gen_cert.sh new file mode 100755 index 0000000..75041f7 --- /dev/null +++ b/testdata/gen_cert.sh @@ -0,0 +1,48 @@ +#! /bin/sh + +# This script generates a private key/certificate for a server, and signs it with the provided CA key +# based on https://docs.ntpd-rs.pendulum-project.org/development/ca/ + +# Because this script generate keys without passwords set, they should only be used in a development setting. + +if [ -z "$1" ]; then + echo "usage: gen-cert.sh name-of-server [ca-name] [filename]" + echo + echo "This will generate a name-of-server.key, name-of-server.pem and name-of-server.chain.pem file" + echo "containing the private key, public certificate, and full certificate chain (respectively)" + echo + echo "The second argument denotes the name of the CA be used (found in the files ca-name.key and ca-name.pem)" + echo "If this is omitted, the name 'testca' will be used." + exit +fi + +NAME="${1:-localhost}" +CA="${2:-root}" +FILENAME="${3:-$NAME}" + +# generate a key +openssl genrsa -out "$FILENAME".key 2048 + +# generate a certificate signing request +openssl req -batch -new -key "$FILENAME".key -out "$FILENAME".csr + +# generate an ext file +cat >> "$FILENAME".ext < "$FILENAME".fullchain.pem + +# cleanup +rm "$FILENAME".csr "$FILENAME".ext diff --git a/testdata/root.key b/testdata/root.key new file mode 100644 index 0000000..a40f6e5 --- /dev/null +++ b/testdata/root.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDXr9mtQIgvaDBk +xr6mw5UVg9Ox35e01rCz1rrl9MqSc9SaKtjCCQVyotokHsXT6DKJ4H+CGDD+Y8UL +hEQN7B8VSfGB8gg/nD7KHk2dzxNt8kkZGWDKanyWWdawrsegcApvwV2eHa5/94sH +HkJCZNJoRMtmoimZ0o848jOIAUoSpO1bIxRq7N2YluJVaMYk/U2GBOfwpjhXcy74 +kQrq1mGyyE3hzJUgtaRGlDsvp3c099b9Pd2fRAmqUzjijibQfheuum4KCLwoZCGv +wnY4iQM6vQjNY06djAqyR6XFGH8s7EVzNFSJyhJZK30FaAYPDeVDIJUKTSrJlTr2 +ddjF5Gp/AgMBAAECggEABrdf82OJiym0xaz9u7MzO49P9tTUoAi9sDg1DcPD4eNE +FU+zHk5ZAmF5Xjlwjg82uczcV1XWx2SeJUZLrnu0LuIXvccM8QmcRsxcCaVGRvqe +9aGf18yy4ovOwgOlJv6mKmpAriSuASfIOml7ow2SNPEyZ1R2ycR6l5ZVlGSmr2ev +TuEdYa5RsuO9PszPChvauMTfHYjto4neqbUISPpsOHqw82WTW4Yiei4SujDU1/ad +bXNC8TxSMo2uDsmGm9cE2W26JV5+hw6xj7ZJD1f9FgDX5Zces19+7u38+xDlZmKO +N3ko0qASF/kST8KpAt0Ag4ok3stDgWGXHGioDh+9VQKBgQDzuZZ1CRkMi+hoxHqW +oij2ggxhrWRV9zBsqBp+Gu7+JOWR0d5aBAO+54Z8YLCCDqOJCFWnv8a6JGvfyXam +L4DrEvii2gpHcHfxdxpY7TxZ2leNzfZtzxWqeilm4+zgI2TlB4ttaqUyjbwGbZ4F +AczMug0ajo9Z44jsVhkZAnFTIwKBgQDijMKxhZDoZtlzksneehDhCq/gz5bWbfzC ++lWgW+fbe1Kjeyfvruh687KH1eG7hqmIG6qKFVJa0xIcNCblE05Q/DE1ANsU1wvr +PmAOGoANSv8PdJQidLZnxqs/Gk3jYHVEQleB65icGaHymheoLCQRJ3hO7uGiXs73 +ahIJGINe9QKBgDTqhW0xpXug4LvmdMtBt+0VfUSz1cYIXj4pHV9lz5/kOOe5DjKz +DnEjaYKVp50FOqJk6dv6+lWt1LII1rbsN2xSeSM6feLW22PUvSazk1fa3QmPv0JG +JOkXjuek8ugTJzPGuJHwBp/8P+eRYy6pHmMQvgmXDC6zBSZy5w4UBrDLAoGBANwg +h4C0VlPqs3l/9JQuqT3xA2n+awLZhhLx6MdS5du2XVqp7OkQW6Y0KpGf+aEbvJia +wT9Abm//zQYIio5lDuDT2wA0X6tFNLiUOAMcLrY7289pqBI+s4nsWTKMXYgEjo0P +pTWA9R0gLuKqZTgsA7ODVXvk0urREmsG7pVV8zgNAoGAYJIR6IVY3t0i5JLSpQix +WmwDW8AOyL90J9ODU4wWOcpcFsuy5VMJYqGceKZ7I8Tb3t2FWPRrrn3FEGrdIfuX +/tUqpVv+9jqnokHmuFf8FaaGtjmqoiOig/KZnpYNvBp6o5rCgXVURvHBAHinNLhM +hRlVokufe2t2gaOlR9uUmtU= +-----END PRIVATE KEY----- diff --git a/testdata/root.pem b/testdata/root.pem new file mode 100644 index 0000000..654db6c --- /dev/null +++ b/testdata/root.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIURu/3EiBqW5zBHLYfNRjzTfs2QMQwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTAwODI3NDRaFw0zMTAy +MDkwODI3NDRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDXr9mtQIgvaDBkxr6mw5UVg9Ox35e01rCz1rrl9MqS +c9SaKtjCCQVyotokHsXT6DKJ4H+CGDD+Y8ULhEQN7B8VSfGB8gg/nD7KHk2dzxNt +8kkZGWDKanyWWdawrsegcApvwV2eHa5/94sHHkJCZNJoRMtmoimZ0o848jOIAUoS +pO1bIxRq7N2YluJVaMYk/U2GBOfwpjhXcy74kQrq1mGyyE3hzJUgtaRGlDsvp3c0 +99b9Pd2fRAmqUzjijibQfheuum4KCLwoZCGvwnY4iQM6vQjNY06djAqyR6XFGH8s +7EVzNFSJyhJZK30FaAYPDeVDIJUKTSrJlTr2ddjF5Gp/AgMBAAGjUzBRMB0GA1Ud +DgQWBBT+zjdNHHDoacZ3tcWDQNpfu9GGcDAfBgNVHSMEGDAWgBT+zjdNHHDoacZ3 +tcWDQNpfu9GGcDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCI +3ZjK6htARQAYx80BIV4ZOm/MPtQxEOMJ0gywcOckwUICHbmdjj5T2XdsE1L9EbYw +8U/5XItzcfnmf5A+7Pf1UqnfOgeCAw7tl4zX5zCHDm0l3nXmOSnyU1RMetJ+aXTT +LZyV6JJxcEFseQsqdBwx6AkXGz4CqLBDMbwi6j+1yRfib11m2gZGYozNFKDrw6xS +L0KFcBWCM8lzb6W5oc3P+oA+EoF3nhgydtb1vNwe9wkubrRl5GkFzRrnEHTDRpLe +NShyxRuBPtQoKwcIfMaNt+9W5qMwrYjh21mCGX122K8kAdXDT35AYcAK2X8WpT9F +nL09Lv6HBpesSih6ZRS3 +-----END CERTIFICATE----- diff --git a/testdata/test.local.chain.pem b/testdata/test.local.chain.pem new file mode 100644 index 0000000..306b2a0 --- /dev/null +++ b/testdata/test.local.chain.pem @@ -0,0 +1,43 @@ +-----BEGIN CERTIFICATE----- +MIIDnjCCAoagAwIBAgIUJqCyAO552xlDEAVcn3rpHS4Z3MMwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTAwODMwMzRaFw0zMTAy +MDkwODMwMzRaMFoxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEzARBgNVBAMMCnRlc3Qu +bG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDYdJ7ubO4PdDrt +Ei2+jwtI7h8TIjsImmmx5Rmeb4doxX1bROFCzAOF0D9TUvWBn/j6c/3s0fzTZf7H +x4XmB11ovlMJ8HpRgdQkPEyy7X475+prTh+QhBGExI7KRS82i/HMIhZmPogectEc +1e6YnmCz4W/t+uuVJLp9wfNrW8PBZjNYakVmok2zdwNw12DpaAGPcyqkAa0atHBG +9v9xCbwX0vz5Dvp5ZhylziSsN84WFajdmf50eQze/EreB3K6Xeyf9jWgW3+VpPDC +tTIQJL9psm8GaaImE2J1X83ZRCZGak6hELRK1gYVNyNB8//gU/+2K/N7jotHW6G8 +yv1Nvf7nAgMBAAGjcTBvMB8GA1UdIwQYMBaAFP7ON00ccOhpxne1xYNA2l+70YZw +MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgUgMBUGA1UdEQQOMAyCCnRlc3QubG9jYWww +HQYDVR0OBBYEFEjgeU9OHAXfIleAExzVjdsRy2UkMA0GCSqGSIb3DQEBCwUAA4IB +AQCClxWBe4qXb3FC/t6tGzmTrvVYqxud7B8nMuq9IyS9cD1bakjywcbb2I0QsW5r +HqwR3hO5fcMQx8pjqMxU5cv1JRuuBumXL3nNmvRPUA6EFrBO944pi2IJvfIx/cx/ +UZykrI1H3breB+AfGFwBfWmeuOYqXuo7XDUj9wmvPbBWX03p23JQGnO1ut3ysgXS +IZS60UwkOAaR25T8CK7qNxd/+JykatN4Yc0CMCIl5cUer5JUOgmOOcz/GZJ6AtjC +1LMEO3qa8OvLcmV+X2nZjs5UnGOPvXA+N42xzcjNCqtIPxAE6kladcIQcdIMVUh7 +WQunC80NmmlvaJuUKJxw/kOn +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIURu/3EiBqW5zBHLYfNRjzTfs2QMQwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTAwODI3NDRaFw0zMTAy +MDkwODI3NDRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDXr9mtQIgvaDBkxr6mw5UVg9Ox35e01rCz1rrl9MqS +c9SaKtjCCQVyotokHsXT6DKJ4H+CGDD+Y8ULhEQN7B8VSfGB8gg/nD7KHk2dzxNt +8kkZGWDKanyWWdawrsegcApvwV2eHa5/94sHHkJCZNJoRMtmoimZ0o848jOIAUoS +pO1bIxRq7N2YluJVaMYk/U2GBOfwpjhXcy74kQrq1mGyyE3hzJUgtaRGlDsvp3c0 +99b9Pd2fRAmqUzjijibQfheuum4KCLwoZCGvwnY4iQM6vQjNY06djAqyR6XFGH8s +7EVzNFSJyhJZK30FaAYPDeVDIJUKTSrJlTr2ddjF5Gp/AgMBAAGjUzBRMB0GA1Ud +DgQWBBT+zjdNHHDoacZ3tcWDQNpfu9GGcDAfBgNVHSMEGDAWgBT+zjdNHHDoacZ3 +tcWDQNpfu9GGcDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCI +3ZjK6htARQAYx80BIV4ZOm/MPtQxEOMJ0gywcOckwUICHbmdjj5T2XdsE1L9EbYw +8U/5XItzcfnmf5A+7Pf1UqnfOgeCAw7tl4zX5zCHDm0l3nXmOSnyU1RMetJ+aXTT +LZyV6JJxcEFseQsqdBwx6AkXGz4CqLBDMbwi6j+1yRfib11m2gZGYozNFKDrw6xS +L0KFcBWCM8lzb6W5oc3P+oA+EoF3nhgydtb1vNwe9wkubrRl5GkFzRrnEHTDRpLe +NShyxRuBPtQoKwcIfMaNt+9W5qMwrYjh21mCGX122K8kAdXDT35AYcAK2X8WpT9F +nL09Lv6HBpesSih6ZRS3 +-----END CERTIFICATE----- diff --git a/testdata/test.local.crt b/testdata/test.local.crt new file mode 100644 index 0000000..7b19245 --- /dev/null +++ b/testdata/test.local.crt @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDnjCCAoagAwIBAgIUJqCyAO552xlDEAVcn3rpHS4Z3MMwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTAwODMwMzRaFw0zMTAy +MDkwODMwMzRaMFoxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEzARBgNVBAMMCnRlc3Qu +bG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDYdJ7ubO4PdDrt +Ei2+jwtI7h8TIjsImmmx5Rmeb4doxX1bROFCzAOF0D9TUvWBn/j6c/3s0fzTZf7H +x4XmB11ovlMJ8HpRgdQkPEyy7X475+prTh+QhBGExI7KRS82i/HMIhZmPogectEc +1e6YnmCz4W/t+uuVJLp9wfNrW8PBZjNYakVmok2zdwNw12DpaAGPcyqkAa0atHBG +9v9xCbwX0vz5Dvp5ZhylziSsN84WFajdmf50eQze/EreB3K6Xeyf9jWgW3+VpPDC +tTIQJL9psm8GaaImE2J1X83ZRCZGak6hELRK1gYVNyNB8//gU/+2K/N7jotHW6G8 +yv1Nvf7nAgMBAAGjcTBvMB8GA1UdIwQYMBaAFP7ON00ccOhpxne1xYNA2l+70YZw +MAkGA1UdEwQCMAAwCwYDVR0PBAQDAgUgMBUGA1UdEQQOMAyCCnRlc3QubG9jYWww +HQYDVR0OBBYEFEjgeU9OHAXfIleAExzVjdsRy2UkMA0GCSqGSIb3DQEBCwUAA4IB +AQCClxWBe4qXb3FC/t6tGzmTrvVYqxud7B8nMuq9IyS9cD1bakjywcbb2I0QsW5r +HqwR3hO5fcMQx8pjqMxU5cv1JRuuBumXL3nNmvRPUA6EFrBO944pi2IJvfIx/cx/ +UZykrI1H3breB+AfGFwBfWmeuOYqXuo7XDUj9wmvPbBWX03p23JQGnO1ut3ysgXS +IZS60UwkOAaR25T8CK7qNxd/+JykatN4Yc0CMCIl5cUer5JUOgmOOcz/GZJ6AtjC +1LMEO3qa8OvLcmV+X2nZjs5UnGOPvXA+N42xzcjNCqtIPxAE6kladcIQcdIMVUh7 +WQunC80NmmlvaJuUKJxw/kOn +-----END CERTIFICATE----- diff --git a/testdata/test.local.key b/testdata/test.local.key new file mode 100644 index 0000000..b462378 --- /dev/null +++ b/testdata/test.local.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDYdJ7ubO4PdDrt +Ei2+jwtI7h8TIjsImmmx5Rmeb4doxX1bROFCzAOF0D9TUvWBn/j6c/3s0fzTZf7H +x4XmB11ovlMJ8HpRgdQkPEyy7X475+prTh+QhBGExI7KRS82i/HMIhZmPogectEc +1e6YnmCz4W/t+uuVJLp9wfNrW8PBZjNYakVmok2zdwNw12DpaAGPcyqkAa0atHBG +9v9xCbwX0vz5Dvp5ZhylziSsN84WFajdmf50eQze/EreB3K6Xeyf9jWgW3+VpPDC +tTIQJL9psm8GaaImE2J1X83ZRCZGak6hELRK1gYVNyNB8//gU/+2K/N7jotHW6G8 +yv1Nvf7nAgMBAAECggEACYHRyzoYmLAruGbwgGqq/biLv9zkh+O0Wb5syiMu6OCn +uV8En9jzHKkBZwkBRIDAwEtg70pn1ucnciHm1Swko6mcXxbjYHoZ1b+aRM+emEX+ +61iilUfftxulA+hXAwfRhnxGzJXZh7DWU3RoBucJ60yvDF5Vg5b54/UlWmVM600b +rV0dQO+OgYABRmG0GRLEnDi3MlG+IQoif1anwjYZL2MYo9py3oNW8cSw2LldkSFs +ghAO0ZVt7XDJJjg96aIbsjsHbxP55PPPJfPc17PTmVEUN4OETF0i+p4B1aBuIVAS +1+rQEDB0Q1avPSjmuRH2zDDJ0bWoqRcgOHQFKl6L7QKBgQDtKQIWoASywvPpaBLk +06zcKxcMcWTfHDjlMaUWkubKrl8yyp3bhzk9N694FBVXc0QzogwH9w4p+WM2UJZ+ +AadhfIXm2ghCSG2+CFQgDtnAuN1q7wplQLox0QL9q+GuF2OSnlmIL5ewCBISzJqa +RMGRq+Xh9REtJzFuQJdg+3kI8wKBgQDppo3hfJgNTh2OxZQx2AWc7ABjeaH3rfkH +D7QV4Xv583Ro72xjNQk2qn6rZrZWOC+KAA0P0ugbKPKKhkqoVllPY2hVWK0RBKu9 +IeYXVdr0RchiWrg7+QJFExaPBTT0zgY7O7WHWpBWsTwi1l10MhQFK/j19KNPYnwr +9o4XdkTvPQKBgBv63pg0wNkmTwiWksQUhSxkmQ+KwU/hUIUZ2lRTeI8pC2o+pWkl +BwfF7hnIpMvweXduAuVdrHofRik+UMMFBu5ldpbJ2neXe/sTmHk/x0kJhnX7Rcq0 +XENMWYH8KWAUulat//olQ0wtZ5R4NzVEL2I0WabS9vfQy/AqBrIWeS0pAoGBAImC +Njogg7RA5vBho6f6BMOnuUrSCNqljsvzF/9QpxAhkb84OUfhrLx5dvcVCfaQNpo3 +EXjgnS1u2d9gzYto1kL8tCiWebRsVU6IJsxRAP2GHDqWPZs+H3HbnAkr/VCd99jg +eSNhz1TWFhhCpNl/p3yrtme6RGqyAXY2MsXoGwXZAoGAb7+cc0+0DpgNC2kRWIFL +TrJjXxrlS15jbJ6cTfvQ4s/k5/MZkd7xPOZalcbqsskNiyKNGydJjBDYttyMbe3z +/h30fJ6g5OxiGAe6Cpy3rk+WdBQeW6ySjV/MhJbIVjoggoTAGdjmN9eicqHChbln +RTjyAMEtDSdILTTPneUXv9I= +-----END PRIVATE KEY----- From 368468b0c8db2f529a767e216a4f49ffd8563854 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 16 Feb 2026 10:06:14 +0100 Subject: [PATCH 2/7] Clippy and formatting fixes for existing code. --- src/lib.rs | 5 +++-- src/transport.rs | 10 +++++----- src/transport/dbus.rs | 2 +- src/transport/websockets_json.rs | 5 +++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5d82282..0d2aabe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,12 +67,12 @@ //! ``` //! [`Connection::send_message`](connection::S2Connection::send_message) accepts an `impl Into`, so you can just give it any compatible //! type and it will work. -//! +//! //! ### Sending/receiving S2 messages //! S2 does not specify a particular transport protocol for S2 messages. As a result, many transport protocols can be used: WebSockets, MQTT, [even D-Bus](https://github.com/victronenergy/venus/wiki/Venus-OS-D%E2%80%90Bus-S2-Interface). //! To facilitate the use of different transport protocols, this crates provides a central abstraction in [`connection::S2Connection`] and [`transport::S2Transport`]. //! An `S2Connection` can use any transport protocol implementing the `S2Transport` trait. -//! +//! //! This crate provides some transport implementations for end-users. Currently, only a WebSockets implementation is provided (in [`transport::websockets_json`]). //! D-Bus support is also planned for the near future. //! @@ -90,6 +90,7 @@ //! It assumes that you are familiar with the S2 standard; if this is not the case, it may be useful to refer to [the S2 documentation website](https://docs.s2standard.org/docs/welcome/). #![warn(missing_docs)] #![cfg_attr(docsrs_s2energy, feature(doc_cfg))] +#![expect(clippy::clone_on_copy)] include!(concat!(env!("OUT_DIR"), "/generated.rs")); diff --git a/src/transport.rs b/src/transport.rs index aa38aa7..5fbc5f8 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -1,10 +1,10 @@ //! Abstractions over transport protocols, and their implementations. -//! +//! //! The S2 specification does not mandate a specific transport protocol. This module provides the primary way //! transport protocols are abstracted in this crate: [`S2Transport`]. Implementing this trait for your //! desired transport protocol allows you to use all of the connection types in this library (like [`S2Connection`](crate::connection::S2Connection)) //! with that transport protocol. -//! +//! //! In addition, this module provides specific transport protocol implementations in its submodules. //! The most relevant of these is [`websockets_json`], which provides an implementation for JSON over //! WebSockets according to [the official JSON schema](https://github.com/flexiblepower/s2-ws-json). @@ -34,7 +34,7 @@ pub trait S2Transport { fn receive(&mut self) -> impl Future> + Send; /// Disconnect this connection. - /// + /// /// This should do whatever is appropriate for the implemented transport protocol. This may include sending /// e.g. a close frame. When the future resolves, the connection should be fully terminated. fn disconnect(self) -> impl Future + Send; @@ -44,9 +44,9 @@ pub trait S2Transport { // So for now it's just unconditionally public (and it might be useful for other people doing tests, so maybe that's fine?). #[doc(hidden)] pub mod test { - use std::convert::Infallible; - use crate::{connection::S2Connection, frbc::StorageStatus}; use super::*; + use crate::{connection::S2Connection, frbc::StorageStatus}; + use std::convert::Infallible; pub struct MockTransport; diff --git a/src/transport/dbus.rs b/src/transport/dbus.rs index 61ec57e..5fc7a08 100644 --- a/src/transport/dbus.rs +++ b/src/transport/dbus.rs @@ -1,3 +1,3 @@ //! D-Bus support. Currently unimplemented. //! -//! D-Bus support will be implemented here, likely according to [this description](https://github.com/victronenergy/venus/wiki/Venus-OS-D%E2%80%90Bus-S2-Interface). It is not currently available. \ No newline at end of file +//! D-Bus support will be implemented here, likely according to [this description](https://github.com/victronenergy/venus/wiki/Venus-OS-D%E2%80%90Bus-S2-Interface). It is not currently available. diff --git a/src/transport/websockets_json.rs b/src/transport/websockets_json.rs index 417ced6..ce65ab9 100644 --- a/src/transport/websockets_json.rs +++ b/src/transport/websockets_json.rs @@ -8,7 +8,7 @@ //! If you want to connect as a WebSocket client, you can use [`connect_as_client`] to obtain an `S2Connection`. //! If you want set up a WebSocket server, you should make an [`WebsocketServer`] and accept connections //! via [`accept_connection`][WebsocketServer::accept_connection]. -//! +//! //! **Note:** this module relies on [`tokio_tungstenite`] for WebSocket communication. As a result, you must use //! [`tokio`] as your async runtime if you wish to use this module. //! @@ -182,9 +182,10 @@ pub async fn connect_as_client( } /// A WebSocket connection. -/// +/// /// End-users should not use this type directly; instead, they should acquire an [`S2Connection`] /// (e.g. using [`connect_as_client`]) and use that instead. +#[expect(clippy::large_enum_variant)] pub enum WebsocketTransport { /// A WebSocket client connection. ClientSocket(WebSocketStream>), From a77d5476548889aa95a2d0381262ec77eb9418a5 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 16 Feb 2026 11:22:58 +0100 Subject: [PATCH 3/7] Split work into multiple crates. --- Cargo.lock | 57 ++++++++++----- Cargo.toml | 73 ++++++------------- s2energy-common/Cargo.toml | 7 ++ s2energy-common/src/lib.rs | 36 +++++++++ s2energy-connection/Cargo.toml | 20 +++++ .../examples}/pairing-client.rs | 6 +- .../examples}/pairing-server.rs | 4 +- s2energy-connection/src/lib.rs | 1 + .../src}/pairing/client.rs | 0 .../src}/pairing/mod.rs | 12 +-- .../src}/pairing/server.rs | 0 .../src}/pairing/transport.rs | 0 .../src}/pairing/wire.rs | 0 .../testdata}/gen_cert.sh | 0 .../testdata}/root.key | 0 .../testdata}/root.pem | 0 .../testdata}/test.local.chain.pem | 0 .../testdata}/test.local.crt | 0 .../testdata}/test.local.key | 0 s2energy-messaging/Cargo.toml | 57 +++++++++++++++ build.rs => s2energy-messaging/build.rs | 0 {src => s2energy-messaging/src}/connection.rs | 15 ++-- {src => s2energy-messaging/src}/lib.rs | 13 ++-- .../src}/s2.schema.json | 0 {src => s2energy-messaging/src}/transport.rs | 35 ++------- .../src}/transport/dbus.rs | 0 .../src}/transport/websockets_json.rs | 50 ++++++------- 27 files changed, 239 insertions(+), 147 deletions(-) create mode 100644 s2energy-common/Cargo.toml create mode 100644 s2energy-common/src/lib.rs create mode 100644 s2energy-connection/Cargo.toml rename {examples => s2energy-connection/examples}/pairing-client.rs (83%) rename {examples => s2energy-connection/examples}/pairing-server.rs (93%) create mode 100644 s2energy-connection/src/lib.rs rename {src => s2energy-connection/src}/pairing/client.rs (100%) rename {src => s2energy-connection/src}/pairing/mod.rs (95%) rename {src => s2energy-connection/src}/pairing/server.rs (100%) rename {src => s2energy-connection/src}/pairing/transport.rs (100%) rename {src => s2energy-connection/src}/pairing/wire.rs (100%) rename {testdata => s2energy-connection/testdata}/gen_cert.sh (100%) rename {testdata => s2energy-connection/testdata}/root.key (100%) rename {testdata => s2energy-connection/testdata}/root.pem (100%) rename {testdata => s2energy-connection/testdata}/test.local.chain.pem (100%) rename {testdata => s2energy-connection/testdata}/test.local.crt (100%) rename {testdata => s2energy-connection/testdata}/test.local.key (100%) create mode 100644 s2energy-messaging/Cargo.toml rename build.rs => s2energy-messaging/build.rs (100%) rename {src => s2energy-messaging/src}/connection.rs (97%) rename {src => s2energy-messaging/src}/lib.rs (96%) rename {src => s2energy-messaging/src}/s2.schema.json (100%) rename {src => s2energy-messaging/src}/transport.rs (57%) rename {src => s2energy-messaging/src}/transport/dbus.rs (100%) rename {src => s2energy-messaging/src}/transport/websockets_json.rs (85%) diff --git a/Cargo.lock b/Cargo.lock index 669f4a7..5bbd612 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,9 +142,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -1145,9 +1145,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", @@ -1287,34 +1287,51 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] -name = "s2energy" -version = "0.3.0" +name = "s2energy-common" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "s2energy-connection" +version = "0.1.0" dependencies = [ "axum", "axum-server", "base64", + "hmac", + "rand", + "reqwest", + "rustls", + "rustls-platform-verifier", + "serde", + "sha2", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "s2energy-messaging" +version = "0.1.0" +dependencies = [ "bon", "chrono", "eyre", "futures-util", - "hmac", "prettyplease", "quote", - "rand", "regress", - "reqwest", - "rustls", - "rustls-platform-verifier", + "s2energy-common", "schemars", "semver", "serde", "serde_json", - "sha2", "syn", "thiserror 2.0.18", "tokio", @@ -1368,9 +1385,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags", "core-foundation 0.10.1", @@ -1381,9 +1398,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -2063,9 +2080,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index e69df8e..0e2a882 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,64 +1,39 @@ -[package] -name = "s2energy" -version = "0.3.0" -edition = "2024" -authors = ["Wester Coenraads "] -license = "Apache-2.0" -description = "Provides type definitions and utilities for the S2 energy flexibility standard" -homepage = "https://s2standard.org" -repository = "https://github.com/flexiblepower/s2-rust" -categories = ["api-bindings"] -keywords = ["s2", "energy", "energy-management", "protocol", "automation"] +[workspace] +members = ["s2energy-common", "s2energy-connection", "s2energy-messaging"] +resolver = "3" -[features] -default = ["websockets-json", "dbus"] -websockets-json = ["dep:futures-util", "dep:tokio-tungstenite", "dep:serde_json"] -dbus = [] - -[dependencies] -chrono = { version = "0.4.42", features = ["serde"] } -regress = "0.10.4" # This dependency is not used directly but necessary for typify to work -uuid = { version = "1.18.1", features = ["v4", "serde"] } -serde = { version = "1.0.228", features = ["derive"] } -semver = "1.0.27" -bon = "3.8.0" -tracing = "0.1.43" -thiserror = "2.0.17" - -# feature=websockets-json -futures-util = { version = "0.3.31", optional = true } -tokio = { version = "1.47.1", features = ["net"] } -tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"], optional = true } -serde_json = { version = "1.0.145", optional = true } -rand = "0.9.2" +[workspace.dependencies] +axum = "0.8.8" base64 = "0.22.1" +bon = "3.8.0" +chrono = { version = "0.4.42", features = ["serde"] } +futures-util = "0.3.31" hmac = "0.12.1" -sha2 = "0.10.9" +rand = "0.9.2" +regress = "0.10.4" reqwest = { version = "0.13.1", features = ["json"] } -axum = "0.8.8" - rustls = "0.23.36" rustls-platform-verifier = "0.6.2" +semver = "1.0.27" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = "0.10.9" +thiserror = "2.0.17" +tokio = { version = "1.47.1", features = ["net"] } +tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] } +tracing = "0.1.43" +uuid = { version = "1.18.1", features = ["v4", "serde"] } -[build-dependencies] +# Buildtime +quote = "1.0.41" prettyplease = "0.2.37" schemars = "0.8.22" syn = { version = "2.0.106", features = ["fold"] } typify = "0.5.0" -serde_json = "1.0.145" -quote = "1.0.41" -[dev-dependencies] +# Development eyre = "0.6.12" -serde_json = "1.0.145" axum-server = { version = "0.8.0", features = ["tls-rustls"] } -# docs.rs-specific configuration -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs_s2energy"] - -# We can't use cfg(docsrs), because several dependencies have issues when passing -# --cfg docsrs as a compiler arg. So here we define our own docsrs cfg. -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs_s2energy)'] } +# Internal +s2energy-common = { path = "./s2energy-common", version = "0.1.0" } diff --git a/s2energy-common/Cargo.toml b/s2energy-common/Cargo.toml new file mode 100644 index 0000000..6f41901 --- /dev/null +++ b/s2energy-common/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "s2energy-common" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde.workspace = true diff --git a/s2energy-common/src/lib.rs b/s2energy-common/src/lib.rs new file mode 100644 index 0000000..6bcfdf4 --- /dev/null +++ b/s2energy-common/src/lib.rs @@ -0,0 +1,36 @@ +//! Abstractions over transport protocols, and their implementations. +//! +//! The S2 specification does not mandate a specific transport protocol. This module provides the primary way +//! transport protocols are abstracted in this crate: [`S2Transport`]. Implementing this trait for your +//! desired transport protocol allows you to use all of the connection types in this library (like [`S2Connection`](crate::connection::S2Connection)) +//! with that transport protocol. +//! +//! In addition, this module provides specific transport protocol implementations in its submodules. +//! The most relevant of these is [`websockets_json`], which provides an implementation for JSON over +//! WebSockets according to [the official JSON schema](https://github.com/flexiblepower/s2-ws-json). +//! This is currently the most common and well-supported way to use S2. + +use std::error::Error; + +use serde::{Serialize, de::DeserializeOwned}; + +/// Trait used to abstract the underlying transport protocol. +/// +/// **End-users are not expected to use this trait directly.** Instead, libraries can implement this trait to provide additional +/// transport protocols that can be used to talk S2 over. +pub trait S2Transport { + /// Error type for errors occurring at a transport level. + type TransportError: Error; + + /// Send an S2 message. + fn send(&mut self, message: impl Serialize + Send) -> impl Future> + Send; + + /// Recceive an S2 message. + fn receive(&mut self) -> impl Future> + Send; + + /// Disconnect this connection. + /// + /// This should do whatever is appropriate for the implemented transport protocol. This may include sending + /// e.g. a close frame. When the future resolves, the connection should be fully terminated. + fn disconnect(self) -> impl Future + Send; +} diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml new file mode 100644 index 0000000..fd858b7 --- /dev/null +++ b/s2energy-connection/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "s2energy-connection" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum.workspace = true +base64.workspace = true +hmac.workspace = true +rand.workspace = true +reqwest.workspace = true +rustls.workspace = true +rustls-platform-verifier.workspace = true +serde.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +axum-server.workspace = true diff --git a/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs similarity index 83% rename from examples/pairing-client.rs rename to s2energy-connection/examples/pairing-client.rs index ca7775f..5cd6417 100644 --- a/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use s2energy::pairing::{ +use s2energy_connection::pairing::{ Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role, }; @@ -45,9 +45,9 @@ async fn main() { .unwrap(); match pair_result.role { - s2energy::pairing::PairingRole::CommunicationClient { initiate_url } => { + s2energy_connection::pairing::PairingRole::CommunicationClient { initiate_url } => { println!("Paired as client, url: {initiate_url}, token: {}", pair_result.token.0) } - s2energy::pairing::PairingRole::CommunicationServer => println!("Paired as server, token: {}", pair_result.token.0), + s2energy_connection::pairing::PairingRole::CommunicationServer => println!("Paired as server, token: {}", pair_result.token.0), } } diff --git a/examples/pairing-server.rs b/s2energy-connection/examples/pairing-server.rs similarity index 93% rename from examples/pairing-server.rs rename to s2energy-connection/examples/pairing-server.rs index 4f021fe..8a35f36 100644 --- a/examples/pairing-server.rs +++ b/s2energy-connection/examples/pairing-server.rs @@ -2,7 +2,9 @@ use axum_server::tls_rustls::RustlsConfig; use rustls::pki_types::{CertificateDer, pem::PemObject}; use std::{net::SocketAddr, path::PathBuf, sync::Arc}; -use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, S2NodeDescription, S2NodeId, S2Role, Server, ServerConfig}; +use s2energy_connection::pairing::{ + EndpointConfig, MessageVersion, PairingToken, S2NodeDescription, S2NodeId, S2Role, Server, ServerConfig, +}; #[allow(unused)] const PAIRING_TOKEN: &[u8] = &[1, 2, 3]; diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs new file mode 100644 index 0000000..77543f6 --- /dev/null +++ b/s2energy-connection/src/lib.rs @@ -0,0 +1 @@ +pub mod pairing; diff --git a/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs similarity index 100% rename from src/pairing/client.rs rename to s2energy-connection/src/pairing/client.rs diff --git a/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs similarity index 95% rename from src/pairing/mod.rs rename to s2energy-connection/src/pairing/mod.rs index 2187199..1d20d72 100644 --- a/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -7,7 +7,7 @@ //! The main configuration struct [`EndpointConfig`] describes an S2 endpoint. It is constructed through //! a builder pattern. For simple configuration, the builder can immediately be build: //! ```rust -//! # use s2energy::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { //! id: S2NodeId(String::from("12121212")), //! brand: String::from("super-reliable-corp"), @@ -23,7 +23,7 @@ //! //! Additional information can be added through methods on the builder. For example, we can add a connection initiate url through: //! ```rust -//! # use s2energy::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { //! id: S2NodeId(String::from("12121212")), //! brand: String::from("super-reliable-corp"), @@ -44,7 +44,7 @@ //! server. For this, you will also need to know the id of the node, and the URL on which its pairing server is reachable. //! ```rust //! # use std::sync::Arc; -//! # use s2energy::pairing::{Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role}; //! # let config = EndpointConfig::builder(S2NodeDescription { //! # id: S2NodeId(String::from("12121212")), //! # brand: String::from("super-reliable-corp"), @@ -75,7 +75,7 @@ //! ```rust //! # use std::{path::PathBuf, net::SocketAddr}; //! # use axum_server::tls_rustls::RustlsConfig; -//! # use s2energy::pairing::{Server, ServerConfig}; +//! # use s2energy_connection::pairing::{Server, ServerConfig}; //! # #[tokio::main(flavor = "current_thread")] //! # async fn main() { //! # let tls_config = RustlsConfig::from_pem_file( @@ -102,7 +102,7 @@ //! ```no_run //! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; //! # use axum_server::tls_rustls::RustlsConfig; -//! # use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; //! # #[tokio::main(flavor = "current_thread")] //! # async fn main() { //! # let tls_config = RustlsConfig::from_pem_file( @@ -137,7 +137,7 @@ //! ```no_run //! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; //! # use axum_server::tls_rustls::RustlsConfig; -//! # use s2energy::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; //! # #[tokio::main(flavor = "current_thread")] //! # async fn main() { //! # let tls_config = RustlsConfig::from_pem_file( diff --git a/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs similarity index 100% rename from src/pairing/server.rs rename to s2energy-connection/src/pairing/server.rs diff --git a/src/pairing/transport.rs b/s2energy-connection/src/pairing/transport.rs similarity index 100% rename from src/pairing/transport.rs rename to s2energy-connection/src/pairing/transport.rs diff --git a/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs similarity index 100% rename from src/pairing/wire.rs rename to s2energy-connection/src/pairing/wire.rs diff --git a/testdata/gen_cert.sh b/s2energy-connection/testdata/gen_cert.sh similarity index 100% rename from testdata/gen_cert.sh rename to s2energy-connection/testdata/gen_cert.sh diff --git a/testdata/root.key b/s2energy-connection/testdata/root.key similarity index 100% rename from testdata/root.key rename to s2energy-connection/testdata/root.key diff --git a/testdata/root.pem b/s2energy-connection/testdata/root.pem similarity index 100% rename from testdata/root.pem rename to s2energy-connection/testdata/root.pem diff --git a/testdata/test.local.chain.pem b/s2energy-connection/testdata/test.local.chain.pem similarity index 100% rename from testdata/test.local.chain.pem rename to s2energy-connection/testdata/test.local.chain.pem diff --git a/testdata/test.local.crt b/s2energy-connection/testdata/test.local.crt similarity index 100% rename from testdata/test.local.crt rename to s2energy-connection/testdata/test.local.crt diff --git a/testdata/test.local.key b/s2energy-connection/testdata/test.local.key similarity index 100% rename from testdata/test.local.key rename to s2energy-connection/testdata/test.local.key diff --git a/s2energy-messaging/Cargo.toml b/s2energy-messaging/Cargo.toml new file mode 100644 index 0000000..1709cfd --- /dev/null +++ b/s2energy-messaging/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "s2energy-messaging" +version = "0.1.0" +edition = "2024" +authors = ["Wester Coenraads "] +license = "Apache-2.0" +description = "Provides type definitions and utilities for the S2 energy flexibility standard" +homepage = "https://s2standard.org" +repository = "https://github.com/flexiblepower/s2-rust" +categories = ["api-bindings"] +keywords = ["s2", "energy", "energy-management", "protocol", "automation"] + +[features] +default = ["websockets-json", "dbus"] +websockets-json = ["dep:futures-util", "dep:tokio", "dep:tokio-tungstenite"] +dbus = [] + +[dependencies] +bon.workspace = true +chrono.workspace = true +regress.workspace = true # This dependency is not used directly but necessary for typify to work +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +uuid.workspace = true + +# internal +s2energy-common.workspace = true + +# feature=websockets-json +futures-util = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, optional = true } + +[build-dependencies] +prettyplease.workspace = true +quote.workspace = true +schemars.workspace = true +serde_json.workspace = true +syn.workspace = true +typify.workspace = true + +[dev-dependencies] +eyre.workspace = true +serde_json.workspace = true + +# docs.rs-specific configuration +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs_s2energy"] + +# We can't use cfg(docsrs), because several dependencies have issues when passing +# --cfg docsrs as a compiler arg. So here we define our own docsrs cfg. +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(docsrs_s2energy)'] } diff --git a/build.rs b/s2energy-messaging/build.rs similarity index 100% rename from build.rs rename to s2energy-messaging/build.rs diff --git a/src/connection.rs b/s2energy-messaging/src/connection.rs similarity index 97% rename from src/connection.rs rename to s2energy-messaging/src/connection.rs index d041062..a561029 100644 --- a/src/connection.rs +++ b/s2energy-messaging/src/connection.rs @@ -4,10 +4,10 @@ //! An `S2Connection` is acquired from an implementing transport protocol, for example by using //! [`websockets_json::connect_as_client`](crate::transport::websockets_json::connect_as_client). -use crate::{ - common::{ControlType, EnergyManagementRole, Handshake, Message, ReceptionStatus, ReceptionStatusValues, ResourceManagerDetails}, - transport::S2Transport, +use crate::common::{ + ControlType, EnergyManagementRole, Handshake, Message, ReceptionStatus, ReceptionStatusValues, ResourceManagerDetails, }; +use s2energy_common::S2Transport; use semver::VersionReq; use thiserror::Error; @@ -198,10 +198,11 @@ impl S2Connection { /// /// # Examples /// ``` -/// # use s2energy::common::{ReceptionStatusValues, Message, Id}; -/// # use s2energy::frbc; -/// # use s2energy::transport::{S2Transport, test::MockTransport}; -/// # use s2energy::connection::ConnectionError; +/// # use s2energy_messaging::common::{ReceptionStatusValues, Message, Id}; +/// # use s2energy_messaging::frbc; +/// # use s2energy_messaging::transport::test::MockTransport; +/// # use s2energy_messaging::connection::ConnectionError; +/// # use s2energy_common::S2Transport; /// # async fn test() -> Result<(), ConnectionError<::TransportError>> { /// # let mut s2_connection = MockTransport::new_connection(); /// // Inspect the message and ensure its contents match our expectations: diff --git a/src/lib.rs b/s2energy-messaging/src/lib.rs similarity index 96% rename from src/lib.rs rename to s2energy-messaging/src/lib.rs index 0d2aabe..a619499 100644 --- a/src/lib.rs +++ b/s2energy-messaging/src/lib.rs @@ -22,10 +22,10 @@ //! ### Creating S2 types //! S2 types have all their fields exposed, so you can construct them using regular Rust constructor syntax: //! ``` -//! # use s2energy::common::Id; +//! # use s2energy_messaging::common::Id; //! # let actuator_id = Id::generate(); //! # let operation_mode_id = Id::generate(); -//! use s2energy::{common::NumberRange, frbc::ActuatorStatus}; +//! use s2energy_messaging::{common::NumberRange, frbc::ActuatorStatus}; //! //! let range = NumberRange { //! start_of_range: 1.0, @@ -47,8 +47,8 @@ //! When sending or receiving S2 messages, you'll be working with [`common::Message`]. This type represents all possible S2 messages in a big enum. When //! receiving messages, you'll want to match on `Message` to determine how to handle it: //! ``` -//! # use s2energy::common::Message; -//! # use s2energy::frbc::StorageStatus; +//! # use s2energy_messaging::common::Message; +//! # use s2energy_messaging::frbc::StorageStatus; //! # let incoming_message = Message::FrbcStorageStatus(StorageStatus::new(2.1)); //! match incoming_message { //! Message::FrbcSystemDescription(system_description) => { /* Handle it */ }, @@ -60,8 +60,8 @@ //! All types that serve as the content of a message (such as [`frbc::SystemDescription`] and [`frbc::StorageStatus`] in the above example) implement //! `Into` for convenience. This means you can do: //! ``` -//! # use s2energy::common::Message; -//! # use s2energy::frbc::StorageStatus; +//! # use s2energy_messaging::common::Message; +//! # use s2energy_messaging::frbc::StorageStatus; //! let storage_status = StorageStatus::new(2.1); //! let message: Message = storage_status.into(); //! ``` @@ -95,7 +95,6 @@ include!(concat!(env!("OUT_DIR"), "/generated.rs")); pub mod connection; -pub mod pairing; pub mod transport; #[cfg(test)] diff --git a/src/s2.schema.json b/s2energy-messaging/src/s2.schema.json similarity index 100% rename from src/s2.schema.json rename to s2energy-messaging/src/s2.schema.json diff --git a/src/transport.rs b/s2energy-messaging/src/transport.rs similarity index 57% rename from src/transport.rs rename to s2energy-messaging/src/transport.rs index 5fbc5f8..1b59a43 100644 --- a/src/transport.rs +++ b/s2energy-messaging/src/transport.rs @@ -10,41 +10,19 @@ //! WebSockets according to [the official JSON schema](https://github.com/flexiblepower/s2-ws-json). //! This is currently the most common and well-supported way to use S2. -use crate::common::Message; -use std::error::Error; - #[doc(hidden)] #[cfg(feature = "dbus")] pub mod dbus; #[cfg(feature = "websockets-json")] pub mod websockets_json; -/// Trait used to abstract the underlying transport protocol. -/// -/// **End-users are not expected to use this trait directly.** Instead, libraries can implement this trait to provide additional -/// transport protocols that can be used to talk S2 over. -pub trait S2Transport { - /// Error type for errors occurring at a transport level. - type TransportError: Error; - - /// Send an S2 message. - fn send(&mut self, message: Message) -> impl Future> + Send; - - /// Recceive an S2 message. - fn receive(&mut self) -> impl Future> + Send; - - /// Disconnect this connection. - /// - /// This should do whatever is appropriate for the implemented transport protocol. This may include sending - /// e.g. a close frame. When the future resolves, the connection should be fully terminated. - fn disconnect(self) -> impl Future + Send; -} - // TODO: for some reason, this module is not visible to doctests when annotated with #[cfg(any(test, doctest))] // So for now it's just unconditionally public (and it might be useful for other people doing tests, so maybe that's fine?). #[doc(hidden)] pub mod test { - use super::*; + use s2energy_common::S2Transport; + use serde::{Serialize, de::DeserializeOwned}; + use crate::{connection::S2Connection, frbc::StorageStatus}; use std::convert::Infallible; @@ -59,12 +37,13 @@ pub mod test { impl S2Transport for MockTransport { type TransportError = Infallible; - async fn send(&mut self, _: Message) -> Result<(), Self::TransportError> { + async fn send(&mut self, _: impl Serialize + Send) -> Result<(), Self::TransportError> { Ok(()) } - async fn receive(&mut self) -> Result { - Ok(Message::FrbcStorageStatus(StorageStatus::new(0.0))) + async fn receive(&mut self) -> Result { + let serialized_message = serde_json::to_string(&crate::common::Message::FrbcStorageStatus(StorageStatus::new(0.0))).unwrap(); + Ok(serde_json::from_str(&serialized_message).unwrap()) } async fn disconnect(self) {} diff --git a/src/transport/dbus.rs b/s2energy-messaging/src/transport/dbus.rs similarity index 100% rename from src/transport/dbus.rs rename to s2energy-messaging/src/transport/dbus.rs diff --git a/src/transport/websockets_json.rs b/s2energy-messaging/src/transport/websockets_json.rs similarity index 85% rename from src/transport/websockets_json.rs rename to s2energy-messaging/src/transport/websockets_json.rs index ce65ab9..35d14e7 100644 --- a/src/transport/websockets_json.rs +++ b/s2energy-messaging/src/transport/websockets_json.rs @@ -15,8 +15,9 @@ //! # Examples //! Setting up a WebSocket server and handling connections to it: //! ```no_run -//! # use s2energy::transport::{S2Transport, websockets_json::{WebsocketServer, WebsocketTransport}}; -//! # use s2energy::connection::ConnectionError; +//! # use s2energy_messaging::transport::{websockets_json::{WebsocketServer, WebsocketTransport}}; +//! # use s2energy_common::S2Transport; +//! # use s2energy_messaging::connection::ConnectionError; //! # async fn test() -> Result<(), ConnectionError<::TransportError>> { //! let server = WebsocketServer::new("0.0.0.0:8080").await?; //! loop { @@ -28,10 +29,11 @@ //! //! Setting up a connection as a Resource Manager: //! ```no_run -//! # use s2energy::common::{Commodity, CommodityQuantity, ControlType, Currency, Duration, Id, ResourceManagerDetails, Role, RoleType}; -//! # use s2energy::frbc; -//! # use s2energy::transport::{S2Transport, websockets_json::{connect_as_client, WebsocketTransport}}; -//! # use s2energy::connection::ConnectionError; +//! # use s2energy_messaging::common::{Commodity, CommodityQuantity, ControlType, Currency, Duration, Id, ResourceManagerDetails, Role, RoleType}; +//! # use s2energy_messaging::frbc; +//! # use s2energy_messaging::transport::{websockets_json::{connect_as_client, WebsocketTransport}}; +//! # use s2energy_common::S2Transport; +//! # use s2energy_messaging::connection::ConnectionError; //! # async fn test() -> Result<(), ConnectionError<::TransportError>> { //! // Connect to the CEM //! let mut s2_connection = connect_as_client("wss://example.com/cem/394727").await?; @@ -63,7 +65,8 @@ //! //! Once you've set up a connection, you can send and receive messages: //! ```no_run -//! # use s2energy::{frbc, connection::ConnectionError, transport::S2Transport, transport::websockets_json::{connect_as_client, WebsocketTransport}}; +//! # use s2energy_messaging::{frbc, connection::ConnectionError, transport::websockets_json::{connect_as_client, WebsocketTransport}}; +//! # use s2energy_common::S2Transport; //! # async fn test() -> Result<(), ConnectionError<::TransportError>> { //! # let mut s2_connection = connect_as_client("no_run").await?; //! // Send a StorageStatus message: @@ -84,9 +87,10 @@ use crate::{ common::{Id, Message as S2Message, ReceptionStatus, ReceptionStatusValues}, connection::{ConnectionError, S2Connection}, - transport::S2Transport, }; use futures_util::{SinkExt, StreamExt}; +use s2energy_common::S2Transport; +use serde::{Serialize, de::DeserializeOwned}; use std::str::FromStr; use thiserror::Error; use tokio::net::{TcpListener, TcpStream, ToSocketAddrs}; @@ -207,7 +211,7 @@ impl WebsocketTransport { impl S2Transport for WebsocketTransport { type TransportError = WebsocketTransportError; - async fn send(&mut self, message: S2Message) -> Result<(), WebsocketTransportError> { + async fn send(&mut self, message: impl Serialize) -> Result<(), WebsocketTransportError> { let serialized = serde_json::to_string(&message).expect("unable to seralize `Message` to JSON; if you see this, you've found a bug in s2energy"); let tungstenite_message = TungsteniteMessage::text(serialized); @@ -219,7 +223,7 @@ impl S2Transport for WebsocketTransport { Ok(()) } - async fn receive(&mut self) -> Result { + async fn receive(&mut self) -> Result { // This is set up as a loop so we can harmlessly ignore empty messages and ping/pong messages. let message = loop { let next = match self { @@ -234,14 +238,11 @@ impl S2Transport for WebsocketTransport { if msg.is_binary() { tracing::warn!("Received binary websocket message, which is not supported. Sending ReceptionStatus INVALID_DATA."); let _ = self - .send( - ReceptionStatus { - diagnostic_label: Some("Binary messages are not supported".to_string()), - status: ReceptionStatusValues::InvalidData, - subject_message_id: Id::from_str("00000000-0000-0000-0000-000000000000").unwrap(), - } - .into(), - ) + .send(S2Message::from(ReceptionStatus { + diagnostic_label: Some("Binary messages are not supported".to_string()), + status: ReceptionStatusValues::InvalidData, + subject_message_id: Id::from_str("00000000-0000-0000-0000-000000000000").unwrap(), + })) .await; return Err(WebsocketTransportError::ReceivedBinaryMessage); @@ -258,14 +259,11 @@ impl S2Transport for WebsocketTransport { Err(err) => { tracing::warn!("Failed to parse incoming message. Message: {msg_string}. Error: {err}"); let _ = self - .send( - ReceptionStatus { - diagnostic_label: Some(format!("Failed to parse message. Error: {err}")), - status: ReceptionStatusValues::InvalidData, - subject_message_id: Id::from_str("00000000-0000-0000-0000-000000000000").unwrap(), - } - .into(), - ) + .send(S2Message::from(ReceptionStatus { + diagnostic_label: Some(format!("Failed to parse message. Error: {err}")), + status: ReceptionStatusValues::InvalidData, + subject_message_id: Id::from_str("00000000-0000-0000-0000-000000000000").unwrap(), + })) .await; return Err(err.into()); } From d0087ee20b46c90c8fb3458db18c632a81d064ce Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 16 Feb 2026 14:59:32 +0100 Subject: [PATCH 4/7] Move shared wire structures for pairing and connection into common module. --- .../examples/pairing-client.rs | 5 +- .../examples/pairing-server.rs | 5 +- s2energy-connection/src/common/mod.rs | 1 + s2energy-connection/src/common/wire.rs | 113 +++++++++++++ s2energy-connection/src/lib.rs | 3 + s2energy-connection/src/pairing/client.rs | 1 + s2energy-connection/src/pairing/mod.rs | 23 ++- s2energy-connection/src/pairing/server.rs | 7 +- s2energy-connection/src/pairing/wire.rs | 154 +++--------------- 9 files changed, 166 insertions(+), 146 deletions(-) create mode 100644 s2energy-connection/src/common/mod.rs create mode 100644 s2energy-connection/src/common/wire.rs diff --git a/s2energy-connection/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs index 5cd6417..517d47b 100644 --- a/s2energy-connection/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -1,7 +1,8 @@ use std::sync::Arc; -use s2energy_connection::pairing::{ - Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role, +use s2energy_connection::{ + Deployment, MessageVersion, S2NodeDescription, S2NodeId, S2Role, + pairing::{Client, ClientConfig, EndpointConfig, PairingRemote}, }; const PAIRING_TOKEN: &[u8] = &[1, 2, 3]; diff --git a/s2energy-connection/examples/pairing-server.rs b/s2energy-connection/examples/pairing-server.rs index 8a35f36..b3bab72 100644 --- a/s2energy-connection/examples/pairing-server.rs +++ b/s2energy-connection/examples/pairing-server.rs @@ -2,8 +2,9 @@ use axum_server::tls_rustls::RustlsConfig; use rustls::pki_types::{CertificateDer, pem::PemObject}; use std::{net::SocketAddr, path::PathBuf, sync::Arc}; -use s2energy_connection::pairing::{ - EndpointConfig, MessageVersion, PairingToken, S2NodeDescription, S2NodeId, S2Role, Server, ServerConfig, +use s2energy_connection::{ + MessageVersion, S2NodeDescription, S2NodeId, S2Role, + pairing::{EndpointConfig, PairingToken, Server, ServerConfig}, }; #[allow(unused)] diff --git a/s2energy-connection/src/common/mod.rs b/s2energy-connection/src/common/mod.rs new file mode 100644 index 0000000..c70eda0 --- /dev/null +++ b/s2energy-connection/src/common/mod.rs @@ -0,0 +1 @@ +pub(crate) mod wire; diff --git a/s2energy-connection/src/common/wire.rs b/s2energy-connection/src/common/wire.rs new file mode 100644 index 0000000..a110e68 --- /dev/null +++ b/s2energy-connection/src/common/wire.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub(crate) enum PairingVersion { + V1, +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub(crate) enum WirePairingVersion { + V1, + #[serde(other)] + Other, +} + +impl TryFrom for PairingVersion { + type Error = (); + + fn try_from(value: WirePairingVersion) -> Result { + match value { + WirePairingVersion::V1 => Ok(PairingVersion::V1), + WirePairingVersion::Other => Err(()), + } + } +} + +/// Message schema version. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MessageVersion(pub String); + +/// Information about the pairing endpoint of a S2 node +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S2EndpointDescription { + /// Name of the endpoint + #[serde(default)] + pub name: Option, + /// URI of a logo to be used for the endpoint in GUIs + #[serde(default)] + pub logo_uri: Option, + /// Type of deployment used by the endpoint (local or globally routable). + #[serde(default)] + pub deployment: Option, +} + +/// One-time access token for secure access to the S2 message communication channel. It must be renewed every time a client wants to access +/// the S2 message communication channel by calling the requestToken endpoint. This token is valid for one time login, with a maximum 5 +/// years, and should have a minimum length of 32 bytes. +#[derive(Serialize, Deserialize, Clone)] +pub struct AccessToken(pub String); + +impl AccessToken { + pub fn new(rng: &mut impl rand::Rng) -> Self { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + + let encoded = STANDARD.encode(bytes); + Self(encoded) + } +} + +/// Unique identifier of the S2 node +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct S2NodeId(pub String); + +/// Information about the S2 node +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct S2NodeDescription { + /// Unique identifier of the node + pub id: S2NodeId, + /// Brandname used for the node + pub brand: String, + /// URI of a logo to be used for the node in GUIs + #[serde(default)] + pub logo_uri: Option, + /// The type of this node. + pub type_: String, + /// Model name of the device this node belongs to. + pub model_name: String, + /// A name for the device configured by the end user/owner. + #[serde(default)] + pub user_defined_name: Option, + /// The S2 role this device has (e.g. CEM or RM). + pub role: S2Role, +} + +/// Identifier of a protocol that can be used for communication of S2 messages between nodes, for example `"WebSocket"` +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct CommunicationProtocol(pub String); + +/// Role within the S2 standard. +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub enum S2Role { + /// Customer Energy Manager. + Cem, + /// Resource Manager. + Rm, +} + +/// Place of deployment for an S2 Node +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub enum Deployment { + /// On a WAN, reachable over the internet + Wan, + /// On the local network, only reachable near the place the device is located. + Lan, +} diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index 77543f6..d97e31d 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -1 +1,4 @@ +pub(crate) mod common; pub mod pairing; + +pub use common::wire::{CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role}; diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index 6d79ee7..e0c28b0 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use reqwest::{StatusCode, Url}; use rustls::pki_types::CertificateDer; +use crate::common::wire::{AccessToken, Deployment, PairingVersion, S2NodeId, S2Role, WirePairingVersion}; use crate::pairing::transport::{HashProvider, hash_providing_https_client}; use crate::pairing::{Pairing, PairingRole, SUPPORTED_PAIRING_VERSIONS}; diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index 1d20d72..e38d5b7 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -7,7 +7,8 @@ //! The main configuration struct [`EndpointConfig`] describes an S2 endpoint. It is constructed through //! a builder pattern. For simple configuration, the builder can immediately be build: //! ```rust -//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::EndpointConfig; +//! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { //! id: S2NodeId(String::from("12121212")), //! brand: String::from("super-reliable-corp"), @@ -23,7 +24,8 @@ //! //! Additional information can be added through methods on the builder. For example, we can add a connection initiate url through: //! ```rust -//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::EndpointConfig; +//! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { //! id: S2NodeId(String::from("12121212")), //! brand: String::from("super-reliable-corp"), @@ -44,7 +46,8 @@ //! server. For this, you will also need to know the id of the node, and the URL on which its pairing server is reachable. //! ```rust //! # use std::sync::Arc; -//! # use s2energy_connection::pairing::{Client, ClientConfig, Deployment, EndpointConfig, MessageVersion, PairingRemote, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{Client, ClientConfig, EndpointConfig, PairingRemote}; +//! # use s2energy_connection::{Deployment, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! # let config = EndpointConfig::builder(S2NodeDescription { //! # id: S2NodeId(String::from("12121212")), //! # brand: String::from("super-reliable-corp"), @@ -102,7 +105,8 @@ //! ```no_run //! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; //! # use axum_server::tls_rustls::RustlsConfig; -//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, PairingToken, Server, ServerConfig}; +//! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! # #[tokio::main(flavor = "current_thread")] //! # async fn main() { //! # let tls_config = RustlsConfig::from_pem_file( @@ -137,7 +141,8 @@ //! ```no_run //! # use std::{path::PathBuf, net::SocketAddr, sync::Arc}; //! # use axum_server::tls_rustls::RustlsConfig; -//! # use s2energy_connection::pairing::{EndpointConfig, MessageVersion, PairingToken, Server, ServerConfig, S2NodeDescription, S2NodeId, S2Role}; +//! # use s2energy_connection::pairing::{EndpointConfig, PairingToken, Server, ServerConfig}; +//! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! # #[tokio::main(flavor = "current_thread")] //! # async fn main() { //! # let tls_config = RustlsConfig::from_pem_file( @@ -183,13 +188,15 @@ mod wire; use rand::Rng; -use wire::{AccessToken, HmacChallenge, HmacChallengeResponse}; +use wire::{HmacChallenge, HmacChallengeResponse}; pub use client::{Client, ClientConfig, PairingRemote}; pub use server::{PairingToken, PendingPairing, RepeatedPairing, Server, ServerConfig}; -pub use wire::{CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role}; -use crate::pairing::wire::PairingVersion; +use crate::{ + CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2Role, + common::wire::{AccessToken, PairingVersion}, +}; const SUPPORTED_PAIRING_VERSIONS: &[PairingVersion] = &[PairingVersion::V1]; diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 98c58c5..b815b71 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -16,9 +16,12 @@ use rustls::pki_types::CertificateDer; use sha2::Digest; use tokio::time::Instant; -use crate::pairing::{PairingRole, SUPPORTED_PAIRING_VERSIONS}; +use crate::{ + common::wire::{AccessToken, PairingVersion, S2EndpointDescription, S2NodeDescription, S2NodeId}, + pairing::{PairingRole, SUPPORTED_PAIRING_VERSIONS}, +}; -use super::{EndpointConfig, Error, Network, Pairing, PairingResult, S2EndpointDescription, S2NodeDescription, wire::*}; +use super::{EndpointConfig, Error, Network, Pairing, PairingResult, wire::*}; const PERMANENT_PAIRING_BUFFER_SIZE: usize = 1; diff --git a/s2energy-connection/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs index 9b47ba6..2751496 100644 --- a/s2energy-connection/src/pairing/wire.rs +++ b/s2energy-connection/src/pairing/wire.rs @@ -2,6 +2,8 @@ use axum::http::{HeaderMap, HeaderName, HeaderValue}; use serde::*; use thiserror::Error; +use crate::common::wire::{AccessToken, CommunicationProtocol, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId}; + #[derive(Error, Debug, Serialize, Deserialize)] pub(crate) enum PairingResponseErrorMessage { #[error("Invalid combination of roles")] @@ -24,34 +26,29 @@ pub(crate) enum PairingResponseErrorMessage { Other, } -#[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "lowercase")] -pub(crate) enum PairingVersion { - V1, -} - -#[derive(Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[serde(rename_all = "lowercase")] -pub(crate) enum WirePairingVersion { - V1, - #[serde(other)] - Other, +#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum HmacHashingAlgorithm { + Sha256, } -impl TryFrom for PairingVersion { - type Error = (); - - fn try_from(value: WirePairingVersion) -> Result { - match value { - WirePairingVersion::V1 => Ok(PairingVersion::V1), - WirePairingVersion::Other => Err(()), - } - } -} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub(crate) struct HmacChallenge( + #[serde( + serialize_with = "base64_bytes::serialize", + deserialize_with = "base64_bytes::deserialize::<_, 32>" + )] + pub(crate) [u8; 32], +); -/// Message schema version. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct MessageVersion(pub String); +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub(crate) struct HmacChallengeResponse( + #[serde( + serialize_with = "base64_bytes::serialize", + deserialize_with = "base64_bytes::deserialize::<_, 32>" + )] + pub(crate) [u8; 32], +); #[derive(Serialize, Deserialize)] pub(crate) struct RequestPairing { @@ -77,113 +74,6 @@ pub(crate) struct RequestPairing { pub force_pairing: bool, } -/// Information about the pairing endpoint of a S2 node -#[derive(Default, Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct S2EndpointDescription { - /// Name of the endpoint - #[serde(default)] - pub name: Option, - /// URI of a logo to be used for the endpoint in GUIs - #[serde(default)] - pub logo_uri: Option, - /// Type of deployment used by the endpoint (local or globally routable). - #[serde(default)] - pub deployment: Option, -} - -/// One-time access token for secure access to the S2 message communication channel. It must be renewed every time a client wants to access -/// the S2 message communication channel by calling the requestToken endpoint. This token is valid for one time login, with a maximum 5 -/// years, and should have a minimum length of 32 bytes. -#[derive(Serialize, Deserialize, Clone)] -pub struct AccessToken(pub String); - -impl AccessToken { - pub fn new(rng: &mut impl rand::Rng) -> Self { - use base64::{Engine as _, engine::general_purpose::STANDARD}; - - let mut bytes = [0u8; 32]; - rng.fill(&mut bytes); - - let encoded = STANDARD.encode(bytes); - Self(encoded) - } -} - -/// Unique identifier of the S2 node -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] -pub struct S2NodeId(pub String); - -/// Information about the S2 node -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct S2NodeDescription { - /// Unique identifier of the node - pub id: S2NodeId, - /// Brandname used for the node - pub brand: String, - /// URI of a logo to be used for the node in GUIs - #[serde(default)] - pub logo_uri: Option, - /// The type of this node. - pub type_: String, - /// Model name of the device this node belongs to. - pub model_name: String, - /// A name for the device configured by the end user/owner. - #[serde(default)] - pub user_defined_name: Option, - /// The S2 role this device has (e.g. CEM or RM). - pub role: S2Role, -} - -/// Identifier of a protocol that can be used for communication of S2 messages between nodes, for example `"WebSocket"` -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] -pub struct CommunicationProtocol(pub String); - -/// Role within the S2 standard. -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] -#[serde(rename_all = "UPPERCASE")] -pub enum S2Role { - /// Customer Energy Manager. - Cem, - /// Resource Manager. - Rm, -} - -/// Place of deployment for an S2 Node -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] -#[serde(rename_all = "UPPERCASE")] -pub enum Deployment { - /// On a WAN, reachable over the internet - Wan, - /// On the local network, only reachable near the place the device is located. - Lan, -} - -#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] -#[serde(rename_all = "UPPERCASE")] -pub(crate) enum HmacHashingAlgorithm { - Sha256, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub(crate) struct HmacChallenge( - #[serde( - serialize_with = "base64_bytes::serialize", - deserialize_with = "base64_bytes::deserialize::<_, 32>" - )] - pub(crate) [u8; 32], -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub(crate) struct HmacChallengeResponse( - #[serde( - serialize_with = "base64_bytes::serialize", - deserialize_with = "base64_bytes::deserialize::<_, 32>" - )] - pub(crate) [u8; 32], -); - /// An identifier that is generated by the server for each pairing attempt. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct PairingAttemptId(String); From 08f0460a16ada8b1cd045ac4512c9b7d979c2e1c Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Tue, 17 Feb 2026 13:41:48 +0100 Subject: [PATCH 5/7] Moved version negotiation logic for client and server to common. --- s2energy-connection/src/common/mod.rs | 40 +++++++++++++++++++++++ s2energy-connection/src/pairing/client.rs | 23 ++----------- s2energy-connection/src/pairing/mod.rs | 14 ++++++-- s2energy-connection/src/pairing/server.rs | 11 +++---- 4 files changed, 59 insertions(+), 29 deletions(-) diff --git a/s2energy-connection/src/common/mod.rs b/s2energy-connection/src/common/mod.rs index c70eda0..b58046b 100644 --- a/s2energy-connection/src/common/mod.rs +++ b/s2energy-connection/src/common/mod.rs @@ -1 +1,41 @@ +use axum::Json; + pub(crate) mod wire; + +use reqwest::{StatusCode, Url}; +use wire::PairingVersion; + +use crate::common::wire::WirePairingVersion; + +pub(crate) const SUPPORTED_PAIRING_VERSIONS: &[PairingVersion] = &[PairingVersion::V1]; + +pub(crate) async fn root() -> Json<&'static [PairingVersion]> { + Json(SUPPORTED_PAIRING_VERSIONS) +} + +pub(crate) enum BaseError { + TransportFailed, + ProtocolError, + NoSupportedVersion, +} + +pub(crate) async fn negotiate_version(client: &reqwest::Client, url: Url) -> Result { + let response = client.get(url).send().await.map_err(|_| BaseError::TransportFailed)?; + let status = response.status(); + if status != StatusCode::OK { + return Err(BaseError::ProtocolError); + } + + let supported_versions = response + .json::>() + .await + .map_err(|_| BaseError::ProtocolError)?; + + for version in supported_versions.into_iter().filter_map(|v| v.try_into().ok()) { + if SUPPORTED_PAIRING_VERSIONS.contains(&version) { + return Ok(version); + } + } + + Err(BaseError::NoSupportedVersion) +} diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index e0c28b0..2e5b08e 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -3,9 +3,10 @@ use std::sync::Arc; use reqwest::{StatusCode, Url}; use rustls::pki_types::CertificateDer; -use crate::common::wire::{AccessToken, Deployment, PairingVersion, S2NodeId, S2Role, WirePairingVersion}; +use crate::common::negotiate_version; +use crate::common::wire::{AccessToken, Deployment, PairingVersion, S2NodeId, S2Role}; use crate::pairing::transport::{HashProvider, hash_providing_https_client}; -use crate::pairing::{Pairing, PairingRole, SUPPORTED_PAIRING_VERSIONS}; +use crate::pairing::{Pairing, PairingRole}; use super::EndpointConfig; use super::wire::*; @@ -80,24 +81,6 @@ impl Client { } } -async fn negotiate_version(client: &reqwest::Client, url: Url) -> Result { - let response = client.get(url).send().await.map_err(|_| Error::TransportFailed)?; - let status = response.status(); - if status != StatusCode::OK { - return Err(Error::ProtocolError); - } - - let supported_versions = response.json::>().await.map_err(|_| Error::ProtocolError)?; - - for version in supported_versions.into_iter().filter_map(|v| v.try_into().ok()) { - if SUPPORTED_PAIRING_VERSIONS.contains(&version) { - return Ok(version); - } - } - - Err(Error::NoSupportedVersion) -} - struct V1Session<'a> { client: reqwest::Client, base_url: Url, diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index e38d5b7..acb7964 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -195,11 +195,9 @@ pub use server::{PairingToken, PendingPairing, RepeatedPairing, Server, ServerCo use crate::{ CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2Role, - common::wire::{AccessToken, PairingVersion}, + common::{BaseError, wire::AccessToken}, }; -const SUPPORTED_PAIRING_VERSIONS: &[PairingVersion] = &[PairingVersion::V1]; - /// Full description of an S2 endpoint #[derive(Debug, Clone)] pub struct EndpointConfig { @@ -384,6 +382,16 @@ pub enum Error { InvalidConfig(ConfigError), } +impl From for Error { + fn from(value: BaseError) -> Self { + match value { + BaseError::TransportFailed => Self::TransportFailed, + BaseError::ProtocolError => Self::ProtocolError, + BaseError::NoSupportedVersion => Self::NoSupportedVersion, + } + } +} + impl From for Error { fn from(value: ConfigError) -> Self { Self::InvalidConfig(value) diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index b815b71..9c5f175 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -17,8 +17,11 @@ use sha2::Digest; use tokio::time::Instant; use crate::{ - common::wire::{AccessToken, PairingVersion, S2EndpointDescription, S2NodeDescription, S2NodeId}, - pairing::{PairingRole, SUPPORTED_PAIRING_VERSIONS}, + common::{ + root, + wire::{AccessToken, PairingVersion, S2EndpointDescription, S2NodeDescription, S2NodeId}, + }, + pairing::PairingRole, }; use super::{EndpointConfig, Error, Network, Pairing, PairingResult, wire::*}; @@ -234,10 +237,6 @@ struct AppStateInner { attempts: Mutex>, } -async fn root() -> Json<&'static [PairingVersion]> { - Json(SUPPORTED_PAIRING_VERSIONS) -} - fn v1_router() -> Router { Router::new() .route("/requestPairing", post(v1_request_pairing)) From b30c4b872709af99382a9774dab249c6da3e8233 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Tue, 17 Feb 2026 13:42:37 +0100 Subject: [PATCH 6/7] Initial rough implementation of connection subprotocol. --- Cargo.lock | 47 ++++ Cargo.toml | 1 + s2energy-connection/Cargo.toml | 1 + .../examples/communication-client.rs | 73 +++++ .../examples/communication-server.rs | 107 +++++++ s2energy-connection/src/common/wire.rs | 33 ++- .../src/communication/client.rs | 148 ++++++++++ s2energy-connection/src/communication/mod.rs | 94 +++++++ .../src/communication/server.rs | 263 ++++++++++++++++++ s2energy-connection/src/communication/wire.rs | 96 +++++++ s2energy-connection/src/lib.rs | 5 +- s2energy-connection/testdata/gen_cert.sh | 2 +- .../testdata/localhost.chain.pem | 43 +++ s2energy-connection/testdata/localhost.key | 28 ++ s2energy-connection/testdata/localhost.pem | 22 ++ 15 files changed, 957 insertions(+), 6 deletions(-) create mode 100644 s2energy-connection/examples/communication-client.rs create mode 100644 s2energy-connection/examples/communication-server.rs create mode 100644 s2energy-connection/src/communication/client.rs create mode 100644 s2energy-connection/src/communication/mod.rs create mode 100644 s2energy-connection/src/communication/server.rs create mode 100644 s2energy-connection/src/communication/wire.rs create mode 100644 s2energy-connection/testdata/localhost.chain.pem create mode 100644 s2energy-connection/testdata/localhost.key create mode 100644 s2energy-connection/testdata/localhost.pem diff --git a/Cargo.lock b/Cargo.lock index 5bbd612..e1a4200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-server" version = "0.8.0" @@ -568,6 +590,30 @@ dependencies = [ "foldhash", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.5.0" @@ -1303,6 +1349,7 @@ name = "s2energy-connection" version = "0.1.0" dependencies = [ "axum", + "axum-extra", "axum-server", "base64", "hmac", diff --git a/Cargo.toml b/Cargo.toml index 0e2a882..d85c744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ resolver = "3" [workspace.dependencies] axum = "0.8.8" +axum-extra = { version = "0.12.5", features = ["typed-header"] } base64 = "0.22.1" bon = "3.8.0" chrono = { version = "0.4.42", features = ["serde"] } diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml index fd858b7..06832d7 100644 --- a/s2energy-connection/Cargo.toml +++ b/s2energy-connection/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] axum.workspace = true +axum-extra.workspace = true base64.workspace = true hmac.workspace = true rand.workspace = true diff --git a/s2energy-connection/examples/communication-client.rs b/s2energy-connection/examples/communication-client.rs new file mode 100644 index 0000000..b50becd --- /dev/null +++ b/s2energy-connection/examples/communication-client.rs @@ -0,0 +1,73 @@ +use std::{convert::Infallible, path::PathBuf, sync::Arc}; + +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use s2energy_connection::{ + AccessToken, MessageVersion, S2NodeId, + communication::{Client, ClientConfig, ClientPairing, NodeConfig}, +}; + +struct MemoryPairing { + communication_url: String, + tokens: Vec, + server: S2NodeId, + client: S2NodeId, +} + +impl ClientPairing for &mut MemoryPairing { + type Error = Infallible; + + fn client_id(&self) -> S2NodeId { + self.client.clone() + } + + fn server_id(&self) -> S2NodeId { + self.server.clone() + } + + fn access_tokens(&self) -> impl AsRef<[AccessToken]> { + &self.tokens + } + + fn communication_url(&self) -> impl AsRef { + &self.communication_url + } + + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { + self.tokens = tokens; + Ok(()) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let client = Client::new( + ClientConfig { + additional_certificates: vec![ + CertificateDer::from_pem_file(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("root.pem")).unwrap(), + ], + endpoint_description: None, + }, + Arc::new(NodeConfig::builder(vec![MessageVersion("v1".into())]).build()), + ); + + let mut pairing = MemoryPairing { + communication_url: "https://localhost:8005/".into(), + tokens: vec![AccessToken("0123456789ABCDEF".into())], + server: S2NodeId("12".into()), + client: S2NodeId("34".into()), + }; + + let connection_info = client.connect(&mut pairing).await.unwrap(); + + println!( + "Url: {}, token: {}", + connection_info.communication_url, connection_info.communication_token.0 + ); + + let connection_info = client.connect(&mut pairing).await.unwrap(); + + println!( + "Url: {}, token: {}", + connection_info.communication_url, connection_info.communication_token.0 + ); +} diff --git a/s2energy-connection/examples/communication-server.rs b/s2energy-connection/examples/communication-server.rs new file mode 100644 index 0000000..ce29135 --- /dev/null +++ b/s2energy-connection/examples/communication-server.rs @@ -0,0 +1,107 @@ +use std::{ + convert::Infallible, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use axum_server::tls_rustls::RustlsConfig; +use s2energy_connection::{ + AccessToken, MessageVersion, S2NodeId, + communication::{NodeConfig, PairingLookupResult, Server, ServerConfig, ServerPairing, ServerPairingStore}, +}; + +struct MemoryPairingStoreInner { + token: AccessToken, + config: Arc, + server: S2NodeId, + client: S2NodeId, +} + +#[derive(Clone)] +struct MemoryPairingStore(Arc>); + +impl MemoryPairingStore { + fn new() -> Self { + MemoryPairingStore(Arc::new(Mutex::new(MemoryPairingStoreInner { + token: AccessToken("0123456789ABCDEF".into()), + config: Arc::new(NodeConfig::builder(vec![MessageVersion("v1".into())]).build()), + server: S2NodeId("12".into()), + client: S2NodeId("34".into()), + }))) + } +} + +impl ServerPairingStore for MemoryPairingStore { + type Error = Infallible; + + type Pairing<'a> + = MemoryPairingStore + where + Self: 'a; + + async fn lookup( + &self, + request: s2energy_connection::communication::PairingLookup, + ) -> Result>, Self::Error> { + let this = self.0.lock().unwrap(); + if this.client == request.client && this.server == request.server { + Ok(PairingLookupResult::Pairing(self.clone())) + } else { + Ok(PairingLookupResult::NeverPaired) + } + } +} + +impl ServerPairing for MemoryPairingStore { + type Error = Infallible; + + fn access_token(&self) -> impl AsRef { + self.0.lock().unwrap().token.clone() + } + + fn config(&self) -> impl AsRef { + self.0.lock().unwrap().config.clone() + } + + async fn set_access_token(&mut self, token: AccessToken) -> Result<(), Self::Error> { + self.0.lock().unwrap().token = token; + Ok(()) + } + + async fn update_remote_node_description(&mut self, _node_description: s2energy_connection::S2NodeDescription) { + println!("Received updated node description"); + } + + async fn update_remote_endpoint_description(&mut self, _endpoint_description: s2energy_connection::S2EndpointDescription) { + println!("Received updated endpoint description"); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let server = Server::new( + ServerConfig { + base_url: "localhost".into(), + endpoint_description: None, + }, + MemoryPairingStore::new(), + ); + + let addr = SocketAddr::from(([127, 0, 0, 1], 8005)); + + let rustls_config = RustlsConfig::from_pem_file( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("localhost.chain.pem"), + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join("localhost.key"), + ) + .await + .unwrap(); + + println!("listening on http://{}", addr); + axum_server::bind_rustls(addr, rustls_config) + .serve(server.get_router().into_make_service()) + .await + .unwrap(); +} diff --git a/s2energy-connection/src/common/wire.rs b/s2energy-connection/src/common/wire.rs index a110e68..8013b20 100644 --- a/s2energy-connection/src/common/wire.rs +++ b/s2energy-connection/src/common/wire.rs @@ -1,3 +1,6 @@ +use axum::extract::FromRequestParts; +use axum_extra::{TypedHeader, headers}; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; #[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -26,11 +29,11 @@ impl TryFrom for PairingVersion { } /// Message schema version. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct MessageVersion(pub String); /// Information about the pairing endpoint of a S2 node -#[derive(Default, Debug, Serialize, Deserialize, Clone)] +#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] pub struct S2EndpointDescription { /// Name of the endpoint @@ -47,7 +50,7 @@ pub struct S2EndpointDescription { /// One-time access token for secure access to the S2 message communication channel. It must be renewed every time a client wants to access /// the S2 message communication channel by calling the requestToken endpoint. This token is valid for one time login, with a maximum 5 /// years, and should have a minimum length of 32 bytes. -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] pub struct AccessToken(pub String); impl AccessToken { @@ -62,12 +65,34 @@ impl AccessToken { } } +impl AsRef for AccessToken { + fn as_ref(&self) -> &AccessToken { + self + } +} + +impl FromRequestParts for AccessToken { + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result { + let Some(token) = Option::>>::from_request_parts(parts, state) + .await + .ok() + .flatten() + else { + return Err(StatusCode::UNAUTHORIZED); + }; + + Ok(AccessToken(token.token().into())) + } +} + /// Unique identifier of the S2 node #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] pub struct S2NodeId(pub String); /// Information about the S2 node -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] pub struct S2NodeDescription { /// Unique identifier of the node diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs new file mode 100644 index 0000000..ca0de6a --- /dev/null +++ b/s2energy-connection/src/communication/client.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use reqwest::{StatusCode, Url}; +use rustls::pki_types::CertificateDer; + +use crate::{ + AccessToken, CommunicationProtocol, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, + common::negotiate_version, + communication::{ + CommunicationResult, Error, NodeConfig, + wire::{CommunicationDetails, CommunicationToken, InitiateConnectionRequest, InitiateConnectionResponse}, + }, +}; + +/// Configuration for communication clients. +pub struct ClientConfig { + /// Additional roots of trust for TLS connections. Useful when testing during the development of WAN endpoints. + /// + /// When the remote is on the LAN, this is not used. + pub additional_certificates: Vec>, + /// Optional description of this endpoint, sent as update to the server. + pub endpoint_description: Option, +} + +pub struct Client { + config: Arc, + additional_certificates: Vec>, + endpoint_description: Option, +} + +pub struct ConnectionInfo { + pub server_node_description: Option, + pub server_endpoint_description: Option, + pub message_version: MessageVersion, + + // TODO: replace with actual transport. + pub communication_token: CommunicationToken, + pub communication_url: String, +} + +pub trait ClientPairing: Send { + type Error: std::error::Error; + + fn client_id(&self) -> S2NodeId; + fn server_id(&self) -> S2NodeId; + fn access_tokens(&self) -> impl AsRef<[AccessToken]>; + fn communication_url(&self) -> impl AsRef; + + fn set_access_tokens(&mut self, tokens: Vec) -> impl Future> + Send; +} + +impl Client { + pub fn new(config: ClientConfig, node_config: Arc) -> Self { + Client { + config: node_config, + additional_certificates: config.additional_certificates, + endpoint_description: config.endpoint_description, + } + } + + pub async fn connect(&self, mut pairing: impl ClientPairing) -> CommunicationResult { + let client = reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|_| Error::TransportFailed)?; + + let communication_url = Url::parse(pairing.communication_url().as_ref()).map_err(|_| Error::InvalidUrl)?; + + let version = negotiate_version(&client, communication_url.clone()).await?; + + match version { + crate::common::wire::PairingVersion::V1 => { + let base_url = communication_url.join("v1/").unwrap(); + + let request = InitiateConnectionRequest { + client_node_id: pairing.client_id(), + server_node_id: pairing.server_id(), + supported_message_versions: self.config.supported_message_versions.clone(), + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + node_description: self.config.node_description().cloned(), + endpoint_description: self.endpoint_description.clone(), + }; + + let Some((initiate_response, current_token)) = ('found: { + for token in pairing.access_tokens().as_ref() { + let response = client + .post(base_url.join("initiateConnection").unwrap()) + .bearer_auth(&token.0) + .json(&request) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + + if response.status() == StatusCode::UNAUTHORIZED { + continue; + } + if response.status() != StatusCode::OK { + return Err(Error::TransportFailed); + } + + break 'found Some(( + response + .json::() + .await + .map_err(|_| Error::TransportFailed)?, + token.clone(), + )); + } + None + }) else { + return Err(Error::NotPaired); + }; + + pairing + .set_access_tokens(vec![current_token, initiate_response.access_token.clone()]) + .await + .map_err(|_| Error::Storage)?; + + let response = client + .post(base_url.join("confirmAccessToken").unwrap()) + .bearer_auth(&initiate_response.access_token.0) + .send() + .await + .map_err(|_| Error::TransportFailed)?; + + if response.status() != StatusCode::OK { + return Err(Error::ProtocolError); + } + + let communication_details = response.json::().await.map_err(|_| Error::TransportFailed)?; + + match communication_details { + CommunicationDetails::WebSocket(web_socket_communication_details) => Ok(ConnectionInfo { + server_node_description: initiate_response.node_description, + server_endpoint_description: initiate_response.endpoint_description, + message_version: initiate_response.message_version, + communication_token: web_socket_communication_details.websocket_token, + communication_url: web_socket_communication_details.websocket_url, + }), + } + } + } + } +} diff --git a/s2energy-connection/src/communication/mod.rs b/s2energy-connection/src/communication/mod.rs new file mode 100644 index 0000000..413bb0f --- /dev/null +++ b/s2energy-connection/src/communication/mod.rs @@ -0,0 +1,94 @@ +use crate::{MessageVersion, S2NodeDescription, common::BaseError}; + +mod client; +mod server; +mod wire; + +pub use client::{Client, ClientConfig, ClientPairing, ConnectionInfo}; +pub use server::{PairingLookup, PairingLookupResult, Server, ServerConfig, ServerPairing, ServerPairingStore}; + +/// Full description of an S2 endpoint +#[derive(Debug, Clone)] +pub struct NodeConfig { + node_description: Option, + supported_message_versions: Vec, +} + +impl NodeConfig { + /// Description of the S2 node. + pub fn node_description(&self) -> Option<&S2NodeDescription> { + self.node_description.as_ref() + } + + /// Message versions supported by this endpoint. + pub fn supported_message_versions(&self) -> &[MessageVersion] { + &self.supported_message_versions + } + + /// Create a builder for a new [`EndpointConfig`] + /// + /// All endpoint configurations must at least contain description of the node and supported message versions. Additional + /// properties can be configured through the builder. + pub fn builder(supported_message_versions: Vec) -> ConfigBuilder { + ConfigBuilder { + node_description: None, + supported_message_versions, + } + } +} + +/// Builder for an [`EndpointConfig`] +pub struct ConfigBuilder { + node_description: Option, + supported_message_versions: Vec, +} + +impl ConfigBuilder { + /// Set the node description. + /// + /// Note that this replaces any previous node decriptions passed + pub fn with_node_description(mut self, node_description: S2NodeDescription) -> Self { + self.node_description = Some(node_description); + self + } + + /// Create the actual [`EndpointConfig`], validating that it is reasonable. + pub fn build(self) -> NodeConfig { + NodeConfig { + node_description: self.node_description, + supported_message_versions: self.supported_message_versions, + } + } +} + +/// Error that occured during the communication process. +#[derive(Debug, Clone)] +pub enum Error { + /// Invalid URL for remote + InvalidUrl, + /// Something went wrong in the transport layers + TransportFailed, + /// The remote reacted outside our expectations + ProtocolError, + /// No shared version with the remote. + NoSupportedVersion, + /// The nodes are no longer paired + Unpaired, + /// The nodes were not paired + NotPaired, + /// Storage failed to persist token + Storage, +} + +impl From for Error { + fn from(value: BaseError) -> Self { + match value { + BaseError::TransportFailed => Self::TransportFailed, + BaseError::ProtocolError => Self::ProtocolError, + BaseError::NoSupportedVersion => Self::NoSupportedVersion, + } + } +} + +/// Convenience type for [`Result`] +pub type CommunicationResult = Result; diff --git a/s2energy-connection/src/communication/server.rs b/s2energy-connection/src/communication/server.rs new file mode 100644 index 0000000..fa918dc --- /dev/null +++ b/s2energy-connection/src/communication/server.rs @@ -0,0 +1,263 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; + +use axum::{ + Json, Router, + extract::State, + response::IntoResponse, + routing::{get, post}, +}; +use reqwest::StatusCode; + +use crate::{ + CommunicationProtocol, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, + common::{root, wire::AccessToken}, + communication::{ + NodeConfig, + wire::{ + CommunicationDetails, CommunicationDetailsErrorMessage, CommunicationToken, InitiateConnectionRequest, + InitiateConnectionResponse, WebSocketCommunicationDetails, + }, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// A pairing to be looked up. +pub struct PairingLookup { + /// Identifier of the remote end of the pairing + pub client: S2NodeId, + /// Identifier of the local end of the pairing + pub server: S2NodeId, +} + +/// Result of looking up a pairing +pub enum PairingLookupResult { + /// Pairing exists + Pairing(Pairing), + /// Pairing existed in the past, but has recently unpaired + Unpaired, + /// Pairing never existed, or existed so long ago that that is no longer known. + NeverPaired, +} + +pub trait ServerPairingStore: Sync + Send + 'static { + type Error: std::error::Error; + type Pairing<'a>: ServerPairing + 'a + where + Self: 'a; + + fn lookup(&self, request: PairingLookup) -> impl Future>, Self::Error>> + Send; +} + +pub trait ServerPairing: Send { + type Error: std::error::Error; + + fn access_token(&self) -> impl AsRef; + fn config(&self) -> impl AsRef; + + fn set_access_token(&mut self, token: AccessToken) -> impl Future> + Send; + fn update_remote_node_description(&mut self, node_description: S2NodeDescription) -> impl Future + Send; + fn update_remote_endpoint_description(&mut self, endpoint_description: S2EndpointDescription) -> impl Future + Send; +} + +/// Configuration for the S2 connection server. +pub struct ServerConfig { + /// URL at which the communication server is reachable. + pub base_url: String, + pub endpoint_description: Option, +} + +pub struct Server { + app_state: AppState, +} + +type AppState = Arc>; + +struct AppStateInner { + store: Store, + pending_tokens: Mutex>, + base_url: String, + endpoint_description: Option, +} + +struct ExpiringSession { + start_time: tokio::time::Instant, + session: Session, +} + +impl ExpiringSession { + fn into_state(self) -> Option { + if self.start_time.elapsed() > Duration::from_secs(15) { + None + } else { + Some(self.session) + } + } +} + +#[expect(unused)] +struct Session { + lookup: PairingLookup, + token: AccessToken, + node_description: Option, + endpoint_description: Option, + message_version: MessageVersion, + communication_protocol: CommunicationProtocol, +} + +impl Server { + pub fn new(config: ServerConfig, store: Store) -> Self { + Server { + app_state: Arc::new(AppStateInner { + store, + pending_tokens: Mutex::new(HashMap::new()), + base_url: config.base_url, + endpoint_description: config.endpoint_description, + }), + } + } + + /// Get an [`axum::Router`] handling the endpoints for the communication protocol. + /// + /// Incomming http requests can be handled by this router through the [axum-server](https://docs.rs/axum-server/0.8.0/axum_server/) crate. + pub fn get_router(&self) -> axum::Router<()> { + Router::new() + .route("/", get(root)) + .nest("/v1", v1_router()) + .with_state(self.app_state.clone()) + } +} + +impl IntoResponse for CommunicationDetailsErrorMessage { + fn into_response(self) -> axum::response::Response { + (StatusCode::BAD_REQUEST, Json(self)).into_response() + } +} + +impl IntoResponse for InitiateConnectionResponse { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } +} + +fn select_overlap(primary: &[T], secondary: &[T]) -> Option { + for el in primary { + if secondary.contains(el) { + return Some(el.clone()); + } + } + + None +} + +fn v1_router() -> Router> { + Router::new() + .route("/initiateConnection", post(v1_initiate_connection)) + .route("/confirmAccessToken", post(v1_confirm_access_token)) +} + +async fn v1_initiate_connection( + State(state): State>, + token: AccessToken, + Json(request): Json, +) -> axum::response::Response { + let lookup = PairingLookup { + client: request.client_node_id, + server: request.server_node_id, + }; + + let pairing = match state.store.lookup(lookup.clone()).await { + Ok(PairingLookupResult::Pairing(pairing)) => pairing, + Ok(PairingLookupResult::Unpaired) => return CommunicationDetailsErrorMessage::NoLongerPaired.into_response(), + Ok(PairingLookupResult::NeverPaired) => return StatusCode::UNAUTHORIZED.into_response(), + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + if pairing.access_token().as_ref() != &token { + return StatusCode::UNAUTHORIZED.into_response(); + } + + let config = pairing.config(); + + let Some(communication_protocol) = select_overlap( + &request.supported_communication_protocols, + &[CommunicationProtocol("WebSocket".into())], + ) else { + return CommunicationDetailsErrorMessage::IncompatibleCommunicationProtocols.into_response(); + }; + let Some(message_version) = select_overlap(&request.supported_message_versions, config.as_ref().supported_message_versions()) else { + return CommunicationDetailsErrorMessage::IncompatibleS2MessageVersions.into_response(); + }; + + let mut pending_tokens = state.pending_tokens.lock().unwrap(); + + let new_access_token = loop { + let candidate = AccessToken::new(&mut rand::rng()); + if !pending_tokens.contains_key(&candidate) { + break candidate; + } + }; + + pending_tokens.insert( + new_access_token.clone(), + ExpiringSession { + start_time: tokio::time::Instant::now(), + session: Session { + lookup, + token, + node_description: request.node_description, + endpoint_description: request.endpoint_description, + message_version: message_version.clone(), + communication_protocol: communication_protocol.clone(), + }, + }, + ); + + InitiateConnectionResponse { + communication_protocol, + message_version, + access_token: new_access_token, + node_description: config.as_ref().node_description().cloned(), + endpoint_description: state.endpoint_description.clone(), + } + .into_response() +} + +impl IntoResponse for CommunicationDetails { + fn into_response(self) -> axum::response::Response { + Json(self).into_response() + } +} + +async fn v1_confirm_access_token( + State(state): State>, + token: AccessToken, +) -> Result { + let session = { + let mut pending_tokens = state.pending_tokens.lock().unwrap(); + pending_tokens + .remove(&token) + .and_then(|v| v.into_state()) + .ok_or(StatusCode::UNAUTHORIZED)? + }; + + let mut pairing = match state.store.lookup(session.lookup.clone()).await { + Ok(PairingLookupResult::Pairing(pairing)) => pairing, + Ok(PairingLookupResult::Unpaired | PairingLookupResult::NeverPaired) => return Err(StatusCode::UNAUTHORIZED), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + pairing + .set_access_token(token) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // TODO: Implement websocket communication + Ok(CommunicationDetails::WebSocket(WebSocketCommunicationDetails { + websocket_token: CommunicationToken::new(&mut rand::rng()), + websocket_url: format!("wss://{}/v1/websocket", state.base_url), + })) +} diff --git a/s2energy-connection/src/communication/wire.rs b/s2energy-connection/src/communication/wire.rs new file mode 100644 index 0000000..efd8317 --- /dev/null +++ b/s2energy-connection/src/communication/wire.rs @@ -0,0 +1,96 @@ +use axum::extract::FromRequestParts; +use axum_extra::{TypedHeader, headers}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{CommunicationProtocol, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, common::wire::AccessToken}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub(crate) enum CommunicationDetails { + WebSocket(WebSocketCommunicationDetails), +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub(crate) struct WebSocketCommunicationDetails { + pub(crate) websocket_token: CommunicationToken, + pub(crate) websocket_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Error, Clone, PartialEq, Eq, Hash)] +pub(crate) enum CommunicationDetailsErrorMessage { + #[error("Incompatible S2 message versions")] + IncompatibleS2MessageVersions, + #[error("Incompatible communication protocols")] + IncompatibleCommunicationProtocols, + #[error("No longer paired")] + NoLongerPaired, + #[error("Parsing error")] + ParsingError, + #[error("Other")] + Other, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct InitiateConnectionRequest { + #[serde(rename = "clientS2NodeId")] + pub(crate) client_node_id: S2NodeId, + #[serde(rename = "serverS2NodeId")] + pub(crate) server_node_id: S2NodeId, + #[serde(rename = "supportedS2MessageVersions")] + pub(crate) supported_message_versions: Vec, + #[serde(rename = "supportedCommunicationProtocols")] + pub(crate) supported_communication_protocols: Vec, + #[serde(rename = "clientS2NodeDescription")] + pub(crate) node_description: Option, + #[serde(rename = "clientS2EndpointDescription")] + pub(crate) endpoint_description: Option, +} + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub(crate) struct InitiateConnectionResponse { + #[serde(rename = "selectedCommunicationProtocol")] + pub(crate) communication_protocol: CommunicationProtocol, + #[serde(rename = "selectedS2MessageVersion")] + pub(crate) message_version: MessageVersion, + #[serde(rename = "accessToken")] + pub(crate) access_token: AccessToken, + #[serde(rename = "serverS2NodeDescription")] + pub(crate) node_description: Option, + #[serde(rename = "serverS2EndpointDescription")] + pub(crate) endpoint_description: Option, +} + +/// One-time access token for secure access to the S2 message communication channel. It must be renewed every time a client wants to access +/// the S2 message communication channel by calling the requestToken endpoint. This token is valid for one time login, with a maximum 5 +/// years, and should have a minimum length of 32 bytes. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct CommunicationToken(pub String); + +impl CommunicationToken { + pub fn new(rng: &mut impl rand::Rng) -> Self { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + + let encoded = STANDARD.encode(bytes); + Self(encoded) + } +} + +impl FromRequestParts for CommunicationToken { + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut axum::http::request::Parts, state: &S) -> Result { + let Some(token) = Option::>>::from_request_parts(parts, state) + .await + .ok() + .flatten() + else { + return Err(StatusCode::UNAUTHORIZED); + }; + + Ok(CommunicationToken(token.token().into())) + } +} diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index d97e31d..a34efce 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -1,4 +1,7 @@ pub(crate) mod common; +pub mod communication; pub mod pairing; -pub use common::wire::{CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role}; +pub use common::wire::{ + AccessToken, CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role, +}; diff --git a/s2energy-connection/testdata/gen_cert.sh b/s2energy-connection/testdata/gen_cert.sh index 75041f7..32f7ce2 100755 --- a/s2energy-connection/testdata/gen_cert.sh +++ b/s2energy-connection/testdata/gen_cert.sh @@ -42,7 +42,7 @@ EOF openssl x509 -req -in "$FILENAME".csr -CA "$CA".pem -CAkey "$CA".key -out "$FILENAME".pem -days 365 -sha256 -extfile "$FILENAME".ext # generate the full certificate chain version -cat "$FILENAME".pem "$CA".pem > "$FILENAME".fullchain.pem +cat "$FILENAME".pem "$CA".pem > "$FILENAME".chain.pem # cleanup rm "$FILENAME".csr "$FILENAME".ext diff --git a/s2energy-connection/testdata/localhost.chain.pem b/s2energy-connection/testdata/localhost.chain.pem new file mode 100644 index 0000000..51f34fd --- /dev/null +++ b/s2energy-connection/testdata/localhost.chain.pem @@ -0,0 +1,43 @@ +-----BEGIN CERTIFICATE----- +MIIDnzCCAoegAwIBAgIUZeLrw4Ef1ghsGyyOQ7o4NRI4by8wDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTcxMjE0NDVaFw0yNzAy +MTcxMjE0NDVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCwtrLZGNMRtGKPZFNCbDu5Fgf+Zx7291enIj6Fp/DW ++TvgZT5ZlaihGn849/P2IVY+0qKQgDPIkfgqh5Xx2atJO74shcr/BAPPGdrVkW1d +9Xte93Qlqi/3497Syy51cXM1+/SmatH1RfbJ7opUKHFw/J2LZOtOo0X7FB4Y2mGz +pA02FGFe7gsZi018z7UBDaw6s4Uhn0VGlCS7/wHwa3RFA0eT70/uon5TDeGlthtX +xa0kw2Wu0uVCDHRThIyh3S2F3YDsqpN9oNafmwlASqD0k5IqnRKZICkL8t0H424G +8V9auyemPdPAz6lQR9XzlVBHjMVy5KAPk4C3rk1klEn9AgMBAAGjgYYwgYMwHwYD +VR0jBBgwFoAU/s43TRxw6GnGd7XFg0DaX7vRhnAwCQYDVR0TBAIwADALBgNVHQ8E +BAMCA6gwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0 +MB0GA1UdDgQWBBTLzjoq/J2PyhSPRcy1QZZGuiUWhzANBgkqhkiG9w0BAQsFAAOC +AQEAI8w9HGhK4iRSA4vHxRTc7JhkFuCAf/V9JLrPtEXubtUfZKsVgKNpIsuwjMgl +dORwBXH5z3UIwU8AeZBgsSex9iufXfQV3hf6z9u647F+Vz7DgrsYsfapKs59olci +Z3Wcf6NFTuQEhEZdzl35w2PpTbIK0lrrJ2WMoVgYFljvCRojuzqDPEJQtiWgxQ7f +MSgFmHkRfJ+gJbYzwozIJPS1uH/ZQQa/iIXRV4UIZddRp6XI2tqBsaqT1r/KjF8P +wMaLefxC0+6C6yHcB5sMptHSvl2VeerWLs4Dag/O53N1QzLoCZnRzAZodiO39FyU +fcvdl6j2lIbax24bgxR3zQUZ2Q== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIURu/3EiBqW5zBHLYfNRjzTfs2QMQwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTAwODI3NDRaFw0zMTAy +MDkwODI3NDRaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDXr9mtQIgvaDBkxr6mw5UVg9Ox35e01rCz1rrl9MqS +c9SaKtjCCQVyotokHsXT6DKJ4H+CGDD+Y8ULhEQN7B8VSfGB8gg/nD7KHk2dzxNt +8kkZGWDKanyWWdawrsegcApvwV2eHa5/94sHHkJCZNJoRMtmoimZ0o848jOIAUoS +pO1bIxRq7N2YluJVaMYk/U2GBOfwpjhXcy74kQrq1mGyyE3hzJUgtaRGlDsvp3c0 +99b9Pd2fRAmqUzjijibQfheuum4KCLwoZCGvwnY4iQM6vQjNY06djAqyR6XFGH8s +7EVzNFSJyhJZK30FaAYPDeVDIJUKTSrJlTr2ddjF5Gp/AgMBAAGjUzBRMB0GA1Ud +DgQWBBT+zjdNHHDoacZ3tcWDQNpfu9GGcDAfBgNVHSMEGDAWgBT+zjdNHHDoacZ3 +tcWDQNpfu9GGcDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCI +3ZjK6htARQAYx80BIV4ZOm/MPtQxEOMJ0gywcOckwUICHbmdjj5T2XdsE1L9EbYw +8U/5XItzcfnmf5A+7Pf1UqnfOgeCAw7tl4zX5zCHDm0l3nXmOSnyU1RMetJ+aXTT +LZyV6JJxcEFseQsqdBwx6AkXGz4CqLBDMbwi6j+1yRfib11m2gZGYozNFKDrw6xS +L0KFcBWCM8lzb6W5oc3P+oA+EoF3nhgydtb1vNwe9wkubrRl5GkFzRrnEHTDRpLe +NShyxRuBPtQoKwcIfMaNt+9W5qMwrYjh21mCGX122K8kAdXDT35AYcAK2X8WpT9F +nL09Lv6HBpesSih6ZRS3 +-----END CERTIFICATE----- diff --git a/s2energy-connection/testdata/localhost.key b/s2energy-connection/testdata/localhost.key new file mode 100644 index 0000000..0ece3c1 --- /dev/null +++ b/s2energy-connection/testdata/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwtrLZGNMRtGKP +ZFNCbDu5Fgf+Zx7291enIj6Fp/DW+TvgZT5ZlaihGn849/P2IVY+0qKQgDPIkfgq +h5Xx2atJO74shcr/BAPPGdrVkW1d9Xte93Qlqi/3497Syy51cXM1+/SmatH1RfbJ +7opUKHFw/J2LZOtOo0X7FB4Y2mGzpA02FGFe7gsZi018z7UBDaw6s4Uhn0VGlCS7 +/wHwa3RFA0eT70/uon5TDeGlthtXxa0kw2Wu0uVCDHRThIyh3S2F3YDsqpN9oNaf +mwlASqD0k5IqnRKZICkL8t0H424G8V9auyemPdPAz6lQR9XzlVBHjMVy5KAPk4C3 +rk1klEn9AgMBAAECggEACRKNgsEthPPX444TtY33pab7PhUZQotXAAkCthCgjXOP +nr5T7Hwsg349bd/9Bx6Vs5/+IfZoXNxxpe1UG23rcf++jGv0t2cDXbdGxD7fzeUe +h7T4oj5nPA4s5cbyBFbmGALEKrnCkcRyc9JACStnt5SgmgQnuIqAEJtAeFIt60UO +MdWwn+5NWUu1E9EbYGDP3X+295VkcJXaJQEoqnFA5JEj9T09VTVa9vq/pTUYn/qB +aW/bmfWiZl5bfu2L+Qv1zs/i0ieEmkC8yMV61msjgwPvaS56KSo2qVBmk3V7/+hJ +eqEtjC0Eg0+87Bn3oi9kSV7ZQD523wzBF3SKZUqJgQKBgQDzP8bErUWnuhMEh75z +7nGa+0vYq1WeC52yPebXnW48mtTvbaZIB2ZK4pypJZHaObqNZPcscLzJ87/rIlwN +J1UvyeOCIXFyjuo/0ehSb4+drs3fyydKrMHFwwl/oXVJ/YW2LUZP7iO/LW2++5oI +9oKXdgvMt5BDJO5uBDMcqAdZ0QKBgQC5+hDCj/Uv62KzAUcVCI1W0PY4nOlT/shi +wQAlyhflz6m2xSkiF9OLuQyJ0C1YuSuiHLo9Q3hufd7U+eRb4GN0xgsD9uu32feJ +jSh48NcRaZ/6HKqISv1eGKTLod+MXrcVC/rXAelvZVuBpnp9MHqrKqO4btxRVoLF +/d6Rh3lMbQKBgQCakiNPpT+G9pHRJiUa7CEat6cZtr5AIOeDdRx0VODQ+B5pSscI +LFOPMHMWdP46qsZlxQvgHH+K4S5KT1opLZ5PML42WeQKRNCL32n+wE+FhqfiFukP +5bl4Xphxlvq+GrDV8+0jK5Nhj4+WdbELEwInFucmnlq4oAY2uMp14jxRkQKBgDf9 +EqKgWD5O7O3bCp1Ib9SdICM3Cf+hio5AcFzwFHW5KOy/OnzrE2LTGPU8WQqG5J3v +bBoZf94zwqv3d0o5qXd0T8inw5sb4avldTPDvdueIu1XR/e0K8byQFqVpwlJUnDh +pGiqSK6iowPLLMEXoTZ6pcNWjLloBAK7RRAm6tuZAoGAbR03owQ2zWjBzKZhIY24 +2MWLdhvdQmKK05x8k13fDG25iMdti9c7V9hf639f/vMGJBDTKdsnoMR7fGLuPkFg +wSBglTSrWMVqMPozCSeVu+JFtmv2nYdUcJyn7HAe4kHErydVzw9Rff9j9AuDWu4d +s1nWYDiS6FgnIYFBcnkl0P4= +-----END PRIVATE KEY----- diff --git a/s2energy-connection/testdata/localhost.pem b/s2energy-connection/testdata/localhost.pem new file mode 100644 index 0000000..96f666f --- /dev/null +++ b/s2energy-connection/testdata/localhost.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDnzCCAoegAwIBAgIUZeLrw4Ef1ghsGyyOQ7o4NRI4by8wDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNjAyMTcxMjE0NDVaFw0yNzAy +MTcxMjE0NDVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCwtrLZGNMRtGKPZFNCbDu5Fgf+Zx7291enIj6Fp/DW ++TvgZT5ZlaihGn849/P2IVY+0qKQgDPIkfgqh5Xx2atJO74shcr/BAPPGdrVkW1d +9Xte93Qlqi/3497Syy51cXM1+/SmatH1RfbJ7opUKHFw/J2LZOtOo0X7FB4Y2mGz +pA02FGFe7gsZi018z7UBDaw6s4Uhn0VGlCS7/wHwa3RFA0eT70/uon5TDeGlthtX +xa0kw2Wu0uVCDHRThIyh3S2F3YDsqpN9oNafmwlASqD0k5IqnRKZICkL8t0H424G +8V9auyemPdPAz6lQR9XzlVBHjMVy5KAPk4C3rk1klEn9AgMBAAGjgYYwgYMwHwYD +VR0jBBgwFoAU/s43TRxw6GnGd7XFg0DaX7vRhnAwCQYDVR0TBAIwADALBgNVHQ8E +BAMCA6gwEwYDVR0lBAwwCgYIKwYBBQUHAwEwFAYDVR0RBA0wC4IJbG9jYWxob3N0 +MB0GA1UdDgQWBBTLzjoq/J2PyhSPRcy1QZZGuiUWhzANBgkqhkiG9w0BAQsFAAOC +AQEAI8w9HGhK4iRSA4vHxRTc7JhkFuCAf/V9JLrPtEXubtUfZKsVgKNpIsuwjMgl +dORwBXH5z3UIwU8AeZBgsSex9iufXfQV3hf6z9u647F+Vz7DgrsYsfapKs59olci +Z3Wcf6NFTuQEhEZdzl35w2PpTbIK0lrrJ2WMoVgYFljvCRojuzqDPEJQtiWgxQ7f +MSgFmHkRfJ+gJbYzwozIJPS1uH/ZQQa/iIXRV4UIZddRp6XI2tqBsaqT1r/KjF8P +wMaLefxC0+6C6yHcB5sMptHSvl2VeerWLs4Dag/O53N1QzLoCZnRzAZodiO39FyU +fcvdl6j2lIbax24bgxR3zQUZ2Q== +-----END CERTIFICATE----- From 3f5ebf77a68a570cf0ff024b039d3add72e23f16 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Tue, 17 Feb 2026 15:00:58 +0100 Subject: [PATCH 7/7] Make S2NodeId wrap uuid::Uuid --- Cargo.lock | 1 + s2energy-connection/Cargo.toml | 1 + .../examples/communication-client.rs | 5 +- .../examples/communication-server.rs | 5 +- .../examples/pairing-client.rs | 7 +- .../examples/pairing-server.rs | 5 +- s2energy-connection/src/common/wire.rs | 77 ++++++++++++++++++- s2energy-connection/src/lib.rs | 3 +- s2energy-connection/src/pairing/mod.rs | 12 +-- s2energy-connection/src/pairing/server.rs | 4 +- 10 files changed, 100 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1a4200..509c738 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1361,6 +1361,7 @@ dependencies = [ "sha2", "thiserror 2.0.18", "tokio", + "uuid", ] [[package]] diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml index 06832d7..ba6e07f 100644 --- a/s2energy-connection/Cargo.toml +++ b/s2energy-connection/Cargo.toml @@ -16,6 +16,7 @@ serde.workspace = true sha2.workspace = true thiserror.workspace = true tokio.workspace = true +uuid.workspace = true [dev-dependencies] axum-server.workspace = true diff --git a/s2energy-connection/examples/communication-client.rs b/s2energy-connection/examples/communication-client.rs index b50becd..0256aa0 100644 --- a/s2energy-connection/examples/communication-client.rs +++ b/s2energy-connection/examples/communication-client.rs @@ -1,4 +1,5 @@ use std::{convert::Infallible, path::PathBuf, sync::Arc}; +use uuid::uuid; use rustls::pki_types::{CertificateDer, pem::PemObject}; use s2energy_connection::{ @@ -53,8 +54,8 @@ async fn main() { let mut pairing = MemoryPairing { communication_url: "https://localhost:8005/".into(), tokens: vec![AccessToken("0123456789ABCDEF".into())], - server: S2NodeId("12".into()), - client: S2NodeId("34".into()), + server: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8").into(), + client: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c7").into(), }; let connection_info = client.connect(&mut pairing).await.unwrap(); diff --git a/s2energy-connection/examples/communication-server.rs b/s2energy-connection/examples/communication-server.rs index ce29135..9fd3b89 100644 --- a/s2energy-connection/examples/communication-server.rs +++ b/s2energy-connection/examples/communication-server.rs @@ -4,6 +4,7 @@ use std::{ path::PathBuf, sync::{Arc, Mutex}, }; +use uuid::uuid; use axum_server::tls_rustls::RustlsConfig; use s2energy_connection::{ @@ -26,8 +27,8 @@ impl MemoryPairingStore { MemoryPairingStore(Arc::new(Mutex::new(MemoryPairingStoreInner { token: AccessToken("0123456789ABCDEF".into()), config: Arc::new(NodeConfig::builder(vec![MessageVersion("v1".into())]).build()), - server: S2NodeId("12".into()), - client: S2NodeId("34".into()), + server: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8").into(), + client: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c7").into(), }))) } } diff --git a/s2energy-connection/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs index 517d47b..b347a6a 100644 --- a/s2energy-connection/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -1,7 +1,8 @@ use std::sync::Arc; +use uuid::uuid; use s2energy_connection::{ - Deployment, MessageVersion, S2NodeDescription, S2NodeId, S2Role, + Deployment, MessageVersion, S2NodeDescription, S2Role, pairing::{Client, ClientConfig, EndpointConfig, PairingRemote}, }; @@ -11,7 +12,7 @@ const PAIRING_TOKEN: &[u8] = &[1, 2, 3]; async fn main() { let config = EndpointConfig::builder( S2NodeDescription { - id: S2NodeId(String::from("12121212")), + id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c7").into(), brand: String::from("super-reliable-corp"), logo_uri: None, type_: String::from("fancy"), @@ -38,7 +39,7 @@ async fn main() { .pair( PairingRemote { url: "https://test.local:8005".into(), - id: S2NodeId(String::from("12121212")), + id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8").into(), }, PAIRING_TOKEN, ) diff --git a/s2energy-connection/examples/pairing-server.rs b/s2energy-connection/examples/pairing-server.rs index b3bab72..8c86734 100644 --- a/s2energy-connection/examples/pairing-server.rs +++ b/s2energy-connection/examples/pairing-server.rs @@ -1,9 +1,10 @@ use axum_server::tls_rustls::RustlsConfig; use rustls::pki_types::{CertificateDer, pem::PemObject}; use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +use uuid::uuid; use s2energy_connection::{ - MessageVersion, S2NodeDescription, S2NodeId, S2Role, + MessageVersion, S2NodeDescription, S2Role, pairing::{EndpointConfig, PairingToken, Server, ServerConfig}, }; @@ -19,7 +20,7 @@ async fn main() { }); let config = EndpointConfig::builder( S2NodeDescription { - id: S2NodeId(String::from("12121212")), + id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8").into(), brand: String::from("super-reliable-corp"), logo_uri: None, type_: String::from("fancy"), diff --git a/s2energy-connection/src/common/wire.rs b/s2energy-connection/src/common/wire.rs index 8013b20..3ee0b6f 100644 --- a/s2energy-connection/src/common/wire.rs +++ b/s2energy-connection/src/common/wire.rs @@ -2,6 +2,7 @@ use axum::extract::FromRequestParts; use axum_extra::{TypedHeader, headers}; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; +use uuid::Uuid; #[derive(Serialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "lowercase")] @@ -87,9 +88,81 @@ impl FromRequestParts for AccessToken { } } +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct InvalidNodeId(uuid::Error); + +impl core::fmt::Display for InvalidNodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for InvalidNodeId {} + +impl From for InvalidNodeId { + fn from(value: uuid::Error) -> Self { + Self(value) + } +} + /// Unique identifier of the S2 node -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] -pub struct S2NodeId(pub String); +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct S2NodeId(Uuid); + +impl From for S2NodeId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From for Uuid { + fn from(value: S2NodeId) -> Self { + value.0 + } +} + +impl TryFrom for S2NodeId { + type Error = InvalidNodeId; + + fn try_from(value: String) -> Result { + Ok(Self(Uuid::try_from(value)?)) + } +} + +impl TryFrom<&str> for S2NodeId { + type Error = InvalidNodeId; + + fn try_from(value: &str) -> Result { + Ok(Self(Uuid::try_from(value)?)) + } +} + +impl std::ops::Deref for S2NodeId { + type Target = Uuid; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for S2NodeId { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl core::fmt::Display for S2NodeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.as_hyphenated().fmt(f) + } +} + +impl S2NodeId { + #[expect(clippy::new_without_default, reason = "New uses non-trivial randomness")] + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} /// Information about the S2 node #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index a34efce..451b390 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -3,5 +3,6 @@ pub mod communication; pub mod pairing; pub use common::wire::{ - AccessToken, CommunicationProtocol, Deployment, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, S2Role, + AccessToken, CommunicationProtocol, Deployment, InvalidNodeId, MessageVersion, S2EndpointDescription, S2NodeDescription, S2NodeId, + S2Role, }; diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index acb7964..c67bb11 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -10,7 +10,7 @@ //! # use s2energy_connection::pairing::EndpointConfig; //! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { -//! id: S2NodeId(String::from("12121212")), +//! id: S2NodeId::try_from("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(), //! brand: String::from("super-reliable-corp"), //! logo_uri: None, //! type_: String::from("fancy"), @@ -27,7 +27,7 @@ //! # use s2energy_connection::pairing::EndpointConfig; //! # use s2energy_connection::{MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! let _config = EndpointConfig::builder(S2NodeDescription { -//! id: S2NodeId(String::from("12121212")), +//! id: S2NodeId::try_from("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(), //! brand: String::from("super-reliable-corp"), //! logo_uri: None, //! type_: String::from("fancy"), @@ -49,7 +49,7 @@ //! # use s2energy_connection::pairing::{Client, ClientConfig, EndpointConfig, PairingRemote}; //! # use s2energy_connection::{Deployment, MessageVersion, S2NodeDescription, S2NodeId, S2Role}; //! # let config = EndpointConfig::builder(S2NodeDescription { -//! # id: S2NodeId(String::from("12121212")), +//! # id: S2NodeId::new(), //! # brand: String::from("super-reliable-corp"), //! # logo_uri: None, //! # type_: String::from("fancy"), @@ -68,7 +68,7 @@ //! //! let pairing_result = client.pair(PairingRemote { //! url: "https://remote.example.com".into(), -//! id: S2NodeId(String::from("56565656")), +//! id: S2NodeId::try_from("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(), //! }, b"ABCDEF0123456"); //! ``` //! @@ -122,7 +122,7 @@ //! # root_certificate: None, //! # }); //! # let config = Arc::new(EndpointConfig::builder(S2NodeDescription { -//! # id: S2NodeId(String::from("12121212")), +//! # id: S2NodeId::new(), //! # brand: String::from("super-reliable-corp"), //! # logo_uri: None, //! # type_: String::from("fancy"), @@ -158,7 +158,7 @@ //! # root_certificate: None, //! # }); //! # let config = Arc::new(EndpointConfig::builder(S2NodeDescription { -//! # id: S2NodeId(String::from("12121212")), +//! # id: S2NodeId::new(), //! # brand: String::from("super-reliable-corp"), //! # logo_uri: None, //! # type_: String::from("fancy"), diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 9c5f175..9bb25d6 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -117,7 +117,7 @@ impl Server { drop(permanent_pairings); let (sender, receiver) = tokio::sync::oneshot::channel(); open_pairings.insert( - config.node_description.id.clone(), + config.node_description.id, PairingRequest { config, sender: ResultSender::Oneshot(sender), @@ -141,7 +141,7 @@ impl Server { drop(open_pairings); let (sender, receiver) = tokio::sync::mpsc::channel(PERMANENT_PAIRING_BUFFER_SIZE); permanent_pairings.insert( - config.node_description.id.clone(), + config.node_description.id, PermanentPairingRequest { config, sender,