diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab7f31f5..155d576d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,15 @@ env: FOUNDRY_VERSION: v1.7.1 jobs: + msrv: + name: Rust 1.91 MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.91.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets --all-features --locked + fmt: name: Format Check runs-on: ubuntu-latest @@ -99,6 +108,22 @@ jobs: - name: Run unit tests without logging run: cargo test --lib --no-default-features + browser-devnet: + name: WebRTC Direct five-node devnet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: ${{ env.FOUNDRY_VERSION }} + - name: Run persistent-session discovery and transfer test + run: >- + cargo test --test webrtc_direct_devnet + -- --ignored --test-threads=1 + security: name: Security Audit runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 01676b35..cd235ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -54,13 +54,28 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -94,9 +109,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b5cc30538e90795a57647bef8d8864aad6e8d86190617009b4ef8d8b647b49a" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" dependencies = [ "alloy-primitives", "num_enum", @@ -122,12 +137,12 @@ dependencies = [ "either", "k256", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "secp256k1 0.30.0", "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -163,15 +178,15 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "alloy-core" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ddde5968de6044d67af107ad835bc0069a7ca245870b94c5958a7d8712b184" +checksum = "fa2d25cf04344ea5eeb47e0cd21c794e646a029959bf5700bd8c05342c0353fe" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -182,9 +197,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a475bb02d9cef2dbb99065c1664ab3fe1f9352e21d6d5ed3f02cdbfc06ed1abc" +checksum = "201b9e973fe90b2effd9ab356d4f2a46ab56046ba9d46f163367553e72c045b0" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -193,7 +208,7 @@ dependencies = [ "itoa", "serde", "serde_json", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -206,14 +221,14 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "alloy-eip2930" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -231,7 +246,7 @@ dependencies = [ "alloy-rlp", "borsh", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -245,7 +260,7 @@ dependencies = [ "borsh", "once_cell", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -301,9 +316,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +checksum = "1dba4e59c3581a39e03e0b0b4a46ee9c41315b5d843b1e903271952b32bedb7c" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -322,7 +337,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -349,7 +364,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -379,34 +394,35 @@ dependencies = [ "alloy-signer-local", "k256", "libc", - "rand 0.8.6", + "rand 0.8.8", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", ] [[package]] name = "alloy-primitives" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +checksum = "ce7b00f0cb42c66ec353076ded1dff1fbf818f6e0e26c40c8a8456c04483fca4" dependencies = [ "alloy-rlp", "bytes", "cfg-if", "const-hex", "derive_more", + "fixed-cache", "foldhash", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "k256", "keccak-asm", "paste", "proptest", - "rand 0.9.4", + "rand 0.9.5", "rapidhash", "ruint", "rustc-hash", @@ -449,7 +465,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -475,7 +491,7 @@ checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -554,7 +570,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -580,7 +596,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -595,48 +611,48 @@ dependencies = [ "alloy-signer", "async-trait", "k256", - "rand 0.8.6", - "thiserror 2.0.18", + "rand 0.8.8", + "thiserror 2.0.20", ] [[package]] name = "alloy-sol-macro" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +checksum = "c64558980fb038cd34b4285ec2b36a2a8bd8d4ddd13b3f6e42d97cb5ee29938e" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +checksum = "1ffb0e793abdbaea9d01259493c8272c3af295d03389e70a2acdf53b57ad1edf" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.14.0", - "proc-macro-error2", + "indexmap 2.14.1", + "proc-macro-error3", "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +checksum = "32c2c0ec8425d9663dac939ba75750f17d2933d2f020814b4f8fde4a60da6d26" dependencies = [ "alloy-json-abi", "const-hex", @@ -646,25 +662,25 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +checksum = "96b37db6a7ad8170596345f864522f789cc7af093f516d6cdf34bbdcfba8adab" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "alloy-sol-types" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +checksum = "1f40e33a0f588dde548c3b6767a71e61b593421a8c1b253b9cf1441dc7cf7ab5" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -680,14 +696,14 @@ checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" dependencies = [ "alloy-json-rpc", "auto_impl", - "base64", + "base64 0.22.1", "derive_more", "futures", "futures-utils-wasm", "parking_lot", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tower", "tracing", @@ -723,7 +739,7 @@ dependencies = [ "nybbles", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -736,14 +752,14 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -835,12 +851,15 @@ dependencies = [ "parking_lot", "postcard", "proptest", - "rand 0.8.6", + "rand 0.8.8", "reqwest", "rmp-serde", "saorsa-core", "saorsa-pqc", + "saorsa-transport", + "saorsa-webrtc", "self-replace", + "self_encryption", "semver 1.0.28", "serde", "serde_json", @@ -848,7 +867,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-test", "tokio-util", @@ -881,9 +900,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -894,6 +913,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -996,7 +1024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1006,7 +1034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1044,7 +1072,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1057,7 +1085,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1114,7 +1142,7 @@ checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1124,7 +1152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1134,7 +1162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1144,7 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1154,37 +1182,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -1193,7 +1243,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -1205,7 +1255,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1227,18 +1277,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1262,7 +1312,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "url", @@ -1276,7 +1326,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1287,9 +1337,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -1297,9 +1347,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -1317,7 +1367,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link 0.2.1", @@ -1345,6 +1395,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -1386,21 +1442,20 @@ dependencies = [ [[package]] name = "bitcoin-consensus-encoding" -version = "1.0.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" dependencies = [ "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", ] [[package]] name = "bitcoin-internals" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" [[package]] name = "bitcoin-io" @@ -1418,14 +1473,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative 0.2.3", ] [[package]] name = "bitflags" -version = "2.13.0" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1444,16 +1505,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -1474,6 +1534,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1485,9 +1554,9 @@ dependencies = [ [[package]] name = "blst" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", @@ -1497,9 +1566,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -1508,15 +1577,36 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", +] + +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a0bcf59056a66c55d8eefd05e9c0f00f9c9cdddbb6bd499623ce49100da43" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -1542,9 +1632,9 @@ checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -1554,9 +1644,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1582,9 +1672,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.7" +version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" dependencies = [ "blst", "cc", @@ -1595,11 +1685,20 @@ dependencies = [ "serde", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" -version = "1.2.66" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1607,6 +1706,18 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1615,9 +1726,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1632,12 +1743,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1681,9 +1792,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1691,9 +1802,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1703,14 +1814,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1734,7 +1845,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1772,9 +1883,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1877,9 +1988,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1901,9 +2012,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -2027,7 +2138,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2051,7 +2162,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2062,7 +2173,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2081,9 +2192,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deflate64" @@ -2091,6 +2202,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" @@ -2098,16 +2240,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "displaydoc", "nom", "num-bigint", @@ -2143,7 +2300,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2165,7 +2322,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -2248,19 +2405,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -2272,6 +2429,43 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "dtls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser 9.0.0", + "hmac", + "log", + "p256", + "p384", + "pem 3.0.6", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", + "x25519-dalek", + "x509-parser 0.16.0", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2308,14 +2502,14 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -2333,6 +2527,7 @@ dependencies = [ "generic-array", "group", "hkdf", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2355,22 +2550,22 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "enum-ordinalize" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -2382,7 +2577,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2411,7 +2606,7 @@ dependencies = [ "ant-merkle", "exponential-backoff", "hex", - "rand 0.8.6", + "rand 0.8.8", "rmp-serde", "serde", "serde_with", @@ -2433,19 +2628,20 @@ dependencies = [ [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fastrlp" @@ -2497,9 +2693,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fips203" @@ -2537,6 +2733,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2544,19 +2750,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.6", + "rand 0.8.8", "rustc-hex", "static_assertions", ] [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2604,9 +2811,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2619,9 +2826,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2629,15 +2836,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2646,38 +2853,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2766,9 +2973,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "group" @@ -2783,9 +2990,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2793,7 +3000,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -2869,7 +3076,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" dependencies = [ - "bitflags", + "bitflags 2.13.1", "byteorder", "heed-traits", "heed-types", @@ -2903,9 +3110,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2915,18 +3122,18 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" dependencies = [ "arrayvec", ] [[package]] name = "hex-conservative" -version = "0.3.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ "arrayvec", ] @@ -2972,9 +3179,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2982,9 +3189,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2992,9 +3199,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3011,18 +3218,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3060,7 +3267,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3071,7 +3278,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -3089,7 +3296,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3103,9 +3310,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3117,9 +3324,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3130,9 +3337,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3144,16 +3351,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3164,15 +3372,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3247,7 +3455,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3269,9 +3477,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3285,14 +3493,36 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] +[[package]] +name = "interceptor" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util 0.12.0", +] + [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -3333,6 +3563,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -3345,7 +3628,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -3360,7 +3643,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3379,7 +3662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3394,9 +3677,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -3428,12 +3711,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -3479,9 +3762,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -3500,9 +3783,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -3515,9 +3798,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lmdb-master-sys" @@ -3541,9 +3824,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -3589,7 +3872,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3602,10 +3885,29 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.8.2" +name = "md-5" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] [[package]] name = "memoffset" @@ -3636,6 +3938,15 @@ name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -3643,26 +3954,39 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3702,9 +4026,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -3747,7 +4071,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3788,7 +4112,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -3805,7 +4129,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -3821,13 +4145,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", ] [[package]] @@ -3862,9 +4195,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] name = "p256" @@ -3872,8 +4205,22 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", "elliptic-curve", "primeorder", + "sha2", ] [[package]] @@ -3911,7 +4258,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3972,10 +4319,29 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3984,9 +4350,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -4018,7 +4384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -4031,7 +4397,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4069,7 +4435,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4096,9 +4462,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -4123,6 +4489,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4138,9 +4519,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4186,36 +4567,36 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4228,9 +4609,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec 0.8.0", - "bitflags", + "bitflags 2.13.1", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -4258,8 +4639,8 @@ dependencies = [ "quinn-udp 0.5.15", "rustc-hash", "rustls", - "socket2", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -4267,9 +4648,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -4282,7 +4663,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4297,7 +4678,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -4310,16 +4691,16 @@ checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" dependencies = [ "cfg_aliases", "libc", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4344,9 +4725,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4356,9 +4737,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4371,7 +4752,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4470,16 +4851,30 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem", + "pem 3.0.6", "ring", "rustls-pki-types", "time", - "x509-parser", - "yasna", + "x509-parser 0.16.0", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem 4.0.0", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna 0.6.0", ] [[package]] @@ -4488,7 +4883,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -4510,34 +4905,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4547,9 +4942,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4568,7 +4963,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -4652,6 +5047,32 @@ dependencies = [ "serde", ] +[[package]] +name = "rtcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util 0.12.0", +] + +[[package]] +name = "rtp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.5", + "serde", + "thiserror 1.0.69", + "webrtc-util 0.12.0", +] + [[package]] name = "ruint" version = "1.20.0" @@ -4672,8 +5093,8 @@ dependencies = [ "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rlp", "ruint-macro", "serde_core", @@ -4689,9 +5110,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" @@ -4738,7 +5159,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -4747,13 +5168,14 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4783,9 +5205,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -4831,9 +5253,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -4886,8 +5308,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5929d77d38c0ffb422393a2420f9d2f9ecd92deaa9859b3bfbbe58bada68e40a" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" dependencies = [ "anyhow", "async-trait", @@ -4902,13 +5323,14 @@ dependencies = [ "once_cell", "parking_lot", "postcard", - "rand 0.8.6", + "rand 0.8.8", + "saorsa-dht-lookup", "saorsa-pqc", "saorsa-transport", "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -4916,6 +5338,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "saorsa-dht-lookup" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" +dependencies = [ + "futures-core", + "futures-util", +] + [[package]] name = "saorsa-pqc" version = "0.5.1" @@ -4939,7 +5370,7 @@ dependencies = [ "log", "pbkdf2", "postcard", - "rand 0.8.6", + "rand 0.8.8", "rand_chacha 0.3.1", "rand_core 0.6.4", "rayon", @@ -4948,7 +5379,7 @@ dependencies = [ "sha2", "sha3 0.10.9", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -4959,12 +5390,12 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.36.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd4be74fa8b82e0321b338bbfbc084c66170614245660b85bb8b041ea808bc4" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=44b3292b3ffa49e6048f2816f9ea5c38c6804815#44b3292b3ffa49e6048f2816f9ea5c38c6804815" dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "base64 0.22.1", "blake3", "bytes", "chrono", @@ -4976,17 +5407,18 @@ dependencies = [ "futures-util", "hex", "igd-next", - "indexmap 2.14.0", + "indexmap 2.14.1", "keyring", "libc", "lru-slab", - "nix", + "nix 0.31.3", "once_cell", "parking_lot", "pin-project-lite", "quinn-udp 0.6.1", - "rand 0.8.6", - "rcgen", + "rand 0.8.8", + "rcgen 0.13.2", + "rcgen 0.14.10", "regex", "reqwest", "rustc-hash", @@ -5000,9 +5432,10 @@ dependencies = [ "serde_json", "serde_yaml", "slab", - "socket2", + "socket2 0.6.5", + "stun 0.7.0", "system-configuration", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tinyvec", "tokio", @@ -5011,11 +5444,33 @@ dependencies = [ "tracing-subscriber", "unicode-width", "uuid", + "webrtc", "windows", "x25519-dalek", "zeroize", ] +[[package]] +name = "saorsa-webrtc" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=da3a6934d85eb3fb63d7f489c362abee158d6ce8#da3a6934d85eb3fb63d7f489c362abee158d6ce8" +dependencies = [ + "base64 0.22.1", + "blake3", + "chacha20poly1305", + "fips203", + "fips204", + "getrandom 0.2.17", + "hex", + "postcard", + "serde", + "serde_bytes", + "serde_json", + "thiserror 2.0.20", + "tiny-keccak", + "zeroize", +] + [[package]] name = "schannel" version = "0.1.29" @@ -5039,9 +5494,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -5055,6 +5510,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6" +dependencies = [ + "rand 0.9.5", + "substring", + "thiserror 1.0.69", + "url", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5077,7 +5544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", ] @@ -5089,7 +5556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes", - "rand 0.9.4", + "rand 0.9.5", "secp256k1-sys 0.11.0", ] @@ -5117,7 +5584,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5145,6 +5612,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "self_encryption" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ab904569f88dcbde4f0feadb693c184577dc81e8243f96bb725e72a779c637" +dependencies = [ + "bincode", + "blake3", + "brotli", + "bytes", + "chacha20poly1305", + "hex", + "rand 0.8.8", + "rand_chacha 0.3.1", + "rayon", + "serde", + "tempfile", + "thiserror 1.0.69", + "tokio", + "xor_name", +] + [[package]] name = "semver" version = "0.11.0" @@ -5171,39 +5660,49 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5232,18 +5731,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.1", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5252,14 +5752,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5268,7 +5768,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "ryu", "serde", @@ -5307,14 +5807,14 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -5349,7 +5849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", ] [[package]] @@ -5399,15 +5899,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version 0.4.1", "simdutf8", @@ -5433,18 +5933,37 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5452,9 +5971,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -5487,6 +6006,53 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "stun" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea256fb46a13f9204e9dee9982997b2c3097db175a9fddaa8350310d03c4d5a3" +dependencies = [ + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.8.8", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util 0.10.0", +] + +[[package]] +name = "stun" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b" +dependencies = [ + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.9.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util 0.12.0", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5512,9 +6078,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -5523,14 +6100,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +checksum = "4c6415502cd1e9ed58b3ceb415164b812d5572757b1a6f0e280ae806723c1fab" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5559,7 +6136,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5568,7 +6145,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5624,11 +6201,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -5639,25 +6216,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -5673,9 +6250,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -5695,9 +6272,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -5714,9 +6291,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -5724,9 +6301,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -5739,9 +6316,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -5749,27 +6326,27 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -5777,9 +6354,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -5800,14 +6377,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -5848,7 +6426,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -5858,23 +6436,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -5904,7 +6482,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -5948,7 +6526,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -5961,7 +6539,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6033,6 +6611,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "turn" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.5", + "ring", + "stun 0.9.0", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util 0.12.0", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6063,6 +6662,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6136,9 +6741,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6167,6 +6772,15 @@ dependencies = [ "libc", ] +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6203,9 +6817,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -6216,9 +6830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -6226,9 +6840,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6236,22 +6850,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -6272,9 +6886,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -6292,13 +6906,204 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "pem 3.0.6", + "portable-atomic", + "rand 0.9.5", + "rcgen 0.13.2", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun 0.9.0", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-data" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-ice" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "serde", + "serde_json", + "stun 0.9.0", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-mdns" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-media" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-srtp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", +] + +[[package]] +name = "webrtc-util" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1438a8fd0d69c5775afb4a71470af92242dbd04059c61895163aa3c1ef933375" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "libc", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.8.8", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "webrtc-util" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + [[package]] name = "wide" version = "0.7.33" @@ -6347,7 +7152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -6359,7 +7164,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -6371,8 +7176,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -6381,7 +7199,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] @@ -6394,7 +7212,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6405,7 +7223,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6426,7 +7244,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -6439,6 +7257,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6448,6 +7275,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -6616,9 +7452,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -6631,9 +7467,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -6656,21 +7492,39 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -6686,9 +7540,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmltree" @@ -6706,7 +7560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd9dddecfdbc7c17ae93da6d28a5a9c4f5564abe7b735d2530c7a159b6b55e8" dependencies = [ "hex", - "rand 0.8.6", + "rand 0.8.8", "rand_core 0.6.4", "serde", "serde_test", @@ -6722,6 +7576,15 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yasna" version = "0.6.0" @@ -6751,28 +7614,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6792,7 +7655,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6813,14 +7676,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6829,9 +7692,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -6840,13 +7703,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -6866,12 +7729,12 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.14.0", + "indexmap 2.14.1", "lzma-rs", "memchr", "pbkdf2", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "xz2", "zeroize", @@ -6879,11 +7742,17 @@ dependencies = [ "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" @@ -6908,9 +7777,9 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index d7b06522..59622f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/WithAutonomi/ant-node" keywords = ["p2p", "decentralized", "quantum-safe", "post-quantum", "dht"] categories = ["network-programming", "cryptography"] -rust-version = "1.75" +rust-version = "1.91" [lib] name = "ant_node" @@ -29,17 +29,15 @@ path = "src/bin/ant-devnet/main.rs" # musl Linux builds (and tends to beat glibc's allocator too). mimalloc = "0.1" -# Wire protocol — the single version-pin shared with ant-client. +# Native application wire protocol shared with ant-client. # Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples # through here automatically; we keep a direct saorsa-core dep for # node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which # Cargo unifies with ant-protocol's version constraint. # -# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. -# Until then, the git pin tracks the matching saorsa-core lineage -# (the rc-2026.4.2 branch) so Cargo can unify the wire types here -# with ant-protocol's re-exports. ant-protocol = "2.3.5" +# Portable WebRTC Direct profile shared by the node and browser WASM adapter. +saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "da3a6934d85eb3fb63d7f489c362abee158d6ce8" } # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.27.3" @@ -113,6 +111,11 @@ page_size = "0.6" postcard = { version = "1.1.3", features = ["use-std"] } bao = "0.13.1" +# ADR-0009 browser transport. The dependency remains optional so minimal +# native-only builds can opt out with `--no-default-features`. +saorsa-transport = { version = "0.36.3", features = ["webrtc-direct"], optional = true } +self_encryption = { version = "0.36", optional = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -174,8 +177,13 @@ name = "poc_shutdown_lmdb_drain" path = "tests/poc_shutdown_lmdb_drain.rs" required-features = ["test-utils"] +[[test]] +name = "webrtc_direct_devnet" +path = "tests/webrtc_direct_devnet.rs" +required-features = ["webrtc-direct"] + [features] -default = ["logging"] +default = ["logging", "webrtc-direct"] # Enable tracing/logging infrastructure. # Included in `default`, so every build we ship — dev, release candidate and # release alike — has logging. Opt out only for a bespoke build that needs it @@ -184,6 +192,16 @@ logging = ["tracing", "tracing-subscriber", "tracing-appender"] # Expose test helpers (cache_insert, payment_verifier accessor) for # integration tests and downstream test harnesses. test-utils = [] +# Direct browser transport from ADR-0009. Enabled by default; minimal +# native-only builds can omit it with `--no-default-features`. +webrtc-direct = [ + "dep:saorsa-transport", + "dep:self_encryption", +] + +[patch.crates-io] +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "915f3f1050006d96569c0efb6402828a01176e27" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "44b3292b3ffa49e6048f2816f9ea5c38c6804815" } [profile.release] lto = true diff --git a/README.md b/README.md index 33c47f4f..b64a4fb3 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,10 @@ let harness = TestHarness::setup_with_evm().await?; assert!(harness.anvil().is_healthy().await); ``` +For the direct-browser testnet, where every node exposes WebRTC Direct and a +default immutable file is published at startup, see +[Browser-enabled local testnet](docs/WEBRTC_DIRECT_TESTNET.md). + ### Roadmap | Phase | Target | Status | @@ -858,7 +862,7 @@ ant-node ### Prerequisites -- Rust 1.75+ (for building from source) +- Rust 1.91+ (for building from source) - Linux, macOS, or Windows ### Build from Source diff --git a/assets/browser-devnet-public.txt b/assets/browser-devnet-public.txt new file mode 100644 index 00000000..ee6b8659 --- /dev/null +++ b/assets/browser-devnet-public.txt @@ -0,0 +1,6 @@ +Hello from an Autonomi browser-enabled local testnet. + +This immutable file was published into node storage when ant-devnet started. +The web application discovers its BLAKE3 address from the browser manifest, +performs the closest-node lookup itself, downloads the bytes directly from a +storage node over WebRTC Direct, and verifies the content address in-browser. diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md new file mode 100644 index 00000000..20f1b419 --- /dev/null +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -0,0 +1,197 @@ +# Browser-enabled local testnet + +This workflow starts a five-node local Autonomi network where every node has a +direct WebRTC Direct endpoint. Startup publishes a default immutable test file +and serves browser bootstrap metadata; the companion site lives in the sibling +`ant-client-web-support` repository. + +## Start the node testnet + +Rust 1.88 or newer is required by the Saorsa WebRTC Direct transport. + +```bash +cargo run --bin ant-devnet -- \ + --preset minimal \ + --base-port 23000 \ + --webrtc-direct \ + --webrtc-direct-base-port 24000 \ + --serve-port 25000 \ + --enable-evm \ + --enable-logging +``` + +The services are: + +| Purpose | Address | +|---|---| +| Native node QUIC | UDP 127.0.0.1:23000-23004 | +| Direct browser WebRTC Direct | UDP 127.0.0.1:24000-24004 | +| Native devnet manifest | http://127.0.0.1:25000/api/devnet-manifest.json | +| Browser bootstrap manifest | http://127.0.0.1:25000/api/browser-manifest.json | +| Manifest service metadata | http://127.0.0.1:25000/api/info | +| Local Anvil JSON-RPC | printed at startup (random loopback port) | + +When `--serve-port` is omitted with `--webrtc-direct`, port 25000 is used. Pass +`--public-file /path/to/file` to replace the built-in +`autonomi-browser-testnet.txt`. The generated default is 5 MiB so the demo +necessarily reconstructs multiple storage records. A custom file may be up to +1 GB (1,000,000,000 bytes) in this local in-memory launcher. The practical +limit depends on the browser having enough available memory. + +The browser manifest contains every node's self-contained WebRTC Direct +multiaddress, with its certificate SHA-256 multihash and peer ID embedded, +plus the public DataMap address, plaintext file hash, and resolved +reconstruction metadata. The HTTP server provides bootstrap metadata only; +the DataMap and file bytes are read from storage nodes over WebRTC Direct. +Each address string is serialized directly from `saorsa_core::MultiAddr`; the +node does not maintain a browser-specific multiaddress codec. + +`--webrtc-direct` requires an explicit payment network. For this local test, +`--enable-evm` starts Anvil and startup prints a **Funded wallet private key**. This +is a disposable local Anvil key for browser upload testing. The browser manifest +contains only public RPC/token/vault configuration and never contains the +key. +If `HELLO.payment.rpc_url` shows `https://arb1.arbitrum.io/rpc`, the devnet was +started without local Anvil; stop it and restart with the command above. + +## Start the browser client + +In `ant-client-web-support/web`: + +```bash +npm ci +npm run dev +``` + +Open `http://127.0.0.1:5173`. The app automatically loads the browser manifest. +To upload, choose a file, paste the funded private key printed by ant-devnet, +and use **Pay and upload file**. The page self-encrypts locally, verifies node +quotes, signs the approval/payment locally, and sends only encrypted records +and public payment proof to nodes. The key field is cleared immediately. The +result address is placed into the download field automatically. + +Use **Download and save file** to fetch the public DataMap and every encrypted +file chunk directly, reconstruct the complete file, validate its whole-file +BLAKE3 hash, and save it under its original filename. + +For a browser-supported video, use **Prepare video stream** and then the native +video controls. The Rust/WASM reader fetches and decrypts only records +overlapping the media element's requested byte ranges. A same-origin service +worker provides standard HTTP range responses locally; no file bytes pass +through the manifest server or another gateway. + +## Automated verification + +```bash +cargo test --test webrtc_direct_devnet -- --ignored +``` + +This starts Anvil and the five-node network, self-encrypts and publishes a +default public file through normal PUT admission with devnet-prepaid cache +entries, extracts a generated certificate pin from the advertised +multiaddress, retrieves and reconstructs it, then obtains a real signed quote, +pays it on-chain, uploads a fresh record through paid `PUT_CHUNK`, and reads it +back through WebRTC Direct. + +## LAN testing + +Use `--host ` to advertise the literal LAN address: + +```bash +cargo run --bin ant-devnet -- \ + --preset minimal \ + --host 192.168.1.50 \ + --webrtc-direct \ + --serve-port 25000 \ + --enable-evm \ + --enable-logging +``` + +Expose the client dev server on the LAN with `npm run dev -- --host 0.0.0.0` +and change its manifest URL to +`http://192.168.1.50:25000/api/browser-manifest.json`. Both the native and +WebRTC Direct UDP ranges must be reachable. Do not use this unsigned local +manifest mode on a public network. + +## Public Internet smoke testing + +The standard `ant-node` build now includes and enables WebRTC Direct, so the +sibling `ant-testnet` tool needs no browser-specific preset or flags. On its +ordinary public droplets, a node maps its native UDP port deterministically +into the existing allowed UDP 32768-65535 range and advertises the external IP +learned by the native transport (falling back to the host's routed IP). Its +persisted DTLS certificate keeps the complete address stable across restarts. + +Deploy the normal testnet against this checkout, for example: + +```bash +cd ../ant-testnet +python3.11 testnet.py \ + --saorsa-node-repo ../ant-node-web-support \ + deploy +``` + +`ant-testnet` always keeps bootstrap droplets public. Read node 0's canonical +address using its existing shell command, without modifying the deployment +tool: + +```bash +python3.11 testnet.py shell --droplet 0 +cat /var/lib/ant/node-0/webrtc-direct.multiaddr +exit +``` + +Start `ant-client-web-support/web`, paste that address into the demo, and use +**Connect and use as bootstrap**. The operation installs the single address as +the Rust browser client's seed without DNS or a browser manifest. The address +contains only the public DTLS certificate hash and ANT peer ID; it contains no +secret key material. To disable the listener in a custom node configuration, +set `webrtc_direct.enabled = false`. A minimal binary can omit the transport +entirely with `--no-default-features`. + +Public listeners apply an independent resource envelope; the native QUIC +limits are not shared with browser traffic. The defaults are: + +| Setting | Default | Scope | +|---|---:|---| +| `max_connections` | 32 | listener | +| `max_connections_per_ip` | 4 | source IP | +| `max_channels_per_connection` | 2 | association | +| `max_channels` | 32 | listener and channel-handler tasks | +| `max_concurrent_requests` | 16 | listener work slots | +| `max_requests_per_second` | 256 | listener work token bucket | +| `max_requests_per_second_per_ip` | 32 | source-IP work token bucket | +| `max_requests_per_second_per_connection` | 16 | association work token bucket | +| `max_in_flight_bytes` | 64 MiB | listener frame memory | +| `max_in_flight_bytes_per_ip` | 16 MiB | source-IP frame memory | +| `max_request_bytes` | 64 KiB | JSON request header | + +The per-IP ceilings must remain strictly below their corresponding global +ceilings. The product of the per-IP connection and per-connection channel +limits must also remain below both global channel and request concurrency. +Invalid combinations fail node startup instead of silently removing the +headroom reserved for other clients. IPv4-mapped IPv6 sources share the IPv4 +source's quota. Rate buckets permit a one-second burst; overload closes the +offending channel or association without queueing more handler tasks. PQ +handshakes consume the same work slots and rate tokens as RPCs, and response +writes use size-scaled deadlines so slow readers release their reservations. + +Each node publishes its certificate-pinned WebRTC Direct multiaddress through +Saorsa's extensible V2 address plane as transport `WebRtcDirect`, independently +of its reachability class. Its signed identity capability selects V2 when the +remote peer supports it; older peers continue receiving the unchanged V1 +`Quic` address projection. `FindNodeV2` returns browser endpoints separately +from QUIC addresses, and the browser verifies the peer-ID and certificate +binding during HELLO. +Consequently one pasted address is enough to enter the network and discover +the browser endpoints of closest peers across independently deployed +processes. Native QUIC dialing ignores the supplemental transport entry. + +The 2026-08-27 public smoke run used the former protocol v3 and headless +Chromium only. From one bootstrap address it traversed multiple independent +nodes, obtained four storage quotes from non-bootstrap closest nodes, +submitted one payment, and stored all four encrypted records. It is historical +connectivity evidence, not v4 or cross-browser acceptance evidence. Nodes +behind the testnet's deliberate inbound-NAT rules remain unreachable without +relayed WebRTC, so their 10-second DataChannel timeouts currently make this +smoke path slower than an all-public fleet. diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md new file mode 100644 index 00000000..d3278092 --- /dev/null +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -0,0 +1,972 @@ +# ADR-0009: Direct browser clients over WebRTC Direct + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Last amended:** 2026-09-03 +- **Decision owners:** +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** [W3C WebRTC](https://www.w3.org/TR/webrtc/), + [WebRTC Data Channels](https://www.rfc-editor.org/rfc/rfc8831), + [libp2p WebRTC Direct](https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md), + [W3C WebTransport](https://www.w3.org/TR/webtransport/), + [WebTransport over HTTP/3](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/) + +## Context + +Web applications must be able to act as full immutable-data clients: they +perform iterative closest-node lookup, download chunks, obtain and verify +storage quotes, pay, and upload chunks themselves. A node must not perform a +whole-network lookup, proxy chunk bytes, or hold a browser user's wallet key. +Ordinary bootstrap peers and end-to-end transport relays remain allowed; +application gateways do not. + +The native node endpoint cannot be used by an unmodified browser. It speaks a +Saorsa-specific QUIC application protocol with ML-KEM/ML-DSA raw-public-key +authentication. Browsers do not expose arbitrary UDP or arbitrary QUIC. They +expose browser-controlled transports such as WebRTC and WebTransport, with +authentication and connection-establishment rules that applications cannot +bypass. + +Nodes must remain easy to deploy. An operator must not need to acquire or +maintain a DNS name, obtain a public-CA certificate, or configure a signaling +service. Node software must generate and persist any browser-transport +credentials automatically. + +Cold bootstrap must also remain decentralized and durable. A web client must +be able to start from a compiled-in list of self-contained, constant +multiaddresses even when that list or the installed web application is months +old. Loading a fresh bootstrap manifest over HTTPS must not be a prerequisite. +A bootstrap address may become unusable because the seed was retired or its +IP, port, or ANT identity actually changed, but it must not expire merely +because a browser transport routinely rotated a short-lived certificate. +Applications therefore ship several independent bootstrap addresses and may +revise them in later releases, but normal certificate maintenance must not +force such a release. + +Many ordinary storage nodes also run behind NAT. Browser support must +distinguish an application gateway, which is rejected, from a transport relay +that forwards end-to-end encrypted traffic and is sometimes unavoidable on +the public Internet. The constant bootstrap set itself consists of stable, +publicly reachable seeds; NATed nodes are learned after bootstrap and use +direct ICE where possible or an end-to-end relay path. + +This ADR records the intended production architecture and distinguishes it +from the repository's earlier, explicitly non-production WebTransport proof +of concept. That proof validated browser interoperability, request framing, +local DHT access, chunk downloads, and paid immutable uploads. It also exposed +the bootstrap-lifetime problem that caused the production transport decision +to be reconsidered. + +## Decision Drivers + +- Browsers perform Kademlia iteration and chunk integrity verification. +- Chunk data flows between the browser and the storing node, never through an + application-level lookup/download gateway. +- A browser can cold-bootstrap from a compiled-in list of constant, + self-contained multiaddresses without first fetching fresh configuration. +- Bootstrap addresses remain usable across routine node restarts and for + substantially longer than one month; they do not contain routinely rotating + certificate pins. +- Operators do not obtain or manage DNS names, public-CA certificates, or a + node-specific signaling service. +- Browser transport keys and certificates are created and persisted by the + node software without operator involvement. +- The existing post-quantum node-to-node port and wire protocols remain + unchanged. +- A public browser protocol is narrow, versioned, bounded, and limited to + immutable reads plus quote/payment-verified immutable writes. +- Wallet secrets remain inside the browser; nodes receive only normal signed + quote artifacts, transaction hashes, and encrypted records. +- NATed nodes have an end-to-end direct or relay path without exposing + plaintext to a signaling or relay peer. +- A 4 MiB chunk is transferred reliably with explicit fragmentation, + backpressure, cancellation, and bounded buffering. +- Endpoint ownership remains bound to the node's persistent ML-DSA identity, + and all browser RPC payloads use fresh ML-KEM-derived application keys even + though WebRTC's DTLS connection establishment remains classical. + +## Considered Options + +1. **Expose the existing Saorsa QUIC endpoint.** Rejected because browser + JavaScript cannot create an arbitrary QUIC connection or configure the + current PQ raw-public-key handshake. +2. **Use HTTP/WebSocket gateways.** Rejected as the production architecture + because the gateway would perform lookup or carry chunk data for the + browser. It creates availability, bandwidth, privacy, and censorship + chokepoints. +3. **Use WebSocket or WebTransport with Web PKI.** A DNS multiaddress and + ordinary CA certificate can remain constant while certificates renew + behind the hostname. This gives WebTransport an excellent byte-stream API, + but it makes every browser-capable node depend on DNS and CA automation and + therefore violates the deployment requirement. +4. **Use hash-pinned WebTransport with self-signed certificates.** This was the + original choice and was the transport used by the repository's superseded + PoC. + WebTransport request/response streams, QUIC flow control, and cancellation + fit 4 MiB chunk transfers well. It also needs no DNS or public CA. However, + WebTransport limits hash-pinned certificates to a two-week validity period. + Even with overlapping current and next pins, a month-old bootstrap + multiaddress normally contains only retired pins. A client cannot learn the + replacements through DHT iteration until one initial connection succeeds. + Fetching a fresh HTTPS manifest would move bootstrap liveness to a separate + WebPKI service and violate the constant-list requirement. This option is + rejected as the production bootstrap and direct-node transport. +5. **Use ordinary signaled WebRTC.** WebRTC provides mature ICE/STUN/TURN NAT + traversal and does not require the remote DTLS certificate to chain to a + public CA. Conventional WebRTC nevertheless requires an out-of-band path to + exchange SDP, ICE candidates, credentials, and certificate fingerprints + for every connection. Making HTTPS or WebSocket signaling mandatory would + introduce the DNS, CA, and signaling dependencies this decision excludes. + Signaled WebRTC remains useful for connections to NATed nodes after the + browser has already joined the network. +6. **Use libp2p WebRTC Direct.** This proves signaling-free + browser-to-public-node WebRTC is practical, but it also adds a second peer + identity, Noise, multistream negotiation, stream emulation, connection + gating, and libp2p's mux lifecycle on top of DTLS/SCTP. Those layers are not + used by the ANT RPC protocol, which already authenticates the persistent + ML-DSA node identity. During the PoC, current JavaScript and Rust libp2p + releases also disagreed about DataChannel close control (`FIN_ACK`), causing + later RPCs on an otherwise healthy association to fail with unexpected EOF. + Carrying vendored compatibility patches for an unnecessary wire stack is + rejected. +7. **Use a Saorsa-owned WebRTC Direct profile (chosen).** A browser dials a + public IP and UDP port + directly, constructs the peer descriptions locally, and establishes an + ICE-lite + DTLS + SCTP association without a signaling server. The + multiaddress contains a stable DTLS certificate fingerprint and the + expected ANT peer ID. Unlike WebTransport's hash-pinned certificate, the + remote WebRTC certificate is authenticated by its SDP fingerprint and does + not need routine two-week rotation. The trade-off is a more complex stack + and a message-oriented DataChannel API that needs bounded application + framing. Saorsa owns the listener, UDP/ICE association routing, certificate + lifecycle, endpoint API, and DataChannel profile while using standard + WebRTC protocol primitives, just as its QUIC implementation owns the + transport while using audited cryptographic primitives. +8. **Use WebRTC Direct only for bootstrap and WebTransport for data.** This + would combine stable bootstrap with WebTransport's superior byte streams. + It is not the initial production choice because every browser-capable node + would need two browser transports, two endpoint forms, and two independent + compatibility and resource-control surfaces. It can be reconsidered if + measured DataChannel performance is inadequate for 4 MiB chunks. + +## Decision + +We will add a separate WebRTC Direct listener to browser-capable nodes. It is +included and enabled in standard node builds so an ordinary deployment is +browser reachable without deployment-specific flags; custom configuration can +disable it, and minimal native-only builds can omit the default feature. +Browser clients will use it to connect directly, perform one-hop +`FIND_NODE` RPCs iteratively, download chunks with `GET_CHUNK`, and store paid +chunks with the same quote and payment checks as native clients. + +The initial transport targets browser-to-public-server WebRTC Direct. It uses +ICE-lite on the node, browser-managed ICE on the client, DTLS for transport +confidentiality and integrity, reliable ordered SCTP DataChannels, and a +mandatory application-layer post-quantum session. That session uses ephemeral +ML-KEM-768 key establishment authenticated by the node's persistent ML-DSA-65 +identity, then protects every browser RPC request and response with +ChaCha20-Poly1305. It does not require a DNS name, public-CA certificate, TURN +server, or out-of-band SDP signaling for a directly reachable node. + +The transport is implemented and versioned by Saorsa. It does not use libp2p +libraries or wire layers: there is no libp2p peer ID, Noise handshake, +multistream selection, connection gater, protobuf stream envelope, or libp2p +DataChannel close protocol. `saorsa-transport` owns ICE-lite/DTLS/SCTP setup, +the shared UDP association mux, persisted certificates, native diagnostic +dialing, and reliable ordered DataChannels. `saorsa-core` owns only the +validated endpoint/address integration. The standalone `saorsa-webrtc` crate +in the transport workspace owns the portable post-quantum handshake, +encrypted-record layer, browser RPC schema, outer framing, address codec, and +transfer limits. `ant-core` owns the runtime-neutral client algorithms and the +browser WASM facade; `ant-node` owns the bounded browser RPC adapter. The two +sides use the same Rust protocol implementation, while the browser transport +adapter calls `RTCPeerConnection` directly through Web APIs. `ant-protocol` +remains independent of the selected carrier transport; this design uses its +released `2.3.1` API without WebRTC-specific source, feature, or dependency +changes. + +The native ML-KEM/ML-DSA transport remains the node-to-node transport and is +not downgraded or replaced. The WebRTC listener has independent connection, +channel, request, timeout, message, and byte limits. Its write surface accepts +only content-addressed chunks accompanied by a verifiable native payment +proof. + +### Public-listener resource isolation + +The public WebRTC listener has a resource envelope independent from native +QUIC. Browser traffic does not consume the native transport's limits, and +native limits are not relied upon to protect the browser listener. The initial +server defaults are: + +| Setting | Default | Scope | +|---|---:|---| +| `max_connections` | 32 | listener | +| `max_connections_per_ip` | 4 | source IP | +| `max_channels_per_connection` | 2 | association | +| `max_channels` | 32 | listener and channel-handler tasks | +| `max_concurrent_requests` | 16 | listener work slots | +| `max_requests_per_second` | 256 | listener work token bucket | +| `max_requests_per_second_per_ip` | 32 | source-IP work token bucket | +| `max_requests_per_second_per_connection` | 16 | association work token bucket | +| `max_in_flight_bytes` | 64 MiB | listener frame memory | +| `max_in_flight_bytes_per_ip` | 16 MiB | source-IP frame memory | +| `max_request_bytes` | 64 KiB | JSON request header | + +These are independent controls, not alternative ways to express one shared +ceiling. Configuration fails startup unless every value is nonzero and a +single source retains strict headroom for other sources in each global pool: + +- `max_connections_per_ip < max_connections`; +- `max_connections_per_ip * max_channels_per_connection` is lower than both + `max_channels` and `max_concurrent_requests`; +- the per-IP request rate is lower than the global request rate, and the + per-connection rate does not exceed the per-IP rate; and +- the per-IP in-flight byte ceiling is lower than the global byte ceiling. + +The source key is the observed remote IP; an IPv4-mapped IPv6 address maps to +the same key as its IPv4 form. Per-IP rate and byte state is shared by every +association from that source. Normal reconnects reuse retained rate state, so +reconnecting alone does not refill a depleted bucket. The source table itself +has a hard bound and evicts only the oldest inactive entry, preventing the +limiter from becoming a source-churn memory attack. + +Admission is non-queueing above the application bounds. The server stops +starting new transport accepts while all global association slots are in use. +After accept, an association must obtain both its global and per-IP share. A +connection may own at most `max_channels_per_connection` active DataChannels, +and every admitted channel must also own one of the global `max_channels` +permits. Excess channels are closed and terminate the offending association. +The v4 protocol expects persistent channels, so an association is closed when +its last application channel ends rather than retaining a stale connection +slot for a hypothetical channel reopen. + +Connection and channel handlers are children of bounded `JoinSet`s rather +than detached tasks. Their semaphore permits and source counters are RAII +guards. Normal shutdown drains connection tasks for five seconds, then aborts +and joins any remainder; closing a connection cancels and joins its +remaining channel tasks. A channel waiting for its next frame has a 60-second +idle deadline. Partial-frame reads and all response writes have total, +size-scaled transfer deadlines, so slow senders and readers cannot retain work +or byte reservations indefinitely. + +A request obtains a global work permit only after its first message arrives, +so an idle persistent channel does not consume a request slot. The permit is +held through frame assembly, processing, and response transmission. The +ML-KEM/ML-DSA session handshake consumes the same global, per-IP, and +per-connection rate tokens and the same work permits as an RPC; otherwise +channel churn would provide an unmetered public-key-cryptography path. All +three rate controls are constant-space token buckets with a one-second burst. +Capacity or rate rejection closes the channel or association without sending +an error response that would amplify attacker traffic. + +Frame memory is reserved atomically against both the source and listener byte +budgets. Once an outer prefix declares a valid length, the complete frame is +reserved before the listener accepts a slow body. Reservations include +ciphertext/plaintext overlap during authenticated decryption, parsed request +content retained during processing, response serialization and encryption, +and bytes held through response writes. `GET_CHUNK` reserves the maximum chunk +size before asking storage to allocate the result, then shrinks to the actual +size. Outbound frames send the four-byte prefix and bounded payload fragments +without allocating another full-frame copy. Every reservation is released on +success, rejection, cancellation, task abort, or protocol error. + +### Stable addresses and transport certificates + +The canonical direct address form is: + +```text +/ip4/
/udp//webrtc-direct + /certhash/ + /p2p/ +``` + +`ip6` is also valid. Constant bootstrap addresses use literal IP addresses; +DNS is neither required nor used as an authentication mechanism. Certificate +multihashes use unpadded base64url multibase (`u`) and contain exactly a +32-byte SHA-256 digest. + +The `/certhash` component is required by WebRTC Direct so the browser can +construct and authenticate the remote DTLS description. It is deliberately a +stable fingerprint, not a temporary WebTransport-style pin. On first startup, +the node generates a P-256 DTLS certificate and stores it beside its persistent +node identity. The certificate has a long validity window and restarts reuse +the same DER bytes, key, and fingerprint. A deterministic, domain-separated +derivation from persistent node key material may be adopted only after +cryptographic review; persistence is the default design. Stable WebRTC Direct +fingerprints across restarts have also been implemented as +[libp2p prior art](https://github.com/libp2p/go-libp2p/pull/3512). + +The DTLS transport key is not the ANT identity credential. Compromise of that +key alone must neither authorize browser RPCs nor disclose their plaintext. +The `/certhash` fingerprint and WebRTC SDP authenticate the DTLS connection; +the application session separately authenticates the ANT identity named by the +multiaddress's `/p2p` suffix. These are independent bindings to the same +endpoint rather than a claim that the DTLS transcript is ML-DSA-signed. + +Before accepting an application request, the browser sends a versioned, +ephemeral ML-KEM-768 encapsulation public key. The node returns an ML-KEM +ciphertext, its 32-byte peer ID, its complete ML-DSA-65 public key, and an +ML-DSA-65 signature over a domain-separated transcript containing the client +hello, KEM ciphertext, and peer ID. The browser verifies that the response peer +ID matches the multiaddress, that BLAKE3 of the ML-DSA public key equals that +peer ID, and that the transcript signature is valid. Any mismatch aborts and +closes the connection. + +Both sides mix the fresh ML-KEM shared secret with the handshake transcript +hash and derive independent client-to-server and server-to-client 256-bit +keys. Every later application frame, including `HELLO`, is authenticated and +encrypted with ChaCha20-Poly1305. Per-direction monotonically increasing +64-bit sequence numbers produce unique nonces and are authenticated as +additional data. Replayed, skipped, reordered, modified, or unauthenticated +records fail closed. Session keys and sequence state are zeroized when the +session is dropped. + +This layer gives application payloads post-quantum confidentiality and node +authentication without replacing WebRTC. ICE, DTLS, SCTP, certificate +fingerprints, packet sizes, message timing, connection metadata, and denial of +service exposure remain properties of the classical WebRTC layer. The +additional encryption therefore does not make all transport metadata or +WebRTC connection establishment post-quantum secure. + +Routine time-based DTLS certificate rotation is not performed. Rotation is an +exceptional operation associated with transport-key compromise or node +identity replacement and produces a new multiaddress. Designated bootstrap +operators must then retain overlap in the compiled bootstrap set across client +releases. This is equivalent to changing a bootstrap peer's ANT identity, not +ordinary certificate maintenance. + +An IP address and port can still change. Constant bootstrap nodes therefore +require stable public addressing and long-lived ANT identities, and clients +ship multiple independently operated seeds. Ordinary nodes are not required +to have stable addresses; their current signed records are learned through the +network. + +### Bootstrap and endpoint discovery + +The web client contains a constant list of bootstrap `MultiAddr` values. These +entries are trust anchors and have no routine time-based expiry. The list is +sufficient to initiate DHT lookup without fetching a manifest, resolving DNS, +or contacting an application service. A newer application release may add or +retire seeds, but bootstrap does not depend on receiving that release. + +The implemented discovery path has two wire-compatible generations backed by +one canonical in-memory address set. The existing Postcard +`PublishAddressSet` operation is frozen: it retains the original closed +`AddressType` enum and carries only the `Quic` projection. Neither WebRTC nor +any future transport is added to that enum or legacy `FIND_NODE` response. + +The new address plane uses a separate `/dht/address/2.0.0` topic and complete +replacement records: + +```text +PublishAddressSetV2 { + seq: u64, + records: [TransportAddressRecord] +} + +TransportAddressRecord { + transport: u16, + reachability: u16, + address: bytes +} +``` + +Known transport identifiers are `Quic = 1` and `WebRtcDirect = 2`. Transport +and reachability are deliberately orthogonal: the known reachability IDs are +Relay, Direct, Unverified, and Lan. WebRTC Direct currently has no relayed +address form, so every WebRTC endpoint is published as `Unverified`; its +reachability is determined by attempting the certificate-pinned browser +connection. Relay acquisition selects `Quic + Direct`; native dialing never +consumes WebRTC records and native QUIC reachability is never reused as WebRTC +evidence. + +The identifiers are numeric fields rather than serialized Rust enums and are +never reused. `address` is a bounded, length-delimited payload that is decoded +only after recognizing `transport`. Consequently a V2-aware node can decode, +retain, and forward an unknown future transport or reachability value without +understanding or dialing it. Known records must decode to a multiaddress whose +transport matches the declared identifier; WebRTC records must also contain +the authenticated owner's peer ID. + +V2 also defines a matching `FindNodeV2` result carrying complete transport +records. This keeps extension addresses out of the legacy `DHTNode` shape while +allowing sequence-bearing DHT gossip to distribute WebRTC endpoints beyond the +direct recipients of a publish. + +Support is advertised by the `addr-v2` capability in the signed identity user +agent. During migration, a new node sends V2 publish and lookup operations to +capable peers and the unchanged V1 operations to older peers. Thus new-to-old +and old-to-new links continue to propagate QUIC addresses, while WebRTC and +future records flow only between upgraded nodes. The V2 topic is separate, so +an old node also ignores an accidentally delivered V2 frame instead of trying +to deserialize an unknown operation. + +Reachability classification, relay acquisition, relay loss, and rebinding +mutate the one canonical address set and derive both wire projections from it; +V1 and V2 are not independent sources of truth. Once the network's minimum +supported version guarantees V2, nodes may stop publishing V1. Relay +acquisition continues through the `Quic + Direct` V2 records. Removing V1 is +an explicit compatibility cutoff: pre-V2 nodes will no longer discover or +join that network, and V1 decoding may be removed in a later cleanup release. + +The browser accepts a discovered endpoint only when its `/p2p` suffix matches +the returned peer, then proves that binding again through certificate-pinned +DTLS and the authenticated ML-KEM application session. The encrypted `HELLO` +checks the endpoint and protocol metadata after cryptographic session +establishment. A malicious DHT responder can omit an endpoint or make a client +spend a bounded failed dial, but cannot authenticate an endpoint as another +peer. + +A later hardening phase may add a separately versioned, independently +cacheable record without changing the existing Postcard `DHTNode` shape: + +```text +BrowserEndpointRecord { + network_id, + peer_id, + sequence, + expires_at, + webrtc_multiaddrs, + capabilities, + protocol_versions, + max_chunk_size, + node_public_key, + ml_dsa_signature +} +``` + +Such independently cacheable records would expire because IP addresses, ports, +relay allocations, and capabilities can change. That expiry would not apply to +the separately configured bootstrap trust anchors and would not be driven by +routine DTLS certificate rotation. + +For that optional record, the ML-DSA signature covers a canonical, +domain-separated encoding. The browser would verify the public-key-to-peer-ID +binding, signature, network ID, monotonic sequence, expiry, capabilities, and +the entire multiaddress before dialing. An address received through an +unauthenticated channel is not made trustworthy merely by containing a +certificate hash. + +The multiaddress is the complete dialing input: no separate IP address, +certificate fingerprint, or peer-ID argument is accepted by the browser +client. This prevents those values from being accidentally mixed between +nodes. + +The address is represented by the network's native address types rather than +an application-owned string. `saorsa-transport` will own a validated WebRTC +Direct transport component, and `saorsa-core::MultiAddr` will own the +`/p2p/` suffix. Canonical formatting, parsing, and string-based +Serde are the single Rust codec used by endpoint records, bootstrap lists, +`HELLO`, and `FIND_NODE`. `ant-node` must not maintain a second WebRTC Direct +multiaddress or certificate-hash codec. + +The native Saorsa QUIC dialer deliberately does not treat a WebRTC Direct +address as a native QUIC dialing candidate. It is a first-class advertised +transport address whose browser stack remains separate from the PQ +node-to-node transport. + +For deployment smoke tests, a browser-enabled node also writes its own +canonical address to `/webrtc-direct.multiaddr`. Deployment tooling +may print or copy this public artifact so an operator can paste one seed into +the browser demo without scraping structured logs or running a manifest +service. This is an operability aid, not the endpoint-discovery protocol; peer +endpoints propagate through DHT address sets. + +With no explicit listener configuration, the node binds IPv4 wildcard and +maps its native UDP port deterministically into UDP 32768-65535. It advertises +the same-family non-relay external IP observed by the native transport, or the +host routing table's selected IP when no observation is available yet. The +automatic port and persisted certificate make the resulting multiaddress +stable across routine restarts. Explicit bind and advertised addresses remain +available for multi-homed and otherwise unusual deployments. + +### WebRTC Direct interoperability status + +The signaling-free connection mechanism has prior art in the [libp2p WebRTC +Direct v1 design](https://github.com/libp2p/specs/blob/master/webrtc/webrtc-direct.md): +the browser and public ICE-lite listener derive the descriptions locally, and +the first STUN binding request gives the listener the browser's observed +address and per-association ICE credential. Saorsa uses that standards-based +mechanism as design input, not the libp2p transport, identity, Noise, mux, or +stream wire protocols. + +The original Saorsa profile was identified by the ICE credential prefix +`saorsa+webrtc+v1/`. Like the prior v1 mechanism, it replaced the ICE ufrag and +password in the browser-generated local SDP. Browser vendors are restricting +that unsupported SDP-munging behavior, creating a documented [Chrome +compatibility risk](https://github.com/libp2p/go-libp2p/issues/3499). + +The implemented v2 profile is identified by `saorsa+webrtc+v2/` and follows the +standards-level technique developed by [WebRTC Direct v2 +work](https://github.com/libp2p/specs/pull/715). The browser sets its generated +offer unchanged, reads its effective local ICE password back from +`RTCPeerConnection.localDescription`, and embeds that password after the v2 +prefix in the synthetic server answer's ufrag. The first STUN request therefore +carries `saorsa+webrtc+v2/:`. The listener validates +both fragments, recovers the client password, and constructs the matching +association without modifying browser-owned local credentials or using a +signaling service. New browser and native diagnostic dials use v2 with no v1 +fallback; the listener accepts v1 during migration. + +Production promotion remains conditional on current Chrome, Firefox, and +Safari interoperability tests for this v2 flow. Saorsa does not depend on +libp2p adopting or shipping it. The ANT ML-KEM/ML-DSA application session +remains the only ANT node-identity and application-encryption protocol on the +WebRTC connection; the pinned DTLS fingerprint remains the transport +authentication mechanism. Unknown connection-establishment versions are +rejected. + +### Browser protocol and DataChannel framing + +The public protocol is not the private Saorsa `WireMessage` or native Postcard +DHT protocol. The application protocol name is `autonomi.web.poc.v4`, its +DataChannel label is `autonomi.web.v4`, and the embedded post-quantum session +has its own independently checked wire version 1. The initial methods are: + +- `HELLO`: return and validate protocol, peer, endpoint, capability, chunk-size, + and payment metadata after the post-quantum session has authenticated the + node. `HELLO` is no longer a separate cryptographic challenge/response. +- `FIND_NODE`: return up to the local DHT K value, ordered by XOR distance. + It never initiates a network lookup on the server. +- `GET_CHUNK`: return a locally stored chunk, `not_found`, or a bounded error. +- `QUOTE_CHUNK`: return the node's ordinary ML-DSA-signed storage quote and, + when present, its commitment sidecar. The browser verifies peer binding, + quote signature, forced price, commitment signature, and commitment pin + before paying. Its canonical signed fields use the native byte encoding; + the EVM-facing `PaymentQuote::hash()` is Keccak-256 over those bytes followed + by the public key and signature. This must not be confused with the BLAKE3 + hashes used for ANT identities, content addresses, and commitment pins. +- `PUT_CHUNK`: accept raw chunk bytes, the previously verified signed quote, + and the payment transaction hash. The listener reconstructs the native + single-node `PaymentProof` and routes the request through the ordinary PUT + handler, including content-address and on-chain payment verification. +- `PING`: optional liveness method after the proof of concept. + +WebRTC DataChannels are messages, not byte streams. One persistent reliable +ordered DataChannel carries a sequence of RPC request/response frames for one +association. Protocol v4 has two framing layers: + +1. The plaintext inner frame is a four-byte JSON-header length, a bounded + versioned JSON header, and the declared raw binary body. Chunk bytes are + never JSON/base64. +2. The shared post-quantum session seals the complete inner frame as one record. + The record contains a type tag, a 64-bit sequence number, and + ChaCha20-Poly1305 ciphertext and authentication tag. A four-byte encrypted + payload length delimits that record for DataChannel reassembly. + +Only the outer encrypted-record length, DataChannel message count, and timing +are visible outside the application session; JSON fields and chunk bytes are +encrypted. The handshake messages use the same bounded outer length prefix but +are not AEAD records because they establish the session keys. Outer frames are +fragmented into DataChannel messages of at most 16 KiB and reassembled before +handshake processing or AEAD opening. No libp2p stream envelope or half-close +control frame exists. + +Frames are self-delimiting at both layers. Receivers validate the bounded outer +length before allocation, authenticate and decrypt the exact record, then +validate the inner JSON header and its declared body length. A client serializes +requests on its persistent channel, waits for the complete declared response, +and can then send the next request without closing the channel. Trailing bytes, +channel closure before completion, mismatched lengths, unexpected sequences, +or failed record authentication are protocol errors. Cryptographic errors close +the association. This design directly removes the cross-version `FIN_ACK` and +RESET lifecycle failure observed with the libp2p PoC. + +High-level browser operations share a bounded pool of authenticated node +associations. Iterative lookups, quote collection, paid storage, and downloads +reuse the existing DataChannel for a node instead of creating a new +`RTCPeerConnection` for every encrypted record. This is both a performance and +compatibility requirement: the Safari PoC observed later DataChannels timing +out after rapid connection churn even though each earlier caller invoked +`close()`. The pool avoids relying on prompt browser resource reclamation, +serializes concurrent RPCs per node, limits live associations, evicts only idle +entries, and closes every entry when the application closes the client. + +The pool belongs to the long-lived browser client and is closed explicitly by +the application. It is not discarded between records or between complete file +operations. The same client also retains learned routing entries and a bounded +negative endpoint cache, so a second chunk lookup does not restart from the +bootstrap list or repeatedly wait on an endpoint that just failed. + +### Client API compatibility + +`ant-core` keeps its existing native `data::Client` and `ClientConfig` public +API. Existing native Rust applications, including `ant-cli`, continue to +construct and call that client without source changes. Native QUIC, Tokio task +management, wallet integrations, and filesystem behavior remain behind that +facade. + +Reusable client algorithms live behind a private runtime-neutral Rust engine. +This includes bounded unordered work scheduling, endpoint failure state, and +the transport-independent iterative lookup driver. The native facade supplies +Tokio/QUIC adapters; the WASM facade supplies browser timers and WebRTC Direct +sessions. Both therefore use the same Rust policies without forcing existing +native callers onto a new trait or configuration type. + +The browser and node adapters also consume the same `saorsa-webrtc` +post-quantum session and framing module. Cryptographic transcript construction, +key derivation, sequence handling, record authentication, and frame bounds are +not reimplemented in JavaScript or separately in `ant-node`. Existing native +applications such as `ant-cli` continue through the unchanged native client +path and do not opt into the browser WebRTC wire protocol. + +Browser applications instantiate the Rust/WASM `BrowserNetworkClient`. That +facade owns bootstrap, routing, quote preparation, paid storage, downloads, +and random-access reads. JavaScript remains only at browser boundaries that +Rust cannot own directly: DOM events, wallet-provider calls, service-worker +message plumbing, and the browser's WebRTC API bindings. + +The sender observes `bufferedAmount`, pauses above the configured high-water +mark, and resumes only after `bufferedamountlow`. Both sides cap total buffered +bytes, validate declared lengths before allocation, support cancellation by +closing the logical RPC channel, and reject bodies that exceed the method +limit. Both sides recompute BLAKE3 and reject content whose hash does not equal +its address. + +Browser sessions are not inserted into node routing tables. Wallet secrets, +replication controls, arbitrary topic forwarding, and native DHT messages are +not exposed. Payment happens against the public EVM RPC and contracts: the +browser signs locally, and only the resulting public proof crosses WebRTC. + +### Lookup behavior + +The browser owns the iterative lookup state machine. The first lookup starts +from the constant WebRTC Direct bootstrap list. Later lookups start from the +closest entries in the Rust client's retained routing view. It queries up to +`ALPHA = 3` unqueried closest endpoints in parallel, merges verified endpoint +records, and stops at convergence or the iteration limit. The implementation +uses the current native `K = 20` behavior. + +Native QUIC and browser WebRTC adapters share the same Rust rule for each +parallel query batch: await the first result, accept additional results during +a bounded grace period, and cancel remaining stragglers. A failed endpoint is +suppressed for a cooldown unless the peer publishes a different address; a +successful request clears the failure. This prevents unreachable NAT-side +listeners from adding their full WebRTC opening timeout to every record. + +V2 address records carry a reachability field independently from transport +type, but WebRTC Direct currently has no relay transport. Its records are +therefore normalized to `Unverified` rather than borrowing the classification +of a native QUIC socket on another UDP port. One-hop browser `FIND_NODE` +responses expose these authenticated, self-contained endpoints and the browser +handles failed dials through its bounded negative-endpoint cache. If relayed +WebRTC is added later, it requires a distinct address form and selection policy +rather than overloading the direct address. + +Every storage node, or a sufficient storage-aware replica set, must expose a +browser endpoint. Filtering native closest results to a sparse browser-only +subset is not considered equivalent to finding the network's actual closest +storage nodes. + +### NAT and relays + +WebRTC Direct removes the signaling server only for publicly reachable +listeners. It does not make a NATed server directly dialable from a static +address, and this implementation has no WebRTC relay transport. An endpoint +that cannot be reached is simply a failed browser dial and is suppressed by +the negative-endpoint cache. Supporting NATed WebRTC nodes would require a +separate signaling and relay design; it is not represented by the current +reachability field. + +### Implemented proof-of-concept slice + +The earlier feature-gated WebTransport PoC has been replaced by the +`webrtc-direct` feature. The current slice provides: + +- a separate Saorsa-owned WebRTC Direct UDP listener in `saorsa-transport` and + a browser dialer built directly on `RTCPeerConnection`/`RTCDataChannel`; +- WebRTC endpoint publication as `Unverified`, independent of native QUIC + reachability, with actual availability determined by a certificate-pinned + browser dial; +- credential-first STUN routing in the shared UDP mux, so a new association is + not sent to a stale ICE agent when a browser reuses a source UDP port; +- a generated and persisted DTLS certificate whose fingerprint remains stable + across restarts; +- native `saorsa-transport` and `saorsa-core::MultiAddr` support for canonical, + literal-IP `/webrtc-direct/certhash/.../p2p/...` addresses with exactly one + fingerprint and no DNS form; +- a protocol v4 browser session backed by the shared `saorsa-webrtc` + post-quantum session v1, which performs ephemeral ML-KEM-768 key + establishment, authenticates the transcript and ANT peer ID with ML-DSA-65, + derives direction-separated keys, and protects every later application frame + with ordered ChaCha20-Poly1305 records; +- a persistent reliable ordered application DataChannel, bounded 16-KiB + messages, declared-length reassembly, and browser `bufferedAmount` + backpressure; +- an independent node-side resource envelope with strict global, per-IP, and + per-association connection/channel/request/rate/byte bounds, owned handler + tasks, and deadline-bounded frame reads and response writes; +- a bounded browser connection pool that reuses authenticated DataChannels + across every lookup, quote, and record in one complete upload or download; +- a Rust/WASM random-access reader that resolves the public root DataMap, + retrieves only encrypted records overlapping the requested plaintext byte + range, and retains a bounded record cache for read-ahead and seeks; +- a same-origin service-worker adapter that exposes those verified ranges to a + native browser media element with standard HTTP range semantics, without + proxying bytes through a bootstrap or application server; and +- the existing local `FIND_NODE`, `GET_CHUNK`, `QUOTE_CHUNK`, and paid + `PUT_CHUNK` behavior over the new transport. + +The WebRTC primitive release currently used by the Rust implementation has a +known AES-256-GCM SRTP construction defect. The Saorsa setting engine therefore +advertises the interoperable AES-128-GCM and AES-128-CM profiles and omits the +broken profile. There is no vendored library patch. The AES-256 profile should +be restored only after upgrading the primitive and adding a regression test. + +Literal private and loopback IPs require no library connection-gater exception +because the browser client does not run libp2p. Address parsing still requires +a literal IP, UDP, `/webrtc-direct`, exactly one SHA-256 certificate pin, and +the expected ANT peer ID before constructing an `RTCPeerConnection`. + +The local manifest remains test scaffolding for ephemeral loopback ports. The +production client is designed to accept the same endpoint values from a +compiled constant list, without fetching a manifest or resolving DNS. + +This implementation currently uses the Saorsa v1 WebRTC +connection-establishment profile and the v4 encrypted application protocol +described above. It is a PoC, not evidence that the production no-mutation gate +has been met. Promotion remains blocked on the cross-browser validation listed +below. + +### Local testnet implementation slice + +The in-process `ant-devnet` launcher can enable a listener on every node. The +listeners share an in-memory endpoint catalog, allowing each local +`FIND_NODE` answer to attach the self-contained WebRTC Direct multiaddress of +every browser-enabled peer in its routing view before DHT publication has +converged. This catalog is development-only; production lookup uses only the +authenticated V2 DHT endpoint records. + +Local testnets may publish a runtime manifest because their loopback addresses +and ephemeral ports are created for each test run. Production bootstrap must +not depend on that mechanism. A local manifest may expose bootstrap +multiaddresses, public-file metadata, public EVM RPC and contract addresses, +and a resolved public root DataMap; it never performs lookup or carries file +bytes and never includes wallet secrets. + +At startup the launcher uses `self_encryption 0.36` to produce encrypted file +chunks and the same public MessagePack `DataMap` used by `ant-client`. It +publishes every record through each candidate node's ordinary PUT handler. It +pre-populates the devnet payment cache for those addresses, while +content-address verification, DHT responsibility, payment-cache admission, +LMDB storage, and verified reads remain active. + +### Protocol v4 automated validation + +Node CI explicitly runs the otherwise ignored five-node WebRTC Direct devnet +integration test. Its native test adapter completes the ML-KEM/ML-DSA +handshake and encrypted `HELLO`, asks a seed for closest nodes, dials an +endpoint from that wire response, performs another lookup on the discovered +node, and keeps each encrypted DataChannel open across multiple requests. It +then downloads and reconstructs a public file, obtains a quote, submits a +payment proof and upload, and reads the result back. The dev-only in-memory +endpoint catalog helps nodes populate their lookup responses; the client no +longer chooses its download peer from that out-of-band catalog. + +This harness proves direct endpoint discovery, encrypted session reuse, and +the node request path. It does **not** execute the browser WASM iterative +lookup state machine. `ant-client` CI builds and lints the WASM target and runs +its generated bindings in Node, but browser-side iterative parity remains a +promotion requirement below. Shared `saorsa-webrtc` unit tests additionally +cover record tampering and replay, wrong peer IDs, tampered node signatures, +invalid outer frames, and version mismatch. + +Node-side resource tests additionally cover fail-fast headroom invariants, +per-IP association isolation, IPv4-mapped IPv6 normalization, token-bucket +refill, preservation of source rate state across reconnects, bounded inactive +source state, global/per-source byte ceilings, rollback after failed global +reservation, and RAII release. The devnet workflow transfers real encrypted +chunks, but it is not a browser resource-limit or fleet test. The adversarial +browser and fleet tests listed under Validation remain promotion requirements. + +There is currently no automated real-browser v4 flow in browser CI. The +historical smoke flow below ran only Chromium and used protocol v3. Therefore +Chrome, Firefox, and Safari interoperability against a matching deployed v4 +node fleet, along with cold bootstrap from the production compiled seed list, +remain unmet acceptance criteria rather than claimed results. + +### Historical public Internet v3 smoke result + +The following results predate the v4 post-quantum record layer. They validate +WebRTC Direct connectivity, decentralized lookup, paid storage, and browser +client behavior, but they do not validate the v4 handshake or encrypted-record +implementation and must be repeated with matching v4 clients and nodes. + +On 2026-08-27 a headless Chromium client loaded the local web application and +dialed a literal public-IPv4 WebRTC Direct address on a DigitalOcean-hosted +node. With no browser manifest available, it completed ICE, DTLS, SCTP, the +DataChannel handshake, and the former ML-DSA `HELLO`; the UI then installed +that single address as the Rust network bootstrap seed and completed a +`FIND_NODE` query without page errors. Restarting the remote node left the +complete multiaddress byte-identical and the same browser client reconnected +using the pre-restart value. + +The result was repeated with the unchanged stock `ant-testnet` workflow after +WebRTC Direct became a default node feature. A normal 60-node deployment used +no browser-specific build, service, firewall, or advertised-address flags; +bootstrap node 0 automatically published its public IPv4 endpoint on the +derived UDP 42768 port. + +Using the pre-V2 address-dissemination prototype, Chromium bootstrapped from +that one address, traversed routing views from dozens of independent peer +processes, obtained four quotes from four non-bootstrap closest nodes, paid +once, and stored all four encrypted records. This verifies that the input +address is a bootstrap seed rather than a storage proxy. Nodes behind the +testnet's deliberate inbound-NAT rules are not reachable through the current +WebRTC Direct transport. Failed direct endpoints are cancelled after the +shared lookup grace period and suppressed by the browser client's negative +cache. + +After replacing that prototype with the compatibility-safe V2 address plane, +a five-node headless-Chromium test again started with exactly one WebRTC seed. +It discovered the remaining browser endpoints through `FindNodeV2`, paid for +and stored eight records across the network, read disjoint and suffix media +ranges, and downloaded the verified reconstruction. The V1/V2 wire migration +itself is additionally covered by legacy-decoder and unknown-identifier +round-trip tests. + +## Consequences + +### Positive + +- A web client can bootstrap from months-old constant IP multiaddresses + without DNS, Web PKI, a fresh manifest, or a signaling server. +- Routine node restarts and certificate maintenance do not change the + advertised address. +- Operators do not manage DNS names or CA certificate issuance; node software + creates and persists the browser transport credential. +- Browsers can become application-level full immutable-data clients without a + lookup, payment, upload, or download gateway. +- Browser-supported videos can start and seek without downloading or + reconstructing the complete file. +- WebRTC supplies a standardized browser API and an established path toward + direct ICE and end-to-end relayed connectivity for NATed nodes. +- The stable DTLS fingerprint authenticates transport setup while the shared + ML-KEM/ML-DSA session independently authenticates the persistent ANT identity + and protects every application payload. +- A future attacker that records the classical DTLS traffic cannot recover RPC + or chunk plaintext by later breaking only the DTLS key exchange; application + confidentiality additionally depends on ML-KEM-768 and 256-bit symmetric + keys. +- Rust producers and consumers share the network's native `MultiAddr` codec; + browser WASM parses and validates the same canonical wire syntax. +- Existing native `ant-core` client applications retain their public API while + native and browser facades share runtime-neutral Rust client policies. +- Existing PQ node networking and compatibility remain isolated. + +### Negative / Trade-offs + +- Browser-capable nodes run a second UDP listener and an ICE-lite + DTLS + SCTP + stack in addition to native QUIC. +- DataChannels require application fragmentation, reassembly, flow control, + and cancellation. They are less natural than WebTransport streams for 4 MiB + chunks. +- The application session adds an ML-KEM-768/ML-DSA-65 handshake, large + post-quantum handshake messages, per-record ChaCha20-Poly1305 work, another + framing layer, and extra copies on top of WebRTC's existing encryption. +- Native media playback needs a small same-origin service-worker bridge because + a page-owned WebRTC client cannot itself expose an HTTP range URL. The page + must remain open while playback uses its authenticated associations. +- A stable DTLS transport key has a larger compromise window. Its compromise + alone cannot authenticate the ANT node or decrypt application records, but + emergency replacement of a bootstrap fingerprint still requires overlap and + client-list updates. +- Constant bootstrap peers require stable public IP addresses and ports even + though ordinary nodes do not. +- Signaling-free WebRTC Direct depends on browser behaviors beyond the basic + WebRTC API. The implemented v2 profile's Chrome, Firefox, and Safari + interoperability must be proven before production. +- Direct operation still requires broad browser-endpoint coverage among + storage nodes. NATed nodes may consume relay bandwidth even though relays + cannot read their traffic. +- WebRTC connection establishment and certificate authentication are still + classical. The additional layer protects application contents, not ICE/DTLS/ + SCTP metadata, lengths, timing, availability, or the browser's WebRTC stack. + +### Neutral / Operational + +- The official web application still needs a secure HTTPS context. Its web + certificate is unrelated to node deployment and is not a bootstrap + dependency after the application has been installed. +- Designated bootstrap nodes have stronger uptime and stable-address + requirements than ordinary storage nodes. +- Origin is policy input, not client authentication. Public listeners enforce + independent per-IP/session request, channel, rate, and byte quotas; origin + does not bypass or replace those controls. +- The post-quantum handshake authenticates the node to the browser, not the + browser user to the node. Client authority remains method-specific; for paid + storage it comes from the normal wallet signature and payment proof. +- Bootstrap peers do not perform lookup or proxy uploads/downloads; they + answer the same bounded one-hop RPCs as other browser-capable nodes. +- Application protocol v4 requires matching browser and node deployments; + plaintext v3 and encrypted v4 peers deliberately fail closed. Native QUIC + nodes and existing `ant-core`/`ant-cli` callers are unaffected. + +## Validation + +The decision advances beyond PoC only after all of the following are covered: + +- A browser bootstraps with networking disabled for manifest/DNS services and + only the compiled literal-IP multiaddresses available. +- A bootstrap multiaddress and certificate fingerprint remain byte-identical + across node restarts and simulated passage of at least one month. +- Documented recovery tests cover certificate compromise, deliberate identity + rotation, one retired bootstrap seed, and overlap between old and new + compiled seed lists. +- WebRTC Direct connection establishment works on current Chrome, Firefox, + and Safari from a real secure context without forbidden SDP mutation. Tests + explicitly cover the Chrome ICE-credential restriction that breaks v1. +- The browser rejects wrong DTLS fingerprints, wrong peer IDs and public-key + bindings, malformed or version-mismatched PQ handshakes, invalid ML-DSA + transcript signatures, modified KEM transcripts, replayed or out-of-order + records, modified ciphertext, and sequence exhaustion. A v3 plaintext frame + sent to a v4 endpoint fails closed rather than downgrading. +- Cryptographic tests cover both traffic directions, direction-separated key + derivation, nonce/sequence uniqueness, transcript domain separation, + handshake and frame bounds, tampering, replay, reordering, and key cleanup. +- Automated tests cover malformed STUN/SDP/SCTP input, oversized messages, + excessive channels, slow readers, connection floods, request amplification, + reconnect churn, task cleanup, and global/per-client byte quotas. Tests must + also show that one source at each configured ceiling leaves another source + admissible. +- UDP-mux regression tests cover source-port reuse: a binding request carrying + a new ICE credential must override a stale address mapping, while binding + responses and non-STUN traffic continue to use the selected address mapping. +- Browser-side iterative lookup parity tests cover XOR ordering, `K`, `ALPHA`, + convergence, retained routing entries, grace cancellation, failure cooldown, + changed endpoints, expired discovered records, and unavailable endpoints. +- Reliable downloads and uploads work at 0 bytes, typical sizes, and 4 MiB, + with BLAKE3 verification, bounded memory, fragmentation, cancellation, and + backpressure measurements. +- Media tests cover disjoint, open-ended, and suffix byte ranges, seeks across + self-encryption chunk boundaries, nested DataMaps, bounded cache behavior, + invalid/multiple ranges, cancellation, and exact reconstructed bytes. +- Multi-record uploads and concurrent downloads remain within the browser + connection-pool bound and complete on Safari without accumulating closed + `RTCPeerConnection` instances. +- Paid-upload tests cover quote/commitment tampering, wrong peers, wrong + content, missing/failed payments, replay/idempotence, wallet rejection, and + successful native-client retrieval of browser-created files. +- A fleet test demonstrates that browser endpoint coverage reaches the storage + nodes selected by native closest-group rules. +- NAT traversal tests measure direct ICE success and exercise an end-to-end + relay path where DTLS terminates at the NATed node, not the relay. +- Regression tests prove the existing native PQ port and native client + behavior are unchanged when browser support is disabled. +- Mixed-deployment tests cover v3/v4 incompatibility and confirm that upgrades + cannot produce a silent plaintext downgrade; deployment documentation treats + protocol v4 as a coordinated browser-client and node rollout. +- WebRTC and the recorded WebTransport baseline are benchmarked for setup + latency, CPU and memory, sustained 4 MiB throughput, cancellation, loss + recovery, and concurrent request behavior before production promotion. +- Review triggers fire when WebRTC Direct v2, browser SDP enforcement, SCTP + DataChannel behavior, node storage placement, or Saorsa relay APIs change + materially. + +## Notes for AI-assisted work + +AI tools may help draft this ADR, but **must not mark it Accepted without human +review**. Accepted ADRs are immutable: create a new superseding ADR rather than +editing an Accepted ADR. diff --git a/src/bin/ant-devnet/cli.rs b/src/bin/ant-devnet/cli.rs index 114503a4..0c9c7c17 100644 --- a/src/bin/ant-devnet/cli.rs +++ b/src/bin/ant-devnet/cli.rs @@ -1,12 +1,17 @@ //! CLI definition for ant-devnet. -use clap::Parser; +use clap::{ArgGroup, Parser}; use std::path::PathBuf; /// Local devnet runner for ant-node. #[derive(Parser, Debug)] #[command(name = "ant-devnet")] #[command(author, version, about, long_about = None)] +#[command(group( + ArgGroup::new("evm-payment") + .args(["enable_evm", "evm_network"]) +))] +#[allow(clippy::struct_excessive_bools)] pub struct Cli { /// Node count to spawn. #[arg(long)] @@ -44,6 +49,23 @@ pub struct Cli { #[arg(long)] pub manifest: Option, + /// Enable one direct-browser WebRTC Direct listener per devnet node. + /// + /// The binary must be built with `--features webrtc-direct`. + #[arg(long, requires = "evm-payment")] + pub webrtc_direct: bool, + + /// First UDP port assigned to devnet WebRTC Direct listeners (0 = allocate). + #[arg(long, requires = "webrtc_direct")] + pub webrtc_direct_base_port: Option, + + /// File to publish into the devnet on startup. + /// + /// When omitted, a built-in text file is published. The resulting BLAKE3 + /// address is included in the browser manifest. + #[arg(long, requires = "webrtc_direct")] + pub public_file: Option, + /// Enable logging output. /// When omitted, the tracing subscriber is not installed and no log /// records are emitted, even if the binary was built with the @@ -81,12 +103,11 @@ pub struct Cli { #[arg(long, conflicts_with = "enable_evm")] pub evm_network: Option, - /// Serve the manifest over a read-only HTTP API on this port (binds - /// 0.0.0.0). Any LAN device can then GET - /// `http://:/api/devnet-manifest.json` (and `/api/info`) — - /// no file copying. Open CORS. Suggested: 8088. Requires `--host` (the API - /// advertises a LAN URL, so a loopback-only devnet would be misleading). - #[arg(long, requires = "host", value_parser = clap::value_parser!(u16).range(1..))] + /// Serve native and browser manifests over a read-only HTTP API. + /// + /// Without `--host` it binds 127.0.0.1. With `--host` it binds 0.0.0.0 + /// and advertises that LAN address. Open CORS. Suggested: 25000. + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] pub serve_port: Option, } @@ -103,6 +124,7 @@ mod tests { assert!(cli.host.is_none()); assert!(cli.evm_network.is_none()); assert!(cli.serve_port.is_none()); + assert!(!cli.webrtc_direct); } /// The LAN flags parse into the expected typed values. @@ -115,11 +137,11 @@ mod tests { "--evm-network", "arbitrum-sepolia", "--serve-port", - "8088", + "25000", ]); assert_eq!(cli.host, Some(Ipv4Addr::new(192, 168, 1, 100))); assert_eq!(cli.evm_network.as_deref(), Some("arbitrum-sepolia")); - assert_eq!(cli.serve_port, Some(8088)); + assert_eq!(cli.serve_port, Some(25_000)); } /// A non-IPv4 `--host` is rejected by clap's value parser. @@ -128,18 +150,43 @@ mod tests { assert!(Cli::try_parse_from(["ant-devnet", "--host", "not-an-ip"]).is_err()); } - /// `--serve-port` requires `--host` (it advertises a LAN URL). + /// `--serve-port` also supports a loopback-only browser manifest API. #[test] - fn serve_port_requires_host() { - assert!(Cli::try_parse_from(["ant-devnet", "--serve-port", "8088"]).is_err()); + fn serve_port_supports_loopback() { + let cli = Cli::parse_from(["ant-devnet", "--serve-port", "25000"]); + assert_eq!(cli.serve_port, Some(25_000)); } /// `--serve-port 0` is rejected (an ephemeral port would be advertised as `:0`). #[test] fn serve_port_rejects_zero() { - assert!( - Cli::try_parse_from(["ant-devnet", "--host", "192.168.1.5", "--serve-port", "0"]) - .is_err() - ); + assert!(Cli::try_parse_from(["ant-devnet", "--serve-port", "0"]).is_err()); + } + + #[test] + fn browser_flags_require_webrtc_direct() { + assert!(Cli::try_parse_from(["ant-devnet", "--public-file", "hello.txt"]).is_err()); + + let cli = Cli::parse_from([ + "ant-devnet", + "--webrtc-direct", + "--enable-evm", + "--webrtc-direct-base-port", + "22000", + "--public-file", + "hello.txt", + ]); + assert!(cli.webrtc_direct); + assert_eq!(cli.webrtc_direct_base_port, Some(22_000)); + } + + #[test] + fn browser_uploads_require_an_explicit_payment_network() { + let result = Cli::try_parse_from(["ant-devnet", "--webrtc-direct"]); + assert!(result.is_err()); + let rendered = result + .err() + .map_or_else(String::new, |error| error.to_string()); + assert!(rendered.contains("--enable-evm") || rendered.contains("--evm-network")); } } diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index 49dea28c..2ceefec8 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -22,7 +22,7 @@ //! //! # LAN devnet backed by Arbitrum Sepolia, manifest served over HTTP //! ant-devnet --preset small --host 192.168.1.100 \ -//! --evm-network arbitrum-sepolia --serve-port 8088 +//! --evm-network arbitrum-sepolia --serve-port 25000 //! ``` #![cfg_attr(not(feature = "logging"), allow(unused_variables))] @@ -33,10 +33,12 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; mod cli; use ant_node::devnet::{Devnet, DevnetConfig, DevnetEvmInfo, DevnetManifest}; +use ant_node::BrowserDevnetManifest; use clap::Parser; use cli::Cli; #[tokio::main] +#[allow(clippy::too_many_lines)] async fn main() -> color_eyre::Result<()> { color_eyre::install()?; @@ -86,6 +88,13 @@ async fn main() -> color_eyre::Result<()> { config.stabilization_timeout = std::time::Duration::from_secs(timeout_secs); } + #[cfg(not(feature = "webrtc-direct"))] + if cli.webrtc_direct { + return Err(color_eyre::eyre::eyre!( + "--webrtc-direct requires a binary built with --features webrtc-direct" + )); + } + // A non-unicast --host would stamp unreachable bootstrap addresses into the // manifest (LAN mode would fail non-obviously), so reject it early. if let Some(host) = cli @@ -98,7 +107,14 @@ async fn main() -> color_eyre::Result<()> { )); } config.advertise_ip = cli.host; - let evm_info = resolve_evm_info( + config.webrtc_direct = cli.webrtc_direct; + if let Some(base_port) = cli.webrtc_direct_base_port { + config.webrtc_direct_base_port = base_port; + } + let ResolvedEvm { + manifest: evm_info, + local_testnet: _local_evm_testnet, + } = resolve_evm_info( cli.evm_network.as_deref(), cli.enable_evm, cli.host, @@ -109,16 +125,43 @@ async fn main() -> color_eyre::Result<()> { let mut devnet = Devnet::new(config).await?; devnet.start().await?; + let created_at = chrono::Utc::now().to_rfc3339(); + + #[cfg(feature = "webrtc-direct")] + let browser_manifest = if cli.webrtc_direct { + let (name, content_type, content) = load_public_file(cli.public_file.as_deref()).await?; + let public_file = devnet + .publish_public_file(name, content_type, &content) + .await?; + let network_id = format!("local-devnet-{}-{}", devnet.config().base_port, created_at); + Some(BrowserDevnetManifest::new( + network_id, + created_at.clone(), + devnet.browser_endpoints(), + devnet.browser_payment_network(), + vec![public_file], + )) + } else { + None + }; + + #[cfg(not(feature = "webrtc-direct"))] + let browser_manifest: Option = None; + let manifest = DevnetManifest { base_port: devnet.config().base_port, node_count: devnet.config().node_count, bootstrap: devnet.bootstrap_addrs(), data_dir: devnet.config().data_dir.clone(), - created_at: chrono::Utc::now().to_rfc3339(), + created_at, evm: evm_info, }; let json = serde_json::to_string_pretty(&manifest)?; + let browser_json = browser_manifest + .as_ref() + .map(serde_json::to_string_pretty) + .transpose()?; if let Some(path) = cli.manifest { tokio::fs::write(&path, &json).await?; ant_node::logging::info!("Wrote manifest to {}", path.display()); @@ -128,8 +171,18 @@ async fn main() -> color_eyre::Result<()> { // Optional read-only HTTP API so LAN devices fetch the manifest instead of // copying files (GET /api/devnet-manifest.json + /api/info). - if let Some(port) = cli.serve_port { - serve_manifest_api(port, cli.host, &manifest, json.clone())?; + let serve_port = cli + .serve_port + .or_else(|| cli.webrtc_direct.then_some(25_000)); + if let Some(port) = serve_port { + serve_manifest_api( + port, + cli.host, + &manifest, + json.clone(), + browser_manifest.as_ref(), + browser_json, + )?; } ant_node::logging::info!("Devnet running. Press Ctrl+C to stop."); @@ -139,16 +192,88 @@ async fn main() -> color_eyre::Result<()> { Ok(()) } +#[cfg(feature = "webrtc-direct")] +async fn load_public_file( + path: Option<&std::path::Path>, +) -> color_eyre::Result<(String, String, Vec)> { + const DEFAULT_NAME: &str = "autonomi-browser-testnet.txt"; + const DEFAULT_SEED: &[u8] = include_bytes!("../../../assets/browser-devnet-public.txt"); + const DEFAULT_SIZE: usize = 5 * 1024 * 1024; + const MAX_FILE_SIZE: u64 = 1_000_000_000; + + let Some(path) = path else { + let mut content = Vec::with_capacity(DEFAULT_SIZE); + while content.len() < DEFAULT_SIZE { + content.extend_from_slice(DEFAULT_SEED); + } + content.truncate(DEFAULT_SIZE); + return Ok(( + DEFAULT_NAME.to_string(), + "text/plain; charset=utf-8".to_string(), + content, + )); + }; + + let name = path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + color_eyre::eyre::eyre!( + "--public-file must identify a file with a valid UTF-8 filename" + ) + })? + .to_string(); + let file_size = tokio::fs::metadata(path) + .await + .map_err(|error| { + color_eyre::eyre::eyre!("failed to inspect public file {}: {error}", path.display()) + })? + .len(); + if file_size > MAX_FILE_SIZE { + return Err(color_eyre::eyre::eyre!( + "--public-file is {file_size} bytes; the browser devnet limit is {MAX_FILE_SIZE} bytes" + )); + } + let content = tokio::fs::read(path).await.map_err(|error| { + color_eyre::eyre::eyre!("failed to read public file {}: {error}", path.display()) + })?; + let content_type = match path + .extension() + .and_then(std::ffi::OsStr::to_str) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("txt" | "md" | "csv") => "text/plain; charset=utf-8", + Some("json") => "application/json", + Some("html" | "htm") => "text/html; charset=utf-8", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("pdf") => "application/pdf", + _ => "application/octet-stream", + } + .to_string(); + + Ok((name, content_type, content)) +} + /// Resolve which EVM backing the devnet uses, updating `config` accordingly: /// an **external** network (`--evm-network`, e.g. Arbitrum Sepolia verified /// against the real deployed contracts, no embedded wallet key); a **local /// Anvil** chain (`--enable-evm`); or **none**. External takes precedence. +struct ResolvedEvm { + manifest: Option, + // Retain ownership until main exits so the Anvil child is terminated on + // normal shutdown instead of being orphaned. + local_testnet: Option, +} + async fn resolve_evm_info( evm_network: Option<&str>, enable_evm: bool, host: Option, config: &mut DevnetConfig, -) -> color_eyre::Result> { +) -> color_eyre::Result { if let Some(net_name) = evm_network { let network = match net_name { "arbitrum-sepolia" => evmlib::Network::ArbitrumSepoliaTest, @@ -165,12 +290,15 @@ async fn resolve_evm_info( "Using external EVM network {net_name}: rpc={rpc_url} token={token_addr} vault={vault_addr}" ); config.evm_network = Some(network); - Ok(Some(DevnetEvmInfo { - rpc_url, - wallet_private_key: String::new(), - payment_token_address: token_addr, - payment_vault_address: vault_addr, - })) + Ok(ResolvedEvm { + manifest: Some(DevnetEvmInfo { + rpc_url, + wallet_private_key: String::new(), + payment_token_address: token_addr, + payment_vault_address: vault_addr, + }), + local_testnet: None, + }) } else if enable_evm { // Anvil binds — and evmlib publishes in the manifest's `rpc_url` — // the address in `ANVIL_IP_ADDR`, defaulting to localhost. A LAN @@ -212,18 +340,20 @@ async fn resolve_evm_info( ant_node::logging::info!("Anvil blockchain running at {rpc_url}"); ant_node::logging::info!("Funded wallet private key: {wallet_key}"); - // Keep testnet alive by leaking it (it will be cleaned up on process exit) - // This is necessary because AnvilInstance stops Anvil when dropped - std::mem::forget(testnet); - - Ok(Some(DevnetEvmInfo { - rpc_url, - wallet_private_key: wallet_key, - payment_token_address: token_addr, - payment_vault_address: vault_addr, - })) + Ok(ResolvedEvm { + manifest: Some(DevnetEvmInfo { + rpc_url, + wallet_private_key: wallet_key, + payment_token_address: token_addr, + payment_vault_address: vault_addr, + }), + local_testnet: Some(testnet), + }) } else { - Ok(None) + Ok(ResolvedEvm { + manifest: None, + local_testnet: None, + }) } } @@ -235,8 +365,10 @@ fn serve_manifest_api( host: Option, manifest: &DevnetManifest, manifest_json: String, + browser_manifest: Option<&BrowserDevnetManifest>, + browser_manifest_json: Option, ) -> color_eyre::Result<()> { - let host_ip = host.map_or_else(local_ip_guess, |i| i.to_string()); + let host_ip = host.map_or_else(|| "127.0.0.1".to_string(), |i| i.to_string()); let evm_block = manifest.evm.as_ref().map_or(serde_json::Value::Null, |e| { let loopback = e.rpc_url.contains("127.0.0.1") || e.rpc_url.contains("localhost"); serde_json::json!({ @@ -255,37 +387,40 @@ fn serve_manifest_api( }) }); let bootstrap = serde_json::to_value(&manifest.bootstrap)?; + let browser_manifest_url = + browser_manifest.map(|_| format!("http://{host_ip}:{port}/api/browser-manifest.json")); + let public_files = browser_manifest.map_or_else(Vec::new, |browser| browser.files.clone()); let info = serde_json::json!({ "host_ip": host_ip, "manifest_url": format!("http://{host_ip}:{port}/api/devnet-manifest.json"), + "browser_manifest_url": browser_manifest_url, "node_count": manifest.node_count as u64, "bootstrap": bootstrap, + "public_files": public_files, "evm": evm_block, }); let info_json = serde_json::to_string_pretty(&info)?; // Bind synchronously so a failure (e.g. the port is already in use) // propagates to the caller instead of the devnet silently coming up // without its manifest API. - let listener = std::net::TcpListener::bind(("0.0.0.0", port)).map_err(|e| { - color_eyre::eyre::eyre!("failed to bind manifest API on 0.0.0.0:{port}: {e}") + let bind_ip = host.map_or(std::net::Ipv4Addr::LOCALHOST, |_| { + std::net::Ipv4Addr::UNSPECIFIED + }); + let listener = std::net::TcpListener::bind((bind_ip, port)).map_err(|e| { + color_eyre::eyre::eyre!("failed to bind manifest API on {bind_ip}:{port}: {e}") })?; ant_node::logging::info!( - "manifest API on http://0.0.0.0:{port}/api/devnet-manifest.json (+ /api/info)" + "manifest API on http://{host_ip}:{port}/api/devnet-manifest.json (+ /api/info)" ); - spawn_manifest_server(listener, manifest_json, info_json); + if browser_manifest.is_some() { + ant_node::logging::info!( + "browser app manifest: http://{host_ip}:{port}/api/browser-manifest.json" + ); + } + spawn_manifest_server(listener, manifest_json, info_json, browser_manifest_json); Ok(()) } -/// Best-effort primary LAN IP (src of the default route) for the info endpoint. -fn local_ip_guess() -> String { - std::net::UdpSocket::bind("0.0.0.0:0") - .and_then(|s| { - s.connect("1.1.1.1:80")?; - Ok(s.local_addr()?.ip().to_string()) - }) - .unwrap_or_else(|_| "127.0.0.1".to_string()) -} - /// Run a tiny read-only HTTP server on `listener` (its own thread) exposing the /// manifest over the LAN. GET-only, open CORS; hand-rolled HTTP/1.1 so there's /// no new dependency. Connections are handled **inline, one at a time** — the @@ -297,6 +432,7 @@ fn spawn_manifest_server( listener: std::net::TcpListener, manifest_json: String, info_json: String, + browser_manifest_json: Option, ) { std::thread::spawn(move || { use std::io::{Read, Write}; @@ -314,11 +450,19 @@ fn spawn_manifest_server( let (status, body) = if method == "GET" { match path { "/api/devnet-manifest.json" => ("200 OK", manifest_json.as_str()), + "/api/browser-manifest.json" => browser_manifest_json.as_deref().map_or( + ( + "404 Not Found", + "{\"error\":\"browser manifest not enabled\"}", + ), + |body| ("200 OK", body), + ), "/api/info" => ("200 OK", info_json.as_str()), "" | "/api" => ( "200 OK", "{\"service\":\"ant-devnet manifest API\",\ - \"endpoints\":[\"/api/devnet-manifest.json\",\"/api/info\"]}", + \"endpoints\":[\"/api/devnet-manifest.json\",\ + \"/api/browser-manifest.json\",\"/api/info\"]}", ), _ => ("404 Not Found", "{\"error\":\"not found\"}"), } diff --git a/src/bin/ant-node/cli.rs b/src/bin/ant-node/cli.rs index 9d1c6356..64cba912 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -28,6 +28,27 @@ pub struct Cli { #[arg(long, env = "ANT_IPV4_ONLY")] pub ipv4_only: bool, + /// Override the default ADR-0009 WebRTC Direct UDP bind address. + /// + /// Port zero selects the stable automatic port derived from `--port`. + #[arg(long, env = "ANT_WEBRTC_DIRECT_BIND")] + pub webrtc_direct_bind: Option, + + /// Override the UDP port used by the WebRTC Direct listener. + /// + /// This takes precedence over the port in `--webrtc-direct-bind`. Port zero + /// selects the automatic port derived from the native QUIC listener. + #[arg(long, visible_alias = "webrtc-port", env = "ANT_WEBRTC_DIRECT_PORT")] + pub webrtc_direct_port: Option, + + /// Literal public UDP address to advertise instead of the bind address. + #[arg( + long, + env = "ANT_WEBRTC_DIRECT_ADVERTISED_ADDR", + requires = "webrtc_direct_bind" + )] + pub webrtc_direct_advertised_addr: Option, + /// Bootstrap peer addresses. #[arg(long, short, env = "ANT_BOOTSTRAP")] pub bootstrap: Vec, @@ -230,6 +251,17 @@ impl Cli { config.port = self.port; config.ipv4_only = self.ipv4_only; + if let Some(bind) = self.webrtc_direct_bind { + config.webrtc_direct.enabled = true; + config.webrtc_direct.bind = bind; + } + if let Some(port) = self.webrtc_direct_port { + config.webrtc_direct.enabled = true; + config.webrtc_direct.bind.set_port(port); + } + if let Some(addr) = self.webrtc_direct_advertised_addr { + config.webrtc_direct.advertised_addr = Some(addr); + } #[cfg(feature = "logging")] { config.log_level = self.log_level.into(); @@ -332,3 +364,49 @@ impl From for NetworkMode { } } } + +#[cfg(test)] +mod tests { + use super::Cli; + use clap::Parser; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn webrtc_direct_port_overrides_the_default_bind_port() -> Result<(), Box> + { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-direct-port", "45000"])?; + let (config, _) = cli.into_config()?; + + assert!(config.webrtc_direct.enabled); + assert_eq!(config.webrtc_direct.bind.port(), 45_000); + Ok(()) + } + + #[test] + fn webrtc_direct_port_overrides_only_the_explicit_bind_port( + ) -> Result<(), Box> { + let cli = Cli::try_parse_from([ + "ant-node", + "--webrtc-direct-bind", + "127.0.0.1:40000", + "--webrtc-direct-port", + "45000", + ])?; + let (config, _) = cli.into_config()?; + + assert_eq!( + config.webrtc_direct.bind.ip(), + IpAddr::V4(Ipv4Addr::LOCALHOST) + ); + assert_eq!(config.webrtc_direct.bind.port(), 45_000); + Ok(()) + } + + #[test] + fn webrtc_port_alias_is_supported() -> Result<(), Box> { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-port", "45000"])?; + + assert_eq!(cli.webrtc_direct_port, Some(45_000)); + Ok(()) + } +} diff --git a/src/browser.rs b/src/browser.rs new file mode 100644 index 00000000..160fb3c6 --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,104 @@ +//! Shared browser-client discovery types. +//! +//! These types describe the public read and paid immutable-write capabilities +//! exposed by browser-enabled nodes. Wallet secrets never form part of these +//! records: browsers sign EVM transactions locally and send only payment +//! receipts to nodes. + +pub use saorsa_webrtc::{BrowserEndpoint, BrowserPaymentNetwork, WebRtcDirectEndpoint}; +use serde::{Deserialize, Serialize}; + +/// Version of the local browser bootstrap manifest. +pub const BROWSER_MANIFEST_VERSION: u16 = 5; + +/// A bootstrap node that a browser can authenticate and contact directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserBootstrapNode { + /// Self-contained browser endpoint for this node. + #[serde(flatten)] + pub endpoint: BrowserEndpoint, +} + +/// Metadata for immutable content published into a browser-enabled devnet. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserPublicFile { + /// Human-readable filename suggested to the browser. + pub name: String, + /// Address of the publicly stored `MessagePack` `DataMap`. + pub address: String, + /// Plaintext content length in bytes. + pub size: usize, + /// MIME type used by the browser when saving the content. + pub content_type: String, + /// BLAKE3 hash of the fully reconstructed plaintext file. + pub blake3: String, + /// Size of the publicly stored `MessagePack` `DataMap` chunk. + pub data_map_size: usize, + /// Resolved root `DataMap` used to reconstruct the file. + pub chunks: Vec, + /// Minimum number of devnet nodes that admitted every required record. + pub replicas: usize, +} + +/// One resolved self-encryption chunk descriptor. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserChunkInfo { + /// Zero-based plaintext order. + pub index: usize, + /// Address of the encrypted chunk stored by nodes. + pub dst_hash: String, + /// BLAKE3 hash of the plaintext chunk and self-encryption key input. + pub src_hash: String, + /// Expected plaintext chunk size. + pub src_size: usize, +} + +pub(crate) fn browser_payment_network(network: &evmlib::Network) -> BrowserPaymentNetwork { + BrowserPaymentNetwork { + rpc_url: network.rpc_url().to_string(), + payment_token_address: format!("{:?}", network.payment_token_address()), + payment_vault_address: format!("{:?}", network.payment_vault_address()), + } +} + +/// Local-devnet handoff consumed by the browser application. +/// +/// This manifest is intentionally a local testnet bootstrap artifact. The +/// production design replaces it with the ML-DSA-signed endpoint records from +/// ADR-0009. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserDevnetManifest { + /// Manifest schema version. + pub version: u16, + /// Opaque identifier that distinguishes concurrent local devnets. + pub network_id: String, + /// Creation time in RFC 3339 form. + pub created_at: String, + /// Direct node endpoints available as initial browser contacts. + pub endpoints: Vec, + /// Public payment contracts and RPC used by browser uploads. + pub payment: BrowserPaymentNetwork, + /// Immutable files published when the devnet started. + pub files: Vec, +} + +impl BrowserDevnetManifest { + /// Construct a versioned local browser manifest. + #[must_use] + pub fn new( + network_id: String, + created_at: String, + endpoints: Vec, + payment: BrowserPaymentNetwork, + files: Vec, + ) -> Self { + Self { + version: BROWSER_MANIFEST_VERSION, + network_id, + created_at, + endpoints, + payment, + files, + } + } +} diff --git a/src/config.rs b/src/config.rs index 2319f96b..e49fb8bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,6 +120,13 @@ pub struct NodeConfig { #[serde(default)] pub storage: StorageConfig, + /// Direct-browser WebRTC Direct listener. + /// + /// This is enabled automatically when the binary includes the default + /// `webrtc-direct` feature. Minimal native-only builds leave it disabled. + #[serde(default)] + pub webrtc_direct: WebRtcDirectConfig, + /// Directory for persisting the close group cache. /// /// When `None` (default), the node's `root_dir` is used — the cache @@ -143,6 +150,163 @@ pub struct NodeConfig { pub log_level: String, } +/// Configuration for the ADR-0009 WebRTC Direct proof of concept. +/// +/// This listener is deliberately separate from the native Saorsa QUIC port. +/// It exposes local closest-node lookup, immutable chunk reads, and paid +/// content-addressed writes through the ordinary payment verifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebRtcDirectConfig { + /// Enable the browser listener. + #[serde(default)] + pub enabled: bool, + + /// UDP address for the WebRTC Direct listener. + #[serde(default = "default_webrtc_direct_bind")] + pub bind: SocketAddr, + + /// Literal public UDP address advertised to browsers. + /// + /// When omitted, a wildcard listener uses the native transport's observed + /// external IP (or the host's routed IP) and an automatically assigned, + /// stable high UDP port. + #[serde(default)] + pub advertised_addr: Option, + + /// PEM file used to persist the stable DTLS certificate and private key. + /// + /// Relative paths are resolved against the node root directory by the + /// caller. The default is `webrtc-direct.pem` beside the node identity. + #[serde(default)] + pub certificate_path: Option, + + /// Maximum simultaneously accepted browser sessions. + #[serde(default = "default_webrtc_direct_max_connections")] + pub max_connections: usize, + + /// Maximum simultaneously accepted browser sessions from one source IP. + /// + /// This must be lower than [`Self::max_connections`] so one public source + /// cannot occupy every listener slot. + #[serde(default = "default_webrtc_direct_max_connections_per_ip")] + pub max_connections_per_ip: usize, + + /// Maximum simultaneously active `DataChannels` on one browser session. + #[serde(default = "default_webrtc_direct_max_channels_per_connection")] + pub max_channels_per_connection: usize, + + /// Maximum simultaneously active `DataChannels` across the listener. + /// + /// Every admitted channel owns one handler task, so this is also the hard + /// global channel-task bound. + #[serde(default = "default_webrtc_direct_max_channels")] + pub max_channels: usize, + + /// Maximum requests being read or processed across the listener. + #[serde(default = "default_webrtc_direct_max_concurrent_requests")] + pub max_concurrent_requests: usize, + + /// Token-bucket request rate across the listener, in requests per second. + #[serde(default = "default_webrtc_direct_max_requests_per_second")] + pub max_requests_per_second: usize, + + /// Token-bucket request rate for one source IP, in requests per second. + #[serde(default = "default_webrtc_direct_max_requests_per_second_per_ip")] + pub max_requests_per_second_per_ip: usize, + + /// Token-bucket request rate for one browser session, in requests per + /// second. + #[serde(default = "default_webrtc_direct_max_requests_per_second_per_connection")] + pub max_requests_per_second_per_connection: usize, + + /// Maximum bytes reserved by frames being assembled, decrypted, or sent + /// across the listener. + #[serde(default = "default_webrtc_direct_max_in_flight_bytes")] + pub max_in_flight_bytes: usize, + + /// Maximum in-flight frame bytes attributable to one source IP. + #[serde(default = "default_webrtc_direct_max_in_flight_bytes_per_ip")] + pub max_in_flight_bytes_per_ip: usize, + + /// Maximum JSON request-header size, in bytes. + /// + /// Binary PUT content has a separate [`crate::ant_protocol::MAX_CHUNK_SIZE`] + /// limit and is never JSON/base64 encoded. + #[serde(default = "default_webrtc_direct_max_request_bytes")] + pub max_request_bytes: usize, +} + +impl Default for WebRtcDirectConfig { + fn default() -> Self { + Self { + enabled: cfg!(feature = "webrtc-direct"), + bind: default_webrtc_direct_bind(), + advertised_addr: None, + certificate_path: None, + max_connections: default_webrtc_direct_max_connections(), + max_connections_per_ip: default_webrtc_direct_max_connections_per_ip(), + max_channels_per_connection: default_webrtc_direct_max_channels_per_connection(), + max_channels: default_webrtc_direct_max_channels(), + max_concurrent_requests: default_webrtc_direct_max_concurrent_requests(), + max_requests_per_second: default_webrtc_direct_max_requests_per_second(), + max_requests_per_second_per_ip: default_webrtc_direct_max_requests_per_second_per_ip(), + max_requests_per_second_per_connection: + default_webrtc_direct_max_requests_per_second_per_connection(), + max_in_flight_bytes: default_webrtc_direct_max_in_flight_bytes(), + max_in_flight_bytes_per_ip: default_webrtc_direct_max_in_flight_bytes_per_ip(), + max_request_bytes: default_webrtc_direct_max_request_bytes(), + } + } +} + +fn default_webrtc_direct_bind() -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) +} + +const fn default_webrtc_direct_max_connections() -> usize { + 32 +} + +const fn default_webrtc_direct_max_connections_per_ip() -> usize { + 4 +} + +const fn default_webrtc_direct_max_channels_per_connection() -> usize { + 2 +} + +const fn default_webrtc_direct_max_channels() -> usize { + 32 +} + +const fn default_webrtc_direct_max_concurrent_requests() -> usize { + 16 +} + +const fn default_webrtc_direct_max_requests_per_second() -> usize { + 256 +} + +const fn default_webrtc_direct_max_requests_per_second_per_ip() -> usize { + 32 +} + +const fn default_webrtc_direct_max_requests_per_second_per_connection() -> usize { + 16 +} + +const fn default_webrtc_direct_max_in_flight_bytes() -> usize { + 64 * 1024 * 1024 +} + +const fn default_webrtc_direct_max_in_flight_bytes_per_ip() -> usize { + 16 * 1024 * 1024 +} + +const fn default_webrtc_direct_max_request_bytes() -> usize { + 64 * 1024 +} + /// Auto-upgrade configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpgradeConfig { @@ -279,6 +443,7 @@ impl Default for NodeConfig { upgrade: UpgradeConfig::default(), payment: PaymentConfig::default(), storage: StorageConfig::default(), + webrtc_direct: WebRtcDirectConfig::default(), close_group_cache_dir: None, max_message_size: default_max_message_size(), log_level: default_log_level(), @@ -607,6 +772,13 @@ mod tests { assert!(config.cache_capacity > 0, "Cache capacity must be positive"); } + #[test] + fn default_webrtc_listener_tracks_compile_time_feature() { + let config = WebRtcDirectConfig::default(); + assert_eq!(config.enabled, cfg!(feature = "webrtc-direct")); + assert_eq!(config.bind, "0.0.0.0:0".parse().expect("wildcard bind")); + } + #[test] fn test_default_evm_network() { use crate::payment::EvmVerifierConfig; diff --git a/src/devnet.rs b/src/devnet.rs index d9e9de09..fc16f0ed 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -28,6 +28,19 @@ use tokio::task::JoinHandle; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +#[cfg(feature = "webrtc-direct")] +use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse}; +#[cfg(feature = "webrtc-direct")] +use crate::browser::{ + browser_payment_network, BrowserBootstrapNode, BrowserPaymentNetwork, BrowserPublicFile, +}; +#[cfg(feature = "webrtc-direct")] +use crate::config::WebRtcDirectConfig; +#[cfg(feature = "webrtc-direct")] +use bytes::Bytes; +#[cfg(feature = "webrtc-direct")] +use std::collections::HashMap; + // ============================================================================= // Devnet Constants // ============================================================================= @@ -215,6 +228,12 @@ pub struct DevnetConfig { /// Optional IPv4 to advertise to peers/clients (LAN devnet). When `Some`, /// nodes bind 0.0.0.0 and advertise this IP instead of 127.0.0.1. pub advertise_ip: Option, + + /// Run one direct-browser WebRTC Direct listener per devnet node. + pub webrtc_direct: bool, + + /// First UDP port in the WebRTC Direct node range (0 = allocate). + pub webrtc_direct_base_port: u16, } impl Default for DevnetConfig { @@ -237,6 +256,8 @@ impl Default for DevnetConfig { cleanup_data_dir: true, evm_network: None, advertise_ip: None, + webrtc_direct: false, + webrtc_direct_base_port: 0, } } } @@ -320,6 +341,10 @@ pub struct DevnetNode { state: Arc>, bootstrap_addrs: Vec, protocol_task: Option>, + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: Option>, + #[cfg(feature = "webrtc-direct")] + browser_endpoint: Option, } impl DevnetNode { @@ -340,6 +365,8 @@ pub struct Devnet { shutdown: CancellationToken, state: Arc>, health_monitor: Option>, + #[cfg(feature = "webrtc-direct")] + browser_endpoint_catalog: Arc, } impl Devnet { @@ -350,6 +377,7 @@ impl Devnet { /// Returns `DevnetError::Config` if the configuration is invalid (e.g. bootstrap /// count exceeds node count or port range overflow). /// Returns `DevnetError::Io` if the data directory cannot be created. + #[allow(clippy::too_many_lines)] pub async fn new(mut config: DevnetConfig) -> Result { if config.bootstrap_count >= config.node_count { return Err(DevnetError::Config( @@ -388,6 +416,64 @@ impl Devnet { ))); } + #[cfg(not(feature = "webrtc-direct"))] + if config.webrtc_direct { + return Err(DevnetError::Config( + "WebRtcDirect devnet support requires the 'webrtc-direct' feature".to_string(), + )); + } + + #[cfg(feature = "webrtc-direct")] + if config.webrtc_direct { + if config.webrtc_direct_base_port == 0 { + let adjacent = max_port; + let adjacent_end = adjacent.checked_add(node_count_u16); + config.webrtc_direct_base_port = if adjacent_end + .is_some_and(|end| end <= DEVNET_PORT_RANGE_MAX) + { + adjacent + } else if let Some(before) = base_port + .checked_sub(node_count_u16) + .filter(|before| *before >= DEVNET_PORT_RANGE_MIN) + { + before + } else { + let mut rng = rand::thread_rng(); + let max_base = DEVNET_PORT_RANGE_MAX.saturating_sub(node_count_u16); + (0..128) + .map(|_| rng.gen_range(DEVNET_PORT_RANGE_MIN..max_base)) + .find(|candidate| { + let end = candidate.saturating_add(node_count_u16); + end <= base_port || *candidate >= max_port + }) + .ok_or_else(|| { + DevnetError::Config( + "Could not allocate a disjoint WebRtcDirect port range".to_string(), + ) + })? + }; + } + + let webrtc_direct_end = config + .webrtc_direct_base_port + .checked_add(node_count_u16) + .ok_or_else(|| { + DevnetError::Config("WebRtcDirect port range overflow".to_string()) + })?; + if config.webrtc_direct_base_port < DEVNET_PORT_RANGE_MIN + || webrtc_direct_end > DEVNET_PORT_RANGE_MAX + { + return Err(DevnetError::Config(format!( + "WebRtcDirect ports must remain in the local test range {DEVNET_PORT_RANGE_MIN}..{DEVNET_PORT_RANGE_MAX}" + ))); + } + if base_port < webrtc_direct_end && config.webrtc_direct_base_port < max_port { + return Err(DevnetError::Config( + "Native and WebRtcDirect devnet port ranges overlap".to_string(), + )); + } + } + tokio::fs::create_dir_all(&config.data_dir).await?; Ok(Self { @@ -396,6 +482,8 @@ impl Devnet { shutdown: CancellationToken::new(), state: Arc::new(RwLock::new(NetworkState::Uninitialized)), health_monitor: None, + #[cfg(feature = "webrtc-direct")] + browser_endpoint_catalog: Arc::new(crate::web_rtc::BrowserEndpointCatalog::default()), }) } @@ -448,6 +536,15 @@ impl Devnet { if let Some(handle) = node.protocol_task.take() { handle.abort(); } + #[cfg(feature = "webrtc-direct")] + if let Some(handle) = node.webrtc_direct_task.take() { + if let Err(error) = handle.await { + warn!( + "Error stopping node {} WebRtcDirect listener: {error}", + node.index + ); + } + } let node_index = node.index; let node_state = Arc::clone(&node.state); @@ -496,6 +593,210 @@ impl Devnet { .collect() } + /// Get every direct browser endpoint in this devnet. + #[cfg(feature = "webrtc-direct")] + #[must_use] + pub fn browser_endpoints(&self) -> Vec { + self.nodes + .iter() + .filter_map(|node| { + node.browser_endpoint + .clone() + .map(|endpoint| BrowserBootstrapNode { endpoint }) + }) + .collect() + } + + /// Publish a complete self-encrypted file to the browser-enabled devnet. + /// + /// The file is split using the same `self_encryption` crate as `ant-client`. + /// Every encrypted data chunk and the public `MessagePack` `DataMap` are then + /// submitted through each node's ordinary chunk PUT handler. Address + /// verification, DHT responsibility, payment-cache admission, and LMDB + /// integrity checks therefore remain active. + /// + /// # Errors + /// + /// Returns an error when WebRTC Direct is disabled, self-encryption fails, + /// a generated chunk is too large, no node admits a required record, or + /// protocol serialization fails. + #[cfg(feature = "webrtc-direct")] + pub async fn publish_public_file( + &self, + name: String, + content_type: String, + content: &[u8], + ) -> Result { + if !self.config.webrtc_direct { + return Err(DevnetError::Config( + "Cannot publish a browser file when WebRtcDirect is disabled".to_string(), + )); + } + if content.len() < self_encryption::MIN_ENCRYPTABLE_BYTES { + return Err(DevnetError::Config(format!( + "Public file is {} bytes; self-encryption requires at least {} bytes", + content.len(), + self_encryption::MIN_ENCRYPTABLE_BYTES + ))); + } + + let (published_data_map, encrypted_chunks) = + self_encryption::encrypt(Bytes::copy_from_slice(content)).map_err(|error| { + DevnetError::Core(format!("Failed to self-encrypt browser file: {error}")) + })?; + let mut records = HashMap::<[u8; 32], Bytes>::new(); + for chunk in encrypted_chunks { + if chunk.content.len() > crate::ant_protocol::MAX_CHUNK_SIZE { + return Err(DevnetError::Core(format!( + "Self-encryption produced a {}-byte chunk; node maximum is {}", + chunk.content.len(), + crate::ant_protocol::MAX_CHUNK_SIZE + ))); + } + let address = crate::client::compute_address(&chunk.content); + records.entry(address).or_insert(chunk.content); + } + + let mut get_local_chunk = |address: self_encryption::XorName| { + records.get(&address.0).cloned().ok_or_else(|| { + self_encryption::Error::Generic(format!( + "Self-encryption output omitted chunk {}", + hex::encode(address.0) + )) + }) + }; + let root_data_map = + self_encryption::get_root_data_map(published_data_map.clone(), &mut get_local_chunk) + .map_err(|error| { + DevnetError::Core(format!("Failed to resolve browser file DataMap: {error}")) + })?; + let serialized_data_map = rmp_serde::to_vec(&published_data_map).map_err(|error| { + DevnetError::Core(format!("Failed to serialize browser file DataMap: {error}")) + })?; + let data_map_size = serialized_data_map.len(); + let data_map_address = crate::client::compute_address(&serialized_data_map); + records.insert(data_map_address, Bytes::from(serialized_data_map)); + + let record_count = records.len(); + let mut replicas = usize::MAX; + for (address, bytes) in &records { + replicas = replicas.min(self.publish_browser_record(*address, bytes).await?); + } + + let chunks = root_data_map + .infos() + .iter() + .map(|info| crate::browser::BrowserChunkInfo { + index: info.index, + dst_hash: hex::encode(info.dst_hash.0), + src_hash: hex::encode(info.src_hash.0), + src_size: info.src_size, + }) + .collect(); + let published = BrowserPublicFile { + name, + address: hex::encode(data_map_address), + size: content.len(), + content_type, + blake3: hex::encode(crate::client::compute_address(content)), + data_map_size, + chunks, + replicas, + }; + info!( + "Published browser devnet file '{}' at {} as {record_count} record(s), each on at least {} node(s)", + published.name, published.address, published.replicas + ); + Ok(published) + } + + /// Public EVM configuration advertised to direct browser clients. + #[cfg(feature = "webrtc-direct")] + #[must_use] + pub fn browser_payment_network(&self) -> BrowserPaymentNetwork { + let network = self + .config + .evm_network + .as_ref() + .unwrap_or(&EvmNetwork::ArbitrumOne); + browser_payment_network(network) + } + + #[cfg(feature = "webrtc-direct")] + async fn publish_browser_record(&self, address: [u8; 32], content: &Bytes) -> Result { + let mut replicas = 0usize; + let mut failures = Vec::new(); + + for node in &self.nodes { + let Some(protocol) = node.ant_protocol.as_ref() else { + failures.push(format!("node {} has no protocol handler", node.index)); + continue; + }; + protocol + .payment_verifier_arc() + .cache_insert_browser_devnet_seed(address); + + let request = ChunkMessage { + request_id: u64::try_from(node.index).unwrap_or(u64::MAX), + body: ChunkMessageBody::PutRequest(ChunkPutRequest::new(address, content.clone())), + }; + let request_bytes = request.encode().map_err(|error| { + DevnetError::Core(format!("Failed to encode public-file PUT: {error}")) + })?; + let response_bytes = protocol + .try_handle_request(&request_bytes) + .await + .map_err(|error| { + DevnetError::Core(format!( + "Node {} public-file PUT failed: {error}", + node.index + )) + })? + .ok_or_else(|| { + DevnetError::Core(format!( + "Node {} returned no public-file PUT response", + node.index + )) + })?; + let response = ChunkMessage::decode(&response_bytes).map_err(|error| { + DevnetError::Core(format!( + "Failed to decode node {} public-file response: {error}", + node.index + )) + })?; + match response.body { + ChunkMessageBody::PutResponse( + ChunkPutResponse::Success { .. } | ChunkPutResponse::AlreadyExists { .. }, + ) => { + replicas += 1; + } + ChunkMessageBody::PutResponse(other) => { + failures.push(format!("node {}: {other:?}", node.index)); + } + other => failures.push(format!( + "node {} returned unexpected response {other:?}", + node.index + )), + } + } + + if replicas == 0 { + return Err(DevnetError::Startup(format!( + "No devnet node admitted browser record {}: {}", + hex::encode(address), + failures.join("; ") + ))); + } + if !failures.is_empty() { + debug!( + "Browser record was admitted by {replicas} nodes; {} non-responsible/failed nodes: {}", + failures.len(), + failures.join("; ") + ); + } + Ok(replicas) + } + async fn start_bootstrap_nodes(&mut self) -> Result<()> { info!("Starting {} bootstrap nodes", self.config.bootstrap_count); @@ -587,6 +888,10 @@ impl Devnet { state: Arc::new(RwLock::new(NodeState::Pending)), bootstrap_addrs, protocol_task: None, + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: None, + #[cfg(feature = "webrtc-direct")] + browser_endpoint: None, }) } @@ -638,6 +943,7 @@ impl Devnet { )) } + #[allow(clippy::too_many_lines)] async fn start_node(&mut self, mut node: DevnetNode) -> Result<()> { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; @@ -675,6 +981,62 @@ impl Devnet { node.p2p_node = Some(Arc::new(p2p_node)); *node.state.write().await = NodeState::Running; + #[cfg(feature = "webrtc-direct")] + if self.config.webrtc_direct { + let index_u16 = u16::try_from(node.index).map_err(|_| { + DevnetError::Config(format!("Node index {} exceeds u16::MAX", node.index)) + })?; + let port = self + .config + .webrtc_direct_base_port + .checked_add(index_u16) + .ok_or_else(|| { + DevnetError::Config(format!( + "WebRtcDirect port overflow for node {}", + node.index + )) + })?; + let advertised_ip = self.config.advertise_ip.unwrap_or(Ipv4Addr::LOCALHOST); + let bind_ip = self + .config + .advertise_ip + .map_or(Ipv4Addr::LOCALHOST, |_| Ipv4Addr::UNSPECIFIED); + let webrtc_direct_config = WebRtcDirectConfig { + enabled: true, + bind: SocketAddr::from((bind_ip, port)), + advertised_addr: Some(SocketAddr::from((advertised_ip, port))), + ..WebRtcDirectConfig::default() + }; + + let p2p = node.p2p_node.clone().ok_or_else(|| { + DevnetError::Startup(format!( + "Node {} lost its P2P handle before WebRtcDirect startup", + node.index + )) + })?; + let server = crate::web_rtc::spawn( + &webrtc_direct_config, + &node.data_dir, + p2p, + node.ant_protocol.clone(), + self.config + .evm_network + .as_ref() + .unwrap_or(&EvmNetwork::ArbitrumOne), + self.shutdown.clone(), + Some(Arc::clone(&self.browser_endpoint_catalog)), + ) + .await + .map_err(|error| { + DevnetError::Startup(format!( + "Failed to start node {} WebRtcDirect listener: {error}", + node.index + )) + })?; + node.browser_endpoint = Some(server.endpoint); + node.webrtc_direct_task = Some(server.task); + } + if let (Some(ref p2p), Some(ref protocol)) = (&node.p2p_node, &node.ant_protocol) { // Wire P2P into AntProtocol for payment-proof closeness checks. protocol.attach_p2p_node(Arc::clone(p2p)); diff --git a/src/lib.rs b/src/lib.rs index 38cc9096..b20a2cd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ #![cfg_attr(not(feature = "logging"), allow(unused_variables, unused_assignments))] pub mod ant_protocol; +pub mod browser; pub mod client; pub mod config; pub mod devnet; @@ -54,17 +55,23 @@ pub mod payment; pub mod replication; pub mod storage; pub mod upgrade; +#[cfg(feature = "webrtc-direct")] +mod web_rtc; pub use ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY, CLOSE_GROUP_SIZE, MAX_CHUNK_SIZE, }; +pub use browser::{ + BrowserBootstrapNode, BrowserChunkInfo, BrowserDevnetManifest, BrowserEndpoint, + BrowserPaymentNetwork, BrowserPublicFile, WebRtcDirectEndpoint, BROWSER_MANIFEST_VERSION, +}; pub use client::{ compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, XorName, }; -pub use config::{NodeConfig, StorageConfig}; +pub use config::{NodeConfig, StorageConfig, WebRtcDirectConfig}; pub use devnet::{Devnet, DevnetConfig, DevnetEvmInfo, DevnetManifest}; pub use error::{Error, Result}; pub use event::{NodeEvent, NodeEventsChannel}; @@ -79,6 +86,6 @@ pub mod core { pub use saorsa_core::identity::{NodeIdentity, PeerId}; pub use saorsa_core::{ IPDiversityConfig, MlDsa65, MultiAddr, NodeConfig as CoreNodeConfig, NodeMode, P2PEvent, - P2PNode, + P2PNode, WebRtcCertificateHash, WebRtcDirectAddr, }; } diff --git a/src/node.rs b/src/node.rs index f98f4dee..002ed28d 100644 --- a/src/node.rs +++ b/src/node.rs @@ -88,6 +88,15 @@ impl NodeBuilder { Self::validate_production_rewards_address(&self.config)?; + #[cfg(not(feature = "webrtc-direct"))] + if self.config.webrtc_direct.enabled { + return Err(Error::Config( + "webrtc_direct is enabled but this binary was not built with the \ + 'webrtc-direct' feature" + .to_string(), + )); + } + // Resolve identity and root_dir (may update self.config.root_dir) let identity = Arc::new(Self::resolve_identity(&mut self.config).await?); let peer_id = identity.peer_id().to_hex(); @@ -213,6 +222,8 @@ impl NodeBuilder { ant_protocol, replication_engine, protocol_task: None, + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: None, upgrade_exit_code: Arc::new(AtomicI32::new(-1)), }; @@ -376,7 +387,7 @@ impl NodeBuilder { if let Ok(cache_dir) = upgrade_cache_dir() { monitor = monitor.with_release_cache(ReleaseCache::new( cache_dir, - std::time::Duration::from_secs(3600), + std::time::Duration::from_hours(1), )); } @@ -472,6 +483,9 @@ pub struct RunningNode { replication_engine: Option, /// Protocol message routing background task. protocol_task: Option>, + /// ADR-0009 experimental browser listener task. + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: Option>, /// Exit code requested by a successful upgrade (-1 = no upgrade exit pending). upgrade_exit_code: Arc, } @@ -532,6 +546,43 @@ impl RunningNode { "Node is running on port: {}", actual_port ); + #[cfg(feature = "webrtc-direct")] + if self.config.webrtc_direct.enabled { + let bind_is_ipv4 = self.config.webrtc_direct.bind.is_ipv4(); + let observed_ip = self + .p2p_node + .transport() + .non_relay_external_addresses() + .into_iter() + .find(|addr| addr.is_ipv4() == bind_is_ipv4) + .map(|addr| addr.ip()); + let webrtc_direct_config = crate::web_rtc::resolve_automatic_config( + &self.config.webrtc_direct, + actual_port, + observed_ip, + ); + let evm_network = self.config.payment.evm_network.clone().into_evm_network(); + match crate::web_rtc::spawn( + &webrtc_direct_config, + &self.config.root_dir, + Arc::clone(&self.p2p_node), + self.ant_protocol.clone(), + &evm_network, + self.shutdown.clone(), + None, + ) + .await + { + Ok(server) => self.webrtc_direct_task = Some(server.task), + Err(error) => { + if let Err(shutdown_error) = self.p2p_node.shutdown().await { + warn!("P2P shutdown after WebRtcDirect startup failure failed: {shutdown_error}"); + } + return Err(error); + } + } + } + // Emit started event if let Err(e) = self.events_tx.send(NodeEvent::Started) { warn!("Failed to send Started event: {e}"); @@ -700,6 +751,15 @@ impl RunningNode { // Run the main event loop with signal handling self.run_event_loop().await?; + // The shared token closes the WebRtcDirect accept loop and active + // browser sessions before storage and native P2P are torn down. + #[cfg(feature = "webrtc-direct")] + if let Some(task) = self.webrtc_direct_task.take() { + if let Err(error) = task.await { + warn!("WebRtcDirect task shutdown failed: {error}"); + } + } + // Shutdown replication engine before P2P so background tasks don't // use a dead P2P layer, and Arc references are released. if let Some(ref mut engine) = self.replication_engine { diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index fd550c77..29c1905a 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1232,6 +1232,18 @@ impl PaymentVerifier { self.cache.insert(xorname); } + /// Mark startup content as prepaid for the in-process browser devnet. + /// + /// This remains crate-private and feature-gated: it is used only by + /// [`crate::devnet::Devnet::publish_public_file`] before that local devnet + /// is handed to a browser. The subsequent PUT still traverses the normal + /// address, responsibility, payment-cache, storage, and read-verification + /// checks. + #[cfg(feature = "webrtc-direct")] + pub(crate) fn cache_insert_browser_devnet_seed(&self, xorname: XorName) { + self.cache.insert(xorname); + } + /// Pre-populate the merkle pool cache. Testing helper that lets e2e tests /// bypass the on-chain `completedMerklePayments` lookup when the point of /// the test is to exercise merkle-verification logic BEFORE the on-chain @@ -2713,7 +2725,7 @@ impl PaymentVerifier { /// at which point a second leader can race for the same pool (see /// [`InflightGuard::drop`]). At steady state the pool cache and pool /// signature verification gate keep this rare in practice. - const CLOSENESS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(240); + const CLOSENESS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(4); /// Width of the storer's authoritative network lookup, in peers. /// @@ -5728,7 +5740,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Create a quote that's 25 hours old (exceeds 24-hour max) - let old_timestamp = SystemTime::now() - Duration::from_secs(25 * 3600); + let old_timestamp = SystemTime::now() - Duration::from_hours(25); let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5758,7 +5770,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Create a quote with a timestamp 1 hour in the future - let future_timestamp = SystemTime::now() + Duration::from_secs(3600); + let future_timestamp = SystemTime::now() + Duration::from_hours(1); let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5819,7 +5831,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Quote 360 seconds in the future — exceeds 300s tolerance - let future_timestamp = SystemTime::now() + Duration::from_secs(360); + let future_timestamp = SystemTime::now() + Duration::from_mins(6); let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5849,7 +5861,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Quote 23 hours old — within 24h max age - let old_timestamp = SystemTime::now() - Duration::from_secs(23 * 3600); + let old_timestamp = SystemTime::now() - Duration::from_hours(23); let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -7562,7 +7574,7 @@ mod tests { // failure mode from the trace in the doc comment will return. assert_eq!( PaymentVerifier::CLOSENESS_LOOKUP_TIMEOUT, - std::time::Duration::from_secs(240), + std::time::Duration::from_mins(4), "CLOSENESS_LOOKUP_TIMEOUT must be 240s; if changing this, update \ the iteration trace in the doc comment and re-validate on a \ fresh testnet" @@ -8154,7 +8166,7 @@ mod tests { let built = test_built_commitment(5); let commitment = built.commitment().clone(); let pin = built.hash(); - let ttl = std::time::Duration::from_secs(3 * 3600); + let ttl = std::time::Duration::from_hours(3); let now = std::time::Instant::now(); // Fresh AND matching pin -> resolves to the commitment. diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs index 9bbfd3ee..cd7271ad 100644 --- a/src/replication/commitment.rs +++ b/src/replication/commitment.rs @@ -372,7 +372,7 @@ pub fn verify_path( let mut cur = *leaf; let mut i = leaf_index; for sibling in path { - cur = if i % 2 == 0 { + cur = if i.is_multiple_of(2) { node_hash(&cur, sibling) } else { node_hash(sibling, &cur) diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index 8c7b2840..a49985b3 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -334,7 +334,7 @@ const MAX_RETAINED_GOSSIPED_SLOTS: usize = 16; /// stops being gossiped the pruner reclaims it promptly. At /// `RETAINED_GOSSIPED_COMMITMENTS = 2` this is `(2 + 1) ×` the 1 h rotation /// interval = 3 h. -pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_secs(3 * 3600); +pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_hours(3); /// Extra answerability margin applied ONLY when reloading retention after a /// restart (ADR-0004 A1). A gossip-stamp refresh in the last persist window may @@ -344,7 +344,7 @@ pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_secs(3 * 36 /// which is harmless — it only makes the responder answer a little longer, and a /// data-deleter still fails the round-2 slice challenge). Sized well above the /// persist interval + gossip cadence, far below the TTL. -const RESTART_STAMP_GRACE: Duration = Duration::from_secs(5 * 60); +const RESTART_STAMP_GRACE: Duration = Duration::from_mins(5); /// One persisted retention slot (ADR-0004 A1): the signed commitment, its /// committed key set (so the tree can be rebuilt without re-reading chunks), and diff --git a/src/replication/config.rs b/src/replication/config.rs index 488b8899..6cb7ea3f 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -846,13 +846,13 @@ pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; /// cooldown. Finer than the cooldown itself so a monetized commitment is /// first-audited promptly after its peer's window reopens; the retry just /// re-checks a small per-peer map, so the tick is cheap. -pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_secs(60); +pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_mins(1); /// Interval for the cumulative first-audit scheduler observability summary. /// /// Deliberately low frequency: this is intended for fleet-level Elasticsearch /// aggregation without recreating the high-volume logging load it measures. -pub const FIRST_AUDIT_SUMMARY_INTERVAL: Duration = Duration::from_secs(5 * 60); +pub const FIRST_AUDIT_SUMMARY_INTERVAL: Duration = Duration::from_mins(5); /// ADR-0004: max monetized-pin events the first-audit drainer drains from its /// channel per wake before it must run the audit-launch phase. @@ -875,7 +875,7 @@ pub const FIRST_AUDIT_DRAIN_BATCH: usize = 64; /// [`FIRST_AUDIT_BUDGET_BURST`] more in the first hour); steady-state demand /// sits far below it because nomination is paid-pin-only and re-nominations /// are suppressed by [`FIRST_AUDIT_PEER_REAUDIT_INTERVAL`]. -pub const FIRST_AUDIT_LAUNCH_INTERVAL: Duration = Duration::from_secs(5 * 60); +pub const FIRST_AUDIT_LAUNCH_INTERVAL: Duration = Duration::from_mins(5); /// ADR-0004 Amendment 2: token-bucket capacity for monetized first-audit /// launches. @@ -918,7 +918,7 @@ pub const FIRST_AUDIT_LAUNCH_JITTER_MAX: Duration = Duration::from_secs(30); /// only, so no gossip-lottery audit can ever cover them). Kept comfortably /// inside the 3h answerability TTL so a re-nomination after the window still /// lands in-window. Gossip-lottery re-audits are unaffected. -pub const FIRST_AUDIT_PEER_REAUDIT_INTERVAL: Duration = Duration::from_secs(2 * 3600); +pub const FIRST_AUDIT_PEER_REAUDIT_INTERVAL: Duration = Duration::from_hours(2); /// ADR-0004 Amendment 2: committed-count jump that overrides the per-peer /// re-audit window, as a ratio (`new > old * NUM / DEN`, integer math). @@ -1433,10 +1433,7 @@ mod tests { #[test] fn default_prune_hysteresis_is_three_days() { let config = ReplicationConfig::default(); - assert_eq!( - config.prune_hysteresis_duration, - Duration::from_secs(3 * 24 * 60 * 60) - ); + assert_eq!(config.prune_hysteresis_duration, Duration::from_hours(72)); } #[test] @@ -2000,8 +1997,8 @@ mod tests { #[test] fn scenario_31_audit_cadence_within_jitter_bounds() { let config = ReplicationConfig { - audit_tick_interval_min: Duration::from_secs(600), - audit_tick_interval_max: Duration::from_secs(1200), + audit_tick_interval_min: Duration::from_mins(10), + audit_tick_interval_max: Duration::from_mins(20), ..ReplicationConfig::default() }; diff --git a/src/replication/mod.rs b/src/replication/mod.rs index c9367996..594437b8 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -402,7 +402,7 @@ impl FirstAuditLimiter { /// call at ENQUEUE time — suppressed nominations never occupy pending /// slots — without disturbing LRU recency. fn window_allows(&self, peer: &PeerId, key_count: u32, now: Instant) -> bool { - self.recent.peek(peer).map_or(true, |prev| { + self.recent.peek(peer).is_none_or(|prev| { now.saturating_duration_since(prev.launched_at) >= config::FIRST_AUDIT_PEER_REAUDIT_INTERVAL || first_audit_count_jump(prev.key_count, key_count) @@ -558,7 +558,7 @@ async fn open_first_audit_reservation( /// needs to be comfortably past the next tick. fn first_audit_far_future() -> Instant { Instant::now() - .checked_add(Duration::from_secs(3600)) + .checked_add(Duration::from_hours(1)) .unwrap_or_else(Instant::now) } @@ -1541,7 +1541,7 @@ const BOOTSTRAP_STATE_SNAPSHOT_INTERVAL_SECS: u64 = 60; /// (`quote_ts ≈ now`), far from either bound. The gossip-lottery path (which pins /// the responder's OWN freshly-gossiped root) is the clock-skew-immune backstop. /// 30 min dwarfs any realistic honest skew while leaving a wide audit window. -const MONETIZED_AUDIT_SKEW_MARGIN: Duration = Duration::from_secs(30 * 60); +const MONETIZED_AUDIT_SKEW_MARGIN: Duration = Duration::from_mins(30); /// ADR-0004 A1 (guardrail A): whether a monetized pin's SIGNED `quote_ts` lands /// inside the answerability window relative to `now`, so first-auditing it cannot @@ -1568,7 +1568,7 @@ fn quote_within_audit_window(quote_ts: SystemTime, now: SystemTime) -> bool { /// per gossip message. This rate limit caps the verify-per-peer rate /// at 1/min, which is comfortably above the legitimate gossip cadence /// (the 10-20 min neighbor-sync round on each peer). -const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_secs(60); +const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_mins(1); /// Hard cap on the size of `last_commitment_by_peer`. /// @@ -9440,9 +9440,8 @@ fn cooldown_allows_audit(map: &mut HashMap, peer: &PeerId, now: /// so this is only an optimization and never the security boundary. fn cooldown_would_allow(map: &HashMap, peer: &PeerId, now: Instant) -> bool { let cooldown = Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS); - map.get(peer).map_or(true, |&last| { - now.saturating_duration_since(last) >= cooldown - }) + map.get(peer) + .is_none_or(|&last| now.saturating_duration_since(last) >= cooldown) } /// The gossip-audit launch decision in ONE place so the ordering is shared @@ -11609,7 +11608,7 @@ mod tests { limiter.commit_launch(peer, 100, base); // A rotated pin with a similar count inside the window is dropped... - let soon = base + Duration::from_secs(60); + let soon = base + Duration::from_mins(1); assert_eq!( limiter.assess(&peer, 100, soon, 0), LimiterVerdict::WindowDeduped @@ -11688,7 +11687,7 @@ mod tests { // horizon. let future = now .checked_add(MONETIZED_AUDIT_SKEW_MARGIN) - .and_then(|t| t.checked_add(Duration::from_secs(60))) + .and_then(|t| t.checked_add(Duration::from_mins(1))) .expect("future"); assert!(!quote_answerable_through_nominal_jitter(future, now)); } @@ -11714,7 +11713,7 @@ mod tests { let dead_quote = SystemTime::now() .checked_sub(GOSSIP_ANSWERABILITY_TTL) - .and_then(|t| t.checked_sub(Duration::from_secs(60))) + .and_then(|t| t.checked_sub(Duration::from_mins(1))) .expect("past wall time"); let stale_high = MonetizedPinEvent { peer, @@ -12017,7 +12016,7 @@ mod tests { let dead_quote = SystemTime::now() .checked_sub(GOSSIP_ANSWERABILITY_TTL) - .and_then(|t| t.checked_sub(Duration::from_secs(60))) + .and_then(|t| t.checked_sub(Duration::from_mins(1))) .expect("past wall time"); scheduler.enqueue( MonetizedPinEvent { @@ -12716,17 +12715,14 @@ mod tests { let now = SystemTime::now(); // Fresh (just quoted) and small future/past skew -> audited. assert!(quote_within_audit_window(now, now)); + assert!(quote_within_audit_window(now + Duration::from_mins(1), now)); assert!(quote_within_audit_window( - now + Duration::from_secs(60), - now - )); - assert!(quote_within_audit_window( - now - Duration::from_secs(3600), + now - Duration::from_hours(1), now )); // Far future (badly-skewed / replayed) -> skipped. assert!(!quote_within_audit_window( - now + MONETIZED_AUDIT_SKEW_MARGIN + Duration::from_secs(60), + now + MONETIZED_AUDIT_SKEW_MARGIN + Duration::from_mins(1), now )); // Older than the window -> skipped (pin may have aged out). diff --git a/src/replication/neighbor_sync.rs b/src/replication/neighbor_sync.rs index 3ab9cab6..8e1d4d01 100644 --- a/src/replication/neighbor_sync.rs +++ b/src/replication/neighbor_sync.rs @@ -608,7 +608,7 @@ mod tests { .last_sync_times .insert(peer_id_from_byte(3), Instant::now()); - let cooldown = Duration::from_secs(3600); // 1 hour + let cooldown = Duration::from_hours(1); // 1 hour let batch = select_sync_batch(&mut state, 2, cooldown); // Peer 1 and peer 3 should be skipped (removed from order). @@ -668,7 +668,7 @@ mod tests { .last_sync_times .insert(peer_id_from_byte(2), Instant::now()); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); let batch = select_sync_batch(&mut state, 4, cooldown); assert!(batch.is_empty()); @@ -806,7 +806,7 @@ mod tests { let peers: Vec = (1..=8).map(peer_id_from_byte).collect(); let mut state = NeighborSyncState::new_cycle(peers); let batch_size = 4; - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark peers 2 and 4 as recently synced (on cooldown). state @@ -1019,7 +1019,7 @@ mod tests { peer_id_from_byte(3), ]; let mut state = NeighborSyncState::new_cycle(peers); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark peer 2 as recently synced. state @@ -1105,7 +1105,7 @@ mod tests { let peer_count: u8 = 10; let peers: Vec = (1..=peer_count).map(peer_id_from_byte).collect(); let mut state = NeighborSyncState::new_cycle(peers); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark all peers as recently synced. for i in 1..=peer_count { @@ -1309,7 +1309,7 @@ mod tests { // The preserved cooldowns cause peers 1,2 to be removed, leaving // only peer 3 selected. - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); let batch = select_sync_batch(&mut new_state, 3, cooldown); assert_eq!( batch, diff --git a/src/replication/paid_list.rs b/src/replication/paid_list.rs index f65172c1..d71bf059 100644 --- a/src/replication/paid_list.rs +++ b/src/replication/paid_list.rs @@ -1129,7 +1129,7 @@ mod tests { // cannot represent the backdated time (the claim-age assertion is // skipped in that case since the subtraction itself proves nothing // about production behaviour). - let grace_plus_margin = grace_period + std::time::Duration::from_secs(3600); + let grace_plus_margin = grace_period + std::time::Duration::from_hours(1); let first_seen = Instant::now() .checked_sub(grace_plus_margin) .unwrap_or_else(Instant::now); diff --git a/src/replication/recent_provers.rs b/src/replication/recent_provers.rs index e10be92c..63f3f4be 100644 --- a/src/replication/recent_provers.rs +++ b/src/replication/recent_provers.rs @@ -61,7 +61,7 @@ pub const MAX_PROVERS_PER_KEY: usize = 16; /// before re-audit is required. 40 min comfortably covers one audit /// cycle on the average peer while still requiring re-proof inside the /// rotation window. -pub const PROVER_ENTRY_TTL: Duration = Duration::from_secs(40 * 60); +pub const PROVER_ENTRY_TTL: Duration = Duration::from_mins(40); /// One cached prover entry: who proved the key, when, and against which /// commitment. diff --git a/src/replication/scheduling.rs b/src/replication/scheduling.rs index 79724291..2f1bb581 100644 --- a/src/replication/scheduling.rs +++ b/src/replication/scheduling.rs @@ -2413,7 +2413,7 @@ mod tests { let key = xor_name_from_byte(0x01); queues.add_pending_verify(key, test_entry(1)); - let evicted = queues.evict_stale(Duration::from_secs(3600)); + let evicted = queues.evict_stale(Duration::from_hours(1)); assert!( evicted.is_empty(), "fresh entry should not be reported as evicted" diff --git a/src/replication/subtree.rs b/src/replication/subtree.rs index 2fc8e2ee..a50a628f 100644 --- a/src/replication/subtree.rs +++ b/src/replication/subtree.rs @@ -153,7 +153,7 @@ fn sqrt_floor(key_count: u32) -> u32 { let mut y = x.div_ceil(2); while y < x { x = y; - y = (x + n / x) / 2; + y = u64::midpoint(x, n / x); } // x == floor(sqrt(n)) here. let ceil = if x.saturating_mul(x) == n { x } else { x + 1 }; diff --git a/src/replication/types.rs b/src/replication/types.rs index 040572e5..c96c69e5 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -1474,7 +1474,7 @@ mod tests { let peer = peer_id_from_byte(9); let mut state = NeighborSyncState::new_cycle(vec![peer]); let first_seen = Instant::now(); - let grace = Duration::from_secs(60); + let grace = Duration::from_mins(1); assert_eq!( state.observe_bootstrap_claim(peer, first_seen, grace), @@ -1503,7 +1503,7 @@ mod tests { let peer = peer_id_from_byte(10); let mut state = NeighborSyncState::new_cycle(vec![peer]); let first_seen = Instant::now(); - let grace = Duration::from_secs(60); + let grace = Duration::from_mins(1); let _ = state.observe_bootstrap_claim(peer, first_seen, grace); diff --git a/src/upgrade/apply.rs b/src/upgrade/apply.rs index 7af8c7d7..897bd8eb 100644 --- a/src/upgrade/apply.rs +++ b/src/upgrade/apply.rs @@ -52,7 +52,7 @@ impl AutoApplyUpgrader { current_version, client: reqwest::Client::builder() .user_agent(concat!("ant-node/", env!("CARGO_PKG_VERSION"))) - .timeout(std::time::Duration::from_secs(300)) + .timeout(std::time::Duration::from_mins(5)) .build() .unwrap_or_else(|_| reqwest::Client::new()), binary_cache: None, diff --git a/src/upgrade/monitor.rs b/src/upgrade/monitor.rs index b6d6b0a3..e366a398 100644 --- a/src/upgrade/monitor.rs +++ b/src/upgrade/monitor.rs @@ -296,7 +296,7 @@ impl UpgradeMonitor { let is_new_version = self .pending_upgrade_version .as_ref() - .map_or(true, |v| *v != info.version); + .is_none_or(|v| *v != info.version); if is_new_version { // New version detected - start rollout timer @@ -564,9 +564,7 @@ fn select_upgrade_from_releases( release_notes: release.body.clone(), }; - let should_replace = best - .as_ref() - .map_or(true, |b| candidate.version > b.version); + let should_replace = best.as_ref().is_none_or(|b| candidate.version > b.version); if should_replace { best = Some(candidate); @@ -894,10 +892,10 @@ mod tests { #[test] fn test_check_interval() { let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24); - assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600)); + assert_eq!(monitor.check_interval(), Duration::from_hours(24)); let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6); - assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600)); + assert_eq!(monitor2.check_interval(), Duration::from_hours(6)); } /// Test 11: Process release - upgrade available diff --git a/src/upgrade/release_cache.rs b/src/upgrade/release_cache.rs index af1496bc..e2d967e8 100644 --- a/src/upgrade/release_cache.rs +++ b/src/upgrade/release_cache.rs @@ -289,7 +289,7 @@ mod tests { #[test] fn test_write_read_roundtrip() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); cache.write("owner/repo", &sample_releases()).unwrap(); @@ -314,7 +314,7 @@ mod tests { #[test] fn test_wrong_repo_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); cache.write("owner/repo", &sample_releases()).unwrap(); @@ -324,7 +324,7 @@ mod tests { #[test] fn test_corrupted_file_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); fs::write(cache.cache_file(), "not valid json!!!").unwrap(); @@ -334,7 +334,7 @@ mod tests { #[test] fn test_missing_file_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); assert!(cache.read_if_valid("owner/repo").is_none()); } diff --git a/src/upgrade/rollout.rs b/src/upgrade/rollout.rs index 0a391863..e0e9ba0c 100644 --- a/src/upgrade/rollout.rs +++ b/src/upgrade/rollout.rs @@ -179,7 +179,7 @@ mod tests { let delay = rollout.calculate_delay(); // Should be between 0 and 24 hours - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); assert!(rollout.is_enabled()); } @@ -254,7 +254,7 @@ mod tests { let rollout = StagedRollout::new(&large_id, 24); let delay = rollout.calculate_delay(); - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); } /// Test 9: Empty node ID handled @@ -264,7 +264,7 @@ mod tests { let delay = rollout.calculate_delay(); // Should still produce a valid delay - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); } /// Test 10: Distribution test - ensure delays are spread across window diff --git a/src/web_rtc.rs b/src/web_rtc.rs new file mode 100644 index 00000000..b63950da --- /dev/null +++ b/src/web_rtc.rs @@ -0,0 +1,2202 @@ +//! ADR-0009 WebRTC Direct browser transport. +//! +//! The listener uses Saorsa's signaling-free WebRTC Direct transport for ICE, +//! DTLS, SCTP, and reliable ordered `DataChannels`. A shared application layer +//! in `saorsa-webrtc` uses ML-KEM-768, ML-DSA-65, and ChaCha20-Poly1305 to bind +//! the node identity and protect every browser RPC without libp2p or Noise. + +use crate::ant_protocol::{ + ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, + ChunkQuoteResponse, MAX_CHUNK_SIZE, +}; +use crate::browser::{browser_payment_network, BrowserEndpoint, BrowserPaymentNetwork}; +use crate::config::WebRtcDirectConfig; +use crate::error::{Error, Result}; +use crate::logging::{debug, info, warn}; +use crate::payment::{serialize_single_node_proof, PaymentProof}; +use crate::storage::AntProtocol; +use evmlib::common::{Amount, TxHash}; +use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; +use parking_lot::{Mutex, RwLock}; +use saorsa_core::identity::NodeIdentity; +use saorsa_core::{DHTNode, MultiAddr, P2PNode, PeerId}; +use saorsa_transport::webrtc_direct::{ + WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, +}; +use saorsa_webrtc::{ + accept_pq_session, decode_pq_frame, encode_response_frame, parse_request_header, + pq_frame_length, transfer_timeout, BrowserCommitmentArtifact, BrowserNode, + BrowserQuoteArtifact, BrowserRequest as Request, BrowserRequestBody as RequestBody, + BrowserResponse as Response, BrowserResponseBody as ResponseBody, + BrowserResponseStatus as ResponseStatus, PqSession, BROWSER_PROTOCOL_NAME, + BROWSER_PROTOCOL_VERSION, MAX_BROWSER_HEADER_BYTES, PQ_CLIENT_HELLO_BYTES, + PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_FRAME_PREFIX_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, + WEBRTC_WRITE_CHUNK_BYTES, +}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::task::{JoinHandle, JoinSet}; +use tokio_util::sync::CancellationToken; + +const MAX_FIND_NODE_RESULTS: usize = 20; +const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_mins(1); +const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); +const AUTOMATIC_PORT_MIN: u32 = 32_768; +const AUTOMATIC_PORT_COUNT: u32 = 65_536 - AUTOMATIC_PORT_MIN; +const TRACKED_SOURCE_MULTIPLIER: usize = 4; +const MIN_TRACKED_SOURCES: usize = 64; +const CONNECTION_CAPACITY_ERROR: &str = "global connection capacity exhausted"; +const SOURCE_CONNECTION_CAPACITY_ERROR: &str = "source connection capacity exhausted"; +const CHANNEL_CAPACITY_ERROR: &str = "global DataChannel capacity exhausted"; +const REQUEST_CAPACITY_ERROR: &str = "global request capacity exhausted"; +const REQUEST_RATE_ERROR: &str = "request rate limit exceeded"; +const GLOBAL_BYTE_CAPACITY_ERROR: &str = "global in-flight byte capacity exhausted"; +const SOURCE_BYTE_CAPACITY_ERROR: &str = "source in-flight byte capacity exhausted"; + +/// Filename containing the node's canonical browser bootstrap address. +/// +/// The file is written below the node root directory after the listener has +/// bound and is safe for deployment tooling to copy or print. Its contents are +/// public bootstrap metadata, not key material. +pub const WEBRTC_DIRECT_MULTIADDR_FILENAME: &str = "webrtc-direct.multiaddr"; + +/// Resolve the zero-configuration listener values used by ordinary nodes. +/// +/// A zero bind port is mapped deterministically from the native QUIC port into +/// the high UDP range. That keeps the complete browser multiaddress stable +/// across restarts and fits the high-port firewall range used by `ant-testnet`. +/// A wildcard bind without an explicit advertised address prefers the public +/// IP observed by the native transport and otherwise uses the IP selected by +/// the host routing table. +pub fn resolve_automatic_config( + config: &WebRtcDirectConfig, + native_port: u16, + observed_ip: Option, +) -> WebRtcDirectConfig { + let mut resolved = config.clone(); + if resolved.bind.port() == 0 { + let port = resolved + .advertised_addr + .map_or_else(|| automatic_webrtc_port(native_port), |addr| addr.port()); + resolved.bind.set_port(port); + } + + if resolved.advertised_addr.is_none() && resolved.bind.ip().is_unspecified() { + let bind_is_ipv4 = resolved.bind.is_ipv4(); + let advertised_ip = observed_ip + .filter(|ip| ip.is_ipv4() == bind_is_ipv4 && !ip.is_unspecified()) + .or_else(|| routed_local_ip(bind_is_ipv4)) + .unwrap_or({ + if bind_is_ipv4 { + IpAddr::V4(Ipv4Addr::LOCALHOST) + } else { + IpAddr::V6(Ipv6Addr::LOCALHOST) + } + }); + resolved.advertised_addr = Some(SocketAddr::new(advertised_ip, resolved.bind.port())); + } + + resolved +} + +fn automatic_webrtc_port(native_port: u16) -> u16 { + let native = u32::from(native_port); + let offset = if native < AUTOMATIC_PORT_MIN { + native + } else { + (native - AUTOMATIC_PORT_MIN + AUTOMATIC_PORT_COUNT / 2) % AUTOMATIC_PORT_COUNT + }; + u16::try_from(AUTOMATIC_PORT_MIN + offset).unwrap_or(u16::MAX) +} + +fn routed_local_ip(ipv4: bool) -> Option { + let (bind, route_probe) = if ipv4 { + ( + SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)), + SocketAddr::from((Ipv4Addr::new(192, 0, 2, 1), 9)), + ) + } else { + ( + SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)), + SocketAddr::from((Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 1), 9)), + ) + }; + let socket = UdpSocket::bind(bind).ok()?; + socket.connect(route_probe).ok()?; + socket.local_addr().ok().map(|addr| addr.ip()) +} + +/// Browser endpoints known to one or more listeners in the same process. +/// +/// Only the in-process devnet supplies this catalog. Independently deployed +/// nodes discover endpoints exclusively from authenticated DHT address +/// records. +#[derive(Default)] +pub struct BrowserEndpointCatalog { + endpoints: RwLock>, +} + +impl BrowserEndpointCatalog { + fn insert(&self, peer_id: PeerId, endpoint: BrowserEndpoint) { + self.endpoints.write().insert(peer_id, endpoint); + } + + fn get(&self, peer_id: &PeerId) -> Option { + self.endpoints.read().get(peer_id).cloned() + } +} + +/// Fixed-capacity token bucket with a one-second burst allowance. +/// +/// The bucket is deliberately constant-space: source churn must not turn the +/// request limiter itself into a memory-exhaustion surface. +struct RequestRateBucket { + rate_per_second: u128, + token_units: u128, + last_refill: Instant, +} + +impl RequestRateBucket { + fn new(rate_per_second: usize) -> Self { + let rate_per_second = rate_per_second as u128; + Self { + rate_per_second, + token_units: rate_per_second.saturating_mul(1_000_000_000), + last_refill: Instant::now(), + } + } + + fn allow(&mut self, now: Instant) -> bool { + let elapsed = now.saturating_duration_since(self.last_refill); + self.last_refill = now; + let capacity = self.rate_per_second.saturating_mul(1_000_000_000); + let refill = elapsed.as_nanos().saturating_mul(self.rate_per_second); + self.token_units = self.token_units.saturating_add(refill).min(capacity); + if self.token_units < 1_000_000_000 { + return false; + } + self.token_units -= 1_000_000_000; + true + } +} + +/// Atomic byte budget and an RAII reservation within it. +/// +/// A custom counter is used instead of a semaphore because WebRTC frames grow +/// incrementally and the accounting must resize without queueing an unbounded +/// number of waiters. +struct ByteBudget { + limit: usize, + in_use: AtomicUsize, +} + +impl ByteBudget { + fn new(limit: usize) -> Self { + Self { + limit, + in_use: AtomicUsize::new(0), + } + } + + fn try_acquire( + self: &Arc, + amount: usize, + error: &'static str, + ) -> ServerResult { + self.in_use + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(amount) + .filter(|next| *next <= self.limit) + }) + .map_err(|_| error.to_string())?; + Ok(ByteReservation { + budget: Arc::clone(self), + amount, + error, + }) + } + + #[cfg(test)] + fn in_use(&self) -> usize { + self.in_use.load(Ordering::Acquire) + } +} + +struct ByteReservation { + budget: Arc, + amount: usize, + error: &'static str, +} + +impl ByteReservation { + fn try_grow(&mut self, amount: usize) -> ServerResult<()> { + self.budget + .in_use + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(amount) + .filter(|next| *next <= self.budget.limit) + }) + .map_err(|_| self.error.to_string())?; + self.amount += amount; + Ok(()) + } + + fn shrink(&mut self, amount: usize) { + let released = amount.min(self.amount); + self.amount -= released; + self.budget.in_use.fetch_sub(released, Ordering::AcqRel); + } +} + +impl Drop for ByteReservation { + fn drop(&mut self) { + self.budget.in_use.fetch_sub(self.amount, Ordering::AcqRel); + } +} + +struct InFlightByteReservation { + source: ByteReservation, + global: ByteReservation, +} + +impl InFlightByteReservation { + fn try_grow(&mut self, amount: usize) -> ServerResult<()> { + self.source.try_grow(amount)?; + if let Err(error) = self.global.try_grow(amount) { + self.source.shrink(amount); + return Err(error); + } + Ok(()) + } + + fn resize(&mut self, amount: usize) -> ServerResult<()> { + if amount > self.source.amount { + self.try_grow(amount - self.source.amount) + } else { + let released = self.source.amount - amount; + self.source.shrink(released); + self.global.shrink(released); + Ok(()) + } + } +} + +struct TrackedBytes { + bytes: Vec, + reservation: InFlightByteReservation, +} + +impl TrackedBytes { + fn reserve_length(&mut self, length: usize) -> ServerResult<()> { + self.reservation.resize(length)?; + if self.bytes.capacity() < length { + self.bytes.reserve_exact(length - self.bytes.len()); + } + Ok(()) + } +} + +struct SourceQuota { + request_rate: Mutex, + bytes: Arc, +} + +struct SourceEntry { + active_connections: usize, + last_seen: Instant, + quota: Arc, +} + +#[derive(Default)] +struct SourceAdmissionState { + sources: HashMap, +} + +/// Admission and accounting shared by every association on one listener. +struct ListenerResources { + connection_limit: Arc, + channel_limit: Arc, + request_limit: Arc, + global_request_rate: Mutex, + global_bytes: Arc, + source_state: Mutex, + max_connections_per_ip: usize, + max_requests_per_second_per_ip: usize, + max_requests_per_second_per_connection: usize, + max_in_flight_bytes_per_ip: usize, + max_tracked_sources: usize, +} + +impl ListenerResources { + fn new(config: &WebRtcDirectConfig) -> Arc { + Arc::new(Self { + connection_limit: Arc::new(Semaphore::new(config.max_connections)), + channel_limit: Arc::new(Semaphore::new(config.max_channels)), + request_limit: Arc::new(Semaphore::new(config.max_concurrent_requests)), + global_request_rate: Mutex::new(RequestRateBucket::new(config.max_requests_per_second)), + global_bytes: Arc::new(ByteBudget::new(config.max_in_flight_bytes)), + source_state: Mutex::new(SourceAdmissionState::default()), + max_connections_per_ip: config.max_connections_per_ip, + max_requests_per_second_per_ip: config.max_requests_per_second_per_ip, + max_requests_per_second_per_connection: config.max_requests_per_second_per_connection, + max_in_flight_bytes_per_ip: config.max_in_flight_bytes_per_ip, + max_tracked_sources: config + .max_connections + .saturating_mul(TRACKED_SOURCE_MULTIPLIER) + .max(MIN_TRACKED_SOURCES), + }) + } + + fn try_admit_connection( + self: &Arc, + remote_addr: SocketAddr, + ) -> ServerResult { + let global = Arc::clone(&self.connection_limit) + .try_acquire_owned() + .map_err(|_| CONNECTION_CAPACITY_ERROR.to_string())?; + let ip = canonical_source_ip(remote_addr.ip()); + let source = { + let mut state = self.source_state.lock(); + if !state.sources.contains_key(&ip) && state.sources.len() >= self.max_tracked_sources { + let eviction = state + .sources + .iter() + .filter(|(_, entry)| entry.active_connections == 0) + .min_by_key(|(_, entry)| entry.last_seen) + .map(|(ip, _)| *ip); + let Some(eviction) = eviction else { + return Err(CONNECTION_CAPACITY_ERROR.to_string()); + }; + state.sources.remove(&eviction); + } + + let entry = state.sources.entry(ip).or_insert_with(|| SourceEntry { + active_connections: 0, + last_seen: Instant::now(), + quota: Arc::new(SourceQuota { + request_rate: Mutex::new(RequestRateBucket::new( + self.max_requests_per_second_per_ip, + )), + bytes: Arc::new(ByteBudget::new(self.max_in_flight_bytes_per_ip)), + }), + }); + if entry.active_connections >= self.max_connections_per_ip { + return Err(SOURCE_CONNECTION_CAPACITY_ERROR.to_string()); + } + entry.active_connections += 1; + entry.last_seen = Instant::now(); + Arc::clone(&entry.quota) + }; + let context = Arc::new(ConnectionResources { + listener: Arc::clone(self), + source, + request_rate: Mutex::new(RequestRateBucket::new( + self.max_requests_per_second_per_connection, + )), + }); + Ok(ConnectionAdmission { + listener: Arc::clone(self), + ip, + context, + _global: global, + }) + } + + fn release_connection(&self, ip: IpAddr) { + let mut state = self.source_state.lock(); + if let Some(entry) = state.sources.get_mut(&ip) { + entry.active_connections = entry.active_connections.saturating_sub(1); + entry.last_seen = Instant::now(); + } + } +} + +struct ConnectionResources { + listener: Arc, + source: Arc, + request_rate: Mutex, +} + +impl ConnectionResources { + fn try_admit_request(&self) -> ServerResult { + let permit = Arc::clone(&self.listener.request_limit) + .try_acquire_owned() + .map_err(|_| REQUEST_CAPACITY_ERROR.to_string())?; + let now = Instant::now(); + if !self.source.request_rate.lock().allow(now) + || !self.request_rate.lock().allow(now) + || !self.listener.global_request_rate.lock().allow(now) + { + return Err(REQUEST_RATE_ERROR.to_string()); + } + Ok(permit) + } + + fn try_reserve_bytes(&self, amount: usize) -> ServerResult { + let source = self + .source + .bytes + .try_acquire(amount, SOURCE_BYTE_CAPACITY_ERROR)?; + let global = self + .listener + .global_bytes + .try_acquire(amount, GLOBAL_BYTE_CAPACITY_ERROR)?; + Ok(InFlightByteReservation { source, global }) + } +} + +struct ConnectionAdmission { + listener: Arc, + ip: IpAddr, + context: Arc, + _global: OwnedSemaphorePermit, +} + +impl Drop for ConnectionAdmission { + fn drop(&mut self) { + self.listener.release_connection(self.ip); + } +} + +fn canonical_source_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or(IpAddr::V6(ip), IpAddr::V4), + IpAddr::V4(ip) => IpAddr::V4(ip), + } +} + +/// A running browser listener and the endpoint clients use to reach it. +pub struct WebRtcDirectServer { + /// Direct endpoint with its certificate pin embedded in the multiaddress. + pub endpoint: BrowserEndpoint, + /// Listener background task. + pub task: JoinHandle<()>, +} + +/// Start the feature-gated browser listener and return its endpoint and task. +pub async fn spawn( + config: &WebRtcDirectConfig, + root_dir: &Path, + p2p: Arc, + ant_protocol: Option>, + evm_network: &evmlib::Network, + shutdown: CancellationToken, + endpoint_catalog: Option>, +) -> Result { + validate_webrtc_config(config)?; + let certificate_path = certificate_path(config, root_dir); + let certificate = load_or_generate_certificate(&certificate_path).await?; + let certificate_sha256 = certificate + .sha256_digest() + .map_err(|error| Error::Startup(error.to_string()))?; + let listener = WebRtcDirectListener::bind(config.bind, certificate) + .await + .map_err(|error| { + Error::Startup(format!("failed to bind WebRTC Direct listener: {error}")) + })?; + let local_addr = listener.local_addr(); + let advertised_addr = advertised_addr(config, local_addr)?; + let peer_id = *p2p.peer_id(); + let identity = Arc::clone(p2p.transport().node_identity()); + let browser_endpoint = + BrowserEndpoint::new(advertised_addr, peer_id.to_bytes(), certificate_sha256) + .map_err(|error| Error::Config(error.to_string()))?; + let supplemental_endpoint = browser_endpoint.multiaddr.parse().map_err(|error| { + Error::Startup(format!( + "shared WebRTC endpoint codec produced an invalid transport address: {error}" + )) + })?; + persist_browser_endpoint(root_dir, &browser_endpoint).await?; + if let Some(catalog) = endpoint_catalog.as_ref() { + catalog.insert(peer_id, browser_endpoint.clone()); + } + let state = Arc::new(ServerState { + config: config.clone(), + identity, + p2p: Arc::clone(&p2p), + ant_protocol, + payment: browser_payment_network(evm_network), + endpoint: browser_endpoint.clone(), + endpoint_catalog, + }); + let resources = ListenerResources::new(config); + + info!( + bind = %local_addr, + multiaddr = %browser_endpoint.multiaddr, + certificate = %certificate_path.display(), + "ADR-0009 WebRTC Direct listening" + ); + + let task = tokio::spawn(serve_webrtc(listener, state, resources, shutdown)); + p2p.dht_manager() + .set_supplemental_self_addresses(vec![supplemental_endpoint]) + .await; + Ok(WebRtcDirectServer { + endpoint: browser_endpoint, + task, + }) +} + +async fn persist_browser_endpoint(root_dir: &Path, endpoint: &BrowserEndpoint) -> Result<()> { + let path = root_dir.join(WEBRTC_DIRECT_MULTIADDR_FILENAME); + let contents = format!("{}\n", endpoint.multiaddr); + tokio::fs::write(&path, contents).await.map_err(|error| { + Error::Startup(format!( + "failed to write WebRTC Direct endpoint {}: {error}", + path.display() + )) + }) +} + +fn validate_webrtc_config(config: &WebRtcDirectConfig) -> Result<()> { + for (name, value) in [ + ("max_connections", config.max_connections), + ("max_connections_per_ip", config.max_connections_per_ip), + ( + "max_channels_per_connection", + config.max_channels_per_connection, + ), + ("max_channels", config.max_channels), + ("max_concurrent_requests", config.max_concurrent_requests), + ] { + if value == 0 || value > Semaphore::MAX_PERMITS { + return Err(Error::Config(format!( + "webrtc_direct.{name} must be between 1 and {}", + Semaphore::MAX_PERMITS + ))); + } + } + for (name, value) in [ + ("max_requests_per_second", config.max_requests_per_second), + ( + "max_requests_per_second_per_ip", + config.max_requests_per_second_per_ip, + ), + ( + "max_requests_per_second_per_connection", + config.max_requests_per_second_per_connection, + ), + ("max_in_flight_bytes", config.max_in_flight_bytes), + ( + "max_in_flight_bytes_per_ip", + config.max_in_flight_bytes_per_ip, + ), + ] { + if value == 0 { + return Err(Error::Config(format!( + "webrtc_direct.{name} must be greater than zero" + ))); + } + } + if config.max_connections_per_ip >= config.max_connections { + return Err(Error::Config( + "webrtc_direct.max_connections_per_ip must be lower than max_connections".to_string(), + )); + } + let source_channel_ceiling = config + .max_connections_per_ip + .checked_mul(config.max_channels_per_connection) + .ok_or_else(|| { + Error::Config("webrtc_direct per-IP DataChannel ceiling overflows usize".to_string()) + })?; + if source_channel_ceiling >= config.max_channels { + return Err(Error::Config( + "webrtc_direct max_connections_per_ip * max_channels_per_connection must be lower than max_channels" + .to_string(), + )); + } + if source_channel_ceiling >= config.max_concurrent_requests { + return Err(Error::Config( + "webrtc_direct max_connections_per_ip * max_channels_per_connection must be lower than max_concurrent_requests" + .to_string(), + )); + } + if config.max_requests_per_second_per_connection > config.max_requests_per_second_per_ip { + return Err(Error::Config( + "webrtc_direct.max_requests_per_second_per_connection must not exceed max_requests_per_second_per_ip" + .to_string(), + )); + } + if config.max_requests_per_second_per_ip >= config.max_requests_per_second { + return Err(Error::Config( + "webrtc_direct.max_requests_per_second_per_ip must be lower than max_requests_per_second" + .to_string(), + )); + } + if config.max_in_flight_bytes_per_ip >= config.max_in_flight_bytes { + return Err(Error::Config( + "webrtc_direct.max_in_flight_bytes_per_ip must be lower than max_in_flight_bytes" + .to_string(), + )); + } + if config.max_request_bytes == 0 || config.max_request_bytes > MAX_BROWSER_HEADER_BYTES { + return Err(Error::Config(format!( + "webrtc_direct.max_request_bytes must be between 1 and {MAX_BROWSER_HEADER_BYTES}" + ))); + } + if config.advertised_addr.is_some_and(|addr| addr.port() == 0) { + return Err(Error::Config( + "webrtc_direct.advertised_addr must not use port zero".to_string(), + )); + } + Ok(()) +} + +fn certificate_path(config: &WebRtcDirectConfig, root_dir: &Path) -> PathBuf { + match config.certificate_path.as_ref() { + Some(path) if path.is_absolute() => path.clone(), + Some(path) => root_dir.join(path), + None => root_dir.join("webrtc-direct.pem"), + } +} + +async fn load_or_generate_certificate(path: &Path) -> Result { + match tokio::fs::read_to_string(path).await { + Ok(pem) => WebRtcCertificate::from_pem(&pem).map_err(|error| { + Error::Startup(format!( + "failed to load WebRTC certificate {}: {error}", + path.display() + )) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let certificate = WebRtcCertificate::generate().map_err(|error| { + Error::Startup(format!("failed to generate WebRTC certificate: {error}")) + })?; + tokio::fs::write(path, certificate.serialize_pem()).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await?; + } + Ok(certificate) + } + Err(error) => Err(error.into()), + } +} + +fn advertised_addr(config: &WebRtcDirectConfig, local_addr: SocketAddr) -> Result { + if let Some(addr) = config.advertised_addr { + return Ok(addr); + } + if local_addr.ip().is_unspecified() { + return Err(Error::Config( + "webrtc_direct.advertised_addr is required for a wildcard bind".to_string(), + )); + } + Ok(local_addr) +} + +#[allow(clippy::significant_drop_tightening)] +async fn serve_webrtc( + mut listener: WebRtcDirectListener, + state: Arc, + resources: Arc, + shutdown: CancellationToken, +) { + let mut connection_tasks = JoinSet::new(); + loop { + let connection = tokio::select! { + biased; + () = shutdown.cancelled() => break, + completed = connection_tasks.join_next(), if !connection_tasks.is_empty() => { + if let Some(Err(error)) = completed { + warn!(%error, "WebRTC Direct connection task failed"); + } + continue; + } + // Do not perform another ICE/DTLS/SCTP accept while every + // application connection slot is occupied. The transport's + // pending-association queue remains bounded, and completed + // sessions release a permit before this branch becomes eligible. + connection = listener.accept(), if resources.connection_limit.available_permits() > 0 => connection, + }; + match connection { + Ok(connection) => { + let remote_addr = connection.remote_addr(); + let admission = match resources.try_admit_connection(remote_addr) { + Ok(admission) => admission, + Err(error) => { + debug!(remote = %remote_addr, %error, "Rejected WebRTC Direct connection"); + if let Err(close_error) = connection.close().await { + debug!(remote = %remote_addr, %close_error, "Failed to close rejected connection"); + } + continue; + } + }; + let connection_resources = Arc::clone(&admission.context); + let connection_state = Arc::clone(&state); + let connection_shutdown = shutdown.clone(); + connection_tasks.spawn(async move { + let _admission = admission; + if let Err(error) = handle_connection( + connection, + connection_state, + connection_resources, + connection_shutdown, + ) + .await + { + debug!(remote = %remote_addr, "WebRTC Direct connection ended: {error}"); + } + }); + } + Err(error) => { + warn!("WebRTC Direct listener error: {error}"); + } + } + } + if let Err(error) = listener.close().await { + debug!("WebRTC Direct listener close failed: {error}"); + } + let drained = tokio::time::timeout(SHUTDOWN_DRAIN_TIMEOUT, async { + while let Some(result) = connection_tasks.join_next().await { + if let Err(error) = result { + debug!(%error, "WebRTC Direct connection task failed during shutdown"); + } + } + }) + .await; + if drained.is_err() { + warn!("WebRTC Direct connection tasks did not drain before shutdown deadline"); + connection_tasks.abort_all(); + while connection_tasks.join_next().await.is_some() {} + } + info!("ADR-0009 WebRTC Direct stopped"); +} + +async fn handle_connection( + mut connection: WebRtcDirectConnection, + state: Arc, + resources: Arc, + shutdown: CancellationToken, +) -> ServerResult<()> { + let remote_addr = connection.remote_addr(); + let channel_shutdown = shutdown.child_token(); + let mut channel_tasks = JoinSet::new(); + let outcome = loop { + let accepted = tokio::select! { + biased; + () = shutdown.cancelled() => break Ok(()), + completed = channel_tasks.join_next(), if !channel_tasks.is_empty() => { + if let Some(Err(error)) = completed { + debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed"); + } + // The v4 protocol uses persistent channels; it has no channel + // reopen/continuation handshake. Once the last channel ends, + // close the association promptly instead of retaining a stale + // per-IP connection slot while waiting for another channel. + if channel_tasks.is_empty() { + break Ok(()); + } + continue; + } + result = connection.accept_data_channel() => result, + }; + let channel = match accepted { + Ok(channel) => channel, + Err(error) => break Err(format!("DataChannel accept failed: {error}")), + }; + if channel_tasks.len() >= state.config.max_channels_per_connection { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); + } + break Err("per-connection DataChannel capacity exhausted".to_string()); + } + let Ok(channel_permit) = Arc::clone(&resources.listener.channel_limit).try_acquire_owned() + else { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); + } + break Err(CHANNEL_CAPACITY_ERROR.to_string()); + }; + let channel_state = Arc::clone(&state); + let channel_resources = Arc::clone(&resources); + let handler_shutdown = channel_shutdown.clone(); + channel_tasks.spawn(async move { + let _channel_permit = channel_permit; + if let Err(error) = handle_webrtc_channel( + &channel, + channel_state, + channel_resources, + handler_shutdown, + ) + .await + { + debug!(remote = %remote_addr, channel = channel.id(), "WebRTC Direct DataChannel ended: {error}"); + } + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, channel = channel.id(), %error, "Failed to close WebRTC Direct DataChannel"); + } + }); + }; + + // Stop every handler before returning its storage/P2P state. Closing the + // association alone is not a sufficient wake-up guarantee for work that + // is currently inside an application request. + channel_shutdown.cancel(); + if let Err(error) = connection.close().await { + debug!(remote = %remote_addr, %error, "Failed to close WebRTC Direct connection"); + } + while let Some(result) = channel_tasks.join_next().await { + if let Err(error) = result { + debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed during shutdown"); + } + } + outcome +} + +#[allow(clippy::significant_drop_tightening, clippy::too_many_lines)] +async fn handle_webrtc_channel( + channel: &WebRtcDataChannel, + state: Arc, + resources: Arc, + shutdown: CancellationToken, +) -> ServerResult<()> { + if channel.label() != WEBRTC_DIRECT_DATA_CHANNEL { + return Err(format!( + "unsupported DataChannel label {:?}", + channel.label() + )); + } + + let mut pq_session = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = establish_pq_session(channel, &state, &resources) => result?, + }; + let mut hello_completed = false; + loop { + let admitted_result = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = read_webrtc_request( + channel, + state.config.max_request_bytes, + &mut pq_session, + &resources, + ) => result, + }; + let admitted = match admitted_result { + Ok(request) => request, + Err(error) if is_quiet_channel_close(&error) => return Ok(()), + Err(error) => { + let response = Response::error(0, "invalid_request", error); + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } + return Ok(()); + } + }; + let AdmittedRequest { + request, + content, + _request_permit, + _in_flight_bytes, + } = admitted; + if request.version != BROWSER_PROTOCOL_VERSION { + let response = Response::error( + request.request_id, + "unsupported_version", + format!( + "protocol version {} is unsupported; expected {BROWSER_PROTOCOL_VERSION}", + request.version + ), + ); + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } + continue; + } + + let is_hello = matches!(&request.body, RequestBody::Hello); + if !is_hello && !hello_completed { + let response = Response::error( + request.request_id, + "authentication_required", + "HELLO must initialize this encrypted WebRTC session first".to_string(), + ); + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } + continue; + } + + let (response, content) = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = process_request(request, content, &state, &resources) => result?, + }; + if is_hello && matches!(&response.status, ResponseStatus::Ok) { + hello_completed = true; + } + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + content.as_ref(), + &resources, + ) => result?, + } + } +} + +fn is_quiet_channel_close(error: &str) -> bool { + matches!( + error, + "DataChannel closed" + | "request idle timeout" + | "request frame timed out" + | REQUEST_CAPACITY_ERROR + | REQUEST_RATE_ERROR + | GLOBAL_BYTE_CAPACITY_ERROR + | SOURCE_BYTE_CAPACITY_ERROR + ) || error.starts_with("PQ session:") +} + +async fn establish_pq_session( + channel: &WebRtcDataChannel, + state: &ServerState, + resources: &ConnectionResources, +) -> ServerResult { + let first_message = receive_first_message(channel, "PQ client hello idle timeout").await?; + // The post-quantum handshake is deliberately charged to the same work and + // rate envelopes as an RPC. Otherwise a source could churn channels and + // force unmetered ML-KEM/ML-DSA work without ever sending a request. + let _handshake_permit = resources.try_admit_request()?; + let client_hello = read_pq_payload_after_first( + first_message, + channel, + PQ_CLIENT_HELLO_BYTES, + "PQ client hello timed out", + resources, + ) + .await?; + let peer_id = *state.p2p.peer_id().to_bytes(); + let public_key = state.identity.public_key().as_bytes(); + let (server_accept, session) = + accept_pq_session(&client_hello.bytes, &peer_id, public_key, |transcript| { + state + .identity + .sign(transcript) + .map(|signature| signature.as_bytes().to_vec()) + }) + .map_err(|error| format!("PQ session: {error}"))?; + write_pq_payload(channel, &server_accept, resources).await?; + Ok(session) +} + +struct AdmittedRequest { + request: Request, + content: Vec, + _request_permit: OwnedSemaphorePermit, + _in_flight_bytes: InFlightByteReservation, +} + +async fn read_webrtc_request( + channel: &WebRtcDataChannel, + max_header_bytes: usize, + pq_session: &mut PqSession, + resources: &ConnectionResources, +) -> ServerResult { + let first_message = receive_first_message(channel, "request idle timeout").await?; + // Admission happens as soon as a client starts a frame. Idle persistent + // channels consume neither request-rate tokens nor request worker slots. + let request_permit = resources.try_admit_request()?; + let max_plaintext_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; + let mut encrypted = read_pq_payload_after_first( + first_message, + channel, + max_plaintext_bytes + PQ_ENCRYPTED_OVERHEAD_BYTES, + "request frame timed out", + resources, + ) + .await?; + let encrypted_len = encrypted.bytes.len(); + // AEAD opening briefly holds ciphertext and plaintext at once. Reserve the + // second buffer before asking the cryptographic layer to allocate it. + encrypted.reservation.try_grow(encrypted_len)?; + let frame = pq_session + .open(&encrypted.bytes) + .map_err(|error| format!("PQ session: {error}"))?; + let TrackedBytes { + bytes: encrypted_bytes, + mut reservation, + } = encrypted; + drop(encrypted_bytes); + reservation.resize(frame.len())?; + + let (request, content_offset) = + parse_request_header(&frame, max_header_bytes).map_err(|error| error.to_string())?; + let content_len = frame.len() - content_offset; + let accounted_request_bytes = frame.len(); + // serde owns the parsed header and the body copy below owns the content. + // Account the copy while the complete plaintext frame is still live, then + // retain one frame-sized reservation for the parsed request's lifetime. + reservation.try_grow(content_len)?; + let content = frame[content_offset..].to_vec(); + drop(frame); + reservation.resize(accounted_request_bytes)?; + Ok(AdmittedRequest { + request, + content, + _request_permit: request_permit, + _in_flight_bytes: reservation, + }) +} + +async fn receive_first_message( + channel: &WebRtcDataChannel, + idle_timeout_message: &str, +) -> ServerResult> { + let message = tokio::time::timeout(REQUEST_IDLE_TIMEOUT, channel.receive()) + .await + .map_err(|_| idle_timeout_message.to_string())? + .map_err(|error| format!("DataChannel message read failed: {error}"))?; + if message.is_empty() { + return Err("DataChannel closed".to_string()); + } + Ok(message) +} + +async fn read_pq_payload_after_first( + first_message: Vec, + channel: &WebRtcDataChannel, + max_payload_bytes: usize, + frame_timeout_message: &str, + resources: &ConnectionResources, +) -> ServerResult { + let frame_started = tokio::time::Instant::now(); + let mut frame_deadline = frame_started + transfer_timeout(PQ_FRAME_PREFIX_BYTES); + let mut frame = TrackedBytes { + reservation: resources.try_reserve_bytes(first_message.len())?, + bytes: first_message, + }; + let mut expected_length = None; + let max_frame_bytes = 4usize + .checked_add(max_payload_bytes) + .ok_or_else(|| "PQ frame limit overflow".to_string())?; + loop { + if frame.bytes.len() > max_frame_bytes { + return Err(format!( + "request exceeds the {max_frame_bytes}-byte frame limit" + )); + } + + if expected_length.is_none() { + expected_length = pq_frame_length(&frame.bytes, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}"))?; + if let Some(length) = expected_length { + if frame.bytes.len() > length { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + // Reserve the complete declared frame before accepting a slow + // body. A sender cannot make many partial 4 MiB frames consume + // unaccounted memory during their transfer windows. + frame.reserve_length(length)?; + frame_deadline = frame_started + transfer_timeout(length); + } + } + + if let Some(length) = expected_length { + if frame.bytes.len() == length { + let payload_len = length - PQ_FRAME_PREFIX_BYTES; + frame.reservation.try_grow(payload_len)?; + let payload = decode_pq_frame(&frame.bytes, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}"))?; + let TrackedBytes { + bytes: encoded_frame, + mut reservation, + } = frame; + drop(encoded_frame); + reservation.resize(payload.len())?; + return Ok(TrackedBytes { + bytes: payload, + reservation, + }); + } + } + + let message = tokio::time::timeout_at(frame_deadline, channel.receive()) + .await + .map_err(|_| frame_timeout_message.to_string())? + .map_err(|error| format!("DataChannel message read failed: {error}"))?; + if message.is_empty() { + return Err("DataChannel closed".to_string()); + } + let next_length = frame + .bytes + .len() + .checked_add(message.len()) + .ok_or_else(|| "PQ frame length overflow".to_string())?; + if next_length > max_frame_bytes + || expected_length.is_some_and(|length| next_length > length) + { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + if expected_length.is_none() { + frame.reserve_length(next_length)?; + } + frame.bytes.extend_from_slice(&message); + } +} + +async fn write_webrtc_response( + channel: &WebRtcDataChannel, + pq_session: &mut PqSession, + response: &Response, + content: Option<&TrackedBytes>, + resources: &ConnectionResources, +) -> ServerResult<()> { + // GET content carries its own reservation from before the storage read. + // This reservation accounts only the new header, plaintext, and ciphertext + // allocations made while encoding the response. + let content = content.map_or(&[][..], |tracked| tracked.bytes.as_slice()); + let encode_reservation = 4usize + .checked_add(MAX_BROWSER_HEADER_BYTES.saturating_mul(2)) + .and_then(|length| length.checked_add(content.len())) + .ok_or_else(|| "response frame length overflow".to_string())?; + let mut reservation = resources.try_reserve_bytes(encode_reservation)?; + let plaintext = encode_response_frame(response, content).map_err(|error| error.to_string())?; + reservation.resize(plaintext.len())?; + let encrypted_len = plaintext + .len() + .checked_add(PQ_ENCRYPTED_OVERHEAD_BYTES) + .ok_or_else(|| "encrypted response length overflow".to_string())?; + reservation.try_grow(encrypted_len)?; + let encrypted = pq_session + .seal(&plaintext) + .map_err(|error| format!("PQ session: {error}"))?; + drop(plaintext); + reservation.resize(content.len() + encrypted.len())?; + write_framed_pq_payload(channel, &encrypted).await +} + +async fn write_pq_payload( + channel: &WebRtcDataChannel, + payload: &[u8], + resources: &ConnectionResources, +) -> ServerResult<()> { + let _reservation = resources.try_reserve_bytes(payload.len())?; + write_framed_pq_payload(channel, payload).await +} + +async fn write_framed_pq_payload(channel: &WebRtcDataChannel, payload: &[u8]) -> ServerResult<()> { + let payload_len = u32::try_from(payload.len()) + .map_err(|_| "PQ session: payload length does not fit u32".to_string())?; + let framed_len = PQ_FRAME_PREFIX_BYTES + .checked_add(payload.len()) + .ok_or_else(|| "PQ response frame length overflow".to_string())?; + let deadline = tokio::time::Instant::now() + transfer_timeout(framed_len); + tokio::time::timeout_at(deadline, channel.send(&payload_len.to_be_bytes())) + .await + .map_err(|_| "response frame timed out".to_string())? + .map_err(|error| format!("response message write failed: {error}"))?; + for chunk in payload.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + tokio::time::timeout_at(deadline, channel.send(chunk)) + .await + .map_err(|_| "response frame timed out".to_string())? + .map_err(|error| format!("response message write failed: {error}"))?; + } + Ok(()) +} + +async fn process_request( + request: Request, + content: Vec, + state: &ServerState, + resources: &ConnectionResources, +) -> ServerResult<(Response, Option)> { + if !matches!(&request.body, RequestBody::PutChunk { .. }) && !content.is_empty() { + return Ok(( + Response::error( + request.request_id, + "unexpected_content", + "only put_chunk accepts binary request content".to_string(), + ), + None, + )); + } + match request.body { + RequestBody::Hello => { + let peer_id = state.p2p.peer_id().to_hex(); + Ok(( + Response::ok( + request.request_id, + ResponseBody::Hello { + protocol: BROWSER_PROTOCOL_NAME.to_string(), + peer_id, + max_chunk_size: MAX_CHUNK_SIZE, + endpoint: state.endpoint.clone(), + payment: state.payment.clone(), + capabilities: vec![ + "find_node".to_string(), + "get_chunk".to_string(), + "quote_chunk".to_string(), + "put_chunk".to_string(), + ], + }, + 0, + ), + None, + )) + } + RequestBody::FindNode { target, count } => { + Ok(process_find_node(request.request_id, target, count, state).await) + } + RequestBody::GetChunk { address } => { + process_get_chunk(request.request_id, address, state, resources).await + } + RequestBody::QuoteChunk { address, size } => { + Ok(process_quote_chunk(request.request_id, address, size, state).await) + } + RequestBody::PutChunk { + address, + quote, + transaction_hash, + } => Ok(process_put_chunk( + request.request_id, + address, + *quote, + transaction_hash, + content, + state, + ) + .await), + } +} + +async fn process_find_node( + request_id: u64, + target: String, + count: Option, + state: &ServerState, +) -> (Response, Option) { + let target_bytes = match decode_32_byte_hex(&target) { + Ok(bytes) => bytes, + Err(error) => return (Response::error(request_id, "invalid_target", error), None), + }; + let count = count + .unwrap_or(MAX_FIND_NODE_RESULTS) + .clamp(1, MAX_FIND_NODE_RESULTS); + let dht = state.p2p.dht_manager(); + let dht_nodes = dht + .find_closest_nodes_local_with_self(&target_bytes, count) + .await; + let mut nodes = Vec::with_capacity(dht_nodes.len()); + for node in dht_nodes { + let supplemental = dht.supplemental_addresses_for_peer(&node.peer_id).await; + nodes.push(browser_node_from_dht( + &node, + &supplemental, + state.endpoint_catalog.as_deref(), + )); + } + ( + Response::ok(request_id, ResponseBody::Nodes { target, nodes }, 0), + None, + ) +} + +fn browser_node_from_dht( + node: &DHTNode, + supplemental: &[MultiAddr], + endpoint_catalog: Option<&BrowserEndpointCatalog>, +) -> BrowserNode { + let addresses = node.addresses_by_priority(); + let discovered_endpoint = supplemental + .iter() + .find(|address| { + address.is_webrtc_direct() + && address.peer_id().is_some_and(|peer| peer == &node.peer_id) + }) + .cloned() + .map(|multiaddr| BrowserEndpoint { + multiaddr: multiaddr.to_string(), + }); + BrowserNode { + webrtc_direct: discovered_endpoint + .or_else(|| endpoint_catalog.and_then(|catalog| catalog.get(&node.peer_id))), + peer_id: node.peer_id.to_hex(), + native_addresses: addresses + .into_iter() + .filter(|address| !address.is_webrtc_direct()) + .map(|address| address.to_string()) + .collect(), + reliability: node.reliability, + } +} + +async fn process_get_chunk( + request_id: u64, + address: String, + state: &ServerState, + resources: &ConnectionResources, +) -> ServerResult<(Response, Option)> { + let address_bytes = match decode_32_byte_hex(&address) { + Ok(bytes) => bytes, + Err(error) => { + return Ok((Response::error(request_id, "invalid_address", error), None)); + } + }; + let Some(ant_protocol) = state.ant_protocol.as_ref() else { + return Ok(( + Response::error( + request_id, + "storage_disabled", + "chunk storage is disabled on this node".to_string(), + ), + None, + )); + }; + + // The storage API allocates its returned Vec internally, so reserve the + // largest permitted chunk before awaiting it. This closes the interval in + // which many concurrent GETs could materialize unaccounted full chunks. + let mut content_reservation = resources.try_reserve_bytes(MAX_CHUNK_SIZE)?; + let response = match ant_protocol.storage().get(&address_bytes).await { + Ok(Some(content)) if content.len() <= MAX_CHUNK_SIZE => { + let content_length = content.len(); + content_reservation.resize(content_length)?; + Ok(( + Response::ok( + request_id, + ResponseBody::Chunk { + address, + size: content_length, + }, + content_length, + ), + Some(TrackedBytes { + bytes: content, + reservation: content_reservation, + }), + )) + } + Ok(Some(content)) => Ok(( + Response::error( + request_id, + "oversize_chunk", + format!( + "stored content is {} bytes; maximum is {MAX_CHUNK_SIZE}", + content.len() + ), + ), + None, + )), + Ok(None) => Ok((Response::not_found(request_id, address), None)), + Err(error) => Ok(( + Response::error( + request_id, + "storage_error", + format!("chunk read failed: {error}"), + ), + None, + )), + }; + response +} + +async fn process_quote_chunk( + request_id: u64, + address: String, + size: u64, + state: &ServerState, +) -> (Response, Option) { + let address_bytes = match decode_32_byte_hex(&address) { + Ok(bytes) => bytes, + Err(error) => return (Response::error(request_id, "invalid_address", error), None), + }; + if size > MAX_CHUNK_SIZE as u64 { + return ( + Response::error( + request_id, + "oversize_chunk", + format!("chunk size {size} exceeds {MAX_CHUNK_SIZE}"), + ), + None, + ); + } + let Some(ant_protocol) = state.ant_protocol.as_ref() else { + return ( + Response::error( + request_id, + "storage_disabled", + "chunk storage is disabled on this node".to_string(), + ), + None, + ); + }; + + let message = ChunkMessage { + request_id, + body: ChunkMessageBody::QuoteRequest(ChunkQuoteRequest::new(address_bytes, size)), + }; + let response = match handle_ant_message(ant_protocol, &message).await { + Ok(response) => response, + Err(error) => return (Response::error(request_id, "quote_failed", error), None), + }; + match response.body { + ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Success { + quote, + already_stored, + commitment, + }) => { + let quote: PaymentQuote = match rmp_serde::from_slice("e) { + Ok(quote) => quote, + Err(error) => { + return ( + Response::error( + request_id, + "invalid_quote", + format!("node generated an invalid quote: {error}"), + ), + None, + ) + } + }; + let artifact = match browser_quote_from_quote( + state.p2p.peer_id(), + "e, + commitment.as_deref(), + ) { + Ok(artifact) => artifact, + Err(error) => return (Response::error(request_id, "invalid_quote", error), None), + }; + ( + Response::ok( + request_id, + ResponseBody::StorageQuote { + address, + already_stored, + quote: artifact, + }, + 0, + ), + None, + ) + } + ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(error)) => ( + Response::error(request_id, "quote_rejected", error.to_string()), + None, + ), + other => ( + Response::error( + request_id, + "invalid_quote_response", + format!("unexpected storage response: {other:?}"), + ), + None, + ), + } +} + +async fn process_put_chunk( + request_id: u64, + address: String, + quote: BrowserQuoteArtifact, + transaction_hash: String, + content: Vec, + state: &ServerState, +) -> (Response, Option) { + let address_bytes = match decode_32_byte_hex(&address) { + Ok(bytes) => bytes, + Err(error) => return (Response::error(request_id, "invalid_address", error), None), + }; + let Some(ant_protocol) = state.ant_protocol.as_ref() else { + return ( + Response::error( + request_id, + "storage_disabled", + "chunk storage is disabled on this node".to_string(), + ), + None, + ); + }; + let proof = match build_payment_proof(address_bytes, quote, &transaction_hash) { + Ok(proof) => proof, + Err(error) => { + return ( + Response::error(request_id, "invalid_payment_proof", error), + None, + ) + } + }; + + let message = ChunkMessage { + request_id, + body: ChunkMessageBody::PutRequest(ChunkPutRequest::with_payment( + address_bytes, + bytes::Bytes::from(content), + proof, + )), + }; + let response = match handle_ant_message(ant_protocol, &message).await { + Ok(response) => response, + Err(error) => return (Response::error(request_id, "put_failed", error), None), + }; + match response.body { + ChunkMessageBody::PutResponse(ChunkPutResponse::Success { address }) => ( + Response::ok( + request_id, + ResponseBody::ChunkStored { + address: hex::encode(address), + already_stored: false, + }, + 0, + ), + None, + ), + ChunkMessageBody::PutResponse(ChunkPutResponse::AlreadyExists { address }) => ( + Response::ok( + request_id, + ResponseBody::ChunkStored { + address: hex::encode(address), + already_stored: true, + }, + 0, + ), + None, + ), + ChunkMessageBody::PutResponse(ChunkPutResponse::PaymentRequired { message }) => ( + Response::error(request_id, "payment_required", message), + None, + ), + ChunkMessageBody::PutResponse(ChunkPutResponse::Error(error)) => ( + Response::error(request_id, "put_rejected", error.to_string()), + None, + ), + other => ( + Response::error( + request_id, + "invalid_put_response", + format!("unexpected storage response: {other:?}"), + ), + None, + ), + } +} + +fn build_payment_proof( + expected_content: [u8; 32], + quote: BrowserQuoteArtifact, + transaction_hash: &str, +) -> ServerResult> { + let (peer_id, payment_quote, commitment) = + payment_quote_from_browser_quote(quote, expected_content)?; + let transaction_hash = TxHash::from_str(transaction_hash) + .map_err(|error| format!("invalid EVM transaction hash: {error}"))?; + let proof = PaymentProof { + proof_of_payment: ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(peer_id), payment_quote)], + }, + tx_hashes: vec![transaction_hash], + commitment_sidecars: commitment.into_iter().collect(), + }; + serialize_single_node_proof(&proof) + .map_err(|error| format!("failed to serialize payment proof: {error}")) +} + +async fn handle_ant_message( + ant_protocol: &AntProtocol, + message: &ChunkMessage, +) -> ServerResult { + let encoded = message + .encode() + .map_err(|error| format!("storage request encoding failed: {error}"))?; + let response = ant_protocol + .try_handle_request(&encoded) + .await + .map_err(|error| format!("storage request failed: {error}"))? + .ok_or_else(|| "storage handler returned no response".to_string())?; + ChunkMessage::decode(&response) + .map_err(|error| format!("storage response decoding failed: {error}")) +} + +fn decode_32_byte_hex(value: &str) -> ServerResult<[u8; 32]> { + let value = value.strip_prefix("0x").unwrap_or(value); + let bytes = hex::decode(value).map_err(|error| format!("expected hexadecimal: {error}"))?; + bytes + .try_into() + .map_err(|bytes: Vec| format!("expected 32 bytes, received {}", bytes.len())) +} + +type ServerResult = std::result::Result; + +fn browser_quote_from_quote( + peer_id: &PeerId, + quote: &PaymentQuote, + commitment: Option<&[u8]>, +) -> ServerResult { + let timestamp_secs = quote + .timestamp + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|error| format!("quote timestamp predates the Unix epoch: {error}"))? + .as_secs(); + let commitment = commitment.map(browser_commitment_from_bytes).transpose()?; + Ok(BrowserQuoteArtifact { + peer_id: peer_id.to_hex(), + content: hex::encode(quote.content.0), + timestamp_secs, + price: quote.price.to_string(), + rewards_address: format!("{:?}", quote.rewards_address), + public_key: hex::encode("e.pub_key), + signature: hex::encode("e.signature), + committed_key_count: quote.committed_key_count, + commitment_pin: quote.commitment_pin.map(hex::encode), + quote_hash: hex::encode(quote.hash()), + commitment, + }) +} + +fn payment_quote_from_browser_quote( + artifact: BrowserQuoteArtifact, + expected_content: [u8; 32], +) -> ServerResult<([u8; 32], PaymentQuote, Option>)> { + let peer_id = decode_32_byte_hex(&artifact.peer_id)?; + let content = decode_32_byte_hex(&artifact.content)?; + if content != expected_content { + return Err("payment quote is for a different chunk address".to_string()); + } + let price = Amount::from_str(&artifact.price) + .map_err(|error| format!("payment quote has an invalid price: {error}"))?; + let rewards_address = RewardsAddress::from_str(&artifact.rewards_address) + .map_err(|error| format!("payment quote has an invalid rewards address: {error}"))?; + let public_key = hex::decode(&artifact.public_key) + .map_err(|error| format!("payment quote public key is not hexadecimal: {error}"))?; + let signature = hex::decode(&artifact.signature) + .map_err(|error| format!("payment quote signature is not hexadecimal: {error}"))?; + let commitment_pin = artifact + .commitment_pin + .as_deref() + .map(decode_32_byte_hex) + .transpose()?; + let timestamp = SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(artifact.timestamp_secs)) + .ok_or_else(|| "payment quote timestamp is out of range".to_string())?; + let quote = PaymentQuote { + content: xor_name::XorName(content), + timestamp, + price, + rewards_address, + pub_key: public_key, + signature, + committed_key_count: artifact.committed_key_count, + commitment_pin, + }; + if hex::encode(quote.hash()) != artifact.quote_hash.to_ascii_lowercase() { + return Err("payment quote hash does not match its signed fields".to_string()); + } + let commitment = artifact + .commitment + .map(|artifact| { + hex::decode(artifact.encoded) + .map_err(|error| format!("commitment is not hexadecimal: {error}")) + }) + .transpose()?; + Ok((peer_id, quote, commitment)) +} + +fn browser_commitment_from_bytes(encoded: &[u8]) -> ServerResult { + let commitment: ::ant_protocol::payment::commitment::StorageCommitment = + rmp_serde::from_slice(encoded) + .map_err(|error| format!("node generated an invalid commitment: {error}"))?; + Ok(BrowserCommitmentArtifact { + encoded: hex::encode(encoded), + root: hex::encode(commitment.root), + key_count: commitment.key_count, + sender_peer_id: hex::encode(commitment.sender_peer_id), + sender_public_key: hex::encode(commitment.sender_public_key), + signature: hex::encode(commitment.signature), + }) +} + +struct ServerState { + config: WebRtcDirectConfig, + identity: Arc, + p2p: Arc, + ant_protocol: Option>, + payment: BrowserPaymentNetwork, + endpoint: BrowserEndpoint, + endpoint_catalog: Option>, +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::significant_drop_tightening +)] +mod tests { + use super::*; + + #[test] + fn default_resource_limits_preserve_headroom_for_other_sources() { + let config = WebRtcDirectConfig::default(); + validate_webrtc_config(&config).expect("default resource limits"); + + let source_channel_ceiling = + config.max_connections_per_ip * config.max_channels_per_connection; + assert!(config.max_connections_per_ip < config.max_connections); + assert!(source_channel_ceiling < config.max_channels); + assert!(source_channel_ceiling < config.max_concurrent_requests); + assert!(config.max_requests_per_second_per_ip < config.max_requests_per_second); + assert!(config.max_in_flight_bytes_per_ip < config.max_in_flight_bytes); + } + + #[test] + fn rejects_resource_limits_that_let_one_ip_exhaust_a_global_pool() { + let mut config = WebRtcDirectConfig::default(); + config.max_connections_per_ip = config.max_connections; + assert!(validate_webrtc_config(&config).is_err()); + + let mut config = WebRtcDirectConfig::default(); + config.max_channels = config.max_connections_per_ip * config.max_channels_per_connection; + assert!(validate_webrtc_config(&config).is_err()); + + let mut config = WebRtcDirectConfig::default(); + config.max_in_flight_bytes_per_ip = config.max_in_flight_bytes; + assert!(validate_webrtc_config(&config).is_err()); + } + + #[test] + fn per_ip_connection_limit_cannot_starve_another_source() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + let attacker: SocketAddr = "198.51.100.1:1000".parse().expect("attacker address"); + let honest: SocketAddr = "203.0.113.2:2000".parse().expect("honest address"); + let mut attacker_admissions = Vec::new(); + + for port in 0..config.max_connections_per_ip { + let mut address = attacker; + address.set_port(u16::try_from(port + 1).expect("test port")); + attacker_admissions.push( + resources + .try_admit_connection(address) + .expect("source share remains"), + ); + } + assert_eq!(attacker_admissions.len(), config.max_connections_per_ip); + assert_eq!( + resources.try_admit_connection(attacker).err().as_deref(), + Some(SOURCE_CONNECTION_CAPACITY_ERROR) + ); + let honest_admission = resources + .try_admit_connection(honest) + .expect("another source retains listener headroom"); + + drop(attacker_admissions.pop()); + resources + .try_admit_connection(attacker) + .expect("released source slot is reusable"); + drop(honest_admission); + } + + #[test] + fn ipv4_mapped_ipv6_cannot_bypass_source_accounting() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + let v4: SocketAddr = "192.0.2.44:1000".parse().expect("IPv4 address"); + let mapped: SocketAddr = "[::ffff:192.0.2.44]:2000".parse().expect("mapped address"); + let mut admissions = vec![resources + .try_admit_connection(v4) + .expect("first connection")]; + for _ in 1..config.max_connections_per_ip { + admissions.push( + resources + .try_admit_connection(mapped) + .expect("mapped source share"), + ); + } + assert_eq!(admissions.len(), config.max_connections_per_ip); + assert_eq!( + resources.try_admit_connection(mapped).err().as_deref(), + Some(SOURCE_CONNECTION_CAPACITY_ERROR) + ); + } + + #[test] + fn request_token_bucket_refills_without_growing_state() { + let mut bucket = RequestRateBucket::new(2); + let start = bucket.last_refill; + assert!(bucket.allow(start)); + assert!(bucket.allow(start)); + assert!(!bucket.allow(start)); + assert!(bucket.allow(start + Duration::from_millis(500))); + assert!(!bucket.allow(start + Duration::from_millis(500))); + assert!(bucket.allow(start + Duration::from_secs(1))); + } + + #[test] + fn per_ip_request_rate_leaves_other_sources_admissible() { + let config = WebRtcDirectConfig { + max_requests_per_second: 4, + max_requests_per_second_per_ip: 2, + max_requests_per_second_per_connection: 2, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let attacker = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("attacker")) + .expect("attacker connection"); + let honest = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("honest")) + .expect("honest connection"); + + assert!(attacker.context.try_admit_request().is_ok()); + assert!(attacker.context.try_admit_request().is_ok()); + assert_eq!( + attacker.context.try_admit_request().err().as_deref(), + Some(REQUEST_RATE_ERROR) + ); + assert!(honest.context.try_admit_request().is_ok()); + } + + #[test] + fn reconnecting_does_not_reset_the_source_request_bucket() { + let config = WebRtcDirectConfig { + max_requests_per_second: 100, + max_requests_per_second_per_ip: 1, + max_requests_per_second_per_connection: 1, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let address = "198.51.100.1:1000".parse().expect("source"); + let first = resources + .try_admit_connection(address) + .expect("first connection"); + assert!(first.context.try_admit_request().is_ok()); + drop(first); + + let replacement = resources + .try_admit_connection(address) + .expect("replacement connection"); + assert_eq!( + replacement.context.try_admit_request().err().as_deref(), + Some(REQUEST_RATE_ERROR) + ); + } + + #[test] + fn inactive_source_rate_state_has_a_hard_bound() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + for index in 0..resources.max_tracked_sources + 10 { + let third = u8::try_from(index / 254).expect("third octet"); + let host = u8::try_from(index % 254 + 1).expect("host octet"); + let address = SocketAddr::from((Ipv4Addr::new(198, 51, third, host), 1000)); + drop( + resources + .try_admit_connection(address) + .expect("sequential source"), + ); + } + assert_eq!( + resources.source_state.lock().sources.len(), + resources.max_tracked_sources + ); + } + + #[test] + fn byte_reservations_are_per_source_global_and_raii_released() { + let config = WebRtcDirectConfig { + max_in_flight_bytes: 256, + max_in_flight_bytes_per_ip: 128, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let attacker = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("attacker")) + .expect("attacker connection"); + let honest = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("honest")) + .expect("honest connection"); + + let attacker_bytes = attacker + .context + .try_reserve_bytes(128) + .expect("attacker source budget"); + assert_eq!(resources.global_bytes.in_use(), 128); + assert_eq!( + attacker.context.try_reserve_bytes(1).err().as_deref(), + Some(SOURCE_BYTE_CAPACITY_ERROR) + ); + let honest_bytes = honest + .context + .try_reserve_bytes(64) + .expect("another source retains byte headroom"); + assert_eq!(resources.global_bytes.in_use(), 192); + + drop(attacker_bytes); + drop(honest_bytes); + assert_eq!(resources.global_bytes.in_use(), 0); + assert_eq!(attacker.context.source.bytes.in_use(), 0); + assert_eq!(honest.context.source.bytes.in_use(), 0); + } + + #[test] + fn global_byte_rejection_rolls_back_the_source_reservation() { + let config = WebRtcDirectConfig { + max_in_flight_bytes: 100, + max_in_flight_bytes_per_ip: 90, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let first = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("first")) + .expect("first connection"); + let second = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("second")) + .expect("second connection"); + let _first_bytes = first + .context + .try_reserve_bytes(60) + .expect("first reservation"); + + assert_eq!( + second.context.try_reserve_bytes(50).err().as_deref(), + Some(GLOBAL_BYTE_CAPACITY_ERROR) + ); + assert_eq!(second.context.source.bytes.in_use(), 0); + assert_eq!(resources.global_bytes.in_use(), 60); + } + + #[test] + fn derives_stable_high_port_from_native_port() { + assert_eq!(automatic_webrtc_port(10_000), 42_768); + assert_eq!(automatic_webrtc_port(10_001), 42_769); + assert_eq!(automatic_webrtc_port(32_768), 49_152); + assert_ne!(automatic_webrtc_port(40_000), 40_000); + } + + #[test] + fn resolves_default_public_listener_from_observed_ip() { + let config = WebRtcDirectConfig::default(); + let resolved = resolve_automatic_config( + &config, + 10_000, + Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + ); + + assert_eq!(resolved.bind, "0.0.0.0:42768".parse().expect("bind")); + assert_eq!( + resolved.advertised_addr, + Some("203.0.113.7:42768".parse().expect("advertised")) + ); + } + + #[test] + fn explicit_listener_addresses_are_preserved() { + let config = WebRtcDirectConfig { + bind: "0.0.0.0:11000".parse().expect("bind"), + advertised_addr: Some("198.51.100.4:11000".parse().expect("advertised")), + ..WebRtcDirectConfig::default() + }; + + assert_eq!( + resolve_automatic_config(&config, 10_000, None).bind, + config.bind + ); + assert_eq!( + resolve_automatic_config(&config, 10_000, None).advertised_addr, + config.advertised_addr + ); + } + + #[test] + fn parses_versioned_requests() { + let request: Request = serde_json::from_str( + r#"{"version":4,"request_id":7,"content_length":0,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, + ) + .expect("valid request"); + + assert_eq!(request.version, BROWSER_PROTOCOL_VERSION); + assert_eq!(request.request_id, 7); + assert!(matches!(request.body, RequestBody::FindNode { .. })); + } + + #[test] + fn validates_fixed_width_hex() { + assert_eq!( + decode_32_byte_hex(&"ab".repeat(32)).expect("32 bytes"), + [0xab; 32] + ); + assert!(decode_32_byte_hex("abcd").is_err()); + assert!(decode_32_byte_hex(&"zz".repeat(32)).is_err()); + } + + #[test] + fn payment_quote_hash_vector_uses_evm_keccak256() { + // Shared with ant-client-web's paymentQuoteHash test. ANT addresses use + // BLAKE3, but the quote hash paid to the EVM vault is evmlib Keccak-256. + assert_eq!( + hex::encode(evmlib::cryptography::hash([0_u8, 1, 2, 3])), + "d98f2e8134922f73748703c8e7084d42f13d2fa1439936ef5a3abcf5646fe83f" + ); + } + + #[test] + fn response_header_declares_raw_content_length() { + let response = Response::ok( + 42, + ResponseBody::Chunk { + address: "11".repeat(32), + size: 3, + }, + 3, + ); + let value = serde_json::to_value(response).expect("serialize response"); + assert_eq!(value["version"], 4); + assert_eq!(value["request_id"], 42); + assert_eq!(value["status"], "ok"); + assert_eq!(value["content_length"], 3); + assert_eq!(value["type"], "chunk"); + } + + #[test] + fn derives_ipv6_advertised_address() { + let config = WebRtcDirectConfig::default(); + let addr = advertised_addr(&config, "[::1]:23456".parse().expect("socket")) + .expect("advertised address"); + assert_eq!(addr, "[::1]:23456".parse().expect("socket")); + } + + #[tokio::test] + async fn dtls_certificate_is_stable_across_reloads() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("webrtc-direct.pem"); + let first = load_or_generate_certificate(&path) + .await + .expect("generate certificate"); + let second = load_or_generate_certificate(&path) + .await + .expect("reload certificate"); + + assert_eq!( + first.sha256_digest().expect("first fingerprint"), + second.sha256_digest().expect("second fingerprint") + ); + assert!(path.exists()); + } + + #[tokio::test] + async fn persists_canonical_browser_bootstrap_address() { + let directory = tempfile::tempdir().expect("temporary directory"); + let peer_id = PeerId::from_bytes([0x42; 32]); + let endpoint = BrowserEndpoint::new( + "203.0.113.7:11000".parse().expect("socket address"), + peer_id.to_bytes(), + [0x24; 32], + ) + .expect("browser endpoint"); + + persist_browser_endpoint(directory.path(), &endpoint) + .await + .expect("persist endpoint"); + + let contents = + tokio::fs::read_to_string(directory.path().join(WEBRTC_DIRECT_MULTIADDR_FILENAME)) + .await + .expect("read endpoint file"); + assert_eq!(contents, format!("{}\n", endpoint.multiaddr)); + } + + #[test] + fn find_node_exposes_propagated_webrtc_endpoint_separately() { + let peer_id = PeerId::from_bytes([0x31; 32]); + let endpoint = BrowserEndpoint::new( + "203.0.113.9:42768".parse().expect("socket address"), + peer_id.to_bytes(), + [0x52; 32], + ) + .expect("browser endpoint"); + let native = "/ip4/203.0.113.9/udp/10000/quic" + .parse() + .expect("native multiaddress"); + let node = DHTNode { + peer_id, + addresses: vec![native], + address_types: Vec::new(), + distance: None, + reliability: 0.75, + }; + + let supplemental = endpoint + .multiaddr + .parse() + .expect("WebRTC Direct multiaddress"); + let browser_node = browser_node_from_dht(&node, std::slice::from_ref(&supplemental), None); + + assert_eq!(browser_node.webrtc_direct, Some(endpoint)); + assert_eq!( + browser_node.native_addresses, + vec!["/ip4/203.0.113.9/udp/10000/quic"] + ); + } + + #[test] + fn production_find_node_does_not_use_dev_endpoint_catalog() { + let peer_id = PeerId::from_bytes([0x32; 32]); + let endpoint = BrowserEndpoint::new( + "203.0.113.10:42768".parse().expect("socket address"), + peer_id.to_bytes(), + [0x53; 32], + ) + .expect("browser endpoint"); + let node = DHTNode { + peer_id, + addresses: Vec::new(), + address_types: Vec::new(), + distance: None, + reliability: 0.75, + }; + let catalog = BrowserEndpointCatalog::default(); + catalog.insert(peer_id, endpoint.clone()); + + let browser_node = browser_node_from_dht(&node, &[], None); + + assert!(browser_node.webrtc_direct.is_none()); + + let devnet_node = browser_node_from_dht(&node, &[], Some(&catalog)); + assert_eq!(devnet_node.webrtc_direct, Some(endpoint)); + } +} diff --git a/tests/e2e/integration_tests.rs b/tests/e2e/integration_tests.rs index 4c11d7d1..8369d445 100644 --- a/tests/e2e/integration_tests.rs +++ b/tests/e2e/integration_tests.rs @@ -90,7 +90,7 @@ async fn test_custom_network_config() { node_count: 7, bootstrap_count: 2, spawn_delay: Duration::from_millis(100), - stabilization_timeout: Duration::from_secs(60), + stabilization_timeout: Duration::from_mins(1), ..Default::default() }; diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs index 9a004db4..168f9074 100644 --- a/tests/e2e/merkle_payment.rs +++ b/tests/e2e/merkle_payment.rs @@ -615,7 +615,7 @@ async fn test_attack_merkle_pay_yourself_fabricated_pool() -> Result<(), Box= 1 || s.timed_out >= 1 || s.failed >= 1 }) .await; diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index a281f5ea..89c332f0 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1522,7 +1522,7 @@ impl TestNetwork { /// Returns an error if any node's bootstrap does not complete within /// the timeout. async fn wait_for_replication_bootstrap(&self) -> Result<()> { - const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120); + const BOOTSTRAP_TIMEOUT: Duration = Duration::from_mins(2); for node in &self.nodes { if let Some(ref engine) = node.replication_engine { @@ -1719,7 +1719,7 @@ impl TestNetwork { /// Returns an error if node creation, startup, or bootstrap fails. pub async fn add_node(&mut self) -> Result { const DHT_WARMUP_QUERIES: usize = 10; - const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120); + const BOOTSTRAP_TIMEOUT: Duration = Duration::from_mins(2); let index = self.nodes.len(); diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs new file mode 100644 index 00000000..9de71e63 --- /dev/null +++ b/tests/webrtc_direct_devnet.rs @@ -0,0 +1,427 @@ +//! Live ADR-0009 local-devnet protocol test. + +use ant_node::devnet::{Devnet, DevnetConfig}; +use ant_node::BrowserEndpoint; +use ant_protocol::MAX_CHUNK_SIZE; +use bytes::Bytes; +use evmlib::common::{Amount, QuoteHash}; +use evmlib::wallet::Wallet; +use evmlib::RewardsAddress; +use saorsa_transport::transport::{WebRtcCertificateHash, WebRtcDirectAddr}; +use saorsa_transport::webrtc_direct::{WebRtcDataChannel, WebRtcDirectClient}; +use saorsa_webrtc::{ + decode_pq_frame, encode_pq_frame, pq_frame_length, PqClientHandshake, PqSession, + BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION, PQ_ENCRYPTED_OVERHEAD_BYTES, + PQ_SERVER_ACCEPT_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, +}; +use self_encryption::{DataMap, EncryptedChunk}; +use serde_json::{json, Value}; +use std::error::Error; +use std::io; +use std::str::FromStr; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "starts a five-node local network"] +#[serial_test::serial] +#[allow(clippy::too_many_lines)] +async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoints( +) -> Result<(), Box> { + let temp = tempfile::tempdir()?; + let evm_testnet = evmlib::testnet::Testnet::new().await?; + let evm_network = evm_testnet.to_network(); + let wallet = Wallet::new_from_private_key( + evm_network.clone(), + &evm_testnet.default_wallet_private_key()?, + )?; + let mut config = DevnetConfig::minimal(); + config.base_port = 0; + config.webrtc_direct = true; + config.webrtc_direct_base_port = 0; + config.data_dir = temp.path().join("browser-devnet"); + config.spawn_delay = std::time::Duration::from_millis(20); + config.evm_network = Some(evm_network); + + let mut devnet = Devnet::new(config).await?; + devnet.start().await?; + + let content = b"browser devnet integration file"; + let public_file = devnet + .publish_public_file( + "browser-devnet.txt".to_string(), + "text/plain".to_string(), + content, + ) + .await?; + let endpoints = devnet.browser_endpoints(); + assert_eq!(endpoints.len(), 5); + assert!(public_file.replicas > 0); + + let endpoint = endpoints + .first() + .ok_or_else(|| io::Error::other("browser-enabled devnet returned no direct endpoints"))?; + let parsed_endpoint = endpoint.endpoint.parse().map_err(io::Error::other)?; + let mut seed_client = BrowserRpcClient::connect(&endpoint.endpoint).await?; + let (hello, hello_content) = seed_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 5, + "type": "hello", + }), + &[], + ) + .await?; + assert_eq!(hello["status"], "ok"); + assert_eq!(hello["protocol"], BROWSER_PROTOCOL_NAME); + assert_eq!( + hello["payment"]["rpc_url"].as_str(), + Some(evm_testnet.to_network().rpc_url().as_str()) + ); + assert_eq!(hello["peer_id"], parsed_endpoint.peer_id); + assert_eq!( + hello["endpoint"]["multiaddr"], + endpoint.endpoint.multiaddr.clone() + ); + assert!(hello_content.is_empty()); + + let (closest, closest_content) = seed_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 6, + "type": "find_node", + "target": public_file.address, + "count": 20, + }), + &[], + ) + .await?; + assert_eq!(closest["status"], "ok"); + assert_eq!(closest["type"], "nodes"); + assert_eq!(closest["target"], public_file.address); + assert!(closest_content.is_empty()); + let discovered = closest["nodes"] + .as_array() + .and_then(|nodes| { + nodes.iter().find(|node| { + node["webrtc_direct"]["multiaddr"] + .as_str() + .is_some_and(|addr| addr != endpoint.endpoint.multiaddr) + }) + }) + .ok_or_else(|| io::Error::other("FIND_NODE returned no browser endpoint"))?; + let discovered_peer = discovered["peer_id"] + .as_str() + .ok_or_else(|| io::Error::other("FIND_NODE node omitted its peer ID"))?; + let download_endpoint: BrowserEndpoint = + serde_json::from_value(discovered["webrtc_direct"].clone())?; + let parsed_download = download_endpoint.parse().map_err(io::Error::other)?; + assert_eq!(parsed_download.peer_id, discovered_peer); + let mut download_client = BrowserRpcClient::connect(&download_endpoint).await?; + let (download_hello, _) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 7, + "type": "hello", + }), + &[], + ) + .await?; + assert_eq!(download_hello["peer_id"], discovered_peer); + let (next_hop, next_hop_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 8, + "type": "find_node", + "target": public_file.address, + "count": 20, + }), + &[], + ) + .await?; + assert_eq!(next_hop["status"], "ok"); + assert_eq!(next_hop["type"], "nodes"); + assert!(next_hop["nodes"] + .as_array() + .is_some_and(|nodes| !nodes.is_empty())); + assert!(next_hop_content.is_empty()); + let (header, data_map_bytes) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 9, + "type": "get_chunk", + "address": public_file.address, + }), + &[], + ) + .await?; + + assert_eq!(header["status"], "ok"); + assert_eq!(header["type"], "chunk"); + assert_eq!(data_map_bytes.len(), public_file.data_map_size); + let data_map: DataMap = rmp_serde::from_slice(&data_map_bytes)?; + assert_eq!(data_map.original_file_size(), content.len()); + assert_eq!(public_file.chunks.len(), data_map.infos().len()); + + let mut encrypted_chunks = Vec::new(); + for (index, chunk) in public_file.chunks.iter().enumerate() { + let request_id = u64::try_from(index)?.saturating_add(10); + let (chunk_header, chunk_bytes) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": request_id, + "type": "get_chunk", + "address": chunk.dst_hash, + }), + &[], + ) + .await?; + assert_eq!(chunk_header["status"], "ok"); + assert_eq!(chunk_header["type"], "chunk"); + encrypted_chunks.push(EncryptedChunk { + content: Bytes::from(chunk_bytes), + }); + } + let decrypted = self_encryption::decrypt(&data_map, &encrypted_chunks)?; + assert_eq!(decrypted, content.as_slice()); + + let upload_content = b"paid browser WebRtcDirect upload"; + let upload_address = hex::encode(blake3::hash(upload_content).as_bytes()); + let (quote_header, quote_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 50, + "type": "quote_chunk", + "address": upload_address, + "size": upload_content.len(), + }), + &[], + ) + .await?; + assert_eq!(quote_header["status"], "ok"); + assert_eq!(quote_header["type"], "storage_quote"); + assert_eq!(quote_header["already_stored"], false); + assert!(quote_content.is_empty()); + let quote = quote_header["quote"].clone(); + let quote_hash = QuoteHash::from_str(required_string("e, "quote_hash")?)?; + let rewards_address = RewardsAddress::from_str(required_string("e, "rewards_address")?)?; + let price = Amount::from_str(required_string("e, "price")?)?; + let (payments, _) = wallet + .pay_for_quotes([(quote_hash, rewards_address, price * Amount::from(3))]) + .await + .map_err(|error| io::Error::other(format!("storage payment failed: {error:?}")))?; + let transaction_hash = payments + .get("e_hash) + .ok_or_else(|| io::Error::other("payment returned no transaction hash for quote"))?; + + let (put_header, put_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 51, + "type": "put_chunk", + "address": upload_address, + "quote": quote, + "transaction_hash": format!("{transaction_hash:?}"), + }), + upload_content, + ) + .await?; + assert_eq!(put_header["status"], "ok"); + assert_eq!(put_header["type"], "chunk_stored"); + assert_eq!(put_header["address"], upload_address); + assert!(put_content.is_empty()); + + let (uploaded_header, uploaded_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 52, + "type": "get_chunk", + "address": upload_address, + }), + &[], + ) + .await?; + assert_eq!(uploaded_header["status"], "ok"); + assert_eq!(uploaded_content, upload_content); + assert!(seed_client.requests_sent() >= 2); + assert!(download_client.requests_sent() >= 6); + + download_client.close().await?; + seed_client.close().await?; + + devnet.shutdown().await?; + Ok(()) +} + +fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, io::Error> { + value[field] + .as_str() + .ok_or_else(|| io::Error::other(format!("quote omitted {field}"))) +} + +struct BrowserRpcClient { + client: WebRtcDirectClient, + pq_session: PqSession, + requests_sent: usize, +} + +impl BrowserRpcClient { + async fn connect(endpoint: &BrowserEndpoint) -> Result> { + let parsed = endpoint.parse().map_err(io::Error::other)?; + let direct_addr = WebRtcDirectAddr::new( + parsed.socket_addr().map_err(io::Error::other)?, + WebRtcCertificateHash::new(parsed.certificate_hash), + )?; + let client = WebRtcDirectClient::dial(&direct_addr, WEBRTC_DIRECT_DATA_CHANNEL) + .await + .map_err(|error| io::Error::other(format!("WebRTC Direct dial failed: {error}")))?; + let expected_peer_id = parsed.peer_id_bytes().map_err(io::Error::other)?; + let pq_session = establish_pq_session(client.data_channel(), &expected_peer_id).await?; + Ok(Self { + client, + pq_session, + requests_sent: 0, + }) + } + + async fn rpc( + &mut self, + request: Value, + content: &[u8], + ) -> Result<(Value, Vec), Box> { + let request_type = request["type"].as_str().unwrap_or("unknown").to_string(); + let result = rpc_stream( + self.client.data_channel(), + &mut self.pq_session, + request, + content, + ) + .await + .map_err(|error| { + io::Error::other(format!("WebRTC Direct {request_type} RPC failed: {error}")) + })?; + self.requests_sent += 1; + Ok(result) + } + + const fn requests_sent(&self) -> usize { + self.requests_sent + } + + async fn close(self) -> Result<(), Box> { + self.client.close().await?; + Ok(()) + } +} + +async fn rpc_stream( + channel: &WebRtcDataChannel, + pq_session: &mut PqSession, + mut request: Value, + content: &[u8], +) -> Result<(Value, Vec), Box> { + request["content_length"] = json!(content.len()); + let request_header = serde_json::to_vec(&request)?; + let request_header_len = u32::try_from(request_header.len())?; + let mut request_frame = Vec::with_capacity(4 + request_header.len() + content.len()); + request_frame.extend_from_slice(&request_header_len.to_be_bytes()); + request_frame.extend_from_slice(&request_header); + request_frame.extend_from_slice(content); + let encrypted = pq_session.seal(&request_frame)?; + send_pq_payload(channel, &encrypted).await?; + + let encrypted = read_pq_payload( + channel, + 4 + 64 * 1024 + MAX_CHUNK_SIZE + PQ_ENCRYPTED_OVERHEAD_BYTES, + ) + .await?; + let frame = pq_session.open(&encrypted)?; + if frame.len() < 4 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "response is truncated").into()); + } + let header_len = u32::from_be_bytes(frame[0..4].try_into()?) as usize; + let content_offset = 4usize + .checked_add(header_len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "header length overflow"))?; + if frame.len() < content_offset { + return Err( + io::Error::new(io::ErrorKind::InvalidData, "response header is truncated").into(), + ); + } + let header: Value = serde_json::from_slice(&frame[4..content_offset])?; + let content_length = header["content_length"] + .as_u64() + .and_then(|length| usize::try_from(length).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid content length"))?; + let expected = content_offset + .checked_add(content_length) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "response length overflow"))?; + if frame.len() != expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebRtcDirect response length does not match its header", + ) + .into()); + } + Ok((header, frame[content_offset..].to_vec())) +} + +async fn establish_pq_session( + channel: &WebRtcDataChannel, + expected_peer_id: &[u8; 32], +) -> Result> { + let (handshake, client_hello) = PqClientHandshake::start()?; + send_pq_payload(channel, &client_hello).await?; + let server_accept = read_pq_payload(channel, PQ_SERVER_ACCEPT_BYTES).await?; + Ok(handshake.finish(&server_accept, expected_peer_id)?) +} + +async fn send_pq_payload( + channel: &WebRtcDataChannel, + payload: &[u8], +) -> Result<(), Box> { + let frame = encode_pq_frame(payload)?; + for chunk in frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + channel.send(chunk).await?; + } + Ok(()) +} + +async fn read_pq_payload( + channel: &WebRtcDataChannel, + max_payload_bytes: usize, +) -> Result, Box> { + let mut frame = Vec::new(); + let expected = loop { + let message = channel.receive().await?; + if message.is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "WebRtcDirect PQ frame channel closed", + ) + .into()); + } + frame.extend_from_slice(&message); + if let Some(expected) = pq_frame_length(&frame, max_payload_bytes)? { + if frame.len() > expected { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebRtcDirect PQ frame has trailing bytes", + ) + .into()); + } + if frame.len() == expected { + break expected; + } + } + }; + debug_assert_eq!(frame.len(), expected); + Ok(decode_pq_frame(&frame, max_payload_bytes)?) +}