diff --git a/Cargo.lock b/Cargo.lock index a2796216..e9a4e490 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,21 @@ 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" @@ -839,7 +854,9 @@ dependencies = [ "rmp-serde", "saorsa-core", "saorsa-pqc 0.5.1", + "saorsa-transport", "self-replace", + "self_encryption", "semver 1.0.28", "serde", "serde_json", @@ -862,20 +879,24 @@ dependencies = [ [[package]] name = "ant-protocol" version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3081ee130dd45e8166bc8ac4d789aac17d143e2e7f54f1c3006503dc63cf3edb" +source = "git+https://github.com/WithAutonomi/ant-protocol.git?rev=4dad14b6947b6264e0b5982c976a385f9fdac9e0#4dad14b6947b6264e0b5982c976a385f9fdac9e0" dependencies = [ "blake3", "bytes", + "chacha20poly1305", "evmlib", + "fips203", + "getrandom 0.2.17", "hex", "postcard", "rmp-serde", "saorsa-core", "saorsa-pqc 0.5.1", "serde", + "tiny-keccak", "tokio", "tracing", + "zeroize", ] [[package]] @@ -893,6 +914,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" @@ -1168,13 +1198,29 @@ 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", @@ -1184,6 +1230,18 @@ dependencies = [ "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.118", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -1410,6 +1468,12 @@ dependencies = [ "hex-conservative 0.2.2", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.0" @@ -1463,6 +1527,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" @@ -1508,6 +1581,27 @@ dependencies = [ "syn 2.0.118", ] +[[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]] name = "bs58" version = "0.5.1" @@ -1584,6 +1678,15 @@ 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" @@ -1596,6 +1699,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" @@ -2088,16 +2203,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", @@ -2247,7 +2377,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", ] @@ -2271,6 +2401,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", + "portable-atomic", + "rand 0.9.4", + "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" @@ -2357,6 +2524,7 @@ dependencies = [ "generic-array", "group", "hkdf", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2893,7 +3061,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" dependencies = [ - "bitflags", + "bitflags 2.13.0", "byteorder", "heed-traits", "heed-types", @@ -3309,9 +3477,31 @@ 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.4", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util 0.12.0", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3625,12 +3815,31 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3676,17 +3885,30 @@ dependencies = [ "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.0", "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3812,7 +4034,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.0", "dispatch2", "objc2", ] @@ -3829,7 +4051,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.0", "block2", "libc", "objc2", @@ -3845,13 +4067,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]] @@ -3896,8 +4127,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]] @@ -4000,6 +4245,15 @@ dependencies = [ "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" @@ -4147,6 +4401,12 @@ 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 = "postcard" version = "1.1.3" @@ -4252,7 +4512,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec 0.8.0", - "bitflags", + "bitflags 2.13.0", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -4492,6 +4752,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.16.0", + "yasna 0.5.2", +] + [[package]] name = "rcgen" version = "0.14.8" @@ -4502,8 +4776,8 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", - "yasna", + "x509-parser 0.18.1", + "yasna 0.6.0", ] [[package]] @@ -4512,7 +4786,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -4676,6 +4950,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.4", + "serde", + "thiserror 1.0.69", + "webrtc-util 0.12.0", +] + [[package]] name = "ruint" version = "1.19.0" @@ -4762,7 +5062,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -4778,6 +5078,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4910,8 +5211,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454529f8a72b4cf22f7d9c3b009ad9d4ba78520e11f6444ae460795d55c002da" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=2ed691c7cb49690c86beb445ee422f22a230ef9a#2ed691c7cb49690c86beb445ee422f22a230ef9a" dependencies = [ "anyhow", "async-trait", @@ -4926,6 +5226,7 @@ dependencies = [ "parking_lot", "postcard", "rand 0.8.6", + "saorsa-dht-lookup", "saorsa-pqc 0.5.1", "saorsa-transport", "serde", @@ -4939,6 +5240,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "saorsa-dht-lookup" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=2ed691c7cb49690c86beb445ee422f22a230ef9a#2ed691c7cb49690c86beb445ee422f22a230ef9a" +dependencies = [ + "futures-core", + "futures-util", +] + [[package]] name = "saorsa-pqc" version = "0.4.2" @@ -5025,12 +5335,12 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3284026c300f642077315b782462b22558e24d621265a8deeae23287b1c5542" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=2c6e23cd6cd4db79f475676e25af9334a044a343#2c6e23cd6cd4db79f475676e25af9334a044a343" dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "base64", "blake3", "bytes", "chrono", @@ -5046,13 +5356,14 @@ dependencies = [ "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", + "rcgen 0.13.2", + "rcgen 0.14.8", "regex", "reqwest", "rustc-hash", @@ -5067,6 +5378,7 @@ dependencies = [ "serde_yaml", "slab", "socket2 0.5.10", + "stun 0.7.0", "system-configuration", "thiserror 2.0.18", "time", @@ -5077,6 +5389,7 @@ dependencies = [ "tracing-subscriber", "unicode-width", "uuid", + "webrtc", "windows", "x25519-dalek", "zeroize", @@ -5121,6 +5434,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.4", + "substring", + "thiserror 1.0.69", + "url", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5183,7 +5508,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5211,6 +5536,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.6", + "rand_chacha 0.3.1", + "rayon", + "serde", + "tempfile", + "thiserror 1.0.69", + "tokio", + "xor_name", +] + [[package]] name = "semver" version = "0.11.0" @@ -5506,6 +5853,15 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -5563,6 +5919,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", + "crc", + "lazy_static", + "md-5", + "rand 0.8.6", + "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", + "crc", + "lazy_static", + "md-5", + "rand 0.9.4", + "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" @@ -5644,7 +6047,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5980,7 +6383,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", "futures-util", "http", @@ -6109,6 +6512,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", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.4", + "ring", + "stun 0.9.0", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util 0.12.0", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6139,6 +6563,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" @@ -6243,6 +6673,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" @@ -6375,6 +6814,197 @@ 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", + "portable-atomic", + "rand 0.9.4", + "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.4", + "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.4", + "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.4", + "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.6", + "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.4", + "thiserror 1.0.69", + "tokio", + "winapi", +] + [[package]] name = "wide" version = "0.7.33" @@ -6738,18 +7368,36 @@ 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", @@ -6804,6 +7452,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" diff --git a/Cargo.toml b/Cargo.toml index 2e396350..1b160e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,11 @@ page_size = "0.6" # Protocol serialization postcard = { version = "1.1.3", features = ["use-std"] } +# ADR-0009 browser transport. The dependency remains optional so minimal +# native-only builds can opt out with `--no-default-features`. +saorsa-transport = { version = "0.35.3", features = ["webrtc-direct"], optional = true } +self_encryption = { version = "0.36", optional = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -166,8 +171,13 @@ name = "poc_price_floor_live" path = "tests/poc_price_floor_live.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 dev builds (`cargo build`, `cargo test`) get logging # automatically. Release builds strip it: @@ -176,6 +186,17 @@ 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] +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol.git", rev = "4dad14b6947b6264e0b5982c976a385f9fdac9e0" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "2ed691c7cb49690c86beb445ee422f22a230ef9a" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "2c6e23cd6cd4db79f475676e25af9334a044a343" } [profile.release] lto = true diff --git a/README.md b/README.md index 33c47f4f..477dcafc 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 | 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..eaa3d350 --- /dev/null +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -0,0 +1,169 @@ +# 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`. + +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. + +On 2026-08-27 this path was exercised against the normal 60-node testnet from +one bootstrap address. Headless Chromium traversed multiple independent nodes, +obtained four storage quotes from four non-bootstrap closest nodes, submitted +one payment, and stored all four encrypted records successfully. 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..57646f6e --- /dev/null +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -0,0 +1,864 @@ +# ADR-0009: Direct browser clients over WebRTC Direct + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Last amended:** 2026-09-02 +- **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. `ant-protocol` owns the shared +post-quantum handshake, encrypted-record layer, outer framing, 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. + +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. + +### 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, and a WebRTC Direct listener is initially +published as `WebRtcDirect + Unverified`. Relay acquisition selects +`Quic + Direct`; native dialing never consumes WebRTC records. + +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 `ant-protocol` +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 reachability independently from transport type. A +WebRTC Direct endpoint inherits its owner's canonical reachability evidence. +One-hop browser `FIND_NODE` responses expose Direct endpoints (and LAN +endpoints in local testnets), but do not describe a relay-only endpoint as +directly dialable. A future relayed WebRTC endpoint remains a separate address +record 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. After initial bootstrap, the browser can use authenticated network +peers to exchange short-lived SDP/ICE information with a NATed node. ICE tries +host and server-reflexive candidates first and uses an end-to-end relay +candidate when required. + +Signaling peers coordinate connection establishment only. They do not perform +DHT lookup on the browser's behalf and do not carry application requests or +chunk bytes. A TURN-like or Saorsa relay forwards encrypted DTLS packets; DTLS +and the inner post-quantum application session terminate at the browser and +storage node, not the relay. The relay sees neither RPC nor chunk plaintext. +Relay allocations are published in signed, expiring endpoint records rather +than the constant bootstrap list. + +### 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`; +- 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 `ant-protocol` + 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; +- 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. This catalog is explicitly a +local replacement for future signed DHT endpoint records, not a production +discovery mechanism. + +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 local validation + +On 2026-09-01 the ignored five-node WebRTC Direct devnet integration test used +the actual native client adapter and shared `ant-protocol` implementation to +complete the ML-KEM/ML-DSA handshake, encrypted `HELLO`, iterative lookup, +download, quote/payment-proof handling, paid upload, and read-back. Shared +protocol unit tests additionally reject tampered and replayed records, wrong +peer IDs, tampered node signatures, and invalid outer-frame lengths. The +`ant-core` browser target builds and lints as WASM, and the browser SDK's +generated bindings, type checks, and unit tests pass with protocol v4. + +This is strong local integration evidence but not the required browser +interoperability result. A real Chrome, Firefox, and Safari run against a +matching deployed v4 node fleet remains an acceptance criterion. + +### 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 still require relayed WebRTC. Their +relay-only direct listeners are no longer returned as usable browser +endpoints, and failed endpoints learned before that classification 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 deployments still + need per-IP/session request, channel, and byte quotas. +- 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, + and global/per-client byte quotas. +- 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 55a34717..16afab71 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 @@ -77,12 +99,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, } @@ -99,6 +120,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. @@ -111,11 +133,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. @@ -124,18 +146,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 a55a0fa0..5fcaf884 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,22 +107,55 @@ async fn main() -> color_eyre::Result<()> { )); } config.advertise_ip = cli.host; - let evm_info = - resolve_evm_info(cli.evm_network.as_deref(), cli.enable_evm, &mut config).await?; + 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, &mut config).await?; 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()); @@ -123,8 +165,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."); @@ -134,15 +186,87 @@ 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, 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, @@ -159,12 +283,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 { ant_node::logging::info!("Starting local Anvil blockchain for EVM payment enforcement..."); let testnet = evmlib::testnet::Testnet::new() @@ -193,18 +320,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, + }) } } @@ -216,8 +345,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!({ @@ -236,37 +367,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 @@ -278,6 +412,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}; @@ -295,11 +430,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..3bb3865d 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -28,6 +28,20 @@ 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, + + /// 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 +244,13 @@ 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(addr) = self.webrtc_direct_advertised_addr { + config.webrtc_direct.advertised_addr = Some(addr); + } #[cfg(feature = "logging")] { config.log_level = self.log_level.into(); diff --git a/src/browser.rs b/src/browser.rs new file mode 100644 index 00000000..0dface37 --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,254 @@ +//! 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. + +use saorsa_core::{MultiAddr, PeerId, WebRtcCertificateHash, WebRtcDirectAddr}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; + +/// Version of the local browser bootstrap manifest. +pub const BROWSER_MANIFEST_VERSION: u16 = 5; + +/// A self-contained browser-compatible transport endpoint. +/// +/// The multiaddress embeds the node's stable DTLS certificate hash. Callers +/// never supply a separate certificate pin or resolve a DNS name. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserEndpoint { + /// Canonical WebRTC Direct multiaddress, including certificate hash and peer ID. + pub multiaddr: MultiAddr, +} + +/// Validated components extracted from a [`BrowserEndpoint`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBrowserEndpoint { + /// Literal UDP socket address passed to the WebRTC Direct dialer. + pub socket_addr: SocketAddr, + /// Persistent ANT peer ID from the `/p2p` suffix. + pub peer_id: PeerId, + /// Stable SHA-256 hash of the node's DTLS certificate. + pub certificate_hash: [u8; 32], +} + +impl BrowserEndpoint { + /// Construct a canonical endpoint from a literal socket address, ANT peer ID, + /// and the stable DTLS certificate's SHA-256 hash. + /// + /// # Errors + /// + /// Returns an error for port zero. + pub fn new( + advertised_addr: SocketAddr, + peer_id: &PeerId, + certificate_hash: [u8; 32], + ) -> Result { + let transport = WebRtcDirectAddr::new( + advertised_addr, + WebRtcCertificateHash::new(certificate_hash), + ) + .map_err(|error| error.to_string())?; + let multiaddr = MultiAddr::webrtc_direct(transport).with_peer_id(*peer_id); + Ok(Self { multiaddr }) + } + + /// Parse and validate this endpoint's transport, hashes, and peer identity. + /// + /// # Errors + /// + /// Returns an error when the multiaddress is malformed, uses an unsupported + /// transport or hash encoding, or omits its peer identity. + pub fn parse(&self) -> Result { + let peer_id = self + .multiaddr + .peer_id() + .copied() + .ok_or_else(|| "WebRtcDirect multiaddress has no peer ID".to_string())?; + let address = self + .multiaddr + .webrtc_direct_addr() + .ok_or_else(|| "multiaddress does not use WebRtcDirect".to_string())?; + Ok(ParsedBrowserEndpoint { + socket_addr: address.socket_addr(), + peer_id, + certificate_hash: *address.certificate_hash().as_bytes(), + }) + } +} + +/// 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, +} + +/// Public EVM configuration required to pay for immutable browser uploads. +/// +/// This deliberately excludes wallet keys. A browser obtains a key from its +/// user at runtime and must never transmit it to a storage node or manifest +/// server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserPaymentNetwork { + /// HTTP JSON-RPC endpoint used to submit and inspect transactions. + pub rpc_url: String, + /// ERC-20 ANT token contract address. + pub payment_token_address: String, + /// Payment vault contract that accepts quote payments. + pub payment_vault_address: String, +} + +impl BrowserPaymentNetwork { + /// Convert the node's concrete EVM network into browser-safe public data. + #[must_use] + pub fn from_evm_network(network: &evmlib::Network) -> Self { + Self { + 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, + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn browser_endpoint_round_trips_stable_hash() { + let peer_id = PeerId::from_bytes([0xab; 32]); + let endpoint = BrowserEndpoint::new( + "127.0.0.1:24000".parse().expect("valid socket address"), + &peer_id, + [0x11; 32], + ) + .expect("valid endpoint"); + + assert!(endpoint + .multiaddr + .to_string() + .starts_with("/ip4/127.0.0.1/udp/24000/webrtc-direct/certhash/u")); + assert_eq!( + endpoint.multiaddr.to_string().matches("/certhash/").count(), + 1 + ); + let parsed = endpoint.parse().expect("round-trip endpoint"); + assert_eq!(parsed.socket_addr, "127.0.0.1:24000".parse().unwrap()); + assert_eq!(parsed.peer_id, peer_id); + assert_eq!(parsed.certificate_hash, [0x11; 32]); + } + + #[test] + fn browser_endpoint_round_trips_ipv6() { + let peer_id = PeerId::from_bytes([0xcd; 32]); + let endpoint = BrowserEndpoint::new( + "[::1]:24000".parse().expect("valid socket address"), + &peer_id, + [0x33; 32], + ) + .expect("valid endpoint"); + let parsed = endpoint.parse().expect("round-trip endpoint"); + assert_eq!(parsed.socket_addr, "[::1]:24000".parse().unwrap()); + } + + #[test] + fn browser_endpoint_rejects_unpinned_or_malformed_addresses() { + let peer_id = PeerId::from_bytes([0xab; 32]).to_hex(); + let unpinned = + format!(r#"{{"multiaddr":"/ip4/127.0.0.1/udp/24000/webrtc-direct/p2p/{peer_id}"}}"#); + assert!(serde_json::from_str::(&unpinned).is_err()); + + let malformed = format!( + r#"{{"multiaddr":"/ip4/127.0.0.1/udp/24000/webrtc-direct/certhash/uAA/p2p/{peer_id}"}}"# + ); + assert!(serde_json::from_str::(&malformed).is_err()); + } + + #[test] + fn browser_endpoint_rejects_port_zero() { + let peer_id = PeerId::from_bytes([0xab; 32]); + let error = BrowserEndpoint::new("127.0.0.1:0".parse().unwrap(), &peer_id, [0x11; 32]) + .expect_err("port zero must fail"); + assert!(error.contains("must not be zero")); + } +} diff --git a/src/config.rs b/src/config.rs index 2319f96b..f84c910d 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,73 @@ 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 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_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_request_bytes() -> usize { + 64 * 1024 +} + /// Auto-upgrade configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpgradeConfig { @@ -279,6 +353,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 +682,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 6c301549..2be7b195 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -28,6 +28,17 @@ 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::{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 // ============================================================================= @@ -169,6 +180,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 { @@ -191,6 +208,8 @@ impl Default for DevnetConfig { cleanup_data_dir: true, evm_network: None, advertise_ip: None, + webrtc_direct: false, + webrtc_direct_base_port: 0, } } } @@ -274,6 +293,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 { @@ -294,6 +317,8 @@ pub struct Devnet { shutdown: CancellationToken, state: Arc>, health_monitor: Option>, + #[cfg(feature = "webrtc-direct")] + browser_endpoint_catalog: Arc, } impl Devnet { @@ -304,6 +329,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( @@ -342,6 +368,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 { @@ -350,6 +434,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()), }) } @@ -402,6 +488,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); @@ -450,6 +545,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); + BrowserPaymentNetwork::from_evm_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); @@ -541,6 +840,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, }) } @@ -592,6 +895,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; @@ -629,6 +933,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(), + 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..08225aa1 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, ParsedBrowserEndpoint, 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 089d2f2c..626bb887 100644 --- a/src/node.rs +++ b/src/node.rs @@ -87,6 +87,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(); @@ -206,6 +215,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)), }; @@ -465,6 +476,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, } @@ -525,6 +539,44 @@ 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 endpoint_catalog = Arc::new(crate::web_rtc::BrowserEndpointCatalog::default()); + 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(), + endpoint_catalog, + ) + .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}"); @@ -693,6 +745,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 a67e8bb8..d826b913 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1104,6 +1104,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 diff --git a/src/web_rtc.rs b/src/web_rtc.rs new file mode 100644 index 00000000..80e3c322 --- /dev/null +++ b/src/web_rtc.rs @@ -0,0 +1,1485 @@ +//! 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 `ant-protocol` 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::{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 ant_protocol::web_rtc::{ + accept_pq_session, decode_pq_frame, encode_pq_frame, pq_frame_length, transfer_timeout, + PqSession, PQ_CLIENT_HELLO_BYTES, PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_FRAME_PREFIX_BYTES, +}; +use evmlib::common::{Amount, TxHash}; +use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; +use parking_lot::RwLock; +use saorsa_core::identity::NodeIdentity; +use saorsa_core::{DHTNode, KnownReachability, MultiAddr, P2PNode, PeerId}; +use saorsa_transport::webrtc_direct::{ + WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, + MAX_DATA_CHANNEL_MESSAGE_SIZE, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; +use tokio::sync::Semaphore; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +const PROTOCOL_VERSION: u16 = 4; +const PROTOCOL_NAME: &str = "autonomi.web.poc.v4"; +const DATA_CHANNEL_LABEL: &str = "autonomi.web.v4"; +const MAX_FIND_NODE_RESULTS: usize = 20; +const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; +const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const WEBRTC_WRITE_CHUNK_BYTES: usize = MAX_DATA_CHANNEL_MESSAGE_SIZE; +const AUTOMATIC_PORT_MIN: u32 = 32_768; +const AUTOMATIC_PORT_COUNT: u32 = 65_536 - AUTOMATIC_PORT_MIN; + +/// 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. +/// +/// The in-process devnet shares this catalog so its listeners can expose one +/// another immediately. Independently deployed nodes discover endpoints from +/// the authenticated DHT address sets; this remains a local fast-path and +/// fallback while those records converge. +#[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() + } +} + +/// 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: Arc, +) -> 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, certificate_sha256) + .map_err(Error::Config)?; + persist_browser_endpoint(root_dir, &browser_endpoint).await?; + endpoint_catalog.insert(peer_id, browser_endpoint.clone()); + let dht = Arc::clone(p2p.dht_manager()); + + let state = Arc::new(ServerState { + config: config.clone(), + identity, + p2p, + ant_protocol, + payment: BrowserPaymentNetwork::from_evm_network(evm_network), + endpoint: browser_endpoint.clone(), + endpoint_catalog, + }); + let connection_limit = Arc::new(Semaphore::new(config.max_connections)); + + info!( + bind = %local_addr, + multiaddr = %browser_endpoint.multiaddr, + certificate = %certificate_path.display(), + "ADR-0009 WebRTC Direct listening" + ); + + let task = tokio::spawn(async move { + serve_webrtc(listener, state, connection_limit, shutdown).await; + }); + dht.set_supplemental_self_addresses(vec![browser_endpoint.multiaddr.clone()]) + .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<()> { + if config.max_connections == 0 { + return Err(Error::Config( + "webrtc_direct.max_connections must be greater than zero".to_string(), + )); + } + if config.max_request_bytes == 0 || config.max_request_bytes > MAX_RESPONSE_HEADER_BYTES { + return Err(Error::Config(format!( + "webrtc_direct.max_request_bytes must be between 1 and {MAX_RESPONSE_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) +} + +async fn serve_webrtc( + mut listener: WebRtcDirectListener, + state: Arc, + connection_limit: Arc, + shutdown: CancellationToken, +) { + loop { + let connection = tokio::select! { + () = shutdown.cancelled() => break, + connection = listener.accept() => connection, + }; + match connection { + Ok(connection) => { + let remote_addr = connection.remote_addr(); + let Ok(permit) = Arc::clone(&connection_limit).try_acquire_owned() else { + debug!(remote = %remote_addr, "Rejected WebRTC Direct connection: busy"); + if let Err(error) = connection.close().await { + debug!(remote = %remote_addr, %error, "Failed to close busy connection"); + } + continue; + }; + let connection_state = Arc::clone(&state); + let connection_shutdown = shutdown.clone(); + tokio::spawn(async move { + let _permit = permit; + if let Err(error) = + handle_connection(connection, connection_state, 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}"); + } + info!("ADR-0009 WebRTC Direct stopped"); +} + +async fn handle_connection( + mut connection: WebRtcDirectConnection, + state: Arc, + shutdown: CancellationToken, +) -> ServerResult<()> { + loop { + let channel = tokio::select! { + () = shutdown.cancelled() => return Ok(()), + result = connection.accept_data_channel() => { + result.map_err(|error| format!("DataChannel accept failed: {error}"))? + } + }; + let state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(error) = handle_webrtc_channel(channel, state).await { + debug!("WebRTC Direct DataChannel ended: {error}"); + } + }); + } +} + +async fn handle_webrtc_channel( + channel: WebRtcDataChannel, + state: Arc, +) -> ServerResult<()> { + if channel.label() != DATA_CHANNEL_LABEL { + if let Err(error) = channel.close().await { + debug!("Failed to close unsupported DataChannel: {error}"); + } + return Err(format!( + "unsupported DataChannel label {:?}", + channel.label() + )); + } + + let mut pq_session = establish_pq_session(&channel, &state).await?; + let mut hello_completed = false; + loop { + let (request, content) = + match read_webrtc_request(&channel, state.config.max_request_bytes, &mut pq_session) + .await + { + Ok(request) => request, + Err(error) + if matches!( + error.as_str(), + "DataChannel closed" | "request idle timeout" | "request frame timed out" + ) || error.starts_with("PQ session:") => + { + if let Err(close_error) = channel.close().await { + debug!("Failed to close idle WebRTC DataChannel: {close_error}"); + } + return Ok(()); + } + Err(error) => { + let response = Response::error(0, "invalid_request", error); + write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; + return Ok(()); + } + }; + if request.version != PROTOCOL_VERSION { + let response = Response::error( + request.id, + "unsupported_version", + format!( + "protocol version {} is unsupported; expected {PROTOCOL_VERSION}", + request.version + ), + ); + write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; + continue; + } + + let is_hello = matches!(&request.body, RequestBody::Hello); + if !is_hello && !hello_completed { + let response = Response::error( + request.id, + "authentication_required", + "HELLO must initialize this encrypted WebRTC session first".to_string(), + ); + write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; + continue; + } + + let (response, content) = process_request(request, content, &state).await; + if is_hello && matches!(&response.status, ResponseStatus::Ok) { + hello_completed = true; + } + write_webrtc_response( + &channel, + &mut pq_session, + &response, + content.as_deref().unwrap_or_default(), + ) + .await?; + } +} + +async fn establish_pq_session( + channel: &WebRtcDataChannel, + state: &ServerState, +) -> ServerResult { + let client_hello = read_pq_payload( + channel, + PQ_CLIENT_HELLO_BYTES, + "PQ client hello idle timeout", + "PQ client hello timed out", + ) + .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, &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).await?; + Ok(session) +} + +async fn read_webrtc_request( + channel: &WebRtcDataChannel, + max_header_bytes: usize, + pq_session: &mut PqSession, +) -> ServerResult<(Request, Vec)> { + let max_plaintext_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; + let encrypted = read_pq_payload( + channel, + max_plaintext_bytes + PQ_ENCRYPTED_OVERHEAD_BYTES, + "request idle timeout", + "request frame timed out", + ) + .await?; + let frame = pq_session + .open(&encrypted) + .map_err(|error| format!("PQ session: {error}"))?; + parse_webrtc_request(&frame, max_header_bytes) +} + +fn parse_webrtc_request(frame: &[u8], max_header_bytes: usize) -> ServerResult<(Request, Vec)> { + if frame.len() < 4 { + return Err("request prefix is incomplete".to_string()); + } + let header_len = u32::from_be_bytes( + frame[..4] + .try_into() + .map_err(|_| "request prefix is incomplete".to_string())?, + ) as usize; + if header_len == 0 || header_len > max_header_bytes { + return Err(format!( + "request header length {header_len} is outside 1..={max_header_bytes}" + )); + } + let content_offset = 4usize + .checked_add(header_len) + .ok_or_else(|| "request header length overflow".to_string())?; + if frame.len() < content_offset { + return Err("request JSON is truncated".to_string()); + } + let request: Request = serde_json::from_slice(&frame[4..content_offset]) + .map_err(|error| format!("request JSON is invalid: {error}"))?; + if request.content_length > MAX_CHUNK_SIZE { + return Err(format!( + "request content length {} exceeds {MAX_CHUNK_SIZE}", + request.content_length + )); + } + let expected_length = content_offset + .checked_add(request.content_length) + .ok_or_else(|| "request frame length overflow".to_string())?; + if frame.len() != expected_length { + return Err(format!( + "request contains {} bytes; declared {expected_length}", + frame.len() + )); + } + Ok((request, frame[content_offset..].to_vec())) +} + +async fn read_pq_payload( + channel: &WebRtcDataChannel, + max_payload_bytes: usize, + idle_timeout_message: &str, + frame_timeout_message: &str, +) -> ServerResult> { + let first_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 first_message.is_empty() { + return Err("DataChannel closed".to_string()); + } + let frame_started = tokio::time::Instant::now(); + let mut frame_deadline = frame_started + transfer_timeout(PQ_FRAME_PREFIX_BYTES); + let mut frame = Vec::new(); + let mut expected_length = None; + let mut next_message = Some(first_message); + let max_frame_bytes = 4usize + .checked_add(max_payload_bytes) + .ok_or_else(|| "PQ frame limit overflow".to_string())?; + loop { + let message = if let Some(message) = next_message.take() { + message + } else { + 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()); + } + if frame.len() + message.len() > max_frame_bytes { + return Err(format!( + "request exceeds the {max_frame_bytes}-byte frame limit" + )); + } + frame.extend_from_slice(&message); + + if expected_length.is_none() { + expected_length = pq_frame_length(&frame, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}"))?; + if let Some(length) = expected_length { + frame_deadline = frame_started + transfer_timeout(length); + } + } + + if let Some(length) = expected_length { + if frame.len() > length { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + if frame.len() == length { + return decode_pq_frame(&frame, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}")); + } + } + } +} + +async fn write_webrtc_response( + channel: &WebRtcDataChannel, + pq_session: &mut PqSession, + response: &Response, + content: &[u8], +) -> ServerResult<()> { + let header = serde_json::to_vec(response) + .map_err(|error| format!("response JSON serialization failed: {error}"))?; + if header.len() > MAX_RESPONSE_HEADER_BYTES { + return Err("response header exceeds protocol limit".to_string()); + } + let header_len = u32::try_from(header.len()) + .map_err(|_| "response header length does not fit u32".to_string())?; + let mut plaintext = Vec::with_capacity(4 + header.len() + content.len()); + plaintext.extend_from_slice(&header_len.to_be_bytes()); + plaintext.extend_from_slice(&header); + plaintext.extend_from_slice(content); + let frame = pq_session + .seal(&plaintext) + .map_err(|error| format!("PQ session: {error}"))?; + write_pq_payload(channel, &frame).await +} + +async fn write_pq_payload(channel: &WebRtcDataChannel, payload: &[u8]) -> ServerResult<()> { + let frame = encode_pq_frame(payload).map_err(|error| format!("PQ session: {error}"))?; + for chunk in frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + channel + .send(chunk) + .await + .map_err(|error| format!("response message write failed: {error}"))?; + } + Ok(()) +} + +async fn process_request( + request: Request, + content: Vec, + state: &ServerState, +) -> (Response, Option>) { + if !matches!(&request.body, RequestBody::PutChunk { .. }) && !content.is_empty() { + return ( + Response::error( + 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(); + ( + Response::ok( + request.id, + ResponseBody::Hello { + protocol: 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 } => { + process_find_node(request.id, target, count, state).await + } + RequestBody::GetChunk { address } => process_get_chunk(request.id, address, state).await, + RequestBody::QuoteChunk { address, size } => { + process_quote_chunk(request.id, address, size, state).await + } + RequestBody::PutChunk { + address, + quote, + transaction_hash, + } => { + process_put_chunk( + 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_address_records_for_peer(&node.peer_id) + .await; + nodes.push(browser_node_from_dht( + &node, + &supplemental, + &state.endpoint_catalog, + )); + } + ( + Response::ok(request_id, ResponseBody::Nodes { target, nodes }, 0), + None, + ) +} + +fn browser_node_from_dht( + node: &DHTNode, + supplemental: &[(MultiAddr, KnownReachability)], + endpoint_catalog: &BrowserEndpointCatalog, +) -> BrowserNode { + let addresses = node.addresses_by_priority(); + let discovered_endpoint = supplemental + .iter() + .find(|(address, reachability)| { + matches!( + reachability, + KnownReachability::Direct | KnownReachability::Lan + ) && address.is_webrtc_direct() + && address.peer_id().is_some_and(|peer| peer == &node.peer_id) + }) + .map(|(address, _)| address.clone()) + .map(|multiaddr| BrowserEndpoint { multiaddr }); + BrowserNode { + webrtc_direct: discovered_endpoint.or_else(|| endpoint_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, +) -> (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, + ); + }; + + match ant_protocol.storage().get(&address_bytes).await { + Ok(Some(content)) if content.len() <= MAX_CHUNK_SIZE => { + let content_length = content.len(); + ( + Response::ok( + request_id, + ResponseBody::Chunk { + address, + size: content_length, + }, + content_length, + ), + Some(content), + ) + } + Ok(Some(content)) => ( + Response::error( + request_id, + "oversize_chunk", + format!( + "stored content is {} bytes; maximum is {MAX_CHUNK_SIZE}", + content.len() + ), + ), + None, + ), + Ok(None) => (Response::not_found(request_id, address), None), + Err(error) => ( + Response::error( + request_id, + "storage_error", + format!("chunk read failed: {error}"), + ), + None, + ), + } +} + +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 BrowserQuoteArtifact::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) = quote.into_payment_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; + +#[derive(Debug, Deserialize)] +struct Request { + version: u16, + #[serde(rename = "request_id")] + id: u64, + content_length: usize, + #[serde(flatten)] + body: RequestBody, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum RequestBody { + Hello, + FindNode { + target: String, + #[serde(default)] + count: Option, + }, + GetChunk { + address: String, + }, + QuoteChunk { + address: String, + size: u64, + }, + PutChunk { + address: String, + quote: Box, + transaction_hash: String, + }, +} + +#[derive(Debug, Serialize)] +struct Response { + version: u16, + request_id: u64, + status: ResponseStatus, + content_length: usize, + #[serde(flatten)] + body: ResponseBody, +} + +impl Response { + fn ok(request_id: u64, body: ResponseBody, content_length: usize) -> Self { + Self { + version: PROTOCOL_VERSION, + request_id, + status: ResponseStatus::Ok, + content_length, + body, + } + } + + fn not_found(request_id: u64, address: String) -> Self { + Self { + version: PROTOCOL_VERSION, + request_id, + status: ResponseStatus::NotFound, + content_length: 0, + body: ResponseBody::ChunkNotFound { address }, + } + } + + fn error(request_id: u64, code: &str, message: String) -> Self { + Self { + version: PROTOCOL_VERSION, + request_id, + status: ResponseStatus::Error, + content_length: 0, + body: ResponseBody::Error { + code: code.to_string(), + message, + }, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum ResponseStatus { + Ok, + NotFound, + Error, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ResponseBody { + Hello { + protocol: String, + peer_id: String, + max_chunk_size: usize, + endpoint: BrowserEndpoint, + payment: BrowserPaymentNetwork, + capabilities: Vec, + }, + Nodes { + target: String, + nodes: Vec, + }, + Chunk { + address: String, + size: usize, + }, + ChunkNotFound { + address: String, + }, + StorageQuote { + address: String, + already_stored: bool, + quote: BrowserQuoteArtifact, + }, + ChunkStored { + address: String, + already_stored: bool, + }, + Error { + code: String, + message: String, + }, +} + +#[derive(Debug, Serialize)] +struct BrowserNode { + peer_id: String, + native_addresses: Vec, + reliability: f64, + webrtc_direct: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BrowserQuoteArtifact { + peer_id: String, + content: String, + timestamp_secs: u64, + price: String, + rewards_address: String, + public_key: String, + signature: String, + committed_key_count: u32, + commitment_pin: Option, + quote_hash: String, + commitment: Option, +} + +impl BrowserQuoteArtifact { + fn 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(BrowserCommitmentArtifact::from_bytes) + .transpose()?; + Ok(Self { + 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 into_payment_quote( + self, + expected_content: [u8; 32], + ) -> ServerResult<([u8; 32], PaymentQuote, Option>)> { + let peer_id = decode_32_byte_hex(&self.peer_id)?; + let content = decode_32_byte_hex(&self.content)?; + if content != expected_content { + return Err("payment quote is for a different chunk address".to_string()); + } + let price = Amount::from_str(&self.price) + .map_err(|error| format!("payment quote has an invalid price: {error}"))?; + let rewards_address = RewardsAddress::from_str(&self.rewards_address) + .map_err(|error| format!("payment quote has an invalid rewards address: {error}"))?; + let public_key = hex::decode(&self.public_key) + .map_err(|error| format!("payment quote public key is not hexadecimal: {error}"))?; + let signature = hex::decode(&self.signature) + .map_err(|error| format!("payment quote signature is not hexadecimal: {error}"))?; + let commitment_pin = self + .commitment_pin + .as_deref() + .map(decode_32_byte_hex) + .transpose()?; + let timestamp = SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(self.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: self.committed_key_count, + commitment_pin, + }; + if hex::encode(quote.hash()) != self.quote_hash.to_ascii_lowercase() { + return Err("payment quote hash does not match its signed fields".to_string()); + } + let commitment = self + .commitment + .map(|artifact| { + hex::decode(artifact.encoded) + .map_err(|error| format!("commitment is not hexadecimal: {error}")) + }) + .transpose()?; + Ok((peer_id, quote, commitment)) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BrowserCommitmentArtifact { + encoded: String, + root: String, + key_count: u32, + sender_peer_id: String, + sender_public_key: String, + signature: String, +} + +impl BrowserCommitmentArtifact { + fn 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(Self { + 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: Arc, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[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, PROTOCOL_VERSION); + assert_eq!(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, + [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, + [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.clone(), KnownReachability::Direct); + let browser_node = browser_node_from_dht( + &node, + std::slice::from_ref(&supplemental), + &BrowserEndpointCatalog::default(), + ); + + 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 find_node_hides_relay_only_webrtc_endpoint() { + let peer_id = PeerId::from_bytes([0x32; 32]); + let endpoint = BrowserEndpoint::new( + "203.0.113.10:42768".parse().expect("socket address"), + &peer_id, + [0x53; 32], + ) + .expect("browser endpoint"); + let node = DHTNode { + peer_id, + addresses: Vec::new(), + address_types: Vec::new(), + distance: None, + reliability: 0.75, + }; + let supplemental = (endpoint.multiaddr, KnownReachability::Relay); + + let browser_node = browser_node_from_dht( + &node, + std::slice::from_ref(&supplemental), + &BrowserEndpointCatalog::default(), + ); + + assert!(browser_node.webrtc_direct.is_none()); + } +} diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs new file mode 100644 index 00000000..6ecd73c3 --- /dev/null +++ b/tests/webrtc_direct_devnet.rs @@ -0,0 +1,376 @@ +//! Live ADR-0009 local-devnet protocol test. + +use ant_node::devnet::{Devnet, DevnetConfig}; +use ant_node::BrowserEndpoint; +use ant_protocol::web_rtc::{ + decode_pq_frame, encode_pq_frame, pq_frame_length, PqClientHandshake, PqSession, + PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_SERVER_ACCEPT_BYTES, +}; +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, MAX_DATA_CHANNEL_MESSAGE_SIZE, +}; +use self_encryption::{DataMap, EncryptedChunk}; +use serde_json::{json, Value}; +use std::error::Error; +use std::io; +use std::str::FromStr; + +const DATA_CHANNEL_LABEL: &str = "autonomi.web.v4"; +const WEBRTC_WRITE_CHUNK_BYTES: usize = MAX_DATA_CHANNEL_MESSAGE_SIZE; + +#[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 (hello, hello_content) = rpc( + &endpoint.endpoint, + json!({ + "version": 4, + "request_id": 5, + "type": "hello", + }), + &[], + ) + .await?; + assert_eq!(hello["status"], "ok"); + assert_eq!(hello["protocol"], "autonomi.web.poc.v4"); + 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.to_hex()); + assert_eq!( + hello["endpoint"]["multiaddr"], + endpoint.endpoint.multiaddr.to_string() + ); + assert!(hello_content.is_empty()); + + let (closest, closest_content) = rpc( + &endpoint.endpoint, + json!({ + "version": 4, + "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_peer = closest["nodes"] + .as_array() + .and_then(|nodes| nodes.iter().find(|node| node["webrtc_direct"].is_object())) + .and_then(|node| node["peer_id"].as_str()) + .ok_or_else(|| io::Error::other("FIND_NODE returned no browser endpoint"))?; + let download_endpoint = endpoints + .iter() + .find(|candidate| { + candidate + .endpoint + .parse() + .is_ok_and(|parsed| parsed.peer_id.to_hex() == discovered_peer) + }) + .ok_or_else(|| io::Error::other("discovered endpoint was not in the devnet catalog"))?; + let (header, data_map_bytes) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 4, + "request_id": 7, + "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) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 4, + "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) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 4, + "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) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 4, + "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) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 4, + "request_id": 52, + "type": "get_chunk", + "address": upload_address, + }), + &[], + ) + .await?; + assert_eq!(uploaded_header["status"], "ok"); + assert_eq!(uploaded_content, upload_content); + + 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}"))) +} + +async fn rpc( + endpoint: &BrowserEndpoint, + request: Value, + content: &[u8], +) -> Result<(Value, Vec), Box> { + let request_type = request["type"].as_str().unwrap_or("unknown").to_string(); + let parsed = endpoint.parse().map_err(io::Error::other)?; + let direct_addr = WebRtcDirectAddr::new( + parsed.socket_addr, + WebRtcCertificateHash::new(parsed.certificate_hash), + )?; + let client = WebRtcDirectClient::dial(&direct_addr, DATA_CHANNEL_LABEL) + .await + .map_err(|error| io::Error::other(format!("WebRTC Direct dial failed: {error}")))?; + let expected_peer_id = *parsed.peer_id.to_bytes(); + let mut pq_session = establish_pq_session(client.data_channel(), &expected_peer_id).await?; + if request["type"] != "hello" { + let _ = rpc_stream( + client.data_channel(), + &mut pq_session, + json!({ + "version": 4, + "request_id": 1, + "type": "hello", + }), + &[], + ) + .await + .map_err(|error| io::Error::other(format!("WebRTC Direct HELLO failed: {error}")))?; + } + let result = rpc_stream(client.data_channel(), &mut pq_session, request, content) + .await + .map_err(|error| { + io::Error::other(format!("WebRTC Direct {request_type} RPC failed: {error}")) + }); + client.close().await?; + Ok(result?) +} + +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)?) +}