From f759f87805f2c08644ceec1950ebae2a401a9e86 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:41:58 +0200 Subject: [PATCH 01/31] feat: add browser-enabled WebTransport testnet --- Cargo.lock | 149 +++- Cargo.toml | 14 + README.md | 4 + assets/browser-devnet-public.txt | 6 + docs/WEBTRANSPORT_TESTNET.md | 86 +++ ...irect-browser-clients-over-webtransport.md | 288 ++++++++ src/bin/ant-devnet/cli.rs | 67 +- src/bin/ant-devnet/main.rs | 173 ++++- src/bin/ant-node/cli.rs | 25 + src/browser.rs | 100 +++ src/config.rs | 99 +++ src/devnet.rs | 373 ++++++++++ src/lib.rs | 9 +- src/node.rs | 44 ++ src/payment/verifier.rs | 12 + src/web_transport.rs | 662 ++++++++++++++++++ tests/webtransport_devnet.rs | 182 +++++ 17 files changed, 2246 insertions(+), 47 deletions(-) create mode 100644 assets/browser-devnet-public.txt create mode 100644 docs/WEBTRANSPORT_TESTNET.md create mode 100644 docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md create mode 100644 src/browser.rs create mode 100644 src/web_transport.rs create mode 100644 tests/webtransport_devnet.rs diff --git a/Cargo.lock b/Cargo.lock index 01676b35..6f97812a 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" @@ -268,7 +283,7 @@ dependencies = [ "either", "serde", "serde_with", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -804,7 +819,7 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce758c01a51171003dce5fe999b7c7021e2e7322404884a9b6f9e9f1bd9235d" dependencies = [ - "sha2", + "sha2 0.10.9", ] [[package]] @@ -841,11 +856,12 @@ dependencies = [ "saorsa-core", "saorsa-pqc", "self-replace", + "self_encryption", "semver 1.0.28", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", @@ -856,6 +872,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "wtransport", "xor_name", "zip", ] @@ -1519,6 +1536,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" @@ -1798,6 +1836,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const_format" version = "0.2.36" @@ -2097,7 +2141,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -2185,7 +2229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2197,6 +2241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -2520,7 +2565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9fb5a367b9846933e271a3c2a992930743f82ae5e8cb7faa780715a80fa0b15" dependencies = [ "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "zeroize", ] @@ -2532,7 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" dependencies = [ "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "zeroize", ] @@ -2964,12 +3009,18 @@ dependencies = [ "hmac", "p256", "rand_core 0.9.5", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", ] +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + [[package]] name = "http" version = "1.4.2" @@ -3414,7 +3465,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3821,6 +3872,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "octets" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" + [[package]] name = "oid-registry" version = "0.8.1" @@ -3963,7 +4020,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4754,6 +4811,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4945,7 +5003,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5145,6 +5203,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" @@ -5332,6 +5412,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -6635,6 +6726,42 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wtransport" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" +dependencies = [ + "bytes", + "pem", + "quinn", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha2 0.11.0", + "socket2 0.6.4", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "url", + "wtransport-proto", + "x509-parser", +] + +[[package]] +name = "wtransport-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" +dependencies = [ + "httlib-huffman", + "octets", + "thiserror 2.0.18", + "url", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d7b06522..4edd5952 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,12 @@ page_size = "0.6" postcard = { version = "1.1.3", features = ["use-std"] } bao = "0.13.1" +# ADR-0009 transport interoperability proof. Kept optional so the existing +# node build and its Rust 1.75 MSRV are unchanged. wtransport 0.7 itself +# requires Rust 1.88 when this feature is enabled. +wtransport = { version = "0.7.1", optional = true } +self_encryption = { version = "0.36", optional = true } + [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -174,6 +180,11 @@ name = "poc_shutdown_lmdb_drain" path = "tests/poc_shutdown_lmdb_drain.rs" required-features = ["test-utils"] +[[test]] +name = "webtransport_devnet" +path = "tests/webtransport_devnet.rs" +required-features = ["webtransport-poc"] + [features] default = ["logging"] # Enable tracing/logging infrastructure. @@ -184,6 +195,9 @@ logging = ["tracing", "tracing-subscriber", "tracing-appender"] # Expose test helpers (cache_insert, payment_verifier accessor) for # integration tests and downstream test harnesses. test-utils = [] +# Non-production direct-browser interoperability proof from ADR-0009. +# This enables a second HTTP/3/WebTransport UDP listener and requires Rust 1.88. +webtransport-poc = ["dep:self_encryption", "dep:wtransport"] [profile.release] lto = true diff --git a/README.md b/README.md index 33c47f4f..7e47aaa4 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 WebTransport and a +default immutable file is published at startup, see +[Browser-enabled local testnet](docs/WEBTRANSPORT_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..f2524220 --- /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 WebTransport, and verifies the content address in-browser. diff --git a/docs/WEBTRANSPORT_TESTNET.md b/docs/WEBTRANSPORT_TESTNET.md new file mode 100644 index 00000000..0e8197a4 --- /dev/null +++ b/docs/WEBTRANSPORT_TESTNET.md @@ -0,0 +1,86 @@ +# Browser-enabled local testnet + +This workflow starts a five-node local Autonomi network where every node has a +direct WebTransport 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 optional WebTransport dependency. + +```bash +cargo run --features webtransport-poc --bin ant-devnet -- \ + --preset minimal \ + --base-port 23000 \ + --webtransport \ + --webtransport-base-port 24000 \ + --serve-port 25000 \ + --enable-logging +``` + +The services are: + +| Purpose | Address | +|---|---| +| Native node QUIC | UDP 127.0.0.1:23000-23004 | +| Direct browser WebTransport | 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 | + +When `--serve-port` is omitted with `--webtransport`, 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 +64 MiB in this local in-memory launcher. + +The browser manifest contains every node's peer ID, direct HTTPS URL, +certificate SHA-256 pin, the public DataMap address, the 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 +WebTransport. + +## 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. +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. + +## Automated verification + +```bash +cargo test --features webtransport-poc --test webtransport_devnet -- --ignored +``` + +This starts the five-node network, self-encrypts and publishes a public file +through normal PUT admission with devnet-prepaid cache entries, pins a generated +certificate, retrieves the DataMap and encrypted chunks from direct endpoints, +and reconstructs the exact original bytes. + +## LAN testing + +Use `--host ` and add the exact site origin: + +```bash +cargo run --features webtransport-poc --bin ant-devnet -- \ + --preset minimal \ + --host 192.168.1.50 \ + --webtransport \ + --webtransport-origin http://192.168.1.50:5173 \ + --serve-port 25000 \ + --enable-logging +``` + +Expose the client dev server on the LAN and change its manifest URL to +`http://192.168.1.50:25000/api/browser-manifest.json`. Both the native and +WebTransport UDP ranges must be reachable. Do not use this unsigned local +manifest mode on a public network. diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md new file mode 100644 index 00000000..bbfe41a6 --- /dev/null +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md @@ -0,0 +1,288 @@ +# ADR-0009: Direct browser clients over WebTransport + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Decision owners:** +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** [W3C WebTransport](https://www.w3.org/TR/webtransport/), + [WebTransport over HTTP/3](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/), + [W3C WebRTC](https://www.w3.org/TR/webrtc/) + +## Context + +Web applications must be able to act as full read clients: they perform the +iterative closest-node lookup themselves and download immutable chunks from +storage nodes. A node must not perform a whole-network lookup or proxy chunk +bytes on the browser's behalf. 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 WebTransport sessions negotiated through HTTP/3 or HTTP/2 and require +browser-compatible TLS authentication. + +Many 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 datagrams and is sometimes unavoidable on the public +Internet. + +This ADR records the intended production architecture and defines a smaller, +explicitly non-production proof of concept. The proof of concept validates +browser interoperability, request framing, local DHT access, and chunk +downloads; signed endpoint dissemination and relayed WebTransport are later +implementation slices. + +## 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. +- Operators must not need to obtain DNS names or public CA certificates. +- The existing post-quantum node-to-node port and wire protocols remain + unchanged. +- A public browser protocol must be narrow, versioned, bounded, and read-only. +- NATed nodes need an end-to-end relay path without exposing plaintext to the + relay. +- A 4 MiB chunk needs reliable streaming and backpressure. +- Endpoint ownership must remain bound to the node's persistent ML-DSA + identity even though browser TLS currently uses classical cryptography. + +## 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. **Make one UDP port detect both native QUIC and WebTransport.** Rejected for + the first implementation. It mixes two TLS stacks, two QUIC protocol + implementations, and different identity models in the most sensitive part + of the node. +4. **Use WebRTC DataChannels.** Not selected as the primary transport. + WebRTC's ICE/STUN/TURN support can establish direct paths through more NATs, + and it does not require Web PKI. However, every peer connection needs an + out-of-band SDP/ICE signaling exchange and a separate ICE + DTLS + SCTP + stack. DataChannels also require application fragmentation and buffered + amount management for 4 MiB chunks. WebRTC remains a candidate fallback if + measured direct-ICE success justifies this complexity. +5. **Add a separate WebTransport listener to each node (chosen).** It maps + directly to request/response streams, leaves native networking unchanged, + and supports a pinned self-signed certificate without operator-managed + Web PKI. + +## Decision + +We will add a separate, opt-in WebTransport-over-HTTP/3 listener to nodes. +Production browser-capable nodes will publish an owner-signed browser endpoint +record. Browser clients will use those records to connect directly, perform +one-hop `FIND_NODE` RPCs iteratively, and download chunks with `GET_CHUNK`. + +### Transport and certificates + +- WebTransport uses a separate UDP socket and port from native Saorsa QUIC. +- Node software generates P-256 X.509v3 certificates automatically. Operators + do not obtain public CA certificates. +- The browser supplies the certificate's SHA-256 DER hash through + `serverCertificateHashes`. +- Production nodes maintain overlapping current and next certificates because + hash-pinned WebTransport certificates may be valid for at most two weeks. +- The listener is read-only and has independent connection, stream, request, + timeout, and byte limits. +- The native ML-KEM/ML-DSA transport remains the node-to-node transport and is + not downgraded or replaced. + +### Endpoint discovery and identity + +Production discovery uses a separately versioned record rather than changing +the existing Postcard `DHTNode` shape in place: + +```text +BrowserEndpointRecord { + network_id, + peer_id, + sequence, + expires_at, + webtransport_urls, + current_certificate_hashes, + next_certificate_hashes, + capabilities, + protocol_versions, + max_chunk_size, + node_public_key, + ml_dsa_signature +} +``` + +The ML-DSA signature covers a canonical, domain-separated encoding. The +browser verifies the public-key-to-peer-ID binding, signature, network ID, +sequence, expiry, capabilities, and certificate hash before connecting. +Initial bootstrap records are distributed with the HTTPS web application; +subsequent records are learned during DHT iteration. + +The classical browser TLS certificate is therefore an ephemeral transport key +bound by an application-layer ML-DSA signature to the node's persistent PQ +identity. Browser TLS confidentiality is not post-quantum until browsers +standardize and expose a suitable PQ TLS mode. + +### Browser protocol + +The public protocol is not the private Saorsa `WireMessage` or native Postcard +DHT protocol. Each client-created bidirectional stream carries one request and +one response. The initial methods are: + +- `HELLO`: negotiate version/network/capabilities and return node identity. +- `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. +- `PING`: optional liveness method after the proof of concept. + +Messages have an explicit version and length framing. Chunk bytes are binary, +not JSON/base64. The browser recomputes BLAKE3 and rejects content whose hash +does not equal the requested address. + +Browser sessions are anonymous read clients and are not inserted into node +routing tables. PUT, payment, quoting, replication, arbitrary topic +forwarding, and native DHT messages are not exposed. + +### Lookup behavior + +The browser owns the iterative lookup state machine. It starts from ordinary +bootstrap nodes, queries up to `ALPHA = 3` unqueried closest endpoints in +parallel, merges verified endpoint records, and stops at convergence or the +iteration limit. The initial implementation targets the current native +`K = 20` behavior. Lookup and chunk retry policies should eventually share +language-independent test vectors with the native client. + +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 + +Publicly reachable nodes accept WebTransport directly. For NATed nodes, +Saorsa's relay layer will be generalized to provide a UDP forwarding socket +usable by the standard WebTransport QUIC implementation. The node publishes +the relay allocation as another signed WebTransport URL. TLS and application +traffic remain end-to-end between browser and storage node; the relay only +forwards encrypted datagrams. + +WebRTC may be reconsidered as an optional path after an interoperability study +measures ICE setup latency, direct-connect success, TURN fallback, node +resource use, and 4 MiB DataChannel performance. + +### Proof-of-concept slice + +The repository PoC is intentionally feature-gated and disabled by default. It +provides: + +- a separate WebTransport listener; +- an automatically generated short-lived P-256 certificate and printed hash; +- exact path and Origin checks; +- bounded JSON requests on one bidirectional stream per RPC; +- a length-prefixed JSON response header followed by optional raw chunk bytes; +- `HELLO`, local `FIND_NODE`, and local `GET_CHUNK`; +- a browser application that pins the certificate, performs the lookup loop, + downloads public file records, reconstructs the complete file, and verifies + both chunk and whole-file BLAKE3 hashes. + +The PoC endpoint descriptors are not yet ML-DSA-signed or disseminated through +the DHT. Peers lacking a browser descriptor remain visible but cannot be +queried by the browser. The PoC must not be enabled on production nodes and is +not evidence that partial fleet deployment is sufficient. + +### 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 direct URL and certificate hash of every browser-enabled +peer in its routing view. This catalog is explicitly a local replacement for +the future signed DHT endpoint record, not a production discovery mechanism. + +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. A read-only HTTP bootstrap +manifest exposes endpoint pins, public-file metadata, and the resolved public +root DataMap needed by this local client; it never performs lookup or carries +file bytes. + +The companion JavaScript client and test site live in the `web/` package of the +`ant-client-web-support` repository. It fetches the public DataMap and every +encrypted data chunk directly, applies the native BLAKE3 KDF, +ChaCha20-Poly1305 authentication, and Brotli decompression, verifies the +reconstructed file, and exposes it through the browser save flow. + +## Consequences + +### Positive + +- Browsers can become application-level full read clients without a lookup or + download gateway. +- Operators do not manage DNS names or CA certificate issuance. +- Existing PQ node networking and compatibility remain isolated. +- Reliable WebTransport streams match large immutable chunk downloads. +- Endpoint records explicitly bind browser TLS to the node's PQ identity. +- The same transport can run end-to-end through a generic UDP relay. + +### Negative / Trade-offs + +- Browser-capable nodes run a second UDP listener and a second QUIC/TLS stack. +- Short-lived pinned certificates require automatic overlap, rotation, and + endpoint-record propagation. +- Current browser TLS is not post-quantum. +- Full direct operation requires broad browser-endpoint coverage among storage + nodes. +- Relayed nodes consume relay bandwidth even though relays cannot read the + traffic. +- WebTransport and its HTTP/3 mapping are still evolving and require an + explicit browser compatibility matrix. +- The PoC's latest WebTransport dependency has a higher feature-specific Rust + toolchain requirement than the default node build. + +### Neutral / Operational + +- The official web application still needs to be served from a secure HTTPS + context; that certificate is unrelated to node operator certificates. +- Origin is policy input, not client authentication. Public deployments still + need per-IP/session request and byte quotas. +- Bootstrap peers remain necessary, as they are for native clients, but do not + perform lookup or proxy downloads. + +## Validation + +The decision advances beyond PoC only after all of the following are covered: + +- Automated protocol framing, oversize-request, malformed-input, path, and + Origin tests. +- Browser end-to-end tests on current Chrome, Firefox, and Safari from a real + secure context using both pinned and WebPKI certificates. +- Browser-side iterative lookup parity tests for XOR ordering, `K`, `ALPHA`, + convergence, retries, and unavailable endpoints. +- Successful streamed downloads at 0 bytes, typical sizes, and 4 MiB, with + BLAKE3 verification and cancellation/backpressure measurements. +- Certificate current/next rotation, stale-record, replay, wrong-peer, + wrong-network, and hash-mismatch tests. +- Connection floods, stream floods, slow readers, request amplification, and + global/per-client byte quota tests. +- A fleet test demonstrating that browser endpoint coverage reaches the + storage nodes selected by native closest-group rules. +- End-to-end relayed WebTransport tests where TLS terminates at the NATed node, + not the relay. +- Regression tests proving the existing native PQ port and native client + behavior are unchanged when browser support is disabled. +- Review triggers when the W3C/IETF WebTransport protocol mapping, browser + support, node storage placement, or Saorsa relay API changes materially. + +## Notes for AI-assisted work + +AI tools may help draft this ADR, but **must not mark it Accepted without human +review**. Accepted ADRs are immutable: create a new superseding ADR rather than +editing an Accepted ADR. diff --git a/src/bin/ant-devnet/cli.rs b/src/bin/ant-devnet/cli.rs index 114503a4..03631beb 100644 --- a/src/bin/ant-devnet/cli.rs +++ b/src/bin/ant-devnet/cli.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "ant-devnet")] #[command(author, version, about, long_about = None)] +#[allow(clippy::struct_excessive_bools)] pub struct Cli { /// Node count to spawn. #[arg(long)] @@ -44,6 +45,28 @@ pub struct Cli { #[arg(long)] pub manifest: Option, + /// Enable one direct-browser WebTransport listener per devnet node. + /// + /// The binary must be built with `--features webtransport-poc`. + #[arg(long)] + pub webtransport: bool, + + /// First UDP port assigned to devnet WebTransport listeners (0 = allocate). + #[arg(long, requires = "webtransport")] + pub webtransport_base_port: Option, + + /// Exact browser Origin accepted by WebTransport listeners. + /// May be supplied more than once. Defaults to the local Vite origins. + #[arg(long = "webtransport-origin", requires = "webtransport")] + pub webtransport_origins: Vec, + + /// 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 = "webtransport")] + pub public_file: Option, + /// Enable logging output. /// When omitted, the tracing subscriber is not installed and no log /// records are emitted, even if the binary was built with the @@ -81,12 +104,11 @@ pub struct Cli { #[arg(long, conflicts_with = "enable_evm")] pub evm_network: Option, - /// Serve the manifest over a read-only HTTP API on this port (binds - /// 0.0.0.0). Any LAN device can then GET - /// `http://:/api/devnet-manifest.json` (and `/api/info`) — - /// no file copying. Open CORS. Suggested: 8088. Requires `--host` (the API - /// advertises a LAN URL, so a loopback-only devnet would be misleading). - #[arg(long, requires = "host", value_parser = clap::value_parser!(u16).range(1..))] + /// Serve native and browser manifests over a read-only HTTP API. + /// + /// Without `--host` it binds 127.0.0.1. With `--host` it binds 0.0.0.0 + /// and advertises that LAN address. Open CORS. Suggested: 25000. + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] pub serve_port: Option, } @@ -103,6 +125,7 @@ mod tests { assert!(cli.host.is_none()); assert!(cli.evm_network.is_none()); assert!(cli.serve_port.is_none()); + assert!(!cli.webtransport); } /// The LAN flags parse into the expected typed values. @@ -115,11 +138,11 @@ mod tests { "--evm-network", "arbitrum-sepolia", "--serve-port", - "8088", + "25000", ]); assert_eq!(cli.host, Some(Ipv4Addr::new(192, 168, 1, 100))); assert_eq!(cli.evm_network.as_deref(), Some("arbitrum-sepolia")); - assert_eq!(cli.serve_port, Some(8088)); + assert_eq!(cli.serve_port, Some(25_000)); } /// A non-IPv4 `--host` is rejected by clap's value parser. @@ -128,18 +151,32 @@ 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_webtransport() { + assert!(Cli::try_parse_from(["ant-devnet", "--public-file", "hello.txt"]).is_err()); + + let cli = Cli::parse_from([ + "ant-devnet", + "--webtransport", + "--webtransport-base-port", + "22000", + "--public-file", + "hello.txt", + ]); + assert!(cli.webtransport); + assert_eq!(cli.webtransport_base_port, Some(22_000)); } } diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index 49dea28c..369f0761 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 = "webtransport-poc"))] + if cli.webtransport { + return Err(color_eyre::eyre::eyre!( + "--webtransport requires a binary built with --features webtransport-poc" + )); + } + // 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,6 +107,15 @@ async fn main() -> color_eyre::Result<()> { )); } config.advertise_ip = cli.host; + config.webtransport = cli.webtransport; + if let Some(base_port) = cli.webtransport_base_port { + config.webtransport_base_port = base_port; + } + if !cli.webtransport_origins.is_empty() { + config.webtransport_allowed_origins = cli.webtransport_origins.clone(); + } else if let Some(host) = cli.host { + config.webtransport_allowed_origins = vec![format!("http://{host}:5173")]; + } let evm_info = resolve_evm_info( cli.evm_network.as_deref(), cli.enable_evm, @@ -109,16 +127,42 @@ async fn main() -> color_eyre::Result<()> { let mut devnet = Devnet::new(config).await?; devnet.start().await?; + let created_at = chrono::Utc::now().to_rfc3339(); + + #[cfg(feature = "webtransport-poc")] + let browser_manifest = if cli.webtransport { + 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(), + vec![public_file], + )) + } else { + None + }; + + #[cfg(not(feature = "webtransport-poc"))] + let browser_manifest: Option = None; + let manifest = DevnetManifest { base_port: devnet.config().base_port, node_count: devnet.config().node_count, bootstrap: devnet.bootstrap_addrs(), data_dir: devnet.config().data_dir.clone(), - created_at: chrono::Utc::now().to_rfc3339(), + created_at, evm: evm_info, }; let json = serde_json::to_string_pretty(&manifest)?; + let browser_json = browser_manifest + .as_ref() + .map(serde_json::to_string_pretty) + .transpose()?; if let Some(path) = cli.manifest { tokio::fs::write(&path, &json).await?; ant_node::logging::info!("Wrote manifest to {}", path.display()); @@ -128,8 +172,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.webtransport.then_some(25_000)); + if let Some(port) = serve_port { + serve_manifest_api( + port, + cli.host, + &manifest, + json.clone(), + browser_manifest.as_ref(), + browser_json, + )?; } ant_node::logging::info!("Devnet running. Press Ctrl+C to stop."); @@ -139,6 +193,71 @@ async fn main() -> color_eyre::Result<()> { Ok(()) } +#[cfg(feature = "webtransport-poc")] +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 = 64 * 1024 * 1024; + + 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 @@ -235,8 +354,10 @@ fn serve_manifest_api( host: Option, manifest: &DevnetManifest, manifest_json: String, + browser_manifest: Option<&BrowserDevnetManifest>, + browser_manifest_json: Option, ) -> color_eyre::Result<()> { - let host_ip = host.map_or_else(local_ip_guess, |i| i.to_string()); + let host_ip = host.map_or_else(|| "127.0.0.1".to_string(), |i| i.to_string()); let evm_block = manifest.evm.as_ref().map_or(serde_json::Value::Null, |e| { let loopback = e.rpc_url.contains("127.0.0.1") || e.rpc_url.contains("localhost"); serde_json::json!({ @@ -255,37 +376,40 @@ fn serve_manifest_api( }) }); let bootstrap = serde_json::to_value(&manifest.bootstrap)?; + let browser_manifest_url = + browser_manifest.map(|_| format!("http://{host_ip}:{port}/api/browser-manifest.json")); + let public_files = browser_manifest.map_or_else(Vec::new, |browser| browser.files.clone()); let info = serde_json::json!({ "host_ip": host_ip, "manifest_url": format!("http://{host_ip}:{port}/api/devnet-manifest.json"), + "browser_manifest_url": browser_manifest_url, "node_count": manifest.node_count as u64, "bootstrap": bootstrap, + "public_files": public_files, "evm": evm_block, }); let info_json = serde_json::to_string_pretty(&info)?; // Bind synchronously so a failure (e.g. the port is already in use) // propagates to the caller instead of the devnet silently coming up // without its manifest API. - let listener = std::net::TcpListener::bind(("0.0.0.0", port)).map_err(|e| { - color_eyre::eyre::eyre!("failed to bind manifest API on 0.0.0.0:{port}: {e}") + let bind_ip = host.map_or(std::net::Ipv4Addr::LOCALHOST, |_| { + std::net::Ipv4Addr::UNSPECIFIED + }); + let listener = std::net::TcpListener::bind((bind_ip, port)).map_err(|e| { + color_eyre::eyre::eyre!("failed to bind manifest API on {bind_ip}:{port}: {e}") })?; ant_node::logging::info!( - "manifest API on http://0.0.0.0:{port}/api/devnet-manifest.json (+ /api/info)" + "manifest API on http://{host_ip}:{port}/api/devnet-manifest.json (+ /api/info)" ); - spawn_manifest_server(listener, manifest_json, info_json); + if browser_manifest.is_some() { + ant_node::logging::info!( + "browser app manifest: http://{host_ip}:{port}/api/browser-manifest.json" + ); + } + spawn_manifest_server(listener, manifest_json, info_json, browser_manifest_json); Ok(()) } -/// Best-effort primary LAN IP (src of the default route) for the info endpoint. -fn local_ip_guess() -> String { - std::net::UdpSocket::bind("0.0.0.0:0") - .and_then(|s| { - s.connect("1.1.1.1:80")?; - Ok(s.local_addr()?.ip().to_string()) - }) - .unwrap_or_else(|_| "127.0.0.1".to_string()) -} - /// Run a tiny read-only HTTP server on `listener` (its own thread) exposing the /// manifest over the LAN. GET-only, open CORS; hand-rolled HTTP/1.1 so there's /// no new dependency. Connections are handled **inline, one at a time** — the @@ -297,6 +421,7 @@ fn spawn_manifest_server( listener: std::net::TcpListener, manifest_json: String, info_json: String, + browser_manifest_json: Option, ) { std::thread::spawn(move || { use std::io::{Read, Write}; @@ -314,11 +439,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..eb50d166 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -28,6 +28,21 @@ pub struct Cli { #[arg(long, env = "ANT_IPV4_ONLY")] pub ipv4_only: bool, + /// Enable the ADR-0009 WebTransport `PoC` on this UDP address. + /// + /// The binary must be built with `--features webtransport-poc`. + #[arg(long, env = "ANT_WEBTRANSPORT_BIND")] + pub webtransport_bind: Option, + + /// Public WebTransport URL to advertise instead of deriving it from the bind address. + #[arg(long, env = "ANT_WEBTRANSPORT_ADVERTISED_URL")] + pub webtransport_advertised_url: Option, + + /// Exact browser Origin allowed to open a WebTransport session. + /// May be supplied more than once. + #[arg(long = "webtransport-origin", env = "ANT_WEBTRANSPORT_ORIGINS")] + pub webtransport_origins: Vec, + /// Bootstrap peer addresses. #[arg(long, short, env = "ANT_BOOTSTRAP")] pub bootstrap: Vec, @@ -230,6 +245,16 @@ impl Cli { config.port = self.port; config.ipv4_only = self.ipv4_only; + if let Some(bind) = self.webtransport_bind { + config.webtransport.enabled = true; + config.webtransport.bind = bind; + } + if let Some(url) = self.webtransport_advertised_url { + config.webtransport.advertised_url = Some(url); + } + if !self.webtransport_origins.is_empty() { + config.webtransport.allowed_origins = self.webtransport_origins; + } #[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..93a45d47 --- /dev/null +++ b/src/browser.rs @@ -0,0 +1,100 @@ +//! Shared browser-client discovery types. +//! +//! These types deliberately describe only public read capabilities. Native +//! node addresses and payment/write APIs remain outside the browser surface. + +use serde::{Deserialize, Serialize}; + +/// Version of the local browser bootstrap manifest. +pub const BROWSER_MANIFEST_VERSION: u16 = 2; + +/// A browser-compatible transport endpoint and its pinned certificate hash. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserEndpoint { + /// HTTPS WebTransport URL, including the session path. + pub url: String, + /// Lowercase SHA-256 hash of the endpoint certificate's DER encoding. + pub certificate_sha256: String, +} + +/// A bootstrap node that a browser can authenticate and contact directly. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserBootstrapNode { + /// Hex-encoded persistent node peer ID. + pub peer_id: String, + /// Browser-compatible 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, +} + +/// 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, + /// 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, + files: Vec, + ) -> Self { + Self { + version: BROWSER_MANIFEST_VERSION, + network_id, + created_at, + endpoints, + files, + } + } +} diff --git a/src/config.rs b/src/config.rs index 2319f96b..be1e5c6f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,6 +120,13 @@ pub struct NodeConfig { #[serde(default)] pub storage: StorageConfig, + /// Experimental direct-browser WebTransport listener. + /// + /// This is the ADR-0009 interoperability proof and is disabled by + /// default. Enabling it requires a build with `webtransport-poc`. + #[serde(default)] + pub webtransport: WebTransportConfig, + /// Directory for persisting the close group cache. /// /// When `None` (default), the node's `root_dir` is used — the cache @@ -143,6 +150,97 @@ pub struct NodeConfig { pub log_level: String, } +/// Configuration for the ADR-0009 WebTransport proof of concept. +/// +/// This listener is deliberately separate from the native Saorsa QUIC port. +/// It exposes only local closest-node lookup and local immutable chunk GET. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebTransportConfig { + /// Enable the experimental listener. + #[serde(default)] + pub enabled: bool, + + /// UDP address for the HTTP/3 listener. + #[serde(default = "default_webtransport_bind")] + pub bind: SocketAddr, + + /// URL advertised to the browser in `HELLO` and self lookup results. + /// + /// When omitted, the URL is derived from the bound socket and + /// [`Self::path`]. A wildcard bind therefore needs an explicit public URL. + #[serde(default)] + pub advertised_url: Option, + + /// WebTransport session path. + #[serde(default = "default_webtransport_path")] + pub path: String, + + /// Exact browser origins accepted by the `PoC`. + /// + /// `"*"` is supported for local experimentation but must not be used for + /// a public deployment. + #[serde(default = "default_webtransport_origins")] + pub allowed_origins: Vec, + + /// Subject alternative names for the automatically generated certificate. + #[serde(default = "default_webtransport_sans")] + pub certificate_sans: Vec, + + /// Maximum simultaneously accepted browser sessions. + #[serde(default = "default_webtransport_max_connections")] + pub max_connections: usize, + + /// Maximum JSON request size, in bytes. + #[serde(default = "default_webtransport_max_request_bytes")] + pub max_request_bytes: usize, +} + +impl Default for WebTransportConfig { + fn default() -> Self { + Self { + enabled: false, + bind: default_webtransport_bind(), + advertised_url: None, + path: default_webtransport_path(), + allowed_origins: default_webtransport_origins(), + certificate_sans: default_webtransport_sans(), + max_connections: default_webtransport_max_connections(), + max_request_bytes: default_webtransport_max_request_bytes(), + } + } +} + +fn default_webtransport_bind() -> SocketAddr { + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) +} + +fn default_webtransport_path() -> String { + "/autonomi/webtransport/v1".to_string() +} + +fn default_webtransport_origins() -> Vec { + vec![ + "http://localhost:5173".to_string(), + "http://127.0.0.1:5173".to_string(), + ] +} + +fn default_webtransport_sans() -> Vec { + vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + ] +} + +const fn default_webtransport_max_connections() -> usize { + 32 +} + +const fn default_webtransport_max_request_bytes() -> usize { + 16 * 1024 +} + /// Auto-upgrade configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpgradeConfig { @@ -279,6 +377,7 @@ impl Default for NodeConfig { upgrade: UpgradeConfig::default(), payment: PaymentConfig::default(), storage: StorageConfig::default(), + webtransport: WebTransportConfig::default(), close_group_cache_dir: None, max_message_size: default_max_message_size(), log_level: default_log_level(), diff --git a/src/devnet.rs b/src/devnet.rs index d9e9de09..c33cac8f 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 = "webtransport-poc")] +use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse}; +#[cfg(feature = "webtransport-poc")] +use crate::browser::{BrowserBootstrapNode, BrowserPublicFile}; +#[cfg(feature = "webtransport-poc")] +use crate::config::WebTransportConfig; +#[cfg(feature = "webtransport-poc")] +use bytes::Bytes; +#[cfg(feature = "webtransport-poc")] +use std::collections::HashMap; + // ============================================================================= // Devnet Constants // ============================================================================= @@ -215,6 +226,15 @@ 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 WebTransport listener per devnet node. + pub webtransport: bool, + + /// First UDP port in the WebTransport node range (0 = allocate). + pub webtransport_base_port: u16, + + /// Browser origins accepted by every devnet WebTransport listener. + pub webtransport_allowed_origins: Vec, } impl Default for DevnetConfig { @@ -237,6 +257,12 @@ impl Default for DevnetConfig { cleanup_data_dir: true, evm_network: None, advertise_ip: None, + webtransport: false, + webtransport_base_port: 0, + webtransport_allowed_origins: vec![ + "http://localhost:5173".to_string(), + "http://127.0.0.1:5173".to_string(), + ], } } } @@ -320,6 +346,10 @@ pub struct DevnetNode { state: Arc>, bootstrap_addrs: Vec, protocol_task: Option>, + #[cfg(feature = "webtransport-poc")] + webtransport_task: Option>, + #[cfg(feature = "webtransport-poc")] + browser_endpoint: Option, } impl DevnetNode { @@ -340,6 +370,8 @@ pub struct Devnet { shutdown: CancellationToken, state: Arc>, health_monitor: Option>, + #[cfg(feature = "webtransport-poc")] + browser_endpoint_catalog: Arc, } impl Devnet { @@ -350,6 +382,7 @@ impl Devnet { /// Returns `DevnetError::Config` if the configuration is invalid (e.g. bootstrap /// count exceeds node count or port range overflow). /// Returns `DevnetError::Io` if the data directory cannot be created. + #[allow(clippy::too_many_lines)] pub async fn new(mut config: DevnetConfig) -> Result { if config.bootstrap_count >= config.node_count { return Err(DevnetError::Config( @@ -388,6 +421,70 @@ impl Devnet { ))); } + #[cfg(not(feature = "webtransport-poc"))] + if config.webtransport { + return Err(DevnetError::Config( + "WebTransport devnet support requires the 'webtransport-poc' feature".to_string(), + )); + } + + #[cfg(feature = "webtransport-poc")] + if config.webtransport { + if config.webtransport_allowed_origins.is_empty() { + return Err(DevnetError::Config( + "At least one WebTransport browser Origin is required".to_string(), + )); + } + + if config.webtransport_base_port == 0 { + let adjacent = max_port; + let adjacent_end = adjacent.checked_add(node_count_u16); + config.webtransport_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 WebTransport port range".to_string(), + ) + })? + }; + } + + let webtransport_end = config + .webtransport_base_port + .checked_add(node_count_u16) + .ok_or_else(|| { + DevnetError::Config("WebTransport port range overflow".to_string()) + })?; + if config.webtransport_base_port < DEVNET_PORT_RANGE_MIN + || webtransport_end > DEVNET_PORT_RANGE_MAX + { + return Err(DevnetError::Config(format!( + "WebTransport ports must remain in the local test range {DEVNET_PORT_RANGE_MIN}..{DEVNET_PORT_RANGE_MAX}" + ))); + } + if base_port < webtransport_end && config.webtransport_base_port < max_port { + return Err(DevnetError::Config( + "Native and WebTransport devnet port ranges overlap".to_string(), + )); + } + } + tokio::fs::create_dir_all(&config.data_dir).await?; Ok(Self { @@ -396,6 +493,10 @@ impl Devnet { shutdown: CancellationToken::new(), state: Arc::new(RwLock::new(NetworkState::Uninitialized)), health_monitor: None, + #[cfg(feature = "webtransport-poc")] + browser_endpoint_catalog: Arc::new( + crate::web_transport::BrowserEndpointCatalog::default(), + ), }) } @@ -448,6 +549,15 @@ impl Devnet { if let Some(handle) = node.protocol_task.take() { handle.abort(); } + #[cfg(feature = "webtransport-poc")] + if let Some(handle) = node.webtransport_task.take() { + if let Err(error) = handle.await { + warn!( + "Error stopping node {} WebTransport listener: {error}", + node.index + ); + } + } let node_index = node.index; let node_state = Arc::clone(&node.state); @@ -496,6 +606,201 @@ impl Devnet { .collect() } + /// Get every direct browser endpoint in this devnet. + #[cfg(feature = "webtransport-poc")] + #[must_use] + pub fn browser_endpoints(&self) -> Vec { + self.nodes + .iter() + .filter_map(|node| { + node.browser_endpoint + .clone() + .map(|endpoint| BrowserBootstrapNode { + peer_id: node.peer_id.to_hex(), + 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 WebTransport is disabled, self-encryption fails, + /// a generated chunk is too large, no node admits a required record, or + /// protocol serialization fails. + #[cfg(feature = "webtransport-poc")] + pub async fn publish_public_file( + &self, + name: String, + content_type: String, + content: &[u8], + ) -> Result { + if !self.config.webtransport { + return Err(DevnetError::Config( + "Cannot publish a browser file when WebTransport 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) + } + + #[cfg(feature = "webtransport-poc")] + async fn publish_browser_record(&self, address: [u8; 32], content: &Bytes) -> Result { + let mut replicas = 0usize; + let mut failures = Vec::new(); + + for node in &self.nodes { + let Some(protocol) = node.ant_protocol.as_ref() else { + failures.push(format!("node {} has no protocol handler", node.index)); + continue; + }; + protocol + .payment_verifier_arc() + .cache_insert_browser_devnet_seed(address); + + let request = ChunkMessage { + request_id: u64::try_from(node.index).unwrap_or(u64::MAX), + body: ChunkMessageBody::PutRequest(ChunkPutRequest::new(address, content.clone())), + }; + let request_bytes = request.encode().map_err(|error| { + DevnetError::Core(format!("Failed to encode public-file PUT: {error}")) + })?; + let response_bytes = protocol + .try_handle_request(&request_bytes) + .await + .map_err(|error| { + DevnetError::Core(format!( + "Node {} public-file PUT failed: {error}", + node.index + )) + })? + .ok_or_else(|| { + DevnetError::Core(format!( + "Node {} returned no public-file PUT response", + node.index + )) + })?; + let response = ChunkMessage::decode(&response_bytes).map_err(|error| { + DevnetError::Core(format!( + "Failed to decode node {} public-file response: {error}", + node.index + )) + })?; + match response.body { + ChunkMessageBody::PutResponse( + ChunkPutResponse::Success { .. } | ChunkPutResponse::AlreadyExists { .. }, + ) => { + replicas += 1; + } + ChunkMessageBody::PutResponse(other) => { + failures.push(format!("node {}: {other:?}", node.index)); + } + other => failures.push(format!( + "node {} returned unexpected response {other:?}", + node.index + )), + } + } + + if replicas == 0 { + return Err(DevnetError::Startup(format!( + "No devnet node admitted browser record {}: {}", + hex::encode(address), + failures.join("; ") + ))); + } + if !failures.is_empty() { + debug!( + "Browser record was admitted by {replicas} nodes; {} non-responsible/failed nodes: {}", + failures.len(), + failures.join("; ") + ); + } + Ok(replicas) + } + async fn start_bootstrap_nodes(&mut self) -> Result<()> { info!("Starting {} bootstrap nodes", self.config.bootstrap_count); @@ -587,6 +892,10 @@ impl Devnet { state: Arc::new(RwLock::new(NodeState::Pending)), bootstrap_addrs, protocol_task: None, + #[cfg(feature = "webtransport-poc")] + webtransport_task: None, + #[cfg(feature = "webtransport-poc")] + browser_endpoint: None, }) } @@ -638,6 +947,7 @@ impl Devnet { )) } + #[allow(clippy::too_many_lines)] async fn start_node(&mut self, mut node: DevnetNode) -> Result<()> { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; @@ -675,6 +985,69 @@ impl Devnet { node.p2p_node = Some(Arc::new(p2p_node)); *node.state.write().await = NodeState::Running; + #[cfg(feature = "webtransport-poc")] + if self.config.webtransport { + let index_u16 = u16::try_from(node.index).map_err(|_| { + DevnetError::Config(format!("Node index {} exceeds u16::MAX", node.index)) + })?; + let port = self + .config + .webtransport_base_port + .checked_add(index_u16) + .ok_or_else(|| { + DevnetError::Config(format!( + "WebTransport 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 mut webtransport_config = WebTransportConfig::default(); + webtransport_config.enabled = true; + webtransport_config.bind = SocketAddr::from((bind_ip, port)); + webtransport_config.advertised_url = Some(format!( + "https://{advertised_ip}:{port}{}", + webtransport_config.path + )); + webtransport_config + .allowed_origins + .clone_from(&self.config.webtransport_allowed_origins); + webtransport_config.certificate_sans = if advertised_ip.is_loopback() { + vec![ + "localhost".to_string(), + Ipv4Addr::LOCALHOST.to_string(), + "::1".to_string(), + ] + } else { + vec![advertised_ip.to_string()] + }; + + let p2p = node.p2p_node.clone().ok_or_else(|| { + DevnetError::Startup(format!( + "Node {} lost its P2P handle before WebTransport startup", + node.index + )) + })?; + let server = crate::web_transport::spawn( + &webtransport_config, + p2p, + node.ant_protocol.clone(), + self.shutdown.clone(), + Arc::clone(&self.browser_endpoint_catalog), + ) + .map_err(|error| { + DevnetError::Startup(format!( + "Failed to start node {} WebTransport listener: {error}", + node.index + )) + })?; + node.browser_endpoint = Some(server.endpoint); + node.webtransport_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..07f0ea74 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 = "webtransport-poc")] +mod web_transport; 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, + BrowserPublicFile, 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, WebTransportConfig}; pub use devnet::{Devnet, DevnetConfig, DevnetEvmInfo, DevnetManifest}; pub use error::{Error, Result}; pub use event::{NodeEvent, NodeEventsChannel}; diff --git a/src/node.rs b/src/node.rs index f98f4dee..6ed03a1a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -88,6 +88,15 @@ impl NodeBuilder { Self::validate_production_rewards_address(&self.config)?; + #[cfg(not(feature = "webtransport-poc"))] + if self.config.webtransport.enabled { + return Err(Error::Config( + "webtransport is enabled but this binary was not built with the \ + 'webtransport-poc' feature" + .to_string(), + )); + } + // Resolve identity and root_dir (may update self.config.root_dir) let identity = Arc::new(Self::resolve_identity(&mut self.config).await?); let peer_id = identity.peer_id().to_hex(); @@ -213,6 +222,8 @@ impl NodeBuilder { ant_protocol, replication_engine, protocol_task: None, + #[cfg(feature = "webtransport-poc")] + webtransport_task: None, upgrade_exit_code: Arc::new(AtomicI32::new(-1)), }; @@ -472,6 +483,9 @@ pub struct RunningNode { replication_engine: Option, /// Protocol message routing background task. protocol_task: Option>, + /// ADR-0009 experimental browser listener task. + #[cfg(feature = "webtransport-poc")] + webtransport_task: Option>, /// Exit code requested by a successful upgrade (-1 = no upgrade exit pending). upgrade_exit_code: Arc, } @@ -532,6 +546,27 @@ impl RunningNode { "Node is running on port: {}", actual_port ); + #[cfg(feature = "webtransport-poc")] + if self.config.webtransport.enabled { + let endpoint_catalog = + Arc::new(crate::web_transport::BrowserEndpointCatalog::default()); + match crate::web_transport::spawn( + &self.config.webtransport, + Arc::clone(&self.p2p_node), + self.ant_protocol.clone(), + self.shutdown.clone(), + endpoint_catalog, + ) { + Ok(server) => self.webtransport_task = Some(server.task), + Err(error) => { + if let Err(shutdown_error) = self.p2p_node.shutdown().await { + warn!("P2P shutdown after WebTransport startup failure failed: {shutdown_error}"); + } + return Err(error); + } + } + } + // Emit started event if let Err(e) = self.events_tx.send(NodeEvent::Started) { warn!("Failed to send Started event: {e}"); @@ -700,6 +735,15 @@ impl RunningNode { // Run the main event loop with signal handling self.run_event_loop().await?; + // The shared token closes the WebTransport accept loop and active + // browser sessions before storage and native P2P are torn down. + #[cfg(feature = "webtransport-poc")] + if let Some(task) = self.webtransport_task.take() { + if let Err(error) = task.await { + warn!("WebTransport task shutdown failed: {error}"); + } + } + // Shutdown replication engine before P2P so background tasks don't // use a dead P2P layer, and Arc references are released. if let Some(ref mut engine) = self.replication_engine { diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index fd550c77..6c79d329 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1232,6 +1232,18 @@ impl PaymentVerifier { self.cache.insert(xorname); } + /// Mark startup content as prepaid for the in-process browser devnet. + /// + /// This remains crate-private and feature-gated: it is used only by + /// [`crate::devnet::Devnet::publish_public_file`] before that local devnet + /// is handed to a browser. The subsequent PUT still traverses the normal + /// address, responsibility, payment-cache, storage, and read-verification + /// checks. + #[cfg(feature = "webtransport-poc")] + 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_transport.rs b/src/web_transport.rs new file mode 100644 index 00000000..0dbd22ad --- /dev/null +++ b/src/web_transport.rs @@ -0,0 +1,662 @@ +//! ADR-0009 WebTransport interoperability proof. +//! +//! This module is feature-gated, disabled by default, and intentionally keeps +//! the browser-facing HTTP/3 stack separate from native Saorsa QUIC. It is not +//! the production endpoint-record or certificate-rotation implementation. + +use crate::ant_protocol::MAX_CHUNK_SIZE; +use crate::browser::BrowserEndpoint; +use crate::config::WebTransportConfig; +use crate::error::{Error, Result}; +use crate::logging::{debug, info, warn}; +use crate::storage::AntProtocol; +use parking_lot::RwLock; +use saorsa_core::P2PNode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; +use wtransport::endpoint::IncomingSession; +use wtransport::stream::{RecvStream, SendStream}; +use wtransport::{Endpoint, Identity, ServerConfig}; + +const PROTOCOL_VERSION: u16 = 1; +const PROTOCOL_NAME: &str = "autonomi.web.poc.v1"; +const MAX_FIND_NODE_RESULTS: usize = 20; +const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5); + +/// Browser endpoints known to one or more listeners in the same process. +/// +/// Production nodes will populate this information from signed endpoint +/// records. The in-process devnet shares one catalog so browser clients can +/// exercise a real multi-node iterative lookup before that DHT record type is +/// available. +#[derive(Default)] +pub struct BrowserEndpointCatalog { + endpoints: RwLock>, +} + +impl BrowserEndpointCatalog { + fn insert(&self, peer_id: String, endpoint: BrowserEndpoint) { + self.endpoints.write().insert(peer_id, endpoint); + } + + fn get(&self, peer_id: &str) -> Option { + self.endpoints.read().get(peer_id).cloned() + } +} + +/// A running browser listener and the endpoint clients use to reach it. +pub struct WebTransportServer { + /// Direct endpoint and certificate pin. + pub endpoint: BrowserEndpoint, + /// Listener background task. + pub task: JoinHandle<()>, +} + +/// Start the feature-gated browser listener and return its endpoint and task. +pub fn spawn( + config: &WebTransportConfig, + p2p: Arc, + ant_protocol: Option>, + shutdown: CancellationToken, + endpoint_catalog: Arc, +) -> Result { + validate_config(config)?; + + let identity = Identity::self_signed(&config.certificate_sans) + .map_err(|error| Error::Config(format!("invalid WebTransport certificate SAN: {error}")))?; + let certificate = identity + .certificate_chain() + .as_slice() + .first() + .ok_or_else(|| Error::Startup("WebTransport identity has no certificate".to_string()))?; + let certificate_sha256 = hex::encode(certificate.hash().as_ref()); + + let server_config = ServerConfig::builder() + .with_bind_address(config.bind) + .with_identity(identity) + .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) + .build(); + let endpoint = Endpoint::server(server_config).map_err(|error| { + Error::Startup(format!("failed to bind WebTransport endpoint: {error}")) + })?; + let local_addr = endpoint.local_addr().map_err(|error| { + Error::Startup(format!( + "failed to read WebTransport bound address: {error}" + )) + })?; + let advertised_url = advertised_url(config, local_addr); + + let browser_endpoint = BrowserEndpoint { + url: advertised_url.clone(), + certificate_sha256: certificate_sha256.clone(), + }; + endpoint_catalog.insert(p2p.peer_id().to_hex(), browser_endpoint.clone()); + + let state = Arc::new(ServerState { + config: config.clone(), + p2p, + ant_protocol, + endpoint: browser_endpoint.clone(), + endpoint_catalog, + }); + let connection_limit = Arc::new(Semaphore::new(config.max_connections)); + + info!( + bind = %local_addr, + url = %advertised_url, + certificate_sha256 = %certificate_sha256, + "ADR-0009 WebTransport PoC listening" + ); + + let task = tokio::spawn(async move { + serve(endpoint, state, connection_limit, shutdown).await; + }); + Ok(WebTransportServer { + endpoint: browser_endpoint, + task, + }) +} + +fn validate_config(config: &WebTransportConfig) -> Result<()> { + if !config.path.starts_with('/') { + return Err(Error::Config( + "webtransport.path must start with '/'".to_string(), + )); + } + if config.allowed_origins.is_empty() { + return Err(Error::Config( + "webtransport.allowed_origins must not be empty".to_string(), + )); + } + if config.certificate_sans.is_empty() { + return Err(Error::Config( + "webtransport.certificate_sans must not be empty".to_string(), + )); + } + if config.max_connections == 0 { + return Err(Error::Config( + "webtransport.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!( + "webtransport.max_request_bytes must be between 1 and {MAX_RESPONSE_HEADER_BYTES}" + ))); + } + if let Some(url) = config.advertised_url.as_deref() { + if !url.starts_with("https://") { + return Err(Error::Config( + "webtransport.advertised_url must use https://".to_string(), + )); + } + } + Ok(()) +} + +fn advertised_url(config: &WebTransportConfig, local_addr: SocketAddr) -> String { + if let Some(url) = config.advertised_url.as_ref() { + return url.clone(); + } + + let host = match local_addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(), + IpAddr::V4(ip) => ip.to_string(), + IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(), + IpAddr::V6(ip) => format!("[{ip}]"), + }; + format!("https://{host}:{}{}", local_addr.port(), config.path) +} + +async fn serve( + endpoint: Endpoint, + state: Arc, + connection_limit: Arc, + shutdown: CancellationToken, +) { + loop { + tokio::select! { + () = shutdown.cancelled() => break, + incoming = endpoint.accept() => { + match Arc::clone(&connection_limit).try_acquire_owned() { + Ok(permit) => { + let state = Arc::clone(&state); + let connection_shutdown = shutdown.clone(); + tokio::spawn(async move { + if let Err(error) = handle_incoming( + incoming, + state, + connection_shutdown, + permit, + ).await { + debug!("WebTransport session ended: {error}"); + } + }); + } + Err(_) => { + tokio::spawn(reject_busy(incoming)); + } + } + } + } + } + endpoint.close(0u32.into(), b"node shutting down"); + info!("ADR-0009 WebTransport PoC stopped"); +} + +async fn reject_busy(incoming: IncomingSession) { + match tokio::time::timeout(REQUEST_TIMEOUT, incoming).await { + Ok(Ok(request)) => request.too_many_requests().await, + Ok(Err(error)) => debug!("Could not reject busy WebTransport session: {error}"), + Err(_) => debug!("Timed out while rejecting busy WebTransport session"), + } +} + +async fn handle_incoming( + incoming: IncomingSession, + state: Arc, + shutdown: CancellationToken, + _permit: OwnedSemaphorePermit, +) -> ServerResult<()> { + let request = tokio::select! { + () = shutdown.cancelled() => return Ok(()), + result = tokio::time::timeout(REQUEST_TIMEOUT, incoming) => { + result + .map_err(|_| "session negotiation timed out".to_string())? + .map_err(|error| format!("session negotiation failed: {error}"))? + } + }; + + if request.path() != state.config.path { + request.not_found().await; + return Ok(()); + } + if !origin_allowed(&state.config.allowed_origins, request.origin()) { + warn!(origin = ?request.origin(), "Rejected WebTransport Origin"); + request.forbidden().await; + return Ok(()); + } + + let remote = request.remote_address(); + let connection = request + .accept() + .await + .map_err(|error| format!("session accept failed: {error}"))?; + debug!(remote = %remote, "Accepted browser WebTransport session"); + + loop { + tokio::select! { + () = shutdown.cancelled() => return Ok(()), + stream = connection.accept_bi() => { + let (send, recv) = stream + .map_err(|error| format!("bidirectional stream accept failed: {error}"))?; + handle_stream(send, recv, Arc::clone(&state)).await?; + } + stream = connection.accept_uni() => { + let recv = stream + .map_err(|error| format!("unidirectional stream accept failed: {error}"))?; + recv.stop(1u32.into()); + } + datagram = connection.receive_datagram() => { + datagram.map_err(|error| format!("datagram receive failed: {error}"))?; + debug!("Discarded unsupported WebTransport datagram"); + } + } + } +} + +async fn handle_stream( + mut send: SendStream, + mut recv: RecvStream, + state: Arc, +) -> ServerResult<()> { + let request = match read_request(&mut recv, state.config.max_request_bytes).await { + Ok(request) => request, + Err(error) => { + let response = Response::error(0, "invalid_request", error); + return write_response(&mut send, &response, &[]).await; + } + }; + + if request.version != PROTOCOL_VERSION { + let request_id = request.id; + let response = Response::error( + request_id, + "unsupported_version", + format!( + "protocol version {} is unsupported; expected {PROTOCOL_VERSION}", + request.version + ), + ); + return write_response(&mut send, &response, &[]).await; + } + + let (response, content) = process_request(request, &state).await; + write_response(&mut send, &response, content.as_deref().unwrap_or_default()).await +} + +async fn read_request(recv: &mut RecvStream, max_bytes: usize) -> ServerResult { + let mut bytes = Vec::new(); + let mut limited = recv.take((max_bytes + 1) as u64); + tokio::time::timeout(REQUEST_TIMEOUT, limited.read_to_end(&mut bytes)) + .await + .map_err(|_| "request body timed out".to_string())? + .map_err(|error| format!("request body read failed: {error}"))?; + + if bytes.len() > max_bytes { + return Err(format!("request exceeds the {max_bytes}-byte limit")); + } + serde_json::from_slice(&bytes).map_err(|error| format!("request JSON is invalid: {error}")) +} + +async fn process_request(request: Request, state: &ServerState) -> (Response, Option>) { + match request.body { + RequestBody::Hello => ( + Response::ok( + request.id, + ResponseBody::Hello { + protocol: PROTOCOL_NAME.to_string(), + peer_id: state.p2p.peer_id().to_hex(), + max_chunk_size: MAX_CHUNK_SIZE, + endpoint: state.endpoint.clone(), + capabilities: vec!["find_node".to_string(), "get_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, + } +} + +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 nodes = state + .p2p + .dht_manager() + .find_closest_nodes_local_with_self(&target_bytes, count) + .await + .into_iter() + .map(|node| { + let peer_id = node.peer_id.to_hex(); + BrowserNode { + webtransport: state.endpoint_catalog.get(&peer_id), + peer_id, + native_addresses: node + .addresses_by_priority() + .into_iter() + .map(|address| address.to_string()) + .collect(), + reliability: node.reliability, + } + }) + .collect(); + ( + Response::ok(request_id, ResponseBody::Nodes { target, nodes }, 0), + None, + ) +} + +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 write_response( + send: &mut SendStream, + 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())?; + send.write_all(&header_len.to_be_bytes()) + .await + .map_err(|error| format!("response prefix write failed: {error}"))?; + send.write_all(&header) + .await + .map_err(|error| format!("response header write failed: {error}"))?; + if !content.is_empty() { + send.write_all(content) + .await + .map_err(|error| format!("response content write failed: {error}"))?; + } + send.finish() + .await + .map_err(|error| format!("response finish failed: {error}")) +} + +fn origin_allowed(allowed: &[String], origin: Option<&str>) -> bool { + allowed.iter().any(|candidate| candidate == "*") + || origin.is_some_and(|origin| allowed.iter().any(|candidate| candidate == origin)) +} + +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, + #[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, + }, +} + +#[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, + capabilities: Vec, + }, + Nodes { + target: String, + nodes: Vec, + }, + Chunk { + address: String, + size: usize, + }, + ChunkNotFound { + address: String, + }, + Error { + code: String, + message: String, + }, +} + +#[derive(Debug, Serialize)] +struct BrowserNode { + peer_id: String, + native_addresses: Vec, + reliability: f64, + webtransport: Option, +} + +struct ServerState { + config: WebTransportConfig, + p2p: Arc, + ant_protocol: Option>, + endpoint: BrowserEndpoint, + endpoint_catalog: Arc, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn parses_versioned_requests() { + let request: Request = serde_json::from_str( + r#"{"version":1,"request_id":7,"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 origins_are_exact_unless_wildcard_is_configured() { + let exact = vec!["http://localhost:5173".to_string()]; + assert!(origin_allowed(&exact, Some("http://localhost:5173"))); + assert!(!origin_allowed(&exact, Some("http://evil.test"))); + assert!(!origin_allowed(&exact, None)); + assert!(origin_allowed(&["*".to_string()], None)); + } + + #[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"], 1); + 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_urls_with_brackets() { + let config = WebTransportConfig::default(); + let url = advertised_url(&config, "[::1]:23456".parse().expect("socket")); + assert_eq!(url, "https://[::1]:23456/autonomi/webtransport/v1"); + } +} diff --git a/tests/webtransport_devnet.rs b/tests/webtransport_devnet.rs new file mode 100644 index 00000000..e8145363 --- /dev/null +++ b/tests/webtransport_devnet.rs @@ -0,0 +1,182 @@ +//! Live ADR-0009 local-devnet protocol test. + +use ant_node::devnet::{Devnet, DevnetConfig}; +use bytes::Bytes; +use self_encryption::{DataMap, EncryptedChunk}; +use serde_json::{json, Value}; +use std::error::Error; +use std::io; +use tokio::io::AsyncReadExt; +use wtransport::endpoint::ConnectOptions; +use wtransport::tls::Sha256Digest; +use wtransport::{ClientConfig, Endpoint}; + +const TEST_ORIGIN: &str = "http://127.0.0.1:5173"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "starts a five-node local network"] +#[allow(clippy::too_many_lines)] +async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<(), Box> { + let temp = tempfile::tempdir()?; + let mut config = DevnetConfig::minimal(); + config.base_port = 0; + config.webtransport = true; + config.webtransport_base_port = 0; + config.webtransport_allowed_origins = vec![TEST_ORIGIN.to_string()]; + config.data_dir = temp.path().join("browser-devnet"); + config.spawn_delay = std::time::Duration::from_millis(20); + + 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 (hello, hello_content) = rpc( + &endpoint.endpoint.url, + &endpoint.endpoint.certificate_sha256, + json!({ + "version": 1, + "request_id": 5, + "type": "hello", + }), + ) + .await?; + assert_eq!(hello["status"], "ok"); + assert_eq!(hello["protocol"], "autonomi.web.poc.v1"); + assert_eq!(hello["peer_id"], endpoint.peer_id); + assert!(hello_content.is_empty()); + + let (closest, closest_content) = rpc( + &endpoint.endpoint.url, + &endpoint.endpoint.certificate_sha256, + json!({ + "version": 1, + "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["webtransport"].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.peer_id == 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.url, + &download_endpoint.endpoint.certificate_sha256, + json!({ + "version": 1, + "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.url, + &download_endpoint.endpoint.certificate_sha256, + json!({ + "version": 1, + "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()); + + devnet.shutdown().await?; + Ok(()) +} + +async fn rpc( + url: &str, + certificate_sha256: &str, + request: Value, +) -> Result<(Value, Vec), Box> { + let hash: [u8; 32] = + hex::decode(certificate_sha256)? + .try_into() + .map_err(|bytes: Vec| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("certificate hash has {} bytes", bytes.len()), + ) + })?; + let client_config = ClientConfig::builder() + .with_bind_default() + .with_server_certificate_hashes([Sha256Digest::new(hash)]) + .build(); + let endpoint = Endpoint::client(client_config)?; + let options = ConnectOptions::builder(url) + .add_header("origin", TEST_ORIGIN) + .build(); + let connection = endpoint.connect(options).await?; + let (mut send, mut recv) = connection.open_bi().await?.await?; + send.write_all(&serde_json::to_vec(&request)?).await?; + send.finish().await?; + + let mut frame = Vec::new(); + recv.read_to_end(&mut frame).await?; + if frame.len() < 4 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "WebTransport response has no header length", + ) + .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 content_offset > frame.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "WebTransport response ended inside its JSON header", + ) + .into()); + } + let header = serde_json::from_slice(&frame[4..content_offset])?; + Ok((header, frame[content_offset..].to_vec())) +} From b5b1e4ba73d6f31bcde4df3863b3f81ab4726a62 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:24:51 +0200 Subject: [PATCH 02/31] feat(webtransport): use native browser multiaddresses --- Cargo.lock | 6 +- Cargo.toml | 5 + docs/WEBTRANSPORT_TESTNET.md | 19 +- ...irect-browser-clients-over-webtransport.md | 79 +++++-- src/browser.rs | 192 +++++++++++++++++- src/config.rs | 2 +- src/devnet.rs | 5 +- src/lib.rs | 4 +- src/web_transport.rs | 44 ++-- tests/webtransport_devnet.rs | 55 +++-- 10 files changed, 321 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6f97812a..b8437b47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "wtransport", "xor_name", "zip", @@ -4944,8 +4945,6 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5929d77d38c0ffb422393a2420f9d2f9ecd92deaa9859b3bfbbe58bada68e40a" dependencies = [ "anyhow", "async-trait", @@ -5017,12 +5016,11 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.36.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cd4be74fa8b82e0321b338bbfbc084c66170614245660b85bb8b041ea808bc4" dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "base64", "blake3", "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4edd5952..4c4716a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ color-eyre = "0.6" # Serialization rmp-serde = "1" hex = "0.4" +url = "2" # Utilities bytes = "1" @@ -199,6 +200,10 @@ test-utils = [] # This enables a second HTTP/3/WebTransport UDP listener and requires Rust 1.88. webtransport-poc = ["dep:self_encryption", "dep:wtransport"] +[patch.crates-io] +saorsa-core = { path = "../saorsa-core-web-support" } +saorsa-transport = { path = "../saorsa-transport-web-support" } + [profile.release] lto = true codegen-units = 1 diff --git a/docs/WEBTRANSPORT_TESTNET.md b/docs/WEBTRANSPORT_TESTNET.md index 0e8197a4..9e067d2f 100644 --- a/docs/WEBTRANSPORT_TESTNET.md +++ b/docs/WEBTRANSPORT_TESTNET.md @@ -35,11 +35,13 @@ When `--serve-port` is omitted with `--webtransport`, port 25000 is used. Pass necessarily reconstructs multiple storage records. A custom file may be up to 64 MiB in this local in-memory launcher. -The browser manifest contains every node's peer ID, direct HTTPS URL, -certificate SHA-256 pin, the public DataMap address, the 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 -WebTransport. +The browser manifest contains every node's self-contained WebTransport +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 WebTransport. +Each address string is serialized directly from `saorsa_core::MultiAddr`; the +node does not maintain a browser-specific multiaddress codec. ## Start the browser client @@ -62,9 +64,10 @@ cargo test --features webtransport-poc --test webtransport_devnet -- --ignored ``` This starts the five-node network, self-encrypts and publishes a public file -through normal PUT admission with devnet-prepaid cache entries, pins a generated -certificate, retrieves the DataMap and encrypted chunks from direct endpoints, -and reconstructs the exact original bytes. +through normal PUT admission with devnet-prepaid cache entries, extracts a +generated certificate pin from the advertised multiaddress, retrieves the +DataMap and encrypted chunks from direct endpoints, and reconstructs the exact +original bytes. ## LAN testing diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md index bbfe41a6..edc66014 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md @@ -2,6 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 +- **Last amended:** 2026-08-04 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -87,7 +88,9 @@ one-hop `FIND_NODE` RPCs iteratively, and download chunks with `GET_CHUNK`. - WebTransport uses a separate UDP socket and port from native Saorsa QUIC. - Node software generates P-256 X.509v3 certificates automatically. Operators do not obtain public CA certificates. -- The browser supplies the certificate's SHA-256 DER hash through +- Each node embeds the certificate's SHA-256 DER multihash in its advertised + WebTransport multiaddress. Applications supply only the multiaddress; the + browser client extracts the digest and passes it internally through `serverCertificateHashes`. - Production nodes maintain overlapping current and next certificates because hash-pinned WebTransport certificates may be valid for at most two weeks. @@ -107,9 +110,7 @@ BrowserEndpointRecord { peer_id, sequence, expires_at, - webtransport_urls, - current_certificate_hashes, - next_certificate_hashes, + webtransport_multiaddrs, capabilities, protocol_versions, max_chunk_size, @@ -118,6 +119,51 @@ BrowserEndpointRecord { } ``` +The canonical direct address form is: + +```text +/ip4/
/udp//quic-v1/webtransport + /certhash/ + [/certhash/] + /p2p/ +``` + +`ip6`, `dns`, `dns4`, and `dns6` host components are also valid. Certificate +multihashes use unpadded base64url multibase (`u`) and must contain exactly a +32-byte SHA-256 digest. Implementations accept at most the current and next +hash. The `/webtransport` component maps to the fixed +`/autonomi/webtransport/v1` HTTPS session path. + +This is represented by the network's native address types rather than an +application-owned string. `saorsa-transport` stores the transport component as +`TransportAddr::WebTransport(WebTransportAddr)`, including the validated host, +port, and certificate hashes. `saorsa-core::MultiAddr` wraps that transport +component and owns the `/p2p/` suffix. Its canonical `Display`, +`FromStr`, and string-based Serde implementations are the single Rust codec +used by endpoint records, manifests, `HELLO`, and `FIND_NODE`. `ant-node` must +not maintain a second WebTransport multiaddress parser or certificate-hash +codec. + +The native Saorsa QUIC dialer deliberately does not treat a WebTransport +address as a native QUIC dialing candidate. It is a first-class advertised +transport address whose browser HTTP/3 stack remains separate from the PQ +node-to-node transport. + +The multiaddress is the complete dialing input: no separate URL, certificate +hash, or peer-ID argument is accepted by the browser client. This prevents the +three values from being accidentally mixed between nodes. A certificate hash +authenticates the ephemeral TLS key, while `/p2p` identifies the expected +persistent ANT identity. The endpoint-record signature binds the whole address +to that identity. An address received through an unauthenticated channel is not +made trustworthy merely by containing a hash; initial bootstrap addresses are +application trust anchors, and discovered addresses require owner signatures. + +During rotation, nodes advertise current and next hashes in the same address, +switch certificates only after the next hash has propagated, then replace the +retired hash with a newly generated next hash. Cached addresses must expire no +later than their last certificate. Rotation and address publication are node +software responsibilities, not operator or web-application configuration. + The ML-DSA signature covers a canonical, domain-separated encoding. The browser verifies the public-key-to-peer-ID binding, signature, network ID, sequence, expiry, capabilities, and certificate hash before connecting. @@ -182,12 +228,16 @@ The repository PoC is intentionally feature-gated and disabled by default. It provides: - a separate WebTransport listener; -- an automatically generated short-lived P-256 certificate and printed hash; +- an automatically generated short-lived P-256 certificate and a self-contained + `/webtransport/certhash/.../p2p/...` multiaddress; +- native `saorsa-transport::TransportAddr` and `saorsa-core::MultiAddr` + parsing, formatting, validation, and serialization for that address; - exact path and Origin checks; - bounded JSON requests on one bidirectional stream per RPC; - a length-prefixed JSON response header followed by optional raw chunk bytes; - `HELLO`, local `FIND_NODE`, and local `GET_CHUNK`; -- a browser application that pins the certificate, performs the lookup loop, +- a browser application that extracts and pins the certificate from the + multiaddress, performs the lookup loop, downloads public file records, reconstructs the complete file, and verifies both chunk and whole-file BLAKE3 hashes. @@ -200,9 +250,10 @@ not evidence that partial fleet deployment is sufficient. 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 direct URL and certificate hash of every browser-enabled -peer in its routing view. This catalog is explicitly a local replacement for -the future signed DHT endpoint record, not a production discovery mechanism. +answer to attach the self-contained WebTransport multiaddress of every +browser-enabled peer in its routing view. This catalog is explicitly a local +replacement for the future signed DHT endpoint record, not a production +discovery mechanism. 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 @@ -210,9 +261,9 @@ 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. A read-only HTTP bootstrap -manifest exposes endpoint pins, public-file metadata, and the resolved public -root DataMap needed by this local client; it never performs lookup or carries -file bytes. +manifest exposes bootstrap multiaddresses, public-file metadata, and the +resolved public root DataMap needed by this local client; it never performs +lookup or carries file bytes. The companion JavaScript client and test site live in the `web/` package of the `ant-client-web-support` repository. It fetches the public DataMap and every @@ -227,6 +278,10 @@ reconstructed file, and exposes it through the browser save flow. - Browsers can become application-level full read clients without a lookup or download gateway. - Operators do not manage DNS names or CA certificate issuance. +- Community clients configure one self-contained bootstrap multiaddress per + seed instead of separate URLs and certificate hashes. +- Rust producers and consumers share the network's native `MultiAddr` codec; + browser JavaScript implements the same canonical wire syntax. - Existing PQ node networking and compatibility remain isolated. - Reliable WebTransport streams match large immutable chunk downloads. - Endpoint records explicitly bind browser TLS to the node's PQ identity. diff --git a/src/browser.rs b/src/browser.rs index 93a45d47..d1504d52 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -3,26 +3,114 @@ //! These types deliberately describe only public read capabilities. Native //! node addresses and payment/write APIs remain outside the browser surface. +use saorsa_core::{ + MultiAddr, PeerId, WebTransportAddr, WebTransportCertificateHash, WebTransportHost, +}; use serde::{Deserialize, Serialize}; +use url::{Host, Url}; /// Version of the local browser bootstrap manifest. -pub const BROWSER_MANIFEST_VERSION: u16 = 2; +pub const BROWSER_MANIFEST_VERSION: u16 = 3; -/// A browser-compatible transport endpoint and its pinned certificate hash. +/// Fixed HTTPS path represented by an Autonomi `/webtransport` multiaddress. +pub const BROWSER_WEBTRANSPORT_PATH: &str = "/autonomi/webtransport/v1"; + +/// A self-contained browser-compatible transport endpoint. +/// +/// The multiaddress embeds the WebTransport certificate hash or overlapping +/// current/next hashes. Callers never supply a separate certificate pin. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowserEndpoint { - /// HTTPS WebTransport URL, including the session path. + /// Canonical WebTransport multiaddress, including certificate hashes and peer ID. + pub multiaddr: MultiAddr, +} + +/// Validated components extracted from a [`BrowserEndpoint`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBrowserEndpoint { + /// HTTPS URL passed to the browser or native WebTransport implementation. pub url: String, - /// Lowercase SHA-256 hash of the endpoint certificate's DER encoding. - pub certificate_sha256: String, + /// Persistent ANT peer ID from the `/p2p` suffix. + pub peer_id: PeerId, + /// SHA-256 hashes of the accepted leaf certificates. + pub certificate_hashes: Vec<[u8; 32]>, +} + +impl BrowserEndpoint { + /// Construct a canonical endpoint from an advertised HTTPS URL, ANT peer ID, + /// and one or two leaf-certificate SHA-256 hashes. + /// + /// # Errors + /// + /// Returns an error for a non-HTTPS URL, a non-standard session path, + /// malformed peer ID, or an invalid certificate-hash count. + pub fn new( + advertised_url: &str, + peer_id: &PeerId, + certificate_hashes: &[[u8; 32]], + ) -> Result { + let url = parse_advertised_url(advertised_url)?; + let host = match url.host() { + Some(Host::Ipv4(ip)) => WebTransportHost::Ip4(ip), + Some(Host::Ipv6(ip)) => WebTransportHost::Ip6(ip), + Some(Host::Domain(domain)) => WebTransportHost::Dns(domain.to_ascii_lowercase()), + None => return Err("WebTransport advertised URL has no host".to_string()), + }; + let port = url + .port_or_known_default() + .ok_or_else(|| "WebTransport advertised URL has no port".to_string())?; + + let certificate_hashes = certificate_hashes + .iter() + .copied() + .map(WebTransportCertificateHash::new) + .collect(); + let transport = WebTransportAddr::new(host, port, certificate_hashes) + .map_err(|error| error.to_string())?; + let multiaddr = MultiAddr::webtransport(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(|| "WebTransport multiaddress has no peer ID".to_string())?; + let address = self + .multiaddr + .webtransport_addr() + .ok_or_else(|| "multiaddress does not use WebTransport".to_string())?; + let url = format!( + "https://{}:{}{}", + address.host().url_host(), + address.port(), + BROWSER_WEBTRANSPORT_PATH + ); + parse_advertised_url(&url)?; + let certificate_hashes = address + .certificate_hashes() + .iter() + .map(|hash| *hash.as_bytes()) + .collect(); + Ok(ParsedBrowserEndpoint { + url, + peer_id, + certificate_hashes, + }) + } } /// A bootstrap node that a browser can authenticate and contact directly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowserBootstrapNode { - /// Hex-encoded persistent node peer ID. - pub peer_id: String, - /// Browser-compatible endpoint for this node. + /// Self-contained browser endpoint for this node. #[serde(flatten)] pub endpoint: BrowserEndpoint, } @@ -98,3 +186,91 @@ impl BrowserDevnetManifest { } } } + +fn parse_advertised_url(advertised_url: &str) -> Result { + let url = Url::parse(advertised_url) + .map_err(|error| format!("invalid WebTransport advertised URL: {error}"))?; + if url.scheme() != "https" { + return Err("WebTransport advertised URL must use https".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("WebTransport advertised URL must not contain credentials".to_string()); + } + if url.path() != BROWSER_WEBTRANSPORT_PATH { + return Err(format!( + "WebTransport advertised URL path must be {BROWSER_WEBTRANSPORT_PATH}" + )); + } + if url.query().is_some() || url.fragment().is_some() { + return Err("WebTransport advertised URL must not contain a query or fragment".to_string()); + } + Ok(url) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn browser_endpoint_round_trips_current_and_next_hashes() { + let peer_id = PeerId::from_bytes([0xab; 32]); + let endpoint = BrowserEndpoint::new( + "https://127.0.0.1:24000/autonomi/webtransport/v1", + &peer_id, + &[[0x11; 32], [0x22; 32]], + ) + .expect("valid endpoint"); + + assert!(endpoint + .multiaddr + .to_string() + .starts_with("/ip4/127.0.0.1/udp/24000/quic-v1/webtransport/certhash/u")); + assert_eq!( + endpoint.multiaddr.to_string().matches("/certhash/").count(), + 2 + ); + let parsed = endpoint.parse().expect("round-trip endpoint"); + assert_eq!( + parsed.url, + "https://127.0.0.1:24000/autonomi/webtransport/v1" + ); + assert_eq!(parsed.peer_id, peer_id); + assert_eq!(parsed.certificate_hashes, vec![[0x11; 32], [0x22; 32]]); + } + + #[test] + fn browser_endpoint_round_trips_ipv6() { + let peer_id = PeerId::from_bytes([0xcd; 32]); + let endpoint = BrowserEndpoint::new( + "https://[::1]:24000/autonomi/webtransport/v1", + &peer_id, + &[[0x33; 32]], + ) + .expect("valid endpoint"); + let parsed = endpoint.parse().expect("round-trip endpoint"); + assert_eq!(parsed.url, "https://[::1]:24000/autonomi/webtransport/v1"); + } + + #[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/quic-v1/webtransport/p2p/{peer_id}"}}"# + ); + assert!(serde_json::from_str::(&unpinned).is_err()); + + let malformed = format!( + r#"{{"multiaddr":"/ip4/127.0.0.1/udp/24000/quic-v1/webtransport/certhash/uAA/p2p/{peer_id}"}}"# + ); + assert!(serde_json::from_str::(&malformed).is_err()); + } + + #[test] + fn browser_endpoint_requires_the_standard_path() { + let peer_id = PeerId::from_bytes([0xab; 32]); + let error = BrowserEndpoint::new("https://127.0.0.1:24000/custom", &peer_id, &[[0x11; 32]]) + .expect_err("custom path must fail"); + assert!(error.contains(BROWSER_WEBTRANSPORT_PATH)); + } +} diff --git a/src/config.rs b/src/config.rs index be1e5c6f..3259bf03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -215,7 +215,7 @@ fn default_webtransport_bind() -> SocketAddr { } fn default_webtransport_path() -> String { - "/autonomi/webtransport/v1".to_string() + crate::browser::BROWSER_WEBTRANSPORT_PATH.to_string() } fn default_webtransport_origins() -> Vec { diff --git a/src/devnet.rs b/src/devnet.rs index c33cac8f..58c32b59 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -615,10 +615,7 @@ impl Devnet { .filter_map(|node| { node.browser_endpoint .clone() - .map(|endpoint| BrowserBootstrapNode { - peer_id: node.peer_id.to_hex(), - endpoint, - }) + .map(|endpoint| BrowserBootstrapNode { endpoint }) }) .collect() } diff --git a/src/lib.rs b/src/lib.rs index 07f0ea74..22cbf337 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,7 +65,7 @@ pub use ant_protocol::{ }; pub use browser::{ BrowserBootstrapNode, BrowserChunkInfo, BrowserDevnetManifest, BrowserEndpoint, - BrowserPublicFile, BROWSER_MANIFEST_VERSION, + BrowserPublicFile, ParsedBrowserEndpoint, BROWSER_MANIFEST_VERSION, BROWSER_WEBTRANSPORT_PATH, }; pub use client::{ compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, @@ -86,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, WebTransportAddr, WebTransportCertificateHash, WebTransportHost, }; } diff --git a/src/web_transport.rs b/src/web_transport.rs index 0dbd22ad..08982549 100644 --- a/src/web_transport.rs +++ b/src/web_transport.rs @@ -5,13 +5,13 @@ //! the production endpoint-record or certificate-rotation implementation. use crate::ant_protocol::MAX_CHUNK_SIZE; -use crate::browser::BrowserEndpoint; +use crate::browser::{BrowserEndpoint, BROWSER_WEBTRANSPORT_PATH}; use crate::config::WebTransportConfig; use crate::error::{Error, Result}; use crate::logging::{debug, info, warn}; use crate::storage::AntProtocol; use parking_lot::RwLock; -use saorsa_core::P2PNode; +use saorsa_core::{P2PNode, PeerId}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; @@ -25,8 +25,8 @@ use wtransport::endpoint::IncomingSession; use wtransport::stream::{RecvStream, SendStream}; use wtransport::{Endpoint, Identity, ServerConfig}; -const PROTOCOL_VERSION: u16 = 1; -const PROTOCOL_NAME: &str = "autonomi.web.poc.v1"; +const PROTOCOL_VERSION: u16 = 2; +const PROTOCOL_NAME: &str = "autonomi.web.poc.v2"; const MAX_FIND_NODE_RESULTS: usize = 20; const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -40,22 +40,22 @@ const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5); /// available. #[derive(Default)] pub struct BrowserEndpointCatalog { - endpoints: RwLock>, + endpoints: RwLock>, } impl BrowserEndpointCatalog { - fn insert(&self, peer_id: String, endpoint: BrowserEndpoint) { + fn insert(&self, peer_id: PeerId, endpoint: BrowserEndpoint) { self.endpoints.write().insert(peer_id, endpoint); } - fn get(&self, peer_id: &str) -> Option { + 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 WebTransportServer { - /// Direct endpoint and certificate pin. + /// Direct endpoint with its certificate pin embedded in the multiaddress. pub endpoint: BrowserEndpoint, /// Listener background task. pub task: JoinHandle<()>, @@ -78,7 +78,7 @@ pub fn spawn( .as_slice() .first() .ok_or_else(|| Error::Startup("WebTransport identity has no certificate".to_string()))?; - let certificate_sha256 = hex::encode(certificate.hash().as_ref()); + let certificate_sha256 = *certificate.hash().as_ref(); let server_config = ServerConfig::builder() .with_bind_address(config.bind) @@ -95,11 +95,10 @@ pub fn spawn( })?; let advertised_url = advertised_url(config, local_addr); - let browser_endpoint = BrowserEndpoint { - url: advertised_url.clone(), - certificate_sha256: certificate_sha256.clone(), - }; - endpoint_catalog.insert(p2p.peer_id().to_hex(), browser_endpoint.clone()); + let peer_id = *p2p.peer_id(); + let browser_endpoint = BrowserEndpoint::new(&advertised_url, &peer_id, &[certificate_sha256]) + .map_err(Error::Config)?; + endpoint_catalog.insert(peer_id, browser_endpoint.clone()); let state = Arc::new(ServerState { config: config.clone(), @@ -112,8 +111,7 @@ pub fn spawn( info!( bind = %local_addr, - url = %advertised_url, - certificate_sha256 = %certificate_sha256, + multiaddr = %browser_endpoint.multiaddr, "ADR-0009 WebTransport PoC listening" ); @@ -127,10 +125,10 @@ pub fn spawn( } fn validate_config(config: &WebTransportConfig) -> Result<()> { - if !config.path.starts_with('/') { - return Err(Error::Config( - "webtransport.path must start with '/'".to_string(), - )); + if config.path != BROWSER_WEBTRANSPORT_PATH { + return Err(Error::Config(format!( + "webtransport.path must be {BROWSER_WEBTRANSPORT_PATH}" + ))); } if config.allowed_origins.is_empty() { return Err(Error::Config( @@ -362,7 +360,7 @@ async fn process_find_node( .map(|node| { let peer_id = node.peer_id.to_hex(); BrowserNode { - webtransport: state.endpoint_catalog.get(&peer_id), + webtransport: state.endpoint_catalog.get(&node.peer_id), peer_id, native_addresses: node .addresses_by_priority() @@ -607,7 +605,7 @@ mod tests { #[test] fn parses_versioned_requests() { let request: Request = serde_json::from_str( - r#"{"version":1,"request_id":7,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, + r#"{"version":2,"request_id":7,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, ) .expect("valid request"); @@ -646,7 +644,7 @@ mod tests { 3, ); let value = serde_json::to_value(response).expect("serialize response"); - assert_eq!(value["version"], 1); + assert_eq!(value["version"], 2); assert_eq!(value["request_id"], 42); assert_eq!(value["status"], "ok"); assert_eq!(value["content_length"], 3); diff --git a/tests/webtransport_devnet.rs b/tests/webtransport_devnet.rs index e8145363..0ed9c8df 100644 --- a/tests/webtransport_devnet.rs +++ b/tests/webtransport_devnet.rs @@ -1,6 +1,7 @@ //! Live ADR-0009 local-devnet protocol test. use ant_node::devnet::{Devnet, DevnetConfig}; +use ant_node::BrowserEndpoint; use bytes::Bytes; use self_encryption::{DataMap, EncryptedChunk}; use serde_json::{json, Value}; @@ -44,26 +45,29 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() 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.url, - &endpoint.endpoint.certificate_sha256, + &endpoint.endpoint, json!({ - "version": 1, + "version": 2, "request_id": 5, "type": "hello", }), ) .await?; assert_eq!(hello["status"], "ok"); - assert_eq!(hello["protocol"], "autonomi.web.poc.v1"); - assert_eq!(hello["peer_id"], endpoint.peer_id); + assert_eq!(hello["protocol"], "autonomi.web.poc.v2"); + 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.url, - &endpoint.endpoint.certificate_sha256, + &endpoint.endpoint, json!({ - "version": 1, + "version": 2, "request_id": 6, "type": "find_node", "target": public_file.address, @@ -82,13 +86,17 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() .ok_or_else(|| io::Error::other("FIND_NODE returned no browser endpoint"))?; let download_endpoint = endpoints .iter() - .find(|candidate| candidate.peer_id == discovered_peer) + .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.url, - &download_endpoint.endpoint.certificate_sha256, + &download_endpoint.endpoint, json!({ - "version": 1, + "version": 2, "request_id": 7, "type": "get_chunk", "address": public_file.address, @@ -107,10 +115,9 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() 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.url, - &download_endpoint.endpoint.certificate_sha256, + &download_endpoint.endpoint, json!({ - "version": 1, + "version": 2, "request_id": request_id, "type": "get_chunk", "address": chunk.dst_hash, @@ -131,25 +138,17 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() } async fn rpc( - url: &str, - certificate_sha256: &str, + endpoint: &BrowserEndpoint, request: Value, ) -> Result<(Value, Vec), Box> { - let hash: [u8; 32] = - hex::decode(certificate_sha256)? - .try_into() - .map_err(|bytes: Vec| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("certificate hash has {} bytes", bytes.len()), - ) - })?; + let parsed = endpoint.parse().map_err(io::Error::other)?; + let hashes = parsed.certificate_hashes.into_iter().map(Sha256Digest::new); let client_config = ClientConfig::builder() .with_bind_default() - .with_server_certificate_hashes([Sha256Digest::new(hash)]) + .with_server_certificate_hashes(hashes) .build(); let endpoint = Endpoint::client(client_config)?; - let options = ConnectOptions::builder(url) + let options = ConnectOptions::builder(&parsed.url) .add_header("origin", TEST_ORIGIN) .build(); let connection = endpoint.connect(options).await?; From 7cf49f76ddfe08d066167a1800a9e4ea07d1977c Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:17:36 +0200 Subject: [PATCH 03/31] feat(webtransport): support paid browser uploads --- docs/WEBTRANSPORT_TESTNET.md | 27 +- ...irect-browser-clients-over-webtransport.md | 96 ++-- src/bin/ant-devnet/cli.rs | 19 +- src/bin/ant-devnet/main.rs | 54 +- src/browser.rs | 39 +- src/config.rs | 7 +- src/devnet.rs | 18 +- src/lib.rs | 3 +- src/node.rs | 2 + src/web_transport.rs | 488 +++++++++++++++++- tests/webtransport_devnet.rs | 111 +++- 11 files changed, 774 insertions(+), 90 deletions(-) diff --git a/docs/WEBTRANSPORT_TESTNET.md b/docs/WEBTRANSPORT_TESTNET.md index 9e067d2f..ab13d058 100644 --- a/docs/WEBTRANSPORT_TESTNET.md +++ b/docs/WEBTRANSPORT_TESTNET.md @@ -16,6 +16,7 @@ cargo run --features webtransport-poc --bin ant-devnet -- \ --webtransport \ --webtransport-base-port 24000 \ --serve-port 25000 \ + --enable-evm \ --enable-logging ``` @@ -28,6 +29,7 @@ The services are: | 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 `--webtransport`, port 25000 is used. Pass `--public-file /path/to/file` to replace the built-in @@ -43,6 +45,14 @@ the DataMap and file bytes are read from storage nodes over WebTransport. Each address string is serialized directly from `saorsa_core::MultiAddr`; the node does not maintain a browser-specific multiaddress codec. +`--webtransport` 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`: @@ -53,6 +63,12 @@ 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. @@ -63,11 +79,12 @@ BLAKE3 hash, and save it under its original filename. cargo test --features webtransport-poc --test webtransport_devnet -- --ignored ``` -This starts the five-node network, self-encrypts and publishes a public file -through normal PUT admission with devnet-prepaid cache entries, extracts a -generated certificate pin from the advertised multiaddress, retrieves the -DataMap and encrypted chunks from direct endpoints, and reconstructs the exact -original bytes. +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 WebTransport. ## LAN testing diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md index edc66014..ec43bb35 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-08-04 +- **Last amended:** 2026-08-05 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -13,11 +13,12 @@ ## Context -Web applications must be able to act as full read clients: they perform the -iterative closest-node lookup themselves and download immutable chunks from -storage nodes. A node must not perform a whole-network lookup or proxy chunk -bytes on the browser's behalf. Ordinary bootstrap peers and end-to-end -transport relays remain allowed; application gateways do not. +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 @@ -32,9 +33,9 @@ Internet. This ADR records the intended production architecture and defines a smaller, explicitly non-production proof of concept. The proof of concept validates -browser interoperability, request framing, local DHT access, and chunk -downloads; signed endpoint dissemination and relayed WebTransport are later -implementation slices. +browser interoperability, request framing, local DHT access, chunk downloads, +and paid immutable uploads; signed endpoint dissemination and relayed +WebTransport are later implementation slices. ## Decision Drivers @@ -44,7 +45,10 @@ implementation slices. - Operators must not need to obtain DNS names or public CA certificates. - The existing post-quantum node-to-node port and wire protocols remain unchanged. -- A public browser protocol must be narrow, versioned, bounded, and read-only. +- A public browser protocol must be 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 need an end-to-end relay path without exposing plaintext to the relay. - A 4 MiB chunk needs reliable streaming and backpressure. @@ -81,7 +85,8 @@ implementation slices. We will add a separate, opt-in WebTransport-over-HTTP/3 listener to nodes. Production browser-capable nodes will publish an owner-signed browser endpoint record. Browser clients will use those records to connect directly, perform -one-hop `FIND_NODE` RPCs iteratively, and download chunks with `GET_CHUNK`. +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. ### Transport and certificates @@ -94,8 +99,9 @@ one-hop `FIND_NODE` RPCs iteratively, and download chunks with `GET_CHUNK`. `serverCertificateHashes`. - Production nodes maintain overlapping current and next certificates because hash-pinned WebTransport certificates may be valid for at most two weeks. -- The listener is read-only and has independent connection, stream, request, - timeout, and byte limits. +- The listener has independent connection, stream, request, timeout, and byte + limits. Its write surface accepts only content-addressed chunks accompanied + by a verifiable native payment proof. - The native ML-KEM/ML-DSA transport remains the node-to-node transport and is not downgraded or replaced. @@ -185,15 +191,29 @@ one response. The initial methods are: - `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. -Messages have an explicit version and length framing. Chunk bytes are binary, -not JSON/base64. The browser recomputes BLAKE3 and rejects content whose hash -does not equal the requested address. +Requests and responses use a four-byte big-endian JSON-header length, a +bounded versioned JSON header, and an optional raw binary body. Chunk bytes are +never JSON/base64. Both sides recompute BLAKE3 and reject content whose hash +does not equal its address. -Browser sessions are anonymous read clients and are not inserted into node -routing tables. PUT, payment, quoting, replication, arbitrary topic -forwarding, and native DHT messages are not exposed. +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 +WebTransport. ### Lookup behavior @@ -233,13 +253,16 @@ provides: - native `saorsa-transport::TransportAddr` and `saorsa-core::MultiAddr` parsing, formatting, validation, and serialization for that address; - exact path and Origin checks; -- bounded JSON requests on one bidirectional stream per RPC; -- a length-prefixed JSON response header followed by optional raw chunk bytes; -- `HELLO`, local `FIND_NODE`, and local `GET_CHUNK`; +- bounded length-prefixed JSON headers on one bidirectional stream per RPC, + followed by optional raw chunk bytes in either direction; +- `HELLO`, local `FIND_NODE`, local `GET_CHUNK`, `QUOTE_CHUNK`, and paid + `PUT_CHUNK`; - a browser application that extracts and pins the certificate from the multiaddress, performs the lookup loop, - downloads public file records, reconstructs the complete file, and verifies - both chunk and whole-file BLAKE3 hashes. + downloads public file records, reconstructs complete files, self-encrypts + uploads, verifies signed storage quotes and commitments, signs EVM payments + locally, uploads encrypted records, and verifies both chunk and whole-file + BLAKE3 hashes. The PoC endpoint descriptors are not yet ML-DSA-signed or disseminated through the DHT. Peers lacking a browser descriptor remain visible but cannot be @@ -261,29 +284,33 @@ 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. A read-only HTTP bootstrap -manifest exposes bootstrap multiaddresses, public-file metadata, and the -resolved public root DataMap needed by this local client; it never performs -lookup or carries file bytes. +manifest exposes bootstrap multiaddresses, public-file metadata, public EVM +RPC and contract addresses, and the resolved public root DataMap needed by +this local client; it never performs lookup or carries file bytes. Wallet +secrets are never included in the manifest. The companion JavaScript client and test site live in the `web/` package of the `ant-client-web-support` repository. It fetches the public DataMap and every encrypted data chunk directly, applies the native BLAKE3 KDF, -ChaCha20-Poly1305 authentication, and Brotli decompression, verifies the -reconstructed file, and exposes it through the browser save flow. +ChaCha20-Poly1305 authentication, and Brotli compression/decompression. It can +verify and save reconstructed files, or obtain quotes, make one batched vault +payment, upload the generated records to closest nodes, and immediately +download the newly published file. ## Consequences ### Positive -- Browsers can become application-level full read clients without a lookup or - download gateway. +- Browsers can become application-level full immutable-data clients without a + lookup, payment, upload, or download gateway. - Operators do not manage DNS names or CA certificate issuance. - Community clients configure one self-contained bootstrap multiaddress per seed instead of separate URLs and certificate hashes. - Rust producers and consumers share the network's native `MultiAddr` codec; browser JavaScript implements the same canonical wire syntax. - Existing PQ node networking and compatibility remain isolated. -- Reliable WebTransport streams match large immutable chunk downloads. +- Reliable WebTransport streams match large immutable chunk downloads and + uploads. - Endpoint records explicitly bind browser TLS to the node's PQ identity. - The same transport can run end-to-end through a generic UDP relay. @@ -309,7 +336,7 @@ reconstructed file, and exposes it through the browser save flow. - Origin is policy input, not client authentication. Public deployments still need per-IP/session request and byte quotas. - Bootstrap peers remain necessary, as they are for native clients, but do not - perform lookup or proxy downloads. + perform lookup or proxy uploads/downloads. ## Validation @@ -323,6 +350,9 @@ The decision advances beyond PoC only after all of the following are covered: convergence, retries, and unavailable endpoints. - Successful streamed downloads at 0 bytes, typical sizes, and 4 MiB, with BLAKE3 verification and cancellation/backpressure measurements. +- Paid-upload tests covering quote/commitment tampering, wrong peers, wrong + content, missing/failed payments, replay/idempotence, wallet rejection, and + successful native-client retrieval of browser-created files. - Certificate current/next rotation, stale-record, replay, wrong-peer, wrong-network, and hash-mismatch tests. - Connection floods, stream floods, slow readers, request amplification, and diff --git a/src/bin/ant-devnet/cli.rs b/src/bin/ant-devnet/cli.rs index 03631beb..1192984a 100644 --- a/src/bin/ant-devnet/cli.rs +++ b/src/bin/ant-devnet/cli.rs @@ -1,12 +1,16 @@ //! 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. @@ -48,7 +52,7 @@ pub struct Cli { /// Enable one direct-browser WebTransport listener per devnet node. /// /// The binary must be built with `--features webtransport-poc`. - #[arg(long)] + #[arg(long, requires = "evm-payment")] pub webtransport: bool, /// First UDP port assigned to devnet WebTransport listeners (0 = allocate). @@ -171,6 +175,7 @@ mod tests { let cli = Cli::parse_from([ "ant-devnet", "--webtransport", + "--enable-evm", "--webtransport-base-port", "22000", "--public-file", @@ -179,4 +184,14 @@ mod tests { assert!(cli.webtransport); assert_eq!(cli.webtransport_base_port, Some(22_000)); } + + #[test] + fn browser_uploads_require_an_explicit_payment_network() { + let result = Cli::try_parse_from(["ant-devnet", "--webtransport"]); + 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 369f0761..e2b82dc4 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -116,7 +116,10 @@ async fn main() -> color_eyre::Result<()> { } else if let Some(host) = cli.host { config.webtransport_allowed_origins = vec![format!("http://{host}:5173")]; } - let evm_info = resolve_evm_info( + let ResolvedEvm { + manifest: evm_info, + local_testnet: _local_evm_testnet, + } = resolve_evm_info( cli.evm_network.as_deref(), cli.enable_evm, cli.host, @@ -140,6 +143,7 @@ async fn main() -> color_eyre::Result<()> { network_id, created_at.clone(), devnet.browser_endpoints(), + devnet.browser_payment_network(), vec![public_file], )) } else { @@ -262,12 +266,19 @@ async fn load_public_file( /// an **external** network (`--evm-network`, e.g. Arbitrum Sepolia verified /// against the real deployed contracts, no embedded wallet key); a **local /// Anvil** chain (`--enable-evm`); or **none**. External takes precedence. +struct ResolvedEvm { + manifest: Option, + // Retain ownership until main exits so the Anvil child is terminated on + // normal shutdown instead of being orphaned. + local_testnet: Option, +} + async fn resolve_evm_info( evm_network: Option<&str>, enable_evm: bool, host: Option, config: &mut DevnetConfig, -) -> color_eyre::Result> { +) -> color_eyre::Result { if let Some(net_name) = evm_network { let network = match net_name { "arbitrum-sepolia" => evmlib::Network::ArbitrumSepoliaTest, @@ -284,12 +295,15 @@ async fn resolve_evm_info( "Using external EVM network {net_name}: rpc={rpc_url} token={token_addr} vault={vault_addr}" ); config.evm_network = Some(network); - Ok(Some(DevnetEvmInfo { - rpc_url, - wallet_private_key: String::new(), - payment_token_address: token_addr, - payment_vault_address: vault_addr, - })) + Ok(ResolvedEvm { + manifest: Some(DevnetEvmInfo { + rpc_url, + wallet_private_key: String::new(), + payment_token_address: token_addr, + payment_vault_address: vault_addr, + }), + local_testnet: None, + }) } else if enable_evm { // Anvil binds — and evmlib publishes in the manifest's `rpc_url` — // the address in `ANVIL_IP_ADDR`, defaulting to localhost. A LAN @@ -331,18 +345,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, + }) } } diff --git a/src/browser.rs b/src/browser.rs index d1504d52..6161e48d 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -1,7 +1,9 @@ //! Shared browser-client discovery types. //! -//! These types deliberately describe only public read capabilities. Native -//! node addresses and payment/write APIs remain outside the browser surface. +//! 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, WebTransportAddr, WebTransportCertificateHash, WebTransportHost, @@ -10,7 +12,7 @@ use serde::{Deserialize, Serialize}; use url::{Host, Url}; /// Version of the local browser bootstrap manifest. -pub const BROWSER_MANIFEST_VERSION: u16 = 3; +pub const BROWSER_MANIFEST_VERSION: u16 = 4; /// Fixed HTTPS path represented by an Autonomi `/webtransport` multiaddress. pub const BROWSER_WEBTRANSPORT_PATH: &str = "/autonomi/webtransport/v1"; @@ -149,6 +151,33 @@ pub struct BrowserChunkInfo { 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 @@ -164,6 +193,8 @@ pub struct BrowserDevnetManifest { 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, } @@ -175,6 +206,7 @@ impl BrowserDevnetManifest { network_id: String, created_at: String, endpoints: Vec, + payment: BrowserPaymentNetwork, files: Vec, ) -> Self { Self { @@ -182,6 +214,7 @@ impl BrowserDevnetManifest { network_id, created_at, endpoints, + payment, files, } } diff --git a/src/config.rs b/src/config.rs index 3259bf03..45f8be9b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -190,7 +190,10 @@ pub struct WebTransportConfig { #[serde(default = "default_webtransport_max_connections")] pub max_connections: usize, - /// Maximum JSON request size, in bytes. + /// 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_webtransport_max_request_bytes")] pub max_request_bytes: usize, } @@ -238,7 +241,7 @@ const fn default_webtransport_max_connections() -> usize { } const fn default_webtransport_max_request_bytes() -> usize { - 16 * 1024 + 64 * 1024 } /// Auto-upgrade configuration. diff --git a/src/devnet.rs b/src/devnet.rs index 58c32b59..0145647b 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -31,7 +31,7 @@ use tokio_util::sync::CancellationToken; #[cfg(feature = "webtransport-poc")] use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse}; #[cfg(feature = "webtransport-poc")] -use crate::browser::{BrowserBootstrapNode, BrowserPublicFile}; +use crate::browser::{BrowserBootstrapNode, BrowserPaymentNetwork, BrowserPublicFile}; #[cfg(feature = "webtransport-poc")] use crate::config::WebTransportConfig; #[cfg(feature = "webtransport-poc")] @@ -723,6 +723,18 @@ impl Devnet { Ok(published) } + /// Public EVM configuration advertised to direct browser clients. + #[cfg(feature = "webtransport-poc")] + #[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 = "webtransport-poc")] async fn publish_browser_record(&self, address: [u8; 32], content: &Bytes) -> Result { let mut replicas = 0usize; @@ -1032,6 +1044,10 @@ impl Devnet { &webtransport_config, p2p, node.ant_protocol.clone(), + self.config + .evm_network + .as_ref() + .unwrap_or(&EvmNetwork::ArbitrumOne), self.shutdown.clone(), Arc::clone(&self.browser_endpoint_catalog), ) diff --git a/src/lib.rs b/src/lib.rs index 22cbf337..43464f07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,7 +65,8 @@ pub use ant_protocol::{ }; pub use browser::{ BrowserBootstrapNode, BrowserChunkInfo, BrowserDevnetManifest, BrowserEndpoint, - BrowserPublicFile, ParsedBrowserEndpoint, BROWSER_MANIFEST_VERSION, BROWSER_WEBTRANSPORT_PATH, + BrowserPaymentNetwork, BrowserPublicFile, ParsedBrowserEndpoint, BROWSER_MANIFEST_VERSION, + BROWSER_WEBTRANSPORT_PATH, }; pub use client::{ compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, diff --git a/src/node.rs b/src/node.rs index 6ed03a1a..03715e95 100644 --- a/src/node.rs +++ b/src/node.rs @@ -550,10 +550,12 @@ impl RunningNode { if self.config.webtransport.enabled { let endpoint_catalog = Arc::new(crate::web_transport::BrowserEndpointCatalog::default()); + let evm_network = self.config.payment.evm_network.clone().into_evm_network(); match crate::web_transport::spawn( &self.config.webtransport, Arc::clone(&self.p2p_node), self.ant_protocol.clone(), + &evm_network, self.shutdown.clone(), endpoint_catalog, ) { diff --git a/src/web_transport.rs b/src/web_transport.rs index 08982549..ac579c15 100644 --- a/src/web_transport.rs +++ b/src/web_transport.rs @@ -4,19 +4,26 @@ //! the browser-facing HTTP/3 stack separate from native Saorsa QUIC. It is not //! the production endpoint-record or certificate-rotation implementation. -use crate::ant_protocol::MAX_CHUNK_SIZE; -use crate::browser::{BrowserEndpoint, BROWSER_WEBTRANSPORT_PATH}; +use crate::ant_protocol::{ + ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, + ChunkQuoteResponse, MAX_CHUNK_SIZE, +}; +use crate::browser::{BrowserEndpoint, BrowserPaymentNetwork, BROWSER_WEBTRANSPORT_PATH}; use crate::config::WebTransportConfig; use crate::error::{Error, Result}; use crate::logging::{debug, info, warn}; +use crate::payment::{serialize_single_node_proof, PaymentProof}; use crate::storage::AntProtocol; +use evmlib::common::{Amount, TxHash}; +use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::RwLock; use saorsa_core::{P2PNode, PeerId}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime}; use tokio::io::AsyncReadExt; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::task::JoinHandle; @@ -25,8 +32,8 @@ use wtransport::endpoint::IncomingSession; use wtransport::stream::{RecvStream, SendStream}; use wtransport::{Endpoint, Identity, ServerConfig}; -const PROTOCOL_VERSION: u16 = 2; -const PROTOCOL_NAME: &str = "autonomi.web.poc.v2"; +const PROTOCOL_VERSION: u16 = 3; +const PROTOCOL_NAME: &str = "autonomi.web.poc.v3"; const MAX_FIND_NODE_RESULTS: usize = 20; const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -66,6 +73,7 @@ pub fn spawn( config: &WebTransportConfig, p2p: Arc, ant_protocol: Option>, + evm_network: &evmlib::Network, shutdown: CancellationToken, endpoint_catalog: Arc, ) -> Result { @@ -104,6 +112,7 @@ pub fn spawn( config: config.clone(), p2p, ant_protocol, + payment: BrowserPaymentNetwork::from_evm_network(evm_network), endpoint: browser_endpoint.clone(), endpoint_catalog, }); @@ -276,7 +285,7 @@ async fn handle_stream( mut recv: RecvStream, state: Arc, ) -> ServerResult<()> { - let request = match read_request(&mut recv, state.config.max_request_bytes).await { + let (request, content) = match read_request(&mut recv, state.config.max_request_bytes).await { Ok(request) => request, Err(error) => { let response = Response::error(0, "invalid_request", error); @@ -297,25 +306,83 @@ async fn handle_stream( return write_response(&mut send, &response, &[]).await; } - let (response, content) = process_request(request, &state).await; + let (response, content) = process_request(request, content, &state).await; write_response(&mut send, &response, content.as_deref().unwrap_or_default()).await } -async fn read_request(recv: &mut RecvStream, max_bytes: usize) -> ServerResult { +async fn read_request( + recv: &mut RecvStream, + max_header_bytes: usize, +) -> ServerResult<(Request, Vec)> { let mut bytes = Vec::new(); - let mut limited = recv.take((max_bytes + 1) as u64); + let max_frame_bytes = 4usize + .saturating_add(max_header_bytes) + .saturating_add(MAX_CHUNK_SIZE); + let mut limited = recv.take((max_frame_bytes + 1) as u64); tokio::time::timeout(REQUEST_TIMEOUT, limited.read_to_end(&mut bytes)) .await .map_err(|_| "request body timed out".to_string())? .map_err(|error| format!("request body read failed: {error}"))?; - if bytes.len() > max_bytes { - return Err(format!("request exceeds the {max_bytes}-byte limit")); + if bytes.len() > max_frame_bytes { + return Err(format!( + "request exceeds the {max_frame_bytes}-byte frame limit" + )); + } + let prefix = bytes + .get(..4) + .ok_or_else(|| "request ended before its four-byte header length".to_string())?; + let header_len = u32::from_be_bytes( + prefix + .try_into() + .map_err(|_| "request header prefix is invalid".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())?; + let header = bytes + .get(4..content_offset) + .ok_or_else(|| "request ended inside its JSON header".to_string())?; + let request: Request = serde_json::from_slice(header) + .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 + )); } - serde_json::from_slice(&bytes).map_err(|error| format!("request JSON is invalid: {error}")) + let expected_len = content_offset + .checked_add(request.content_length) + .ok_or_else(|| "request content length overflow".to_string())?; + if bytes.len() != expected_len { + return Err(format!( + "request length mismatch: declared {} content bytes", + request.content_length + )); + } + Ok((request, bytes[content_offset..].to_vec())) } -async fn process_request(request: Request, state: &ServerState) -> (Response, Option>) { +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 => ( Response::ok( @@ -325,7 +392,13 @@ async fn process_request(request: Request, state: &ServerState) -> (Response, Op peer_id: state.p2p.peer_id().to_hex(), max_chunk_size: MAX_CHUNK_SIZE, endpoint: state.endpoint.clone(), - capabilities: vec!["find_node".to_string(), "get_chunk".to_string()], + payment: state.payment.clone(), + capabilities: vec![ + "find_node".to_string(), + "get_chunk".to_string(), + "quote_chunk".to_string(), + "put_chunk".to_string(), + ], }, 0, ), @@ -335,6 +408,24 @@ async fn process_request(request: Request, state: &ServerState) -> (Response, Op 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 + } } } @@ -435,6 +526,221 @@ async fn process_get_chunk( } } +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}")) +} + async fn write_response( send: &mut SendStream, response: &Response, @@ -483,6 +789,7 @@ struct Request { version: u16, #[serde(rename = "request_id")] id: u64, + content_length: usize, #[serde(flatten)] body: RequestBody, } @@ -499,6 +806,15 @@ enum RequestBody { GetChunk { address: String, }, + QuoteChunk { + address: String, + size: u64, + }, + PutChunk { + address: String, + quote: Box, + transaction_hash: String, + }, } #[derive(Debug, Serialize)] @@ -562,6 +878,7 @@ enum ResponseBody { peer_id: String, max_chunk_size: usize, endpoint: BrowserEndpoint, + payment: BrowserPaymentNetwork, capabilities: Vec, }, Nodes { @@ -575,6 +892,15 @@ enum ResponseBody { ChunkNotFound { address: String, }, + StorageQuote { + address: String, + already_stored: bool, + quote: BrowserQuoteArtifact, + }, + ChunkStored { + address: String, + already_stored: bool, + }, Error { code: String, message: String, @@ -589,10 +915,130 @@ struct BrowserNode { webtransport: 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: WebTransportConfig, p2p: Arc, ant_protocol: Option>, + payment: BrowserPaymentNetwork, endpoint: BrowserEndpoint, endpoint_catalog: Arc, } @@ -605,7 +1051,7 @@ mod tests { #[test] fn parses_versioned_requests() { let request: Request = serde_json::from_str( - r#"{"version":2,"request_id":7,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, + r#"{"version":3,"request_id":7,"content_length":0,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, ) .expect("valid request"); @@ -624,6 +1070,16 @@ mod tests { 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 origins_are_exact_unless_wildcard_is_configured() { let exact = vec!["http://localhost:5173".to_string()]; @@ -644,7 +1100,7 @@ mod tests { 3, ); let value = serde_json::to_value(response).expect("serialize response"); - assert_eq!(value["version"], 2); + assert_eq!(value["version"], 3); assert_eq!(value["request_id"], 42); assert_eq!(value["status"], "ok"); assert_eq!(value["content_length"], 3); diff --git a/tests/webtransport_devnet.rs b/tests/webtransport_devnet.rs index 0ed9c8df..4692fa47 100644 --- a/tests/webtransport_devnet.rs +++ b/tests/webtransport_devnet.rs @@ -3,10 +3,14 @@ use ant_node::devnet::{Devnet, DevnetConfig}; use ant_node::BrowserEndpoint; use bytes::Bytes; +use evmlib::common::{Amount, QuoteHash}; +use evmlib::wallet::Wallet; +use evmlib::RewardsAddress; use self_encryption::{DataMap, EncryptedChunk}; use serde_json::{json, Value}; use std::error::Error; use std::io; +use std::str::FromStr; use tokio::io::AsyncReadExt; use wtransport::endpoint::ConnectOptions; use wtransport::tls::Sha256Digest; @@ -16,9 +20,17 @@ const TEST_ORIGIN: &str = "http://127.0.0.1:5173"; #[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_over_a_direct_node_endpoint() -> Result<(), Box> { +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.webtransport = true; @@ -26,6 +38,7 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() config.webtransport_allowed_origins = vec![TEST_ORIGIN.to_string()]; 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?; @@ -49,14 +62,19 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() let (hello, hello_content) = rpc( &endpoint.endpoint, json!({ - "version": 2, + "version": 3, "request_id": 5, "type": "hello", }), + &[], ) .await?; assert_eq!(hello["status"], "ok"); - assert_eq!(hello["protocol"], "autonomi.web.poc.v2"); + assert_eq!(hello["protocol"], "autonomi.web.poc.v3"); + 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"], @@ -67,12 +85,13 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() let (closest, closest_content) = rpc( &endpoint.endpoint, json!({ - "version": 2, + "version": 3, "request_id": 6, "type": "find_node", "target": public_file.address, "count": 20, }), + &[], ) .await?; assert_eq!(closest["status"], "ok"); @@ -96,11 +115,12 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() let (header, data_map_bytes) = rpc( &download_endpoint.endpoint, json!({ - "version": 2, + "version": 3, "request_id": 7, "type": "get_chunk", "address": public_file.address, }), + &[], ) .await?; @@ -117,11 +137,12 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() let (chunk_header, chunk_bytes) = rpc( &download_endpoint.endpoint, json!({ - "version": 2, + "version": 3, "request_id": request_id, "type": "get_chunk", "address": chunk.dst_hash, }), + &[], ) .await?; assert_eq!(chunk_header["status"], "ok"); @@ -133,13 +154,82 @@ async fn seeded_public_file_downloads_over_a_direct_node_endpoint() -> Result<() let decrypted = self_encryption::decrypt(&data_map, &encrypted_chunks)?; assert_eq!(decrypted, content.as_slice()); + let upload_content = b"paid browser WebTransport upload"; + let upload_address = hex::encode(blake3::hash(upload_content).as_bytes()); + let (quote_header, quote_content) = rpc( + &download_endpoint.endpoint, + json!({ + "version": 3, + "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": 3, + "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": 3, + "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, + mut request: Value, + content: &[u8], ) -> Result<(Value, Vec), Box> { let parsed = endpoint.parse().map_err(io::Error::other)?; let hashes = parsed.certificate_hashes.into_iter().map(Sha256Digest::new); @@ -153,7 +243,12 @@ async fn rpc( .build(); let connection = endpoint.connect(options).await?; let (mut send, mut recv) = connection.open_bi().await?.await?; - send.write_all(&serde_json::to_vec(&request)?).await?; + request["content_length"] = json!(content.len()); + let request_header = serde_json::to_vec(&request)?; + let request_header_len = u32::try_from(request_header.len())?; + send.write_all(&request_header_len.to_be_bytes()).await?; + send.write_all(&request_header).await?; + send.write_all(content).await?; send.finish().await?; let mut frame = Vec::new(); From 0a09dc56550eeb95e1b0cf534216bfb09f24e854 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:00:08 +0200 Subject: [PATCH 04/31] chore(webtransport): lock shared DHT lookup crate --- Cargo.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b8437b47..46bc748d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4960,6 +4960,7 @@ dependencies = [ "parking_lot", "postcard", "rand 0.8.6", + "saorsa-dht-lookup", "saorsa-pqc", "saorsa-transport", "serde", @@ -4973,6 +4974,10 @@ dependencies = [ "wyz", ] +[[package]] +name = "saorsa-dht-lookup" +version = "0.1.0" + [[package]] name = "saorsa-pqc" version = "0.5.1" From 6ee83e4fcdf55e42be67b5c84f6f8a6082c854f3 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:39:09 +0200 Subject: [PATCH 05/31] feat(webrtc): replace WebTransport browser support --- Cargo.lock | 743 +++++++++++++++--- Cargo.toml | 21 +- README.md | 4 +- assets/browser-devnet-public.txt | 2 +- ...RT_TESTNET.md => WEBRTC_DIRECT_TESTNET.md} | 37 +- ...rect-browser-clients-over-webrtc-direct.md | 573 ++++++++++++++ ...irect-browser-clients-over-webtransport.md | 373 --------- src/bin/ant-devnet/cli.rs | 33 +- src/bin/ant-devnet/main.rs | 27 +- src/bin/ant-node/cli.rs | 36 +- src/browser.rs | 141 +--- src/config.rs | 88 +-- src/devnet.rs | 154 ++-- src/lib.rs | 9 +- src/node.rs | 44 +- src/payment/verifier.rs | 2 +- src/{web_transport.rs => web_rtc.rs} | 659 +++++++++------- ...port_devnet.rs => webrtc_direct_devnet.rs} | 139 ++-- 18 files changed, 1910 insertions(+), 1175 deletions(-) rename docs/{WEBTRANSPORT_TESTNET.md => WEBRTC_DIRECT_TESTNET.md} (74%) create mode 100644 docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md delete mode 100644 docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md rename src/{web_transport.rs => web_rtc.rs} (63%) rename tests/{webtransport_devnet.rs => webrtc_direct_devnet.rs} (68%) diff --git a/Cargo.lock b/Cargo.lock index 46bc748d..b9280cd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,7 +283,7 @@ dependencies = [ "either", "serde", "serde_with", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -819,7 +819,7 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce758c01a51171003dce5fe999b7c7021e2e7322404884a9b6f9e9f1bd9235d" dependencies = [ - "sha2 0.10.9", + "sha2", ] [[package]] @@ -854,14 +854,15 @@ dependencies = [ "reqwest", "rmp-serde", "saorsa-core", - "saorsa-pqc", + "saorsa-pqc 0.5.1", + "saorsa-transport", "self-replace", "self_encryption", "semver 1.0.28", "serde", "serde_json", "serial_test", - "sha2 0.10.9", + "sha2", "tar", "tempfile", "thiserror 2.0.18", @@ -872,8 +873,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "url", - "wtransport", "xor_name", "zip", ] @@ -912,6 +911,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" @@ -1187,13 +1195,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", @@ -1203,6 +1227,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" @@ -1439,6 +1475,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" @@ -1492,6 +1534,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -1634,6 +1685,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" @@ -1646,6 +1706,18 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1837,12 +1909,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const_format" version = "0.2.36" @@ -2142,17 +2208,32 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "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", @@ -2230,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", + "const-oid", "crypto-common 0.1.7", "subtle", ] @@ -2242,7 +2323,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -2294,7 +2374,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", ] @@ -2345,6 +2425,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -2379,6 +2484,7 @@ dependencies = [ "generic-array", "group", "hkdf", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2566,7 +2672,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9fb5a367b9846933e271a3c2a992930743f82ae5e8cb7faa780715a80fa0b15" dependencies = [ "rand_core 0.6.4", - "sha2 0.10.9", + "sha2", "sha3 0.10.9", "zeroize", ] @@ -2578,7 +2684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" dependencies = [ "rand_core 0.6.4", - "sha2 0.10.9", + "sha2", "sha3 0.10.9", "zeroize", ] @@ -2915,7 +3021,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", @@ -3010,18 +3116,12 @@ dependencies = [ "hmac", "p256", "rand_core 0.9.5", - "sha2 0.10.9", + "sha2", "subtle", "x25519-dalek", "zeroize", ] -[[package]] -name = "httlib-huffman" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" - [[package]] name = "http" version = "1.4.2" @@ -3337,9 +3437,30 @@ 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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ab04c530fd82e414e40394cabe5f0ebfe30d119f10fe29d6e3561926af412e" +dependencies = [ + "async-trait", + "bytes", + "log", + "portable-atomic", + "rand 0.8.6", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3466,7 +3587,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3653,12 +3774,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" @@ -3704,17 +3844,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]] @@ -3840,7 +3993,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", ] @@ -3857,7 +4010,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", @@ -3874,10 +4027,13 @@ dependencies = [ ] [[package]] -name = "octets" -version = "0.3.6" +name = "oid-registry" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "866cb5af6f3aa3c1b44c3c2d79d22165fbb1b102e1b3fb499864bfe34736ec4b" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] [[package]] name = "oid-registry" @@ -3885,7 +4041,7 @@ 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]] @@ -3930,8 +4086,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]] @@ -4021,7 +4191,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4034,6 +4204,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" @@ -4181,6 +4360,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" @@ -4286,7 +4471,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", @@ -4526,6 +4711,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" @@ -4536,8 +4735,8 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", - "yasna", + "x509-parser 0.18.1", + "yasna 0.6.0", ] [[package]] @@ -4546,7 +4745,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -4710,6 +4909,32 @@ dependencies = [ "serde", ] +[[package]] +name = "rtcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8306430fb118b7834bbee50e744dc34826eca1da2158657a3d6cbc70e24c2096" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68baca5b6cb4980678713f0d06ef3a432aa642baefcbfd0f4dd2ef9eb5ab550" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.8.6", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + [[package]] name = "ruint" version = "1.20.0" @@ -4796,7 +5021,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", @@ -4978,6 +5203,49 @@ dependencies = [ name = "saorsa-dht-lookup" version = "0.1.0" +[[package]] +name = "saorsa-pqc" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d4bae22bfc65b379efcaae0c9ec5075916a79c05e97d595a4b78fb8ff6545b" +dependencies = [ + "aead", + "aes-gcm", + "anyhow", + "blake3", + "bytes", + "chacha20poly1305", + "curve25519-dalek", + "ed25519-dalek", + "fips203", + "fips204", + "fips205", + "futures", + "hkdf", + "hmac", + "hpke", + "libc", + "log", + "pbkdf2", + "postcard", + "rand 0.8.6", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "rayon", + "serde", + "serde_json", + "sha2", + "sha3 0.10.9", + "subtle", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "wide", + "x25519-dalek", + "zeroize", +] + [[package]] name = "saorsa-pqc" version = "0.5.1" @@ -5007,7 +5275,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5020,7 +5288,7 @@ dependencies = [ [[package]] name = "saorsa-transport" -version = "0.36.3" +version = "0.35.3" dependencies = [ "anyhow", "async-trait", @@ -5041,13 +5309,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", @@ -5061,7 +5330,8 @@ dependencies = [ "serde_json", "serde_yaml", "slab", - "socket2", + "socket2 0.5.10", + "stun", "system-configuration", "thiserror 2.0.18", "time", @@ -5072,6 +5342,7 @@ dependencies = [ "tracing-subscriber", "unicode-width", "uuid", + "webrtc", "windows", "x25519-dalek", "zeroize", @@ -5116,6 +5387,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdp" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02a526161f474ae94b966ba622379d939a8fe46c930eebbadb73e339622599d5" +dependencies = [ + "rand 0.8.6", + "substring", + "thiserror 1.0.69", + "url", +] + [[package]] name = "sec1" version = "0.7.3" @@ -5178,7 +5461,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", @@ -5415,17 +5698,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - [[package]] name = "sha3" version = "0.10.9" @@ -5534,6 +5806,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.6.4" @@ -5581,6 +5862,34 @@ 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", +] + +[[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" @@ -5662,7 +5971,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", ] @@ -5998,7 +6307,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", @@ -6127,6 +6436,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "turn" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0044fdae001dd8a1e247ea6289abf12f4fcea1331a2364da512f9cd680bbd8cb" +dependencies = [ + "async-trait", + "base64", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.8.6", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + [[package]] name = "typenum" version = "1.20.1" @@ -6261,6 +6591,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" @@ -6393,6 +6732,217 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30367074d9f18231d28a74fab0120856b2b665da108d71a12beab7185a36f97b" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "cfg-if", + "hex", + "interceptor", + "lazy_static", + "log", + "pem", + "portable-atomic", + "rand 0.8.6", + "rcgen 0.13.2", + "regex", + "ring", + "rtcp", + "rtp", + "rustls", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "time", + "tokio", + "turn", + "url", + "waitgroup", + "webrtc-data", + "webrtc-dtls", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec93b991efcd01b73c5b3503fa8adba159d069abe5785c988ebe14fcf8f05d1" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-dtls" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c9b89fc909f9da0499283b1112cd98f72fec28e55a54a9e352525ca65cd95c" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "der-parser 9.0.0", + "hkdf", + "hmac", + "log", + "p256", + "p384", + "pem", + "portable-atomic", + "rand 0.8.6", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser 0.16.0", +] + +[[package]] +name = "webrtc-ice" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348b28b593f7709ac98d872beb58c0009523df652c78e01b950ab9c537ff17d" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.8.6", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6dfe9686c6c9c51428da4de415cb6ca2dc0591ce2b63212e23fd9cccf0e316b" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e153be16b8650021ad3e9e49ab6e5fa9fb7f6d1c23c213fd8bbd1a1135a4c704" +dependencies = [ + "byteorder", + "bytes", + "rand 0.8.6", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5faf3846ec4b7e64b56338d62cbafe084aa79806b0379dff5cc74a8b7a2b3063" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.8.6", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771db9993712a8fb3886d5be4613ebf27250ef422bd4071988bf55f1ed1a64fa" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[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 = "wide" version = "0.7.33" @@ -6729,42 +7279,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wtransport" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4aacf790813ee1956751491800537f4e04af7557b7b370501ccbfbc85963e4" -dependencies = [ - "bytes", - "pem", - "quinn", - "rcgen", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "sha2 0.11.0", - "socket2 0.6.4", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "url", - "wtransport-proto", - "x509-parser", -] - -[[package]] -name = "wtransport-proto" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5867c629e4252f7439d82315923daaf27f4fa442410d51b78ab93ef4c432a11" -dependencies = [ - "httlib-huffman", - "octets", - "thiserror 2.0.18", - "url", -] - [[package]] name = "wyz" version = "0.5.1" @@ -6786,18 +7300,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", @@ -6852,6 +7384,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 4c4716a5..6d8a9d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,7 +87,6 @@ color-eyre = "0.6" # Serialization rmp-serde = "1" hex = "0.4" -url = "2" # Utilities bytes = "1" @@ -114,10 +113,9 @@ page_size = "0.6" postcard = { version = "1.1.3", features = ["use-std"] } bao = "0.13.1" -# ADR-0009 transport interoperability proof. Kept optional so the existing -# node build and its Rust 1.75 MSRV are unchanged. wtransport 0.7 itself -# requires Rust 1.88 when this feature is enabled. -wtransport = { version = "0.7.1", optional = true } +# ADR-0009 browser transport. Kept optional so native-only nodes do not pull +# in the ICE/DTLS/SCTP stack. +saorsa-transport = { version = "0.35.3", features = ["webrtc-direct"], optional = true } self_encryption = { version = "0.36", optional = true } [target.'cfg(unix)'.dependencies] @@ -182,9 +180,9 @@ path = "tests/poc_shutdown_lmdb_drain.rs" required-features = ["test-utils"] [[test]] -name = "webtransport_devnet" -path = "tests/webtransport_devnet.rs" -required-features = ["webtransport-poc"] +name = "webrtc_direct_devnet" +path = "tests/webrtc_direct_devnet.rs" +required-features = ["webrtc-direct"] [features] default = ["logging"] @@ -197,8 +195,11 @@ logging = ["tracing", "tracing-subscriber", "tracing-appender"] # integration tests and downstream test harnesses. test-utils = [] # Non-production direct-browser interoperability proof from ADR-0009. -# This enables a second HTTP/3/WebTransport UDP listener and requires Rust 1.88. -webtransport-poc = ["dep:self_encryption", "dep:wtransport"] +# This enables a second WebRTC Direct UDP listener. +webrtc-direct = [ + "dep:saorsa-transport", + "dep:self_encryption", +] [patch.crates-io] saorsa-core = { path = "../saorsa-core-web-support" } diff --git a/README.md b/README.md index 7e47aaa4..477dcafc 100644 --- a/README.md +++ b/README.md @@ -617,9 +617,9 @@ let harness = TestHarness::setup_with_evm().await?; assert!(harness.anvil().is_healthy().await); ``` -For the direct-browser testnet, where every node exposes WebTransport and a +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/WEBTRANSPORT_TESTNET.md). +[Browser-enabled local testnet](docs/WEBRTC_DIRECT_TESTNET.md). ### Roadmap diff --git a/assets/browser-devnet-public.txt b/assets/browser-devnet-public.txt index f2524220..ee6b8659 100644 --- a/assets/browser-devnet-public.txt +++ b/assets/browser-devnet-public.txt @@ -3,4 +3,4 @@ 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 WebTransport, and verifies the content address in-browser. +storage node over WebRTC Direct, and verifies the content address in-browser. diff --git a/docs/WEBTRANSPORT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md similarity index 74% rename from docs/WEBTRANSPORT_TESTNET.md rename to docs/WEBRTC_DIRECT_TESTNET.md index ab13d058..2516599e 100644 --- a/docs/WEBTRANSPORT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -1,20 +1,20 @@ # Browser-enabled local testnet This workflow starts a five-node local Autonomi network where every node has a -direct WebTransport endpoint. Startup publishes a default immutable test file +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 optional WebTransport dependency. +Rust 1.88 or newer is required by the optional Saorsa WebRTC Direct transport. ```bash -cargo run --features webtransport-poc --bin ant-devnet -- \ +cargo run --features webrtc-direct --bin ant-devnet -- \ --preset minimal \ --base-port 23000 \ - --webtransport \ - --webtransport-base-port 24000 \ + --webrtc-direct \ + --webrtc-direct-base-port 24000 \ --serve-port 25000 \ --enable-evm \ --enable-logging @@ -25,27 +25,27 @@ The services are: | Purpose | Address | |---|---| | Native node QUIC | UDP 127.0.0.1:23000-23004 | -| Direct browser WebTransport | UDP 127.0.0.1:24000-24004 | +| 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 `--webtransport`, port 25000 is used. Pass +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 64 MiB in this local in-memory launcher. -The browser manifest contains every node's self-contained WebTransport +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 WebTransport. +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. -`--webtransport` requires an explicit payment network. For this local test, +`--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 @@ -76,7 +76,7 @@ BLAKE3 hash, and save it under its original filename. ## Automated verification ```bash -cargo test --features webtransport-poc --test webtransport_devnet -- --ignored +cargo test --features webrtc-direct --test webrtc_direct_devnet -- --ignored ``` This starts Anvil and the five-node network, self-encrypts and publishes a @@ -84,23 +84,24 @@ 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 WebTransport. +back through WebRTC Direct. ## LAN testing -Use `--host ` and add the exact site origin: +Use `--host ` to advertise the literal LAN address: ```bash -cargo run --features webtransport-poc --bin ant-devnet -- \ +cargo run --features webrtc-direct --bin ant-devnet -- \ --preset minimal \ --host 192.168.1.50 \ - --webtransport \ - --webtransport-origin http://192.168.1.50:5173 \ + --webrtc-direct \ --serve-port 25000 \ + --enable-evm \ --enable-logging ``` -Expose the client dev server on the LAN and change its manifest URL to +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 -WebTransport UDP ranges must be reachable. Do not use this unsigned local +WebRTC Direct UDP ranges must be reachable. Do not use this unsigned local manifest mode on a public network. 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..992243b3 --- /dev/null +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -0,0 +1,573 @@ +# ADR-0009: Direct browser clients over WebRTC Direct + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Last amended:** 2026-08-25 +- **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 + even though browser DTLS currently uses classical cryptography. + +## 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, opt-in WebRTC Direct listener to browser-capable +nodes. 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 ML-DSA identity handshake. 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-node` owns the bounded browser +RPC protocol, and browser clients use `RTCPeerConnection` directly. + +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 not authorize browser RPCs. Before accepting application +requests, the node proves possession of its ML-DSA identity key in a +domain-separated handshake covering at least the network ID, protocol version, +fresh browser challenge, expected peer ID, and advertised DTLS fingerprint. The +browser verifies the public-key-to-peer-ID binding and the signature. A +mismatched `/p2p` identity aborts the connection. + +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. + +Production discovery uses a separately versioned record rather than changing +the existing Postcard `DHTNode` shape in place: + +```text +BrowserEndpointRecord { + network_id, + peer_id, + sequence, + expires_at, + webrtc_multiaddrs, + capabilities, + protocol_versions, + max_chunk_size, + node_public_key, + ml_dsa_signature +} +``` + +Discovered records expire because IP addresses, ports, relay allocations, and +capabilities can change. That expiry does not apply to the separately +configured bootstrap trust anchors and is not driven by routine DTLS +certificate rotation. + +The ML-DSA signature covers a canonical, domain-separated encoding. The +browser verifies 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. + +### 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 current Saorsa profile is identified by the ICE credential prefix +`saorsa+webrtc+v1/`. Like the prior v1 mechanism, it replaces 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). Ongoing +[WebRTC Direct v2 work](https://github.com/libp2p/specs/pull/715) is useful +interoperability research because it avoids that mutation, but Saorsa does not +depend on libp2p adopting or shipping it. + +Production is therefore conditional on a new, explicitly versioned Saorsa +connection-establishment profile that works without forbidden SDP mutation. +We should adopt compatible standards-level techniques and cross-browser test +vectors from v2 work where they fit. The ANT ML-DSA handshake remains the only +node-identity protocol. Unknown connection-establishment versions are rejected, +and v1 is not a silent fallback once browsers no longer support it. + +### Browser protocol and DataChannel framing + +The public protocol is not the private Saorsa `WireMessage` or native Postcard +DHT protocol. The initial methods are: + +- `HELLO`: negotiate version/network/capabilities and complete node identity + authentication. +- `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. The application framing is a four-byte JSON-header length, a +bounded versioned JSON header, and the declared raw binary body; chunk bytes +are never JSON/base64. Application frames are fragmented into DataChannel +messages of at most 16 KiB and reassembled directly by the receiver. No +libp2p stream envelope or half-close control frame exists. + +Application frames are self-delimiting: receivers validate the JSON header and +its declared body length rather than trusting DataChannel boundaries. 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, and mismatched lengths are +protocol errors. 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 complete file operation finishes. + +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. It starts from the +constant WebRTC Direct bootstrap list, queries up to `ALPHA = 3` unqueried +closest endpoints in parallel, merges verified endpoint records, and stops at +convergence or the iteration limit. The initial implementation targets the +current native `K = 20` behavior. Lookup and chunk retry policies should +eventually share language-independent test vectors with the native client. + +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 application identity authentication terminate at the storage node, not +the relay. 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 per-connection ML-DSA `HELLO` challenge before other RPCs. The signed + transcript binds the challenge, ANT peer ID, and full advertised endpoint; + the browser verifies both the signature and the public-key-to-peer-ID hash; +- 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; + 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 connection-establishment +profile 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. + +## 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. +- 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 is separately bound to the persistent PQ node + identity rather than being treated as the ANT identity. +- Rust producers and consumers share the network's native `MultiAddr` codec; + browser JavaScript implements the same canonical wire syntax. +- 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. +- A stable DTLS transport key has a larger compromise window. ML-DSA + application authentication limits its authority, 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 v2 profile and 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. +- Current browser DTLS is not post-quantum. + +### 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. +- Bootstrap peers do not perform lookup or proxy uploads/downloads; they + answer the same bounded one-hop RPCs as other browser-capable nodes. + +## 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 fingerprints, wrong peer IDs, wrong networks, + replayed handshakes, invalid ML-DSA signatures, and signatures not bound to + the DTLS transcript. +- 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, retries, 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. +- 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. +- 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/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md b/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md deleted file mode 100644 index ec43bb35..00000000 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webtransport.md +++ /dev/null @@ -1,373 +0,0 @@ -# ADR-0009: Direct browser clients over WebTransport - -- **Status:** Proposed -- **Date:** 2026-08-03 -- **Last amended:** 2026-08-05 -- **Decision owners:** -- **Reviewers:** -- **Supersedes:** none -- **Superseded by:** none -- **Related:** [W3C WebTransport](https://www.w3.org/TR/webtransport/), - [WebTransport over HTTP/3](https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/), - [W3C WebRTC](https://www.w3.org/TR/webrtc/) - -## 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 WebTransport sessions negotiated through HTTP/3 or HTTP/2 and require -browser-compatible TLS authentication. - -Many 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 datagrams and is sometimes unavoidable on the public -Internet. - -This ADR records the intended production architecture and defines a smaller, -explicitly non-production proof of concept. The proof of concept validates -browser interoperability, request framing, local DHT access, chunk downloads, -and paid immutable uploads; signed endpoint dissemination and relayed -WebTransport are later implementation slices. - -## 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. -- Operators must not need to obtain DNS names or public CA certificates. -- The existing post-quantum node-to-node port and wire protocols remain - unchanged. -- A public browser protocol must be 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 need an end-to-end relay path without exposing plaintext to the - relay. -- A 4 MiB chunk needs reliable streaming and backpressure. -- Endpoint ownership must remain bound to the node's persistent ML-DSA - identity even though browser TLS currently uses classical cryptography. - -## 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. **Make one UDP port detect both native QUIC and WebTransport.** Rejected for - the first implementation. It mixes two TLS stacks, two QUIC protocol - implementations, and different identity models in the most sensitive part - of the node. -4. **Use WebRTC DataChannels.** Not selected as the primary transport. - WebRTC's ICE/STUN/TURN support can establish direct paths through more NATs, - and it does not require Web PKI. However, every peer connection needs an - out-of-band SDP/ICE signaling exchange and a separate ICE + DTLS + SCTP - stack. DataChannels also require application fragmentation and buffered - amount management for 4 MiB chunks. WebRTC remains a candidate fallback if - measured direct-ICE success justifies this complexity. -5. **Add a separate WebTransport listener to each node (chosen).** It maps - directly to request/response streams, leaves native networking unchanged, - and supports a pinned self-signed certificate without operator-managed - Web PKI. - -## Decision - -We will add a separate, opt-in WebTransport-over-HTTP/3 listener to nodes. -Production browser-capable nodes will publish an owner-signed browser endpoint -record. Browser clients will use those records 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. - -### Transport and certificates - -- WebTransport uses a separate UDP socket and port from native Saorsa QUIC. -- Node software generates P-256 X.509v3 certificates automatically. Operators - do not obtain public CA certificates. -- Each node embeds the certificate's SHA-256 DER multihash in its advertised - WebTransport multiaddress. Applications supply only the multiaddress; the - browser client extracts the digest and passes it internally through - `serverCertificateHashes`. -- Production nodes maintain overlapping current and next certificates because - hash-pinned WebTransport certificates may be valid for at most two weeks. -- The listener has independent connection, stream, request, timeout, and byte - limits. Its write surface accepts only content-addressed chunks accompanied - by a verifiable native payment proof. -- The native ML-KEM/ML-DSA transport remains the node-to-node transport and is - not downgraded or replaced. - -### Endpoint discovery and identity - -Production discovery uses a separately versioned record rather than changing -the existing Postcard `DHTNode` shape in place: - -```text -BrowserEndpointRecord { - network_id, - peer_id, - sequence, - expires_at, - webtransport_multiaddrs, - capabilities, - protocol_versions, - max_chunk_size, - node_public_key, - ml_dsa_signature -} -``` - -The canonical direct address form is: - -```text -/ip4/
/udp//quic-v1/webtransport - /certhash/ - [/certhash/] - /p2p/ -``` - -`ip6`, `dns`, `dns4`, and `dns6` host components are also valid. Certificate -multihashes use unpadded base64url multibase (`u`) and must contain exactly a -32-byte SHA-256 digest. Implementations accept at most the current and next -hash. The `/webtransport` component maps to the fixed -`/autonomi/webtransport/v1` HTTPS session path. - -This is represented by the network's native address types rather than an -application-owned string. `saorsa-transport` stores the transport component as -`TransportAddr::WebTransport(WebTransportAddr)`, including the validated host, -port, and certificate hashes. `saorsa-core::MultiAddr` wraps that transport -component and owns the `/p2p/` suffix. Its canonical `Display`, -`FromStr`, and string-based Serde implementations are the single Rust codec -used by endpoint records, manifests, `HELLO`, and `FIND_NODE`. `ant-node` must -not maintain a second WebTransport multiaddress parser or certificate-hash -codec. - -The native Saorsa QUIC dialer deliberately does not treat a WebTransport -address as a native QUIC dialing candidate. It is a first-class advertised -transport address whose browser HTTP/3 stack remains separate from the PQ -node-to-node transport. - -The multiaddress is the complete dialing input: no separate URL, certificate -hash, or peer-ID argument is accepted by the browser client. This prevents the -three values from being accidentally mixed between nodes. A certificate hash -authenticates the ephemeral TLS key, while `/p2p` identifies the expected -persistent ANT identity. The endpoint-record signature binds the whole address -to that identity. An address received through an unauthenticated channel is not -made trustworthy merely by containing a hash; initial bootstrap addresses are -application trust anchors, and discovered addresses require owner signatures. - -During rotation, nodes advertise current and next hashes in the same address, -switch certificates only after the next hash has propagated, then replace the -retired hash with a newly generated next hash. Cached addresses must expire no -later than their last certificate. Rotation and address publication are node -software responsibilities, not operator or web-application configuration. - -The ML-DSA signature covers a canonical, domain-separated encoding. The -browser verifies the public-key-to-peer-ID binding, signature, network ID, -sequence, expiry, capabilities, and certificate hash before connecting. -Initial bootstrap records are distributed with the HTTPS web application; -subsequent records are learned during DHT iteration. - -The classical browser TLS certificate is therefore an ephemeral transport key -bound by an application-layer ML-DSA signature to the node's persistent PQ -identity. Browser TLS confidentiality is not post-quantum until browsers -standardize and expose a suitable PQ TLS mode. - -### Browser protocol - -The public protocol is not the private Saorsa `WireMessage` or native Postcard -DHT protocol. Each client-created bidirectional stream carries one request and -one response. The initial methods are: - -- `HELLO`: negotiate version/network/capabilities and return node identity. -- `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. - -Requests and responses use a four-byte big-endian JSON-header length, a -bounded versioned JSON header, and an optional raw binary body. Chunk bytes are -never JSON/base64. 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 -WebTransport. - -### Lookup behavior - -The browser owns the iterative lookup state machine. It starts from ordinary -bootstrap nodes, queries up to `ALPHA = 3` unqueried closest endpoints in -parallel, merges verified endpoint records, and stops at convergence or the -iteration limit. The initial implementation targets the current native -`K = 20` behavior. Lookup and chunk retry policies should eventually share -language-independent test vectors with the native client. - -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 - -Publicly reachable nodes accept WebTransport directly. For NATed nodes, -Saorsa's relay layer will be generalized to provide a UDP forwarding socket -usable by the standard WebTransport QUIC implementation. The node publishes -the relay allocation as another signed WebTransport URL. TLS and application -traffic remain end-to-end between browser and storage node; the relay only -forwards encrypted datagrams. - -WebRTC may be reconsidered as an optional path after an interoperability study -measures ICE setup latency, direct-connect success, TURN fallback, node -resource use, and 4 MiB DataChannel performance. - -### Proof-of-concept slice - -The repository PoC is intentionally feature-gated and disabled by default. It -provides: - -- a separate WebTransport listener; -- an automatically generated short-lived P-256 certificate and a self-contained - `/webtransport/certhash/.../p2p/...` multiaddress; -- native `saorsa-transport::TransportAddr` and `saorsa-core::MultiAddr` - parsing, formatting, validation, and serialization for that address; -- exact path and Origin checks; -- bounded length-prefixed JSON headers on one bidirectional stream per RPC, - followed by optional raw chunk bytes in either direction; -- `HELLO`, local `FIND_NODE`, local `GET_CHUNK`, `QUOTE_CHUNK`, and paid - `PUT_CHUNK`; -- a browser application that extracts and pins the certificate from the - multiaddress, performs the lookup loop, - downloads public file records, reconstructs complete files, self-encrypts - uploads, verifies signed storage quotes and commitments, signs EVM payments - locally, uploads encrypted records, and verifies both chunk and whole-file - BLAKE3 hashes. - -The PoC endpoint descriptors are not yet ML-DSA-signed or disseminated through -the DHT. Peers lacking a browser descriptor remain visible but cannot be -queried by the browser. The PoC must not be enabled on production nodes and is -not evidence that partial fleet deployment is sufficient. - -### 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 WebTransport multiaddress of every -browser-enabled peer in its routing view. This catalog is explicitly a local -replacement for the future signed DHT endpoint record, not a production -discovery mechanism. - -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. A read-only HTTP bootstrap -manifest exposes bootstrap multiaddresses, public-file metadata, public EVM -RPC and contract addresses, and the resolved public root DataMap needed by -this local client; it never performs lookup or carries file bytes. Wallet -secrets are never included in the manifest. - -The companion JavaScript client and test site live in the `web/` package of the -`ant-client-web-support` repository. It fetches the public DataMap and every -encrypted data chunk directly, applies the native BLAKE3 KDF, -ChaCha20-Poly1305 authentication, and Brotli compression/decompression. It can -verify and save reconstructed files, or obtain quotes, make one batched vault -payment, upload the generated records to closest nodes, and immediately -download the newly published file. - -## Consequences - -### Positive - -- Browsers can become application-level full immutable-data clients without a - lookup, payment, upload, or download gateway. -- Operators do not manage DNS names or CA certificate issuance. -- Community clients configure one self-contained bootstrap multiaddress per - seed instead of separate URLs and certificate hashes. -- Rust producers and consumers share the network's native `MultiAddr` codec; - browser JavaScript implements the same canonical wire syntax. -- Existing PQ node networking and compatibility remain isolated. -- Reliable WebTransport streams match large immutable chunk downloads and - uploads. -- Endpoint records explicitly bind browser TLS to the node's PQ identity. -- The same transport can run end-to-end through a generic UDP relay. - -### Negative / Trade-offs - -- Browser-capable nodes run a second UDP listener and a second QUIC/TLS stack. -- Short-lived pinned certificates require automatic overlap, rotation, and - endpoint-record propagation. -- Current browser TLS is not post-quantum. -- Full direct operation requires broad browser-endpoint coverage among storage - nodes. -- Relayed nodes consume relay bandwidth even though relays cannot read the - traffic. -- WebTransport and its HTTP/3 mapping are still evolving and require an - explicit browser compatibility matrix. -- The PoC's latest WebTransport dependency has a higher feature-specific Rust - toolchain requirement than the default node build. - -### Neutral / Operational - -- The official web application still needs to be served from a secure HTTPS - context; that certificate is unrelated to node operator certificates. -- Origin is policy input, not client authentication. Public deployments still - need per-IP/session request and byte quotas. -- Bootstrap peers remain necessary, as they are for native clients, but do not - perform lookup or proxy uploads/downloads. - -## Validation - -The decision advances beyond PoC only after all of the following are covered: - -- Automated protocol framing, oversize-request, malformed-input, path, and - Origin tests. -- Browser end-to-end tests on current Chrome, Firefox, and Safari from a real - secure context using both pinned and WebPKI certificates. -- Browser-side iterative lookup parity tests for XOR ordering, `K`, `ALPHA`, - convergence, retries, and unavailable endpoints. -- Successful streamed downloads at 0 bytes, typical sizes, and 4 MiB, with - BLAKE3 verification and cancellation/backpressure measurements. -- Paid-upload tests covering quote/commitment tampering, wrong peers, wrong - content, missing/failed payments, replay/idempotence, wallet rejection, and - successful native-client retrieval of browser-created files. -- Certificate current/next rotation, stale-record, replay, wrong-peer, - wrong-network, and hash-mismatch tests. -- Connection floods, stream floods, slow readers, request amplification, and - global/per-client byte quota tests. -- A fleet test demonstrating that browser endpoint coverage reaches the - storage nodes selected by native closest-group rules. -- End-to-end relayed WebTransport tests where TLS terminates at the NATed node, - not the relay. -- Regression tests proving the existing native PQ port and native client - behavior are unchanged when browser support is disabled. -- Review triggers when the W3C/IETF WebTransport protocol mapping, browser - support, node storage placement, or Saorsa relay API changes 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 1192984a..0c9c7c17 100644 --- a/src/bin/ant-devnet/cli.rs +++ b/src/bin/ant-devnet/cli.rs @@ -49,26 +49,21 @@ pub struct Cli { #[arg(long)] pub manifest: Option, - /// Enable one direct-browser WebTransport listener per devnet node. + /// Enable one direct-browser WebRTC Direct listener per devnet node. /// - /// The binary must be built with `--features webtransport-poc`. + /// The binary must be built with `--features webrtc-direct`. #[arg(long, requires = "evm-payment")] - pub webtransport: bool, + pub webrtc_direct: bool, - /// First UDP port assigned to devnet WebTransport listeners (0 = allocate). - #[arg(long, requires = "webtransport")] - pub webtransport_base_port: Option, - - /// Exact browser Origin accepted by WebTransport listeners. - /// May be supplied more than once. Defaults to the local Vite origins. - #[arg(long = "webtransport-origin", requires = "webtransport")] - pub webtransport_origins: Vec, + /// 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 = "webtransport")] + #[arg(long, requires = "webrtc_direct")] pub public_file: Option, /// Enable logging output. @@ -129,7 +124,7 @@ mod tests { assert!(cli.host.is_none()); assert!(cli.evm_network.is_none()); assert!(cli.serve_port.is_none()); - assert!(!cli.webtransport); + assert!(!cli.webrtc_direct); } /// The LAN flags parse into the expected typed values. @@ -169,25 +164,25 @@ mod tests { } #[test] - fn browser_flags_require_webtransport() { + 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", - "--webtransport", + "--webrtc-direct", "--enable-evm", - "--webtransport-base-port", + "--webrtc-direct-base-port", "22000", "--public-file", "hello.txt", ]); - assert!(cli.webtransport); - assert_eq!(cli.webtransport_base_port, Some(22_000)); + 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", "--webtransport"]); + let result = Cli::try_parse_from(["ant-devnet", "--webrtc-direct"]); assert!(result.is_err()); let rendered = result .err() diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index e2b82dc4..203d1b71 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -88,10 +88,10 @@ async fn main() -> color_eyre::Result<()> { config.stabilization_timeout = std::time::Duration::from_secs(timeout_secs); } - #[cfg(not(feature = "webtransport-poc"))] - if cli.webtransport { + #[cfg(not(feature = "webrtc-direct"))] + if cli.webrtc_direct { return Err(color_eyre::eyre::eyre!( - "--webtransport requires a binary built with --features webtransport-poc" + "--webrtc-direct requires a binary built with --features webrtc-direct" )); } @@ -107,14 +107,9 @@ async fn main() -> color_eyre::Result<()> { )); } config.advertise_ip = cli.host; - config.webtransport = cli.webtransport; - if let Some(base_port) = cli.webtransport_base_port { - config.webtransport_base_port = base_port; - } - if !cli.webtransport_origins.is_empty() { - config.webtransport_allowed_origins = cli.webtransport_origins.clone(); - } else if let Some(host) = cli.host { - config.webtransport_allowed_origins = vec![format!("http://{host}:5173")]; + 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, @@ -132,8 +127,8 @@ async fn main() -> color_eyre::Result<()> { let created_at = chrono::Utc::now().to_rfc3339(); - #[cfg(feature = "webtransport-poc")] - let browser_manifest = if cli.webtransport { + #[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) @@ -150,7 +145,7 @@ async fn main() -> color_eyre::Result<()> { None }; - #[cfg(not(feature = "webtransport-poc"))] + #[cfg(not(feature = "webrtc-direct"))] let browser_manifest: Option = None; let manifest = DevnetManifest { @@ -178,7 +173,7 @@ async fn main() -> color_eyre::Result<()> { // copying files (GET /api/devnet-manifest.json + /api/info). let serve_port = cli .serve_port - .or_else(|| cli.webtransport.then_some(25_000)); + .or_else(|| cli.webrtc_direct.then_some(25_000)); if let Some(port) = serve_port { serve_manifest_api( port, @@ -197,7 +192,7 @@ async fn main() -> color_eyre::Result<()> { Ok(()) } -#[cfg(feature = "webtransport-poc")] +#[cfg(feature = "webrtc-direct")] async fn load_public_file( path: Option<&std::path::Path>, ) -> color_eyre::Result<(String, String, Vec)> { diff --git a/src/bin/ant-node/cli.rs b/src/bin/ant-node/cli.rs index eb50d166..00e1dcb4 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -28,20 +28,19 @@ pub struct Cli { #[arg(long, env = "ANT_IPV4_ONLY")] pub ipv4_only: bool, - /// Enable the ADR-0009 WebTransport `PoC` on this UDP address. + /// Enable the ADR-0009 WebRTC Direct `PoC` on this UDP address. /// - /// The binary must be built with `--features webtransport-poc`. - #[arg(long, env = "ANT_WEBTRANSPORT_BIND")] - pub webtransport_bind: Option, + /// The binary must be built with `--features webrtc-direct`. + #[arg(long, env = "ANT_WEBRTC_DIRECT_BIND")] + pub webrtc_direct_bind: Option, - /// Public WebTransport URL to advertise instead of deriving it from the bind address. - #[arg(long, env = "ANT_WEBTRANSPORT_ADVERTISED_URL")] - pub webtransport_advertised_url: Option, - - /// Exact browser Origin allowed to open a WebTransport session. - /// May be supplied more than once. - #[arg(long = "webtransport-origin", env = "ANT_WEBTRANSPORT_ORIGINS")] - pub webtransport_origins: Vec, + /// 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")] @@ -245,15 +244,12 @@ impl Cli { config.port = self.port; config.ipv4_only = self.ipv4_only; - if let Some(bind) = self.webtransport_bind { - config.webtransport.enabled = true; - config.webtransport.bind = bind; - } - if let Some(url) = self.webtransport_advertised_url { - config.webtransport.advertised_url = Some(url); + if let Some(bind) = self.webrtc_direct_bind { + config.webrtc_direct.enabled = true; + config.webrtc_direct.bind = bind; } - if !self.webtransport_origins.is_empty() { - config.webtransport.allowed_origins = self.webtransport_origins; + if let Some(addr) = self.webrtc_direct_advertised_addr { + config.webrtc_direct.advertised_addr = Some(addr); } #[cfg(feature = "logging")] { diff --git a/src/browser.rs b/src/browser.rs index 6161e48d..0dface37 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -5,71 +5,52 @@ //! records: browsers sign EVM transactions locally and send only payment //! receipts to nodes. -use saorsa_core::{ - MultiAddr, PeerId, WebTransportAddr, WebTransportCertificateHash, WebTransportHost, -}; +use saorsa_core::{MultiAddr, PeerId, WebRtcCertificateHash, WebRtcDirectAddr}; use serde::{Deserialize, Serialize}; -use url::{Host, Url}; +use std::net::SocketAddr; /// Version of the local browser bootstrap manifest. -pub const BROWSER_MANIFEST_VERSION: u16 = 4; - -/// Fixed HTTPS path represented by an Autonomi `/webtransport` multiaddress. -pub const BROWSER_WEBTRANSPORT_PATH: &str = "/autonomi/webtransport/v1"; +pub const BROWSER_MANIFEST_VERSION: u16 = 5; /// A self-contained browser-compatible transport endpoint. /// -/// The multiaddress embeds the WebTransport certificate hash or overlapping -/// current/next hashes. Callers never supply a separate certificate pin. +/// 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 WebTransport multiaddress, including certificate hashes and peer ID. + /// 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 { - /// HTTPS URL passed to the browser or native WebTransport implementation. - pub url: String, + /// 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, - /// SHA-256 hashes of the accepted leaf certificates. - pub certificate_hashes: Vec<[u8; 32]>, + /// Stable SHA-256 hash of the node's DTLS certificate. + pub certificate_hash: [u8; 32], } impl BrowserEndpoint { - /// Construct a canonical endpoint from an advertised HTTPS URL, ANT peer ID, - /// and one or two leaf-certificate SHA-256 hashes. + /// 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 a non-HTTPS URL, a non-standard session path, - /// malformed peer ID, or an invalid certificate-hash count. + /// Returns an error for port zero. pub fn new( - advertised_url: &str, + advertised_addr: SocketAddr, peer_id: &PeerId, - certificate_hashes: &[[u8; 32]], + certificate_hash: [u8; 32], ) -> Result { - let url = parse_advertised_url(advertised_url)?; - let host = match url.host() { - Some(Host::Ipv4(ip)) => WebTransportHost::Ip4(ip), - Some(Host::Ipv6(ip)) => WebTransportHost::Ip6(ip), - Some(Host::Domain(domain)) => WebTransportHost::Dns(domain.to_ascii_lowercase()), - None => return Err("WebTransport advertised URL has no host".to_string()), - }; - let port = url - .port_or_known_default() - .ok_or_else(|| "WebTransport advertised URL has no port".to_string())?; - - let certificate_hashes = certificate_hashes - .iter() - .copied() - .map(WebTransportCertificateHash::new) - .collect(); - let transport = WebTransportAddr::new(host, port, certificate_hashes) - .map_err(|error| error.to_string())?; - let multiaddr = MultiAddr::webtransport(transport).with_peer_id(*peer_id); + 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 }) } @@ -84,27 +65,15 @@ impl BrowserEndpoint { .multiaddr .peer_id() .copied() - .ok_or_else(|| "WebTransport multiaddress has no peer ID".to_string())?; + .ok_or_else(|| "WebRtcDirect multiaddress has no peer ID".to_string())?; let address = self .multiaddr - .webtransport_addr() - .ok_or_else(|| "multiaddress does not use WebTransport".to_string())?; - let url = format!( - "https://{}:{}{}", - address.host().url_host(), - address.port(), - BROWSER_WEBTRANSPORT_PATH - ); - parse_advertised_url(&url)?; - let certificate_hashes = address - .certificate_hashes() - .iter() - .map(|hash| *hash.as_bytes()) - .collect(); + .webrtc_direct_addr() + .ok_or_else(|| "multiaddress does not use WebRtcDirect".to_string())?; Ok(ParsedBrowserEndpoint { - url, + socket_addr: address.socket_addr(), peer_id, - certificate_hashes, + certificate_hash: *address.certificate_hash().as_bytes(), }) } } @@ -220,90 +189,66 @@ impl BrowserDevnetManifest { } } -fn parse_advertised_url(advertised_url: &str) -> Result { - let url = Url::parse(advertised_url) - .map_err(|error| format!("invalid WebTransport advertised URL: {error}"))?; - if url.scheme() != "https" { - return Err("WebTransport advertised URL must use https".to_string()); - } - if !url.username().is_empty() || url.password().is_some() { - return Err("WebTransport advertised URL must not contain credentials".to_string()); - } - if url.path() != BROWSER_WEBTRANSPORT_PATH { - return Err(format!( - "WebTransport advertised URL path must be {BROWSER_WEBTRANSPORT_PATH}" - )); - } - if url.query().is_some() || url.fragment().is_some() { - return Err("WebTransport advertised URL must not contain a query or fragment".to_string()); - } - Ok(url) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; #[test] - fn browser_endpoint_round_trips_current_and_next_hashes() { + fn browser_endpoint_round_trips_stable_hash() { let peer_id = PeerId::from_bytes([0xab; 32]); let endpoint = BrowserEndpoint::new( - "https://127.0.0.1:24000/autonomi/webtransport/v1", + "127.0.0.1:24000".parse().expect("valid socket address"), &peer_id, - &[[0x11; 32], [0x22; 32]], + [0x11; 32], ) .expect("valid endpoint"); assert!(endpoint .multiaddr .to_string() - .starts_with("/ip4/127.0.0.1/udp/24000/quic-v1/webtransport/certhash/u")); + .starts_with("/ip4/127.0.0.1/udp/24000/webrtc-direct/certhash/u")); assert_eq!( endpoint.multiaddr.to_string().matches("/certhash/").count(), - 2 + 1 ); let parsed = endpoint.parse().expect("round-trip endpoint"); - assert_eq!( - parsed.url, - "https://127.0.0.1:24000/autonomi/webtransport/v1" - ); + assert_eq!(parsed.socket_addr, "127.0.0.1:24000".parse().unwrap()); assert_eq!(parsed.peer_id, peer_id); - assert_eq!(parsed.certificate_hashes, vec![[0x11; 32], [0x22; 32]]); + 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( - "https://[::1]:24000/autonomi/webtransport/v1", + "[::1]:24000".parse().expect("valid socket address"), &peer_id, - &[[0x33; 32]], + [0x33; 32], ) .expect("valid endpoint"); let parsed = endpoint.parse().expect("round-trip endpoint"); - assert_eq!(parsed.url, "https://[::1]:24000/autonomi/webtransport/v1"); + 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/quic-v1/webtransport/p2p/{peer_id}"}}"# - ); + 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/quic-v1/webtransport/certhash/uAA/p2p/{peer_id}"}}"# + 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_requires_the_standard_path() { + fn browser_endpoint_rejects_port_zero() { let peer_id = PeerId::from_bytes([0xab; 32]); - let error = BrowserEndpoint::new("https://127.0.0.1:24000/custom", &peer_id, &[[0x11; 32]]) - .expect_err("custom path must fail"); - assert!(error.contains(BROWSER_WEBTRANSPORT_PATH)); + 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 45f8be9b..6c826f95 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,12 +120,12 @@ pub struct NodeConfig { #[serde(default)] pub storage: StorageConfig, - /// Experimental direct-browser WebTransport listener. + /// Experimental direct-browser WebRTC Direct listener. /// /// This is the ADR-0009 interoperability proof and is disabled by - /// default. Enabling it requires a build with `webtransport-poc`. + /// default. Enabling it requires a build with `webrtc-direct`. #[serde(default)] - pub webtransport: WebTransportConfig, + pub webrtc_direct: WebRtcDirectConfig, /// Directory for persisting the close group cache. /// @@ -150,97 +150,69 @@ pub struct NodeConfig { pub log_level: String, } -/// Configuration for the ADR-0009 WebTransport proof of concept. +/// Configuration for the ADR-0009 WebRTC Direct proof of concept. /// /// This listener is deliberately separate from the native Saorsa QUIC port. -/// It exposes only local closest-node lookup and local immutable chunk GET. +/// 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 WebTransportConfig { +pub struct WebRtcDirectConfig { /// Enable the experimental listener. #[serde(default)] pub enabled: bool, - /// UDP address for the HTTP/3 listener. - #[serde(default = "default_webtransport_bind")] + /// UDP address for the WebRTC Direct listener. + #[serde(default = "default_webrtc_direct_bind")] pub bind: SocketAddr, - /// URL advertised to the browser in `HELLO` and self lookup results. + /// Literal public UDP address advertised to browsers. /// - /// When omitted, the URL is derived from the bound socket and - /// [`Self::path`]. A wildcard bind therefore needs an explicit public URL. + /// When omitted, the address is derived from the bound socket. A wildcard + /// bind therefore needs an explicit public address. #[serde(default)] - pub advertised_url: Option, + pub advertised_addr: Option, - /// WebTransport session path. - #[serde(default = "default_webtransport_path")] - pub path: String, - - /// Exact browser origins accepted by the `PoC`. + /// PEM file used to persist the stable DTLS certificate and private key. /// - /// `"*"` is supported for local experimentation but must not be used for - /// a public deployment. - #[serde(default = "default_webtransport_origins")] - pub allowed_origins: Vec, - - /// Subject alternative names for the automatically generated certificate. - #[serde(default = "default_webtransport_sans")] - pub certificate_sans: Vec, + /// 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_webtransport_max_connections")] + #[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_webtransport_max_request_bytes")] + #[serde(default = "default_webrtc_direct_max_request_bytes")] pub max_request_bytes: usize, } -impl Default for WebTransportConfig { +impl Default for WebRtcDirectConfig { fn default() -> Self { Self { enabled: false, - bind: default_webtransport_bind(), - advertised_url: None, - path: default_webtransport_path(), - allowed_origins: default_webtransport_origins(), - certificate_sans: default_webtransport_sans(), - max_connections: default_webtransport_max_connections(), - max_request_bytes: default_webtransport_max_request_bytes(), + 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_webtransport_bind() -> SocketAddr { +fn default_webrtc_direct_bind() -> SocketAddr { SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) } -fn default_webtransport_path() -> String { - crate::browser::BROWSER_WEBTRANSPORT_PATH.to_string() -} - -fn default_webtransport_origins() -> Vec { - vec![ - "http://localhost:5173".to_string(), - "http://127.0.0.1:5173".to_string(), - ] -} - -fn default_webtransport_sans() -> Vec { - vec![ - "localhost".to_string(), - "127.0.0.1".to_string(), - "::1".to_string(), - ] -} - -const fn default_webtransport_max_connections() -> usize { +const fn default_webrtc_direct_max_connections() -> usize { 32 } -const fn default_webtransport_max_request_bytes() -> usize { +const fn default_webrtc_direct_max_request_bytes() -> usize { 64 * 1024 } @@ -380,7 +352,7 @@ impl Default for NodeConfig { upgrade: UpgradeConfig::default(), payment: PaymentConfig::default(), storage: StorageConfig::default(), - webtransport: WebTransportConfig::default(), + webrtc_direct: WebRtcDirectConfig::default(), close_group_cache_dir: None, max_message_size: default_max_message_size(), log_level: default_log_level(), diff --git a/src/devnet.rs b/src/devnet.rs index 0145647b..adb9af11 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -28,15 +28,15 @@ use tokio::task::JoinHandle; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -#[cfg(feature = "webtransport-poc")] +#[cfg(feature = "webrtc-direct")] use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse}; -#[cfg(feature = "webtransport-poc")] +#[cfg(feature = "webrtc-direct")] use crate::browser::{BrowserBootstrapNode, BrowserPaymentNetwork, BrowserPublicFile}; -#[cfg(feature = "webtransport-poc")] -use crate::config::WebTransportConfig; -#[cfg(feature = "webtransport-poc")] +#[cfg(feature = "webrtc-direct")] +use crate::config::WebRtcDirectConfig; +#[cfg(feature = "webrtc-direct")] use bytes::Bytes; -#[cfg(feature = "webtransport-poc")] +#[cfg(feature = "webrtc-direct")] use std::collections::HashMap; // ============================================================================= @@ -227,14 +227,11 @@ pub struct DevnetConfig { /// nodes bind 0.0.0.0 and advertise this IP instead of 127.0.0.1. pub advertise_ip: Option, - /// Run one direct-browser WebTransport listener per devnet node. - pub webtransport: bool, + /// Run one direct-browser WebRTC Direct listener per devnet node. + pub webrtc_direct: bool, - /// First UDP port in the WebTransport node range (0 = allocate). - pub webtransport_base_port: u16, - - /// Browser origins accepted by every devnet WebTransport listener. - pub webtransport_allowed_origins: Vec, + /// First UDP port in the WebRTC Direct node range (0 = allocate). + pub webrtc_direct_base_port: u16, } impl Default for DevnetConfig { @@ -257,12 +254,8 @@ impl Default for DevnetConfig { cleanup_data_dir: true, evm_network: None, advertise_ip: None, - webtransport: false, - webtransport_base_port: 0, - webtransport_allowed_origins: vec![ - "http://localhost:5173".to_string(), - "http://127.0.0.1:5173".to_string(), - ], + webrtc_direct: false, + webrtc_direct_base_port: 0, } } } @@ -346,9 +339,9 @@ pub struct DevnetNode { state: Arc>, bootstrap_addrs: Vec, protocol_task: Option>, - #[cfg(feature = "webtransport-poc")] - webtransport_task: Option>, - #[cfg(feature = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: Option>, + #[cfg(feature = "webrtc-direct")] browser_endpoint: Option, } @@ -370,8 +363,8 @@ pub struct Devnet { shutdown: CancellationToken, state: Arc>, health_monitor: Option>, - #[cfg(feature = "webtransport-poc")] - browser_endpoint_catalog: Arc, + #[cfg(feature = "webrtc-direct")] + browser_endpoint_catalog: Arc, } impl Devnet { @@ -421,25 +414,19 @@ impl Devnet { ))); } - #[cfg(not(feature = "webtransport-poc"))] - if config.webtransport { + #[cfg(not(feature = "webrtc-direct"))] + if config.webrtc_direct { return Err(DevnetError::Config( - "WebTransport devnet support requires the 'webtransport-poc' feature".to_string(), + "WebRtcDirect devnet support requires the 'webrtc-direct' feature".to_string(), )); } - #[cfg(feature = "webtransport-poc")] - if config.webtransport { - if config.webtransport_allowed_origins.is_empty() { - return Err(DevnetError::Config( - "At least one WebTransport browser Origin is required".to_string(), - )); - } - - if config.webtransport_base_port == 0 { + #[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.webtransport_base_port = if adjacent_end + config.webrtc_direct_base_port = if adjacent_end .is_some_and(|end| end <= DEVNET_PORT_RANGE_MAX) { adjacent @@ -459,28 +446,28 @@ impl Devnet { }) .ok_or_else(|| { DevnetError::Config( - "Could not allocate a disjoint WebTransport port range".to_string(), + "Could not allocate a disjoint WebRtcDirect port range".to_string(), ) })? }; } - let webtransport_end = config - .webtransport_base_port + let webrtc_direct_end = config + .webrtc_direct_base_port .checked_add(node_count_u16) .ok_or_else(|| { - DevnetError::Config("WebTransport port range overflow".to_string()) + DevnetError::Config("WebRtcDirect port range overflow".to_string()) })?; - if config.webtransport_base_port < DEVNET_PORT_RANGE_MIN - || webtransport_end > DEVNET_PORT_RANGE_MAX + if config.webrtc_direct_base_port < DEVNET_PORT_RANGE_MIN + || webrtc_direct_end > DEVNET_PORT_RANGE_MAX { return Err(DevnetError::Config(format!( - "WebTransport ports must remain in the local test range {DEVNET_PORT_RANGE_MIN}..{DEVNET_PORT_RANGE_MAX}" + "WebRtcDirect ports must remain in the local test range {DEVNET_PORT_RANGE_MIN}..{DEVNET_PORT_RANGE_MAX}" ))); } - if base_port < webtransport_end && config.webtransport_base_port < max_port { + if base_port < webrtc_direct_end && config.webrtc_direct_base_port < max_port { return Err(DevnetError::Config( - "Native and WebTransport devnet port ranges overlap".to_string(), + "Native and WebRtcDirect devnet port ranges overlap".to_string(), )); } } @@ -493,10 +480,8 @@ impl Devnet { shutdown: CancellationToken::new(), state: Arc::new(RwLock::new(NetworkState::Uninitialized)), health_monitor: None, - #[cfg(feature = "webtransport-poc")] - browser_endpoint_catalog: Arc::new( - crate::web_transport::BrowserEndpointCatalog::default(), - ), + #[cfg(feature = "webrtc-direct")] + browser_endpoint_catalog: Arc::new(crate::web_rtc::BrowserEndpointCatalog::default()), }) } @@ -549,11 +534,11 @@ impl Devnet { if let Some(handle) = node.protocol_task.take() { handle.abort(); } - #[cfg(feature = "webtransport-poc")] - if let Some(handle) = node.webtransport_task.take() { + #[cfg(feature = "webrtc-direct")] + if let Some(handle) = node.webrtc_direct_task.take() { if let Err(error) = handle.await { warn!( - "Error stopping node {} WebTransport listener: {error}", + "Error stopping node {} WebRtcDirect listener: {error}", node.index ); } @@ -607,7 +592,7 @@ impl Devnet { } /// Get every direct browser endpoint in this devnet. - #[cfg(feature = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] #[must_use] pub fn browser_endpoints(&self) -> Vec { self.nodes @@ -630,19 +615,19 @@ impl Devnet { /// /// # Errors /// - /// Returns an error when WebTransport is disabled, self-encryption fails, + /// 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 = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] pub async fn publish_public_file( &self, name: String, content_type: String, content: &[u8], ) -> Result { - if !self.config.webtransport { + if !self.config.webrtc_direct { return Err(DevnetError::Config( - "Cannot publish a browser file when WebTransport is disabled".to_string(), + "Cannot publish a browser file when WebRtcDirect is disabled".to_string(), )); } if content.len() < self_encryption::MIN_ENCRYPTABLE_BYTES { @@ -724,7 +709,7 @@ impl Devnet { } /// Public EVM configuration advertised to direct browser clients. - #[cfg(feature = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] #[must_use] pub fn browser_payment_network(&self) -> BrowserPaymentNetwork { let network = self @@ -735,7 +720,7 @@ impl Devnet { BrowserPaymentNetwork::from_evm_network(network) } - #[cfg(feature = "webtransport-poc")] + #[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(); @@ -901,9 +886,9 @@ impl Devnet { state: Arc::new(RwLock::new(NodeState::Pending)), bootstrap_addrs, protocol_task: None, - #[cfg(feature = "webtransport-poc")] - webtransport_task: None, - #[cfg(feature = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: None, + #[cfg(feature = "webrtc-direct")] browser_endpoint: None, }) } @@ -994,18 +979,18 @@ impl Devnet { node.p2p_node = Some(Arc::new(p2p_node)); *node.state.write().await = NodeState::Running; - #[cfg(feature = "webtransport-poc")] - if self.config.webtransport { + #[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 - .webtransport_base_port + .webrtc_direct_base_port .checked_add(index_u16) .ok_or_else(|| { DevnetError::Config(format!( - "WebTransport port overflow for node {}", + "WebRtcDirect port overflow for node {}", node.index )) })?; @@ -1014,34 +999,22 @@ impl Devnet { .config .advertise_ip .map_or(Ipv4Addr::LOCALHOST, |_| Ipv4Addr::UNSPECIFIED); - let mut webtransport_config = WebTransportConfig::default(); - webtransport_config.enabled = true; - webtransport_config.bind = SocketAddr::from((bind_ip, port)); - webtransport_config.advertised_url = Some(format!( - "https://{advertised_ip}:{port}{}", - webtransport_config.path - )); - webtransport_config - .allowed_origins - .clone_from(&self.config.webtransport_allowed_origins); - webtransport_config.certificate_sans = if advertised_ip.is_loopback() { - vec![ - "localhost".to_string(), - Ipv4Addr::LOCALHOST.to_string(), - "::1".to_string(), - ] - } else { - vec![advertised_ip.to_string()] + 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 WebTransport startup", + "Node {} lost its P2P handle before WebRtcDirect startup", node.index )) })?; - let server = crate::web_transport::spawn( - &webtransport_config, + let server = crate::web_rtc::spawn( + &webrtc_direct_config, + &node.data_dir, p2p, node.ant_protocol.clone(), self.config @@ -1051,14 +1024,15 @@ impl Devnet { self.shutdown.clone(), Arc::clone(&self.browser_endpoint_catalog), ) + .await .map_err(|error| { DevnetError::Startup(format!( - "Failed to start node {} WebTransport listener: {error}", + "Failed to start node {} WebRtcDirect listener: {error}", node.index )) })?; node.browser_endpoint = Some(server.endpoint); - node.webtransport_task = Some(server.task); + node.webrtc_direct_task = Some(server.task); } if let (Some(ref p2p), Some(ref protocol)) = (&node.p2p_node, &node.ant_protocol) { diff --git a/src/lib.rs b/src/lib.rs index 43464f07..08225aa1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,8 +55,8 @@ pub mod payment; pub mod replication; pub mod storage; pub mod upgrade; -#[cfg(feature = "webtransport-poc")] -mod web_transport; +#[cfg(feature = "webrtc-direct")] +mod web_rtc; pub use ant_protocol::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, @@ -66,13 +66,12 @@ pub use ant_protocol::{ pub use browser::{ BrowserBootstrapNode, BrowserChunkInfo, BrowserDevnetManifest, BrowserEndpoint, BrowserPaymentNetwork, BrowserPublicFile, ParsedBrowserEndpoint, BROWSER_MANIFEST_VERSION, - BROWSER_WEBTRANSPORT_PATH, }; 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, WebTransportConfig}; +pub use config::{NodeConfig, StorageConfig, WebRtcDirectConfig}; pub use devnet::{Devnet, DevnetConfig, DevnetEvmInfo, DevnetManifest}; pub use error::{Error, Result}; pub use event::{NodeEvent, NodeEventsChannel}; @@ -87,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, WebTransportAddr, WebTransportCertificateHash, WebTransportHost, + P2PNode, WebRtcCertificateHash, WebRtcDirectAddr, }; } diff --git a/src/node.rs b/src/node.rs index 03715e95..3ba89a61 100644 --- a/src/node.rs +++ b/src/node.rs @@ -88,11 +88,11 @@ impl NodeBuilder { Self::validate_production_rewards_address(&self.config)?; - #[cfg(not(feature = "webtransport-poc"))] - if self.config.webtransport.enabled { + #[cfg(not(feature = "webrtc-direct"))] + if self.config.webrtc_direct.enabled { return Err(Error::Config( - "webtransport is enabled but this binary was not built with the \ - 'webtransport-poc' feature" + "webrtc_direct is enabled but this binary was not built with the \ + 'webrtc-direct' feature" .to_string(), )); } @@ -222,8 +222,8 @@ impl NodeBuilder { ant_protocol, replication_engine, protocol_task: None, - #[cfg(feature = "webtransport-poc")] - webtransport_task: None, + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: None, upgrade_exit_code: Arc::new(AtomicI32::new(-1)), }; @@ -484,8 +484,8 @@ pub struct RunningNode { /// Protocol message routing background task. protocol_task: Option>, /// ADR-0009 experimental browser listener task. - #[cfg(feature = "webtransport-poc")] - webtransport_task: Option>, + #[cfg(feature = "webrtc-direct")] + webrtc_direct_task: Option>, /// Exit code requested by a successful upgrade (-1 = no upgrade exit pending). upgrade_exit_code: Arc, } @@ -546,23 +546,25 @@ impl RunningNode { "Node is running on port: {}", actual_port ); - #[cfg(feature = "webtransport-poc")] - if self.config.webtransport.enabled { - let endpoint_catalog = - Arc::new(crate::web_transport::BrowserEndpointCatalog::default()); + #[cfg(feature = "webrtc-direct")] + if self.config.webrtc_direct.enabled { + 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_transport::spawn( - &self.config.webtransport, + match crate::web_rtc::spawn( + &self.config.webrtc_direct, + &self.config.root_dir, Arc::clone(&self.p2p_node), self.ant_protocol.clone(), &evm_network, self.shutdown.clone(), endpoint_catalog, - ) { - Ok(server) => self.webtransport_task = Some(server.task), + ) + .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 WebTransport startup failure failed: {shutdown_error}"); + warn!("P2P shutdown after WebRtcDirect startup failure failed: {shutdown_error}"); } return Err(error); } @@ -737,12 +739,12 @@ impl RunningNode { // Run the main event loop with signal handling self.run_event_loop().await?; - // The shared token closes the WebTransport accept loop and active + // The shared token closes the WebRtcDirect accept loop and active // browser sessions before storage and native P2P are torn down. - #[cfg(feature = "webtransport-poc")] - if let Some(task) = self.webtransport_task.take() { + #[cfg(feature = "webrtc-direct")] + if let Some(task) = self.webrtc_direct_task.take() { if let Err(error) = task.await { - warn!("WebTransport task shutdown failed: {error}"); + warn!("WebRtcDirect task shutdown failed: {error}"); } } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 6c79d329..ec8fa25f 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1239,7 +1239,7 @@ impl PaymentVerifier { /// is handed to a browser. The subsequent PUT still traverses the normal /// address, responsibility, payment-cache, storage, and read-verification /// checks. - #[cfg(feature = "webtransport-poc")] + #[cfg(feature = "webrtc-direct")] pub(crate) fn cache_insert_browser_devnet_seed(&self, xorname: XorName) { self.cache.insert(xorname); } diff --git a/src/web_transport.rs b/src/web_rtc.rs similarity index 63% rename from src/web_transport.rs rename to src/web_rtc.rs index ac579c15..c6c48952 100644 --- a/src/web_transport.rs +++ b/src/web_rtc.rs @@ -1,15 +1,15 @@ -//! ADR-0009 WebTransport interoperability proof. +//! ADR-0009 WebRTC Direct browser transport. //! -//! This module is feature-gated, disabled by default, and intentionally keeps -//! the browser-facing HTTP/3 stack separate from native Saorsa QUIC. It is not -//! the production endpoint-record or certificate-rotation implementation. +//! The listener uses Saorsa's signaling-free WebRTC Direct transport for ICE, +//! DTLS, SCTP, and reliable ordered `DataChannels`. ANT's ML-DSA HELLO binds the +//! pinned WebRTC endpoint to the node identity without a libp2p or Noise layer. use crate::ant_protocol::{ ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, MAX_CHUNK_SIZE, }; -use crate::browser::{BrowserEndpoint, BrowserPaymentNetwork, BROWSER_WEBTRANSPORT_PATH}; -use crate::config::WebTransportConfig; +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}; @@ -17,27 +17,31 @@ use crate::storage::AntProtocol; use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::RwLock; +use saorsa_core::identity::NodeIdentity; use saorsa_core::{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, SocketAddr}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime}; -use tokio::io::AsyncReadExt; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::sync::Semaphore; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use wtransport::endpoint::IncomingSession; -use wtransport::stream::{RecvStream, SendStream}; -use wtransport::{Endpoint, Identity, ServerConfig}; const PROTOCOL_VERSION: u16 = 3; const PROTOCOL_NAME: &str = "autonomi.web.poc.v3"; +const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; const MAX_FIND_NODE_RESULTS: usize = 20; const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(5); +const WEBRTC_WRITE_CHUNK_BYTES: usize = MAX_DATA_CHANNEL_MESSAGE_SIZE; /// Browser endpoints known to one or more listeners in the same process. /// @@ -61,7 +65,7 @@ impl BrowserEndpointCatalog { } /// A running browser listener and the endpoint clients use to reach it. -pub struct WebTransportServer { +pub struct WebRtcDirectServer { /// Direct endpoint with its certificate pin embedded in the multiaddress. pub endpoint: BrowserEndpoint, /// Listener background task. @@ -69,47 +73,37 @@ pub struct WebTransportServer { } /// Start the feature-gated browser listener and return its endpoint and task. -pub fn spawn( - config: &WebTransportConfig, +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_config(config)?; - - let identity = Identity::self_signed(&config.certificate_sans) - .map_err(|error| Error::Config(format!("invalid WebTransport certificate SAN: {error}")))?; - let certificate = identity - .certificate_chain() - .as_slice() - .first() - .ok_or_else(|| Error::Startup("WebTransport identity has no certificate".to_string()))?; - let certificate_sha256 = *certificate.hash().as_ref(); - - let server_config = ServerConfig::builder() - .with_bind_address(config.bind) - .with_identity(identity) - .keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)) - .build(); - let endpoint = Endpoint::server(server_config).map_err(|error| { - Error::Startup(format!("failed to bind WebTransport endpoint: {error}")) - })?; - let local_addr = endpoint.local_addr().map_err(|error| { - Error::Startup(format!( - "failed to read WebTransport bound address: {error}" - )) - })?; - let advertised_url = advertised_url(config, local_addr); - +) -> 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 browser_endpoint = BrowserEndpoint::new(&advertised_url, &peer_id, &[certificate_sha256]) + let identity = Arc::clone(p2p.transport().node_identity()); + let browser_endpoint = BrowserEndpoint::new(advertised_addr, &peer_id, certificate_sha256) .map_err(Error::Config)?; endpoint_catalog.insert(peer_id, browser_endpoint.clone()); let state = Arc::new(ServerState { config: config.clone(), + identity, p2p, ant_protocol, payment: BrowserPaymentNetwork::from_evm_network(evm_network), @@ -121,251 +115,302 @@ pub fn spawn( info!( bind = %local_addr, multiaddr = %browser_endpoint.multiaddr, - "ADR-0009 WebTransport PoC listening" + certificate = %certificate_path.display(), + "ADR-0009 WebRTC Direct listening" ); let task = tokio::spawn(async move { - serve(endpoint, state, connection_limit, shutdown).await; + serve_webrtc(listener, state, connection_limit, shutdown).await; }); - Ok(WebTransportServer { + Ok(WebRtcDirectServer { endpoint: browser_endpoint, task, }) } -fn validate_config(config: &WebTransportConfig) -> Result<()> { - if config.path != BROWSER_WEBTRANSPORT_PATH { - return Err(Error::Config(format!( - "webtransport.path must be {BROWSER_WEBTRANSPORT_PATH}" - ))); - } - if config.allowed_origins.is_empty() { - return Err(Error::Config( - "webtransport.allowed_origins must not be empty".to_string(), - )); - } - if config.certificate_sans.is_empty() { - return Err(Error::Config( - "webtransport.certificate_sans must not be empty".to_string(), - )); - } +fn validate_webrtc_config(config: &WebRtcDirectConfig) -> Result<()> { if config.max_connections == 0 { return Err(Error::Config( - "webtransport.max_connections must be greater than zero".to_string(), + "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!( - "webtransport.max_request_bytes must be between 1 and {MAX_RESPONSE_HEADER_BYTES}" + "webrtc_direct.max_request_bytes must be between 1 and {MAX_RESPONSE_HEADER_BYTES}" ))); } - if let Some(url) = config.advertised_url.as_deref() { - if !url.starts_with("https://") { - return Err(Error::Config( - "webtransport.advertised_url must use https://".to_string(), - )); - } + 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 advertised_url(config: &WebTransportConfig, local_addr: SocketAddr) -> String { - if let Some(url) = config.advertised_url.as_ref() { - return url.clone(); +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"), } +} - let host = match local_addr.ip() { - IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(), - IpAddr::V4(ip) => ip.to_string(), - IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(), - IpAddr::V6(ip) => format!("[{ip}]"), - }; - format!("https://{host}:{}{}", local_addr.port(), config.path) +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( - endpoint: Endpoint, +async fn serve_webrtc( + mut listener: WebRtcDirectListener, state: Arc, connection_limit: Arc, shutdown: CancellationToken, ) { loop { - tokio::select! { + let connection = tokio::select! { () = shutdown.cancelled() => break, - incoming = endpoint.accept() => { - match Arc::clone(&connection_limit).try_acquire_owned() { - Ok(permit) => { - let state = Arc::clone(&state); - let connection_shutdown = shutdown.clone(); - tokio::spawn(async move { - if let Err(error) = handle_incoming( - incoming, - state, - connection_shutdown, - permit, - ).await { - debug!("WebTransport session ended: {error}"); - } - }); + 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"); } - Err(_) => { - tokio::spawn(reject_busy(incoming)); + 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}"); } } } - endpoint.close(0u32.into(), b"node shutting down"); - info!("ADR-0009 WebTransport PoC stopped"); -} - -async fn reject_busy(incoming: IncomingSession) { - match tokio::time::timeout(REQUEST_TIMEOUT, incoming).await { - Ok(Ok(request)) => request.too_many_requests().await, - Ok(Err(error)) => debug!("Could not reject busy WebTransport session: {error}"), - Err(_) => debug!("Timed out while rejecting busy WebTransport session"), + if let Err(error) = listener.close().await { + debug!("WebRTC Direct listener close failed: {error}"); } + info!("ADR-0009 WebRTC Direct stopped"); } -async fn handle_incoming( - incoming: IncomingSession, +async fn handle_connection( + mut connection: WebRtcDirectConnection, state: Arc, shutdown: CancellationToken, - _permit: OwnedSemaphorePermit, ) -> ServerResult<()> { - let request = tokio::select! { - () = shutdown.cancelled() => return Ok(()), - result = tokio::time::timeout(REQUEST_TIMEOUT, incoming) => { - result - .map_err(|_| "session negotiation timed out".to_string())? - .map_err(|error| format!("session negotiation failed: {error}"))? - } - }; - - if request.path() != state.config.path { - request.not_found().await; - return Ok(()); - } - if !origin_allowed(&state.config.allowed_origins, request.origin()) { - warn!(origin = ?request.origin(), "Rejected WebTransport Origin"); - request.forbidden().await; - return Ok(()); - } - - let remote = request.remote_address(); - let connection = request - .accept() - .await - .map_err(|error| format!("session accept failed: {error}"))?; - debug!(remote = %remote, "Accepted browser WebTransport session"); - + let authenticated = Arc::new(AtomicBool::new(false)); loop { - tokio::select! { + let channel = tokio::select! { () = shutdown.cancelled() => return Ok(()), - stream = connection.accept_bi() => { - let (send, recv) = stream - .map_err(|error| format!("bidirectional stream accept failed: {error}"))?; - handle_stream(send, recv, Arc::clone(&state)).await?; - } - stream = connection.accept_uni() => { - let recv = stream - .map_err(|error| format!("unidirectional stream accept failed: {error}"))?; - recv.stop(1u32.into()); + result = connection.accept_data_channel() => { + result.map_err(|error| format!("DataChannel accept failed: {error}"))? } - datagram = connection.receive_datagram() => { - datagram.map_err(|error| format!("datagram receive failed: {error}"))?; - debug!("Discarded unsupported WebTransport datagram"); + }; + let state = Arc::clone(&state); + let authenticated = Arc::clone(&authenticated); + tokio::spawn(async move { + if let Err(error) = handle_webrtc_channel(channel, state, authenticated).await { + debug!("WebRTC Direct DataChannel ended: {error}"); } - } + }); } } -async fn handle_stream( - mut send: SendStream, - mut recv: RecvStream, +async fn handle_webrtc_channel( + channel: WebRtcDataChannel, state: Arc, + authenticated: Arc, ) -> ServerResult<()> { - let (request, content) = match read_request(&mut recv, state.config.max_request_bytes).await { - Ok(request) => request, - Err(error) => { - let response = Response::error(0, "invalid_request", error); - return write_response(&mut send, &response, &[]).await; + if channel.label() != DATA_CHANNEL_LABEL { + if let Err(error) = channel.close().await { + debug!("Failed to close unsupported DataChannel: {error}"); } - }; - - if request.version != PROTOCOL_VERSION { - let request_id = request.id; - let response = Response::error( - request_id, - "unsupported_version", - format!( - "protocol version {} is unsupported; expected {PROTOCOL_VERSION}", - request.version - ), - ); - return write_response(&mut send, &response, &[]).await; + return Err(format!( + "unsupported DataChannel label {:?}", + channel.label() + )); } - let (response, content) = process_request(request, content, &state).await; - write_response(&mut send, &response, content.as_deref().unwrap_or_default()).await + loop { + let (request, content) = + match read_webrtc_request(&channel, state.config.max_request_bytes).await { + Ok(request) => request, + Err(error) if error == "DataChannel closed" => return Ok(()), + Err(error) => { + let response = Response::error(0, "invalid_request", error); + write_webrtc_response(&channel, &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, &response, &[]).await?; + continue; + } + + let is_hello = matches!(&request.body, RequestBody::Hello { .. }); + if !is_hello && !authenticated.load(Ordering::Acquire) { + let response = Response::error( + request.id, + "authentication_required", + "HELLO must authenticate this WebRTC connection first".to_string(), + ); + write_webrtc_response(&channel, &response, &[]).await?; + continue; + } + + let (response, content) = process_request(request, content, &state).await; + if is_hello && matches!(&response.status, ResponseStatus::Ok) { + authenticated.store(true, Ordering::Release); + } + write_webrtc_response(&channel, &response, content.as_deref().unwrap_or_default()).await?; + } } -async fn read_request( - recv: &mut RecvStream, +async fn read_webrtc_request( + channel: &WebRtcDataChannel, max_header_bytes: usize, ) -> ServerResult<(Request, Vec)> { - let mut bytes = Vec::new(); - let max_frame_bytes = 4usize - .saturating_add(max_header_bytes) - .saturating_add(MAX_CHUNK_SIZE); - let mut limited = recv.take((max_frame_bytes + 1) as u64); - tokio::time::timeout(REQUEST_TIMEOUT, limited.read_to_end(&mut bytes)) + let read = async { + let mut frame = Vec::new(); + let mut expected_length = None; + let max_frame_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; + loop { + let message = channel + .receive() + .await + .map_err(|error| format!("request 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() && frame.len() >= 4 { + 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}" + )); + } + if frame.len() >= 4 + header_len { + let request: Request = serde_json::from_slice(&frame[4..4 + header_len]) + .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 + )); + } + expected_length = Some((4 + header_len + request.content_length, request)); + } + } + + if let Some((length, _)) = expected_length.as_ref() { + if frame.len() > *length { + return Err("request contains bytes after its declared frame".to_string()); + } + if frame.len() == *length { + let (_, request) = expected_length + .take() + .ok_or_else(|| "request length state was lost".to_string())?; + let header_len = u32::from_be_bytes( + frame[..4] + .try_into() + .map_err(|_| "request prefix is incomplete".to_string())?, + ) as usize; + return Ok((request, frame.split_off(4 + header_len))); + } + } + } + }; + tokio::time::timeout(REQUEST_TIMEOUT, read) .await - .map_err(|_| "request body timed out".to_string())? - .map_err(|error| format!("request body read failed: {error}"))?; + .map_err(|_| "request timed out".to_string())? +} - if bytes.len() > max_frame_bytes { - return Err(format!( - "request exceeds the {max_frame_bytes}-byte frame limit" - )); - } - let prefix = bytes - .get(..4) - .ok_or_else(|| "request ended before its four-byte header length".to_string())?; - let header_len = u32::from_be_bytes( - prefix - .try_into() - .map_err(|_| "request header prefix is invalid".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())?; - let header = bytes - .get(4..content_offset) - .ok_or_else(|| "request ended inside its JSON header".to_string())?; - let request: Request = serde_json::from_slice(header) - .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 - )); +async fn write_webrtc_response( + channel: &WebRtcDataChannel, + 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 expected_len = content_offset - .checked_add(request.content_length) - .ok_or_else(|| "request content length overflow".to_string())?; - if bytes.len() != expected_len { - return Err(format!( - "request length mismatch: declared {} content bytes", - request.content_length - )); + let header_len = u32::try_from(header.len()) + .map_err(|_| "response header length does not fit u32".to_string())?; + let mut frame = Vec::with_capacity(4 + header.len() + content.len()); + frame.extend_from_slice(&header_len.to_be_bytes()); + frame.extend_from_slice(&header); + frame.extend_from_slice(content); + for chunk in frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + channel + .send(chunk) + .await + .map_err(|error| format!("response message write failed: {error}"))?; } - Ok((request, bytes[content_offset..].to_vec())) + Ok(()) } async fn process_request( @@ -384,26 +429,55 @@ async fn process_request( ); } match request.body { - RequestBody::Hello => ( - Response::ok( - request.id, - ResponseBody::Hello { - protocol: PROTOCOL_NAME.to_string(), - peer_id: state.p2p.peer_id().to_hex(), - 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::Hello { challenge } => { + let challenge_bytes = match decode_32_byte_hex(&challenge) { + Ok(bytes) => bytes, + Err(error) => { + return ( + Response::error(request.id, "invalid_challenge", error), + None, + ) + } + }; + let peer_id = state.p2p.peer_id().to_hex(); + let transcript = hello_transcript(&challenge_bytes, &peer_id, &state.endpoint); + let signature = match state.identity.sign(&transcript) { + Ok(signature) => signature, + Err(error) => { + return ( + Response::error( + request.id, + "identity_signing_failed", + format!("could not sign HELLO: {error}"), + ), + None, + ) + } + }; + ( + Response::ok( + request.id, + ResponseBody::Hello { + protocol: PROTOCOL_NAME.to_string(), + peer_id, + challenge, + public_key: hex::encode(state.identity.public_key().as_bytes()), + signature: hex::encode(signature.as_bytes()), + 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 } @@ -451,7 +525,7 @@ async fn process_find_node( .map(|node| { let peer_id = node.peer_id.to_hex(); BrowserNode { - webtransport: state.endpoint_catalog.get(&node.peer_id), + webrtc_direct: state.endpoint_catalog.get(&node.peer_id), peer_id, native_addresses: node .addresses_by_priority() @@ -741,39 +815,6 @@ async fn handle_ant_message( .map_err(|error| format!("storage response decoding failed: {error}")) } -async fn write_response( - send: &mut SendStream, - 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())?; - send.write_all(&header_len.to_be_bytes()) - .await - .map_err(|error| format!("response prefix write failed: {error}"))?; - send.write_all(&header) - .await - .map_err(|error| format!("response header write failed: {error}"))?; - if !content.is_empty() { - send.write_all(content) - .await - .map_err(|error| format!("response content write failed: {error}"))?; - } - send.finish() - .await - .map_err(|error| format!("response finish failed: {error}")) -} - -fn origin_allowed(allowed: &[String], origin: Option<&str>) -> bool { - allowed.iter().any(|candidate| candidate == "*") - || origin.is_some_and(|origin| allowed.iter().any(|candidate| candidate == origin)) -} - 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}"))?; @@ -782,6 +823,14 @@ fn decode_32_byte_hex(value: &str) -> ServerResult<[u8; 32]> { .map_err(|bytes: Vec| format!("expected 32 bytes, received {}", bytes.len())) } +fn hello_transcript(challenge: &[u8; 32], peer_id: &str, endpoint: &BrowserEndpoint) -> Vec { + let mut transcript = b"autonomi-webrtc-direct-hello-v1\0".to_vec(); + transcript.extend_from_slice(challenge); + transcript.extend_from_slice(peer_id.as_bytes()); + transcript.extend_from_slice(endpoint.multiaddr.to_string().as_bytes()); + transcript +} + type ServerResult = std::result::Result; #[derive(Debug, Deserialize)] @@ -797,7 +846,9 @@ struct Request { #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum RequestBody { - Hello, + Hello { + challenge: String, + }, FindNode { target: String, #[serde(default)] @@ -876,6 +927,9 @@ enum ResponseBody { Hello { protocol: String, peer_id: String, + challenge: String, + public_key: String, + signature: String, max_chunk_size: usize, endpoint: BrowserEndpoint, payment: BrowserPaymentNetwork, @@ -912,7 +966,7 @@ struct BrowserNode { peer_id: String, native_addresses: Vec, reliability: f64, - webtransport: Option, + webrtc_direct: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1035,7 +1089,8 @@ impl BrowserCommitmentArtifact { } struct ServerState { - config: WebTransportConfig, + config: WebRtcDirectConfig, + identity: Arc, p2p: Arc, ant_protocol: Option>, payment: BrowserPaymentNetwork, @@ -1080,15 +1135,6 @@ mod tests { ); } - #[test] - fn origins_are_exact_unless_wildcard_is_configured() { - let exact = vec!["http://localhost:5173".to_string()]; - assert!(origin_allowed(&exact, Some("http://localhost:5173"))); - assert!(!origin_allowed(&exact, Some("http://evil.test"))); - assert!(!origin_allowed(&exact, None)); - assert!(origin_allowed(&["*".to_string()], None)); - } - #[test] fn response_header_declares_raw_content_length() { let response = Response::ok( @@ -1108,9 +1154,28 @@ mod tests { } #[test] - fn derives_ipv6_urls_with_brackets() { - let config = WebTransportConfig::default(); - let url = advertised_url(&config, "[::1]:23456".parse().expect("socket")); - assert_eq!(url, "https://[::1]:23456/autonomi/webtransport/v1"); + 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()); } } diff --git a/tests/webtransport_devnet.rs b/tests/webrtc_direct_devnet.rs similarity index 68% rename from tests/webtransport_devnet.rs rename to tests/webrtc_direct_devnet.rs index 4692fa47..62c397b1 100644 --- a/tests/webtransport_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -6,17 +6,18 @@ 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; -use tokio::io::AsyncReadExt; -use wtransport::endpoint::ConnectOptions; -use wtransport::tls::Sha256Digest; -use wtransport::{ClientConfig, Endpoint}; -const TEST_ORIGIN: &str = "http://127.0.0.1:5173"; +const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; +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"] @@ -33,9 +34,8 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint )?; let mut config = DevnetConfig::minimal(); config.base_port = 0; - config.webtransport = true; - config.webtransport_base_port = 0; - config.webtransport_allowed_origins = vec![TEST_ORIGIN.to_string()]; + 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); @@ -65,6 +65,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint "version": 3, "request_id": 5, "type": "hello", + "challenge": "11".repeat(32), }), &[], ) @@ -100,7 +101,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint assert!(closest_content.is_empty()); let discovered_peer = closest["nodes"] .as_array() - .and_then(|nodes| nodes.iter().find(|node| node["webtransport"].is_object())) + .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 @@ -154,7 +155,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let decrypted = self_encryption::decrypt(&data_map, &encrypted_chunks)?; assert_eq!(decrypted, content.as_slice()); - let upload_content = b"paid browser WebTransport upload"; + 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, @@ -228,49 +229,97 @@ fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, io::Err async fn rpc( endpoint: &BrowserEndpoint, - mut request: Value, + 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 hashes = parsed.certificate_hashes.into_iter().map(Sha256Digest::new); - let client_config = ClientConfig::builder() - .with_bind_default() - .with_server_certificate_hashes(hashes) - .build(); - let endpoint = Endpoint::client(client_config)?; - let options = ConnectOptions::builder(&parsed.url) - .add_header("origin", TEST_ORIGIN) - .build(); - let connection = endpoint.connect(options).await?; - let (mut send, mut recv) = connection.open_bi().await?.await?; + 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}")))?; + if request["type"] != "hello" { + let _ = rpc_stream( + client.data_channel(), + json!({ + "version": 3, + "request_id": 1, + "type": "hello", + "challenge": "00".repeat(32), + }), + &[], + ) + .await + .map_err(|error| io::Error::other(format!("WebRTC Direct HELLO failed: {error}")))?; + } + let result = rpc_stream(client.data_channel(), 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, + 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())?; - send.write_all(&request_header_len.to_be_bytes()).await?; - send.write_all(&request_header).await?; - send.write_all(content).await?; - send.finish().await?; + 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); + for chunk in request_frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + channel.send(chunk).await?; + } let mut frame = Vec::new(); - recv.read_to_end(&mut frame).await?; - if frame.len() < 4 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "WebTransport response has no header length", - ) - .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 content_offset > frame.len() { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "WebTransport response ended inside its JSON header", - ) - .into()); - } + let content_offset = loop { + let message = channel.receive().await?; + if message.is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "WebRtcDirect response channel closed", + ) + .into()); + } + frame.extend_from_slice(&message); + if frame.len() < 4 { + continue; + } + 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 { + continue; + } + 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 has trailing bytes", + ) + .into()); + } + if frame.len() == expected { + break content_offset; + } + }; let header = serde_json::from_slice(&frame[4..content_offset])?; Ok((header, frame[content_offset..].to_vec())) } From 411dc6a78334d350b87ec380c9db40b75156670f Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:19:07 +0200 Subject: [PATCH 06/31] feat(devnet): raise browser file limit to 1 GB --- docs/WEBRTC_DIRECT_TESTNET.md | 3 ++- src/bin/ant-devnet/main.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md index 2516599e..a56e33ea 100644 --- a/docs/WEBRTC_DIRECT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -35,7 +35,8 @@ 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 -64 MiB in this local in-memory launcher. +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, diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index 203d1b71..2ceefec8 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -199,7 +199,7 @@ async fn load_public_file( 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 = 64 * 1024 * 1024; + const MAX_FILE_SIZE: u64 = 1_000_000_000; let Some(path) = path else { let mut content = Vec::with_capacity(DEFAULT_SIZE); From 53a6fd7c8c5ac0a288bb09d1315a81bf84ba3d24 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:52:23 +0200 Subject: [PATCH 07/31] feat(webrtc): publish versioned browser endpoints --- Cargo.toml | 10 +- docs/WEBRTC_DIRECT_TESTNET.md | 69 ++++- ...rect-browser-clients-over-webrtc-direct.md | 157 ++++++++++- src/bin/ant-node/cli.rs | 4 +- src/config.rs | 24 +- src/node.rs | 15 +- src/web_rtc.rs | 256 ++++++++++++++++-- 7 files changed, 476 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6d8a9d7e..9670e509 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,8 +113,8 @@ page_size = "0.6" postcard = { version = "1.1.3", features = ["use-std"] } bao = "0.13.1" -# ADR-0009 browser transport. Kept optional so native-only nodes do not pull -# in the ICE/DTLS/SCTP stack. +# 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 } @@ -185,7 +185,7 @@ path = "tests/webrtc_direct_devnet.rs" required-features = ["webrtc-direct"] [features] -default = ["logging"] +default = ["logging", "webrtc-direct"] # Enable tracing/logging infrastructure. # Included in `default`, so every build we ship — dev, release candidate and # release alike — has logging. Opt out only for a bespoke build that needs it @@ -194,8 +194,8 @@ logging = ["tracing", "tracing-subscriber", "tracing-appender"] # Expose test helpers (cache_insert, payment_verifier accessor) for # integration tests and downstream test harnesses. test-utils = [] -# Non-production direct-browser interoperability proof from ADR-0009. -# This enables a second WebRTC Direct UDP listener. +# 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", diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md index a56e33ea..eaa3d350 100644 --- a/docs/WEBRTC_DIRECT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -7,10 +7,10 @@ and serves browser bootstrap metadata; the companion site lives in the sibling ## Start the node testnet -Rust 1.88 or newer is required by the optional Saorsa WebRTC Direct transport. +Rust 1.88 or newer is required by the Saorsa WebRTC Direct transport. ```bash -cargo run --features webrtc-direct --bin ant-devnet -- \ +cargo run --bin ant-devnet -- \ --preset minimal \ --base-port 23000 \ --webrtc-direct \ @@ -74,10 +74,16 @@ 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 --features webrtc-direct --test webrtc_direct_devnet -- --ignored +cargo test --test webrtc_direct_devnet -- --ignored ``` This starts Anvil and the five-node network, self-encrypts and publishes a @@ -92,7 +98,7 @@ back through WebRTC Direct. Use `--host ` to advertise the literal LAN address: ```bash -cargo run --features webrtc-direct --bin ant-devnet -- \ +cargo run --bin ant-devnet -- \ --preset minimal \ --host 192.168.1.50 \ --webrtc-direct \ @@ -106,3 +112,58 @@ 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 index 992243b3..a949bc3b 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -152,8 +152,11 @@ to be reconsidered. ## Decision -We will add a separate, opt-in WebRTC Direct listener to browser-capable -nodes. Browser clients will use it to connect directly, perform one-hop +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. @@ -234,8 +237,71 @@ 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. -Production discovery uses a separately versioned record rather than changing -the existing Postcard `DHTNode` shape in place: +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 ML-DSA HELLO. 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 { @@ -252,16 +318,17 @@ BrowserEndpointRecord { } ``` -Discovered records expire because IP addresses, ports, relay allocations, and -capabilities can change. That expiry does not apply to the separately -configured bootstrap trust anchors and is not driven by routine DTLS -certificate rotation. +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. -The ML-DSA signature covers a canonical, domain-separated encoding. The -browser verifies 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. +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 @@ -281,6 +348,21 @@ 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 @@ -420,7 +502,12 @@ The earlier feature-gated WebTransport PoC has been replaced by the backpressure; - a bounded browser connection pool that reuses authenticated DataChannels across every lookup, quote, and record in one complete upload or download; - and +- 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. @@ -467,6 +554,40 @@ 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. +### Public Internet smoke result + +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 authenticated 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; failed +direct attempts are tolerated but currently add the full DataChannel opening +timeout to lookup latency. + +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 @@ -479,6 +600,8 @@ LMDB storage, and verified reads remain active. 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 is separately bound to the persistent PQ node @@ -494,6 +617,9 @@ LMDB storage, and verified reads remain active. - DataChannels require application fragmentation, reassembly, flow control, and cancellation. They are less natural than WebTransport streams for 4 MiB chunks. +- 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. ML-DSA application authentication limits its authority, but emergency replacement of a bootstrap fingerprint still requires overlap and client-list updates. @@ -547,6 +673,9 @@ The decision advances beyond PoC only after all of the following are covered: - 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. diff --git a/src/bin/ant-node/cli.rs b/src/bin/ant-node/cli.rs index 00e1dcb4..3bb3865d 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -28,9 +28,9 @@ pub struct Cli { #[arg(long, env = "ANT_IPV4_ONLY")] pub ipv4_only: bool, - /// Enable the ADR-0009 WebRTC Direct `PoC` on this UDP address. + /// Override the default ADR-0009 WebRTC Direct UDP bind address. /// - /// The binary must be built with `--features webrtc-direct`. + /// Port zero selects the stable automatic port derived from `--port`. #[arg(long, env = "ANT_WEBRTC_DIRECT_BIND")] pub webrtc_direct_bind: Option, diff --git a/src/config.rs b/src/config.rs index 6c826f95..f84c910d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -120,10 +120,10 @@ pub struct NodeConfig { #[serde(default)] pub storage: StorageConfig, - /// Experimental direct-browser WebRTC Direct listener. + /// Direct-browser WebRTC Direct listener. /// - /// This is the ADR-0009 interoperability proof and is disabled by - /// default. Enabling it requires a build with `webrtc-direct`. + /// 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, @@ -157,7 +157,7 @@ pub struct NodeConfig { /// content-addressed writes through the ordinary payment verifier. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebRtcDirectConfig { - /// Enable the experimental listener. + /// Enable the browser listener. #[serde(default)] pub enabled: bool, @@ -167,8 +167,9 @@ pub struct WebRtcDirectConfig { /// Literal public UDP address advertised to browsers. /// - /// When omitted, the address is derived from the bound socket. A wildcard - /// bind therefore needs an explicit public address. + /// 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, @@ -194,7 +195,7 @@ pub struct WebRtcDirectConfig { impl Default for WebRtcDirectConfig { fn default() -> Self { Self { - enabled: false, + enabled: cfg!(feature = "webrtc-direct"), bind: default_webrtc_direct_bind(), advertised_addr: None, certificate_path: None, @@ -205,7 +206,7 @@ impl Default for WebRtcDirectConfig { } fn default_webrtc_direct_bind() -> SocketAddr { - SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)) } const fn default_webrtc_direct_max_connections() -> usize { @@ -681,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/node.rs b/src/node.rs index 3ba89a61..2b7e1b1e 100644 --- a/src/node.rs +++ b/src/node.rs @@ -548,10 +548,23 @@ impl RunningNode { #[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( - &self.config.webrtc_direct, + &webrtc_direct_config, &self.config.root_dir, Arc::clone(&self.p2p_node), self.ant_protocol.clone(), diff --git a/src/web_rtc.rs b/src/web_rtc.rs index c6c48952..b28407e0 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -18,14 +18,14 @@ use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::RwLock; use saorsa_core::identity::NodeIdentity; -use saorsa_core::{P2PNode, PeerId}; +use saorsa_core::{DHTNode, 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::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; @@ -42,13 +42,88 @@ const MAX_FIND_NODE_RESULTS: usize = 20; const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); 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. /// -/// Production nodes will populate this information from signed endpoint -/// records. The in-process devnet shares one catalog so browser clients can -/// exercise a real multi-node iterative lookup before that DHT record type is -/// available. +/// 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>, @@ -99,7 +174,9 @@ pub async fn spawn( 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(), @@ -122,12 +199,25 @@ pub async fn spawn( 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( @@ -516,32 +606,51 @@ async fn process_find_node( let count = count .unwrap_or(MAX_FIND_NODE_RESULTS) .clamp(1, MAX_FIND_NODE_RESULTS); - let nodes = state - .p2p - .dht_manager() + let dht = state.p2p.dht_manager(); + let dht_nodes = dht .find_closest_nodes_local_with_self(&target_bytes, count) - .await - .into_iter() - .map(|node| { - let peer_id = node.peer_id.to_hex(); - BrowserNode { - webrtc_direct: state.endpoint_catalog.get(&node.peer_id), - peer_id, - native_addresses: node - .addresses_by_priority() - .into_iter() - .map(|address| address.to_string()) - .collect(), - reliability: node.reliability, - } - }) - .collect(); + .await; + let mut nodes = Vec::with_capacity(dht_nodes.len()); + for node in dht_nodes { + let supplemental = dht.supplemental_addresses_for_peer(&node.peer_id).await; + nodes.push(browser_node_from_dht( + &node, + &supplemental, + &state.endpoint_catalog, + )); + } ( Response::ok(request_id, ResponseBody::Nodes { target, nodes }, 0), None, ) } +fn browser_node_from_dht( + node: &DHTNode, + supplemental: &[MultiAddr], + endpoint_catalog: &BrowserEndpointCatalog, +) -> BrowserNode { + let addresses = node.addresses_by_priority(); + let discovered_endpoint = supplemental + .iter() + .find(|address| { + address.is_webrtc_direct() + && address.peer_id().is_some_and(|peer| peer == &node.peer_id) + }) + .cloned() + .map(|multiaddr| BrowserEndpoint { multiaddr }); + 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, @@ -1103,6 +1212,48 @@ struct ServerState { 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( @@ -1178,4 +1329,59 @@ mod tests { ); 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 browser_node = browser_node_from_dht( + &node, + std::slice::from_ref(&endpoint.multiaddr), + &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"] + ); + } } From 336b2b07b0908f5aa3a75816a11ecd3472319e04 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:44:14 +0200 Subject: [PATCH 08/31] fix(webrtc): keep browser sessions reusable --- Cargo.lock | 4 + ...rect-browser-clients-over-webrtc-direct.md | 72 +++++++++++++--- src/web_rtc.rs | 86 +++++++++++++++---- 3 files changed, 134 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b9280cd9..3d0b967f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5202,6 +5202,10 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" +dependencies = [ + "futures-core", + "futures-util", +] [[package]] name = "saorsa-pqc" 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 index a949bc3b..9e2e308b 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-08-25 +- **Last amended:** 2026-08-28 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -436,7 +436,34 @@ 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 complete file operation finishes. +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. + +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 @@ -452,12 +479,26 @@ browser signs locally, and only the resulting public proof crosses WebRTC. ### Lookup behavior -The browser owns the iterative lookup state machine. It starts from the -constant WebRTC Direct bootstrap list, queries up to `ALPHA = 3` unqueried -closest endpoints in parallel, merges verified endpoint records, and stops at -convergence or the iteration limit. The initial implementation targets the -current native `K = 20` behavior. Lookup and chunk retry policies should -eventually share language-independent test vectors with the native client. +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 @@ -576,9 +617,11 @@ 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; failed -direct attempts are tolerated but currently add the full DataChannel opening -timeout to lookup latency. +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. @@ -607,7 +650,9 @@ round-trip tests. - The stable DTLS fingerprint is separately bound to the persistent PQ node identity rather than being treated as the ANT identity. - Rust producers and consumers share the network's native `MultiAddr` codec; - browser JavaScript implements the same canonical wire syntax. + 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 @@ -669,7 +714,8 @@ The decision advances beyond PoC only after all of the following are covered: 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, retries, expired discovered records, and unavailable endpoints. + 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. diff --git a/src/web_rtc.rs b/src/web_rtc.rs index b28407e0..2908bc78 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -18,7 +18,7 @@ use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::RwLock; use saorsa_core::identity::NodeIdentity; -use saorsa_core::{DHTNode, MultiAddr, P2PNode, PeerId}; +use saorsa_core::{DHTNode, KnownReachability, MultiAddr, P2PNode, PeerId}; use saorsa_transport::webrtc_direct::{ WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, MAX_DATA_CHANNEL_MESSAGE_SIZE, @@ -40,7 +40,8 @@ const PROTOCOL_NAME: &str = "autonomi.web.poc.v3"; const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; const MAX_FIND_NODE_RESULTS: usize = 20; const MAX_RESPONSE_HEADER_BYTES: usize = 64 * 1024; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const REQUEST_FRAME_TIMEOUT: Duration = Duration::from_secs(10); 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; @@ -369,7 +370,17 @@ async fn handle_webrtc_channel( let (request, content) = match read_webrtc_request(&channel, state.config.max_request_bytes).await { Ok(request) => request, - Err(error) if error == "DataChannel closed" => return Ok(()), + Err(error) + if matches!( + error.as_str(), + "DataChannel closed" | "request idle timeout" | "request frame timed out" + ) => + { + 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, &response, &[]).await?; @@ -412,15 +423,27 @@ async fn read_webrtc_request( channel: &WebRtcDataChannel, max_header_bytes: usize, ) -> ServerResult<(Request, Vec)> { + let first_message = tokio::time::timeout(REQUEST_IDLE_TIMEOUT, channel.receive()) + .await + .map_err(|_| "request idle timeout".to_string())? + .map_err(|error| format!("request message read failed: {error}"))?; + if first_message.is_empty() { + return Err("DataChannel closed".to_string()); + } let read = async { let mut frame = Vec::new(); let mut expected_length = None; + let mut next_message = Some(first_message); let max_frame_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; loop { - let message = channel - .receive() - .await - .map_err(|error| format!("request message read failed: {error}"))?; + let message = if let Some(message) = next_message.take() { + message + } else { + channel + .receive() + .await + .map_err(|error| format!("request message read failed: {error}"))? + }; if message.is_empty() { return Err("DataChannel closed".to_string()); } @@ -473,9 +496,9 @@ async fn read_webrtc_request( } } }; - tokio::time::timeout(REQUEST_TIMEOUT, read) + tokio::time::timeout(REQUEST_FRAME_TIMEOUT, read) .await - .map_err(|_| "request timed out".to_string())? + .map_err(|_| "request frame timed out".to_string())? } async fn write_webrtc_response( @@ -612,7 +635,9 @@ async fn process_find_node( .await; let mut nodes = Vec::with_capacity(dht_nodes.len()); for node in dht_nodes { - let supplemental = dht.supplemental_addresses_for_peer(&node.peer_id).await; + let supplemental = dht + .supplemental_address_records_for_peer(&node.peer_id) + .await; nodes.push(browser_node_from_dht( &node, &supplemental, @@ -627,17 +652,20 @@ async fn process_find_node( fn browser_node_from_dht( node: &DHTNode, - supplemental: &[MultiAddr], + supplemental: &[(MultiAddr, KnownReachability)], endpoint_catalog: &BrowserEndpointCatalog, ) -> BrowserNode { let addresses = node.addresses_by_priority(); let discovered_endpoint = supplemental .iter() - .find(|address| { - address.is_webrtc_direct() + .find(|(address, reachability)| { + matches!( + reachability, + KnownReachability::Direct | KnownReachability::Lan + ) && address.is_webrtc_direct() && address.peer_id().is_some_and(|peer| peer == &node.peer_id) }) - .cloned() + .map(|(address, _)| address.clone()) .map(|multiaddr| BrowserEndpoint { multiaddr }); BrowserNode { webrtc_direct: discovered_endpoint.or_else(|| endpoint_catalog.get(&node.peer_id)), @@ -1372,9 +1400,10 @@ mod tests { reliability: 0.75, }; + let supplemental = (endpoint.multiaddr.clone(), KnownReachability::Direct); let browser_node = browser_node_from_dht( &node, - std::slice::from_ref(&endpoint.multiaddr), + std::slice::from_ref(&supplemental), &BrowserEndpointCatalog::default(), ); @@ -1384,4 +1413,31 @@ mod tests { 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()); + } } From 1f281495726b9ee097836561c7a09ff7a54dfce0 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:36 +0200 Subject: [PATCH 09/31] fix(webrtc): scale request deadlines with payloads --- src/web_rtc.rs | 117 ++++++++++++++++++++++++------------------------- 1 file changed, 58 insertions(+), 59 deletions(-) diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 2908bc78..9bd6408d 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -14,6 +14,7 @@ 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::transfer_timeout; use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::RwLock; @@ -41,7 +42,6 @@ const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; 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 REQUEST_FRAME_TIMEOUT: Duration = Duration::from_secs(10); 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; @@ -430,75 +430,74 @@ async fn read_webrtc_request( if first_message.is_empty() { return Err("DataChannel closed".to_string()); } - let read = async { - let mut frame = Vec::new(); - let mut expected_length = None; - let mut next_message = Some(first_message); - let max_frame_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; - loop { - let message = if let Some(message) = next_message.take() { - message - } else { - channel - .receive() - .await - .map_err(|error| format!("request message read failed: {error}"))? - }; - if message.is_empty() { - return Err("DataChannel closed".to_string()); - } - if frame.len() + message.len() > max_frame_bytes { + let frame_started = tokio::time::Instant::now(); + let mut frame_deadline = frame_started + transfer_timeout(0); + let mut frame = Vec::new(); + let mut expected_length = None; + let mut next_message = Some(first_message); + let max_frame_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; + loop { + let message = if let Some(message) = next_message.take() { + message + } else { + tokio::time::timeout_at(frame_deadline, channel.receive()) + .await + .map_err(|_| "request frame timed out".to_string())? + .map_err(|error| format!("request 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() && frame.len() >= 4 { + 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 exceeds the {max_frame_bytes}-byte frame limit" + "request header length {header_len} is outside 1..={max_header_bytes}" )); } - frame.extend_from_slice(&message); + if frame.len() >= 4 + header_len { + let request: Request = serde_json::from_slice(&frame[4..4 + header_len]) + .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 frame_length = 4 + header_len + request.content_length; + frame_deadline = frame_started + transfer_timeout(frame_length); + expected_length = Some((frame_length, request)); + } + } - if expected_length.is_none() && frame.len() >= 4 { + if let Some((length, _)) = expected_length.as_ref() { + if frame.len() > *length { + return Err("request contains bytes after its declared frame".to_string()); + } + if frame.len() == *length { + let (_, request) = expected_length + .take() + .ok_or_else(|| "request length state was lost".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}" - )); - } - if frame.len() >= 4 + header_len { - let request: Request = serde_json::from_slice(&frame[4..4 + header_len]) - .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 - )); - } - expected_length = Some((4 + header_len + request.content_length, request)); - } - } - - if let Some((length, _)) = expected_length.as_ref() { - if frame.len() > *length { - return Err("request contains bytes after its declared frame".to_string()); - } - if frame.len() == *length { - let (_, request) = expected_length - .take() - .ok_or_else(|| "request length state was lost".to_string())?; - let header_len = u32::from_be_bytes( - frame[..4] - .try_into() - .map_err(|_| "request prefix is incomplete".to_string())?, - ) as usize; - return Ok((request, frame.split_off(4 + header_len))); - } + return Ok((request, frame.split_off(4 + header_len))); } } - }; - tokio::time::timeout(REQUEST_FRAME_TIMEOUT, read) - .await - .map_err(|_| "request frame timed out".to_string())? + } } async fn write_webrtc_response( From dffa781c435dd255dcb7ec4de20666b1b3e198e6 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:32:14 +0200 Subject: [PATCH 10/31] chore(deps): pin browser support draft stack --- Cargo.lock | 5 ++++- Cargo.toml | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d0b967f..0c8f7f21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5170,6 +5170,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.27.3" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" dependencies = [ "anyhow", "async-trait", @@ -5202,6 +5203,7 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" dependencies = [ "futures-core", "futures-util", @@ -5292,7 +5294,8 @@ dependencies = [ [[package]] name = "saorsa-transport" -version = "0.35.3" +version = "0.36.3" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=44b3292b3ffa49e6048f2816f9ea5c38c6804815#44b3292b3ffa49e6048f2816f9ea5c38c6804815" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 9670e509..479076be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,7 +115,7 @@ bao = "0.13.1" # ADR-0009 browser transport. The dependency remains optional so minimal # native-only builds can opt out with `--no-default-features`. -saorsa-transport = { version = "0.35.3", features = ["webrtc-direct"], optional = true } +saorsa-transport = { version = "0.36.3", features = ["webrtc-direct"], optional = true } self_encryption = { version = "0.36", optional = true } [target.'cfg(unix)'.dependencies] @@ -202,8 +202,8 @@ webrtc-direct = [ ] [patch.crates-io] -saorsa-core = { path = "../saorsa-core-web-support" } -saorsa-transport = { path = "../saorsa-transport-web-support" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "915f3f1050006d96569c0efb6402828a01176e27" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "44b3292b3ffa49e6048f2816f9ea5c38c6804815" } [profile.release] lto = true From b7095f45390ee6ab23c2106de5f4fa701bdd9d65 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:16:07 +0200 Subject: [PATCH 11/31] feat(webrtc): require PQ browser sessions --- Cargo.lock | 3 + ...rect-browser-clients-over-webrtc-direct.md | 220 +++++++++++---- src/web_rtc.rs | 263 ++++++++++-------- tests/webrtc_direct_devnet.rs | 141 +++++++--- 4 files changed, 416 insertions(+), 211 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c8f7f21..ad28368b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,10 @@ checksum = "dd13dd1f51e3e9b3e871673b7d52a65054c9431b0656f4fa9589098126c3dafd" dependencies = [ "blake3", "bytes", + "chacha20poly1305", "evmlib", + "fips203", + "getrandom 0.2.17", "hex", "postcard", "rmp-serde", 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 index 9e2e308b..c7fafe97 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-08-28 +- **Last amended:** 2026-09-01 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -83,8 +83,9 @@ to be reconsidered. 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 - even though browser DTLS currently uses classical cryptography. +- 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 @@ -163,9 +164,11 @@ 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 ML-DSA identity handshake. It does not require a -DNS name, public-CA certificate, TURN server, or out-of-band SDP signaling for -a directly reachable node. +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, @@ -173,8 +176,12 @@ 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-node` owns the bounded browser -RPC protocol, and browser clients use `RTCPeerConnection` directly. +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, @@ -209,12 +216,36 @@ 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 not authorize browser RPCs. Before accepting application -requests, the node proves possession of its ML-DSA identity key in a -domain-separated handshake covering at least the network ID, protocol version, -fresh browser challenge, expected peer ID, and advertised DTLS fingerprint. The -browser verifies the public-key-to-peer-ID binding and the signature. A -mismatched `/p2p` identity aborts the connection. +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 @@ -296,9 +327,11 @@ 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 ML-DSA HELLO. 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. +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: @@ -385,17 +418,22 @@ depend on libp2p adopting or shipping it. Production is therefore conditional on a new, explicitly versioned Saorsa connection-establishment profile that works without forbidden SDP mutation. We should adopt compatible standards-level techniques and cross-browser test -vectors from v2 work where they fit. The ANT ML-DSA handshake remains the only -node-identity protocol. Unknown connection-establishment versions are rejected, -and v1 is not a silent fallback once browsers no longer support it. +vectors from v2 work where they fit. 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, and v1 is not a silent fallback once browsers no longer support it. ### Browser protocol and DataChannel framing The public protocol is not the private Saorsa `WireMessage` or native Postcard -DHT protocol. The initial methods are: +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`: negotiate version/network/capabilities and complete node identity - authentication. +- `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. @@ -414,18 +452,32 @@ DHT protocol. The initial methods are: WebRTC DataChannels are messages, not byte streams. One persistent reliable ordered DataChannel carries a sequence of RPC request/response frames for one -association. The application framing is a four-byte JSON-header length, a -bounded versioned JSON header, and the declared raw binary body; chunk bytes -are never JSON/base64. Application frames are fragmented into DataChannel -messages of at most 16 KiB and reassembled directly by the receiver. No -libp2p stream envelope or half-close control frame exists. - -Application frames are self-delimiting: receivers validate the JSON header and -its declared body length rather than trusting DataChannel boundaries. 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, and mismatched lengths are -protocol errors. This design directly removes the cross-version `FIN_ACK` and +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 @@ -459,6 +511,13 @@ 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 @@ -517,9 +576,10 @@ 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 application identity authentication terminate at the storage node, not -the relay. Relay allocations are published in signed, expiring endpoint -records rather than the constant bootstrap list. +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 @@ -535,9 +595,11 @@ The earlier feature-gated WebTransport PoC has been replaced by the - 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 per-connection ML-DSA `HELLO` challenge before other RPCs. The signed - transcript binds the challenge, ANT peer ID, and full advertised endpoint; - the browser verifies both the signature and the public-key-to-peer-ID hash; +- 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; @@ -567,10 +629,11 @@ 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 connection-establishment -profile 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. +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 @@ -595,12 +658,32 @@ 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. -### Public Internet smoke result +### 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 authenticated ML-DSA `HELLO`; the UI then installed +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 @@ -647,8 +730,13 @@ round-trip tests. 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 is separately bound to the persistent PQ node - identity rather than being treated as the ANT identity. +- 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 @@ -662,12 +750,16 @@ round-trip tests. - 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. ML-DSA - application authentication limits its authority, but emergency replacement - of a bootstrap fingerprint still requires overlap and client-list updates. +- 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 @@ -676,7 +768,9 @@ round-trip tests. - Direct operation still requires broad browser-endpoint coverage among storage nodes. NATed nodes may consume relay bandwidth even though relays cannot read their traffic. -- Current browser DTLS is not post-quantum. +- 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 @@ -687,8 +781,14 @@ round-trip tests. 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 @@ -704,9 +804,14 @@ The decision advances beyond PoC only after all of the following are covered: - 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 fingerprints, wrong peer IDs, wrong networks, - replayed handshakes, invalid ML-DSA signatures, and signatures not bound to - the DTLS transcript. +- 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. @@ -734,6 +839,9 @@ The decision advances beyond PoC only after all of the following are covered: 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. diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 9bd6408d..80e3c322 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -1,8 +1,9 @@ //! ADR-0009 WebRTC Direct browser transport. //! //! The listener uses Saorsa's signaling-free WebRTC Direct transport for ICE, -//! DTLS, SCTP, and reliable ordered `DataChannels`. ANT's ML-DSA HELLO binds the -//! pinned WebRTC endpoint to the node identity without a libp2p or Noise layer. +//! 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, @@ -14,7 +15,10 @@ 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::transfer_timeout; +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; @@ -29,16 +33,15 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::str::FromStr; -use std::sync::atomic::{AtomicBool, Ordering}; 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 = 3; -const PROTOCOL_NAME: &str = "autonomi.web.poc.v3"; -const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; +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); @@ -333,7 +336,6 @@ async fn handle_connection( state: Arc, shutdown: CancellationToken, ) -> ServerResult<()> { - let authenticated = Arc::new(AtomicBool::new(false)); loop { let channel = tokio::select! { () = shutdown.cancelled() => return Ok(()), @@ -342,9 +344,8 @@ async fn handle_connection( } }; let state = Arc::clone(&state); - let authenticated = Arc::clone(&authenticated); tokio::spawn(async move { - if let Err(error) = handle_webrtc_channel(channel, state, authenticated).await { + if let Err(error) = handle_webrtc_channel(channel, state).await { debug!("WebRTC Direct DataChannel ended: {error}"); } }); @@ -354,7 +355,6 @@ async fn handle_connection( async fn handle_webrtc_channel( channel: WebRtcDataChannel, state: Arc, - authenticated: Arc, ) -> ServerResult<()> { if channel.label() != DATA_CHANNEL_LABEL { if let Err(error) = channel.close().await { @@ -366,15 +366,19 @@ async fn handle_webrtc_channel( )); } + 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).await { + 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}"); @@ -383,7 +387,7 @@ async fn handle_webrtc_channel( } Err(error) => { let response = Response::error(0, "invalid_request", error); - write_webrtc_response(&channel, &response, &[]).await?; + write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; return Ok(()); } }; @@ -396,54 +400,148 @@ async fn handle_webrtc_channel( request.version ), ); - write_webrtc_response(&channel, &response, &[]).await?; + write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; continue; } - let is_hello = matches!(&request.body, RequestBody::Hello { .. }); - if !is_hello && !authenticated.load(Ordering::Acquire) { + let is_hello = matches!(&request.body, RequestBody::Hello); + if !is_hello && !hello_completed { let response = Response::error( request.id, "authentication_required", - "HELLO must authenticate this WebRTC connection first".to_string(), + "HELLO must initialize this encrypted WebRTC session first".to_string(), ); - write_webrtc_response(&channel, &response, &[]).await?; + 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) { - authenticated.store(true, Ordering::Release); + hello_completed = true; } - write_webrtc_response(&channel, &response, content.as_deref().unwrap_or_default()).await?; + 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(|_| "request idle timeout".to_string())? - .map_err(|error| format!("request message read failed: {error}"))?; + .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(0); + 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 = 4 + max_header_bytes + MAX_CHUNK_SIZE; + 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(|_| "request frame timed out".to_string())? - .map_err(|error| format!("request message read failed: {error}"))? + .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()); @@ -455,46 +553,21 @@ async fn read_webrtc_request( } frame.extend_from_slice(&message); - if expected_length.is_none() && frame.len() >= 4 { - 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}" - )); - } - if frame.len() >= 4 + header_len { - let request: Request = serde_json::from_slice(&frame[4..4 + header_len]) - .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 frame_length = 4 + header_len + request.content_length; - frame_deadline = frame_started + transfer_timeout(frame_length); - expected_length = Some((frame_length, request)); + 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.as_ref() { - if frame.len() > *length { - return Err("request contains bytes after its declared frame".to_string()); + 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 { - let (_, request) = expected_length - .take() - .ok_or_else(|| "request length state was lost".to_string())?; - let header_len = u32::from_be_bytes( - frame[..4] - .try_into() - .map_err(|_| "request prefix is incomplete".to_string())?, - ) as usize; - return Ok((request, frame.split_off(4 + header_len))); + if frame.len() == length { + return decode_pq_frame(&frame, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}")); } } } @@ -502,6 +575,7 @@ async fn read_webrtc_request( async fn write_webrtc_response( channel: &WebRtcDataChannel, + pq_session: &mut PqSession, response: &Response, content: &[u8], ) -> ServerResult<()> { @@ -512,10 +586,18 @@ async fn write_webrtc_response( } let header_len = u32::try_from(header.len()) .map_err(|_| "response header length does not fit u32".to_string())?; - let mut frame = Vec::with_capacity(4 + header.len() + content.len()); - frame.extend_from_slice(&header_len.to_be_bytes()); - frame.extend_from_slice(&header); - frame.extend_from_slice(content); + 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) @@ -541,40 +623,14 @@ async fn process_request( ); } match request.body { - RequestBody::Hello { challenge } => { - let challenge_bytes = match decode_32_byte_hex(&challenge) { - Ok(bytes) => bytes, - Err(error) => { - return ( - Response::error(request.id, "invalid_challenge", error), - None, - ) - } - }; + RequestBody::Hello => { let peer_id = state.p2p.peer_id().to_hex(); - let transcript = hello_transcript(&challenge_bytes, &peer_id, &state.endpoint); - let signature = match state.identity.sign(&transcript) { - Ok(signature) => signature, - Err(error) => { - return ( - Response::error( - request.id, - "identity_signing_failed", - format!("could not sign HELLO: {error}"), - ), - None, - ) - } - }; ( Response::ok( request.id, ResponseBody::Hello { protocol: PROTOCOL_NAME.to_string(), peer_id, - challenge, - public_key: hex::encode(state.identity.public_key().as_bytes()), - signature: hex::encode(signature.as_bytes()), max_chunk_size: MAX_CHUNK_SIZE, endpoint: state.endpoint.clone(), payment: state.payment.clone(), @@ -959,14 +1015,6 @@ fn decode_32_byte_hex(value: &str) -> ServerResult<[u8; 32]> { .map_err(|bytes: Vec| format!("expected 32 bytes, received {}", bytes.len())) } -fn hello_transcript(challenge: &[u8; 32], peer_id: &str, endpoint: &BrowserEndpoint) -> Vec { - let mut transcript = b"autonomi-webrtc-direct-hello-v1\0".to_vec(); - transcript.extend_from_slice(challenge); - transcript.extend_from_slice(peer_id.as_bytes()); - transcript.extend_from_slice(endpoint.multiaddr.to_string().as_bytes()); - transcript -} - type ServerResult = std::result::Result; #[derive(Debug, Deserialize)] @@ -982,9 +1030,7 @@ struct Request { #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum RequestBody { - Hello { - challenge: String, - }, + Hello, FindNode { target: String, #[serde(default)] @@ -1063,9 +1109,6 @@ enum ResponseBody { Hello { protocol: String, peer_id: String, - challenge: String, - public_key: String, - signature: String, max_chunk_size: usize, endpoint: BrowserEndpoint, payment: BrowserPaymentNetwork, @@ -1284,7 +1327,7 @@ mod tests { #[test] fn parses_versioned_requests() { let request: Request = serde_json::from_str( - r#"{"version":3,"request_id":7,"content_length":0,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, + r#"{"version":4,"request_id":7,"content_length":0,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, ) .expect("valid request"); @@ -1324,7 +1367,7 @@ mod tests { 3, ); let value = serde_json::to_value(response).expect("serialize response"); - assert_eq!(value["version"], 3); + assert_eq!(value["version"], 4); assert_eq!(value["request_id"], 42); assert_eq!(value["status"], "ok"); assert_eq!(value["content_length"], 3); diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs index 62c397b1..6ecd73c3 100644 --- a/tests/webrtc_direct_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -2,6 +2,11 @@ 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; @@ -16,7 +21,7 @@ use std::error::Error; use std::io; use std::str::FromStr; -const DATA_CHANNEL_LABEL: &str = "autonomi.web.v3"; +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)] @@ -62,16 +67,15 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (hello, hello_content) = rpc( &endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 5, "type": "hello", - "challenge": "11".repeat(32), }), &[], ) .await?; assert_eq!(hello["status"], "ok"); - assert_eq!(hello["protocol"], "autonomi.web.poc.v3"); + 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()) @@ -86,7 +90,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (closest, closest_content) = rpc( &endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 6, "type": "find_node", "target": public_file.address, @@ -116,7 +120,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (header, data_map_bytes) = rpc( &download_endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 7, "type": "get_chunk", "address": public_file.address, @@ -138,7 +142,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (chunk_header, chunk_bytes) = rpc( &download_endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": request_id, "type": "get_chunk", "address": chunk.dst_hash, @@ -160,7 +164,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (quote_header, quote_content) = rpc( &download_endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 50, "type": "quote_chunk", "address": upload_address, @@ -188,7 +192,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (put_header, put_content) = rpc( &download_endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 51, "type": "put_chunk", "address": upload_address, @@ -206,7 +210,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint let (uploaded_header, uploaded_content) = rpc( &download_endpoint.endpoint, json!({ - "version": 3, + "version": 4, "request_id": 52, "type": "get_chunk", "address": upload_address, @@ -241,21 +245,23 @@ async fn rpc( 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": 3, + "version": 4, "request_id": 1, "type": "hello", - "challenge": "00".repeat(32), }), &[], ) .await .map_err(|error| io::Error::other(format!("WebRTC Direct HELLO failed: {error}")))?; } - let result = rpc_stream(client.data_channel(), request, content) + 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}")) @@ -266,6 +272,7 @@ async fn rpc( async fn rpc_stream( channel: &WebRtcDataChannel, + pq_session: &mut PqSession, mut request: Value, content: &[u8], ) -> Result<(Value, Vec), Box> { @@ -276,50 +283,94 @@ async fn rpc_stream( request_frame.extend_from_slice(&request_header_len.to_be_bytes()); request_frame.extend_from_slice(&request_header); request_frame.extend_from_slice(content); - for chunk in request_frame.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + 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 content_offset = loop { + let expected = loop { let message = channel.receive().await?; if message.is_empty() { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, - "WebRtcDirect response channel closed", + "WebRtcDirect PQ frame channel closed", ) .into()); } frame.extend_from_slice(&message); - if frame.len() < 4 { - continue; - } - 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 { - continue; - } - 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 has trailing bytes", - ) - .into()); - } - if frame.len() == expected { - break content_offset; + 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; + } } }; - let header = serde_json::from_slice(&frame[4..content_offset])?; - Ok((header, frame[content_offset..].to_vec())) + debug_assert_eq!(frame.len(), expected); + Ok(decode_pq_frame(&frame, max_payload_bytes)?) } From 373a653fb07793846a1c6e62d1d6924e92e50e75 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:38:03 +0200 Subject: [PATCH 12/31] feat(webrtc): adopt no-mutation direct v2 profile --- ...rect-browser-clients-over-webrtc-direct.md | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) 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 index c7fafe97..57646f6e 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-09-01 +- **Last amended:** 2026-09-02 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -406,23 +406,31 @@ 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 current Saorsa profile is identified by the ICE credential prefix -`saorsa+webrtc+v1/`. Like the prior v1 mechanism, it replaces the ICE ufrag and +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). Ongoing -[WebRTC Direct v2 work](https://github.com/libp2p/specs/pull/715) is useful -interoperability research because it avoids that mutation, but Saorsa does not -depend on libp2p adopting or shipping it. - -Production is therefore conditional on a new, explicitly versioned Saorsa -connection-establishment profile that works without forbidden SDP mutation. -We should adopt compatible standards-level techniques and cross-browser test -vectors from v2 work where they fit. The ANT ML-KEM/ML-DSA application session +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, and v1 is not a silent fallback once browsers no longer support it. +rejected. ### Browser protocol and DataChannel framing @@ -763,8 +771,8 @@ round-trip tests. - 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 v2 profile and Chrome, Firefox, and Safari interoperability - must be proven before production. + 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. From 2a83d522382b8e3ae3232ab3d583a846d4c4666e Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:45:44 +0200 Subject: [PATCH 13/31] feat: add WebRTC port CLI option --- src/bin/ant-node/cli.rs | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/bin/ant-node/cli.rs b/src/bin/ant-node/cli.rs index 3bb3865d..dcde4a4b 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -34,6 +34,13 @@ pub struct Cli { #[arg(long, env = "ANT_WEBRTC_DIRECT_BIND")] pub webrtc_direct_bind: Option, + /// Override the UDP port used by the WebRTC Direct listener. + /// + /// This takes precedence over the port in `--webrtc-direct-bind`. Port zero + /// selects the automatic port derived from the native QUIC listener. + #[arg(long, visible_alias = "webrtc-port", env = "ANT_WEBRTC_DIRECT_PORT")] + pub webrtc_direct_port: Option, + /// Literal public UDP address to advertise instead of the bind address. #[arg( long, @@ -248,6 +255,10 @@ impl Cli { config.webrtc_direct.enabled = true; config.webrtc_direct.bind = bind; } + if let Some(port) = self.webrtc_direct_port { + config.webrtc_direct.enabled = true; + config.webrtc_direct.bind.set_port(port); + } if let Some(addr) = self.webrtc_direct_advertised_addr { config.webrtc_direct.advertised_addr = Some(addr); } @@ -353,3 +364,47 @@ impl From for NetworkMode { } } } + +#[cfg(test)] +mod tests { + use super::Cli; + use clap::Parser; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn webrtc_direct_port_overrides_the_default_bind_port() { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-direct-port", "45000"]) + .expect("parse WebRTC Direct port"); + let (config, _) = cli.into_config().expect("build node config"); + + assert!(config.webrtc_direct.enabled); + assert_eq!(config.webrtc_direct.bind.port(), 45_000); + } + + #[test] + fn webrtc_direct_port_overrides_only_the_explicit_bind_port() { + let cli = Cli::try_parse_from([ + "ant-node", + "--webrtc-direct-bind", + "127.0.0.1:40000", + "--webrtc-direct-port", + "45000", + ]) + .expect("parse WebRTC Direct bind and port"); + let (config, _) = cli.into_config().expect("build node config"); + + assert_eq!( + config.webrtc_direct.bind.ip(), + IpAddr::V4(Ipv4Addr::LOCALHOST) + ); + assert_eq!(config.webrtc_direct.bind.port(), 45_000); + } + + #[test] + fn webrtc_port_alias_is_supported() { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-port", "45000"]) + .expect("parse WebRTC port alias"); + + assert_eq!(cli.webrtc_direct_port, Some(45_000)); + } +} From 82cc5d737f9cd80fdb67e16ce682f9d2d6c60f17 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:59:51 +0200 Subject: [PATCH 14/31] fix(webrtc): isolate public listener resources --- docs/WEBRTC_DIRECT_TESTNET.md | 27 + ...rect-browser-clients-over-webrtc-direct.md | 103 +- src/config.rs | 90 ++ src/web_rtc.rs | 1161 ++++++++++++++--- 4 files changed, 1230 insertions(+), 151 deletions(-) diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md index eaa3d350..1b71aedf 100644 --- a/docs/WEBRTC_DIRECT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -149,6 +149,33 @@ secret key material. To disable the listener in a custom node configuration, set `webrtc_direct.enabled = false`. A minimal binary can omit the transport entirely with `--no-default-features`. +Public listeners apply an independent resource envelope; the native QUIC +limits are not shared with browser traffic. The defaults are: + +| Setting | Default | Scope | +|---|---:|---| +| `max_connections` | 32 | listener | +| `max_connections_per_ip` | 4 | source IP | +| `max_channels_per_connection` | 2 | association | +| `max_channels` | 32 | listener and channel-handler tasks | +| `max_concurrent_requests` | 16 | listener work slots | +| `max_requests_per_second` | 256 | listener work token bucket | +| `max_requests_per_second_per_ip` | 32 | source-IP work token bucket | +| `max_requests_per_second_per_connection` | 16 | association work token bucket | +| `max_in_flight_bytes` | 64 MiB | listener frame memory | +| `max_in_flight_bytes_per_ip` | 16 MiB | source-IP frame memory | +| `max_request_bytes` | 64 KiB | JSON request header | + +The per-IP ceilings must remain strictly below their corresponding global +ceilings. The product of the per-IP connection and per-connection channel +limits must also remain below both global channel and request concurrency. +Invalid combinations fail node startup instead of silently removing the +headroom reserved for other clients. IPv4-mapped IPv6 sources share the IPv4 +source's quota. Rate buckets permit a one-second burst; overload closes the +offending channel or association without queueing more handler tasks. PQ +handshakes consume the same work slots and rate tokens as RPCs, and response +writes use size-scaled deadlines so slow readers release their reservations. + Each node publishes its certificate-pinned WebRTC Direct multiaddress through Saorsa's extensible V2 address plane as transport `WebRtcDirect`, independently of its reachability class. Its signed identity capability selects V2 when the 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 index 57646f6e..7e3717de 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -2,7 +2,7 @@ - **Status:** Proposed - **Date:** 2026-08-03 -- **Last amended:** 2026-09-02 +- **Last amended:** 2026-09-03 - **Decision owners:** - **Reviewers:** - **Supersedes:** none @@ -189,6 +189,85 @@ channel, request, timeout, message, and byte limits. Its write surface accepts only content-addressed chunks accompanied by a verifiable native payment proof. +### Public-listener resource isolation + +The public WebRTC listener has a resource envelope independent from native +QUIC. Browser traffic does not consume the native transport's limits, and +native limits are not relied upon to protect the browser listener. The initial +server defaults are: + +| Setting | Default | Scope | +|---|---:|---| +| `max_connections` | 32 | listener | +| `max_connections_per_ip` | 4 | source IP | +| `max_channels_per_connection` | 2 | association | +| `max_channels` | 32 | listener and channel-handler tasks | +| `max_concurrent_requests` | 16 | listener work slots | +| `max_requests_per_second` | 256 | listener work token bucket | +| `max_requests_per_second_per_ip` | 32 | source-IP work token bucket | +| `max_requests_per_second_per_connection` | 16 | association work token bucket | +| `max_in_flight_bytes` | 64 MiB | listener frame memory | +| `max_in_flight_bytes_per_ip` | 16 MiB | source-IP frame memory | +| `max_request_bytes` | 64 KiB | JSON request header | + +These are independent controls, not alternative ways to express one shared +ceiling. Configuration fails startup unless every value is nonzero and a +single source retains strict headroom for other sources in each global pool: + +- `max_connections_per_ip < max_connections`; +- `max_connections_per_ip * max_channels_per_connection` is lower than both + `max_channels` and `max_concurrent_requests`; +- the per-IP request rate is lower than the global request rate, and the + per-connection rate does not exceed the per-IP rate; and +- the per-IP in-flight byte ceiling is lower than the global byte ceiling. + +The source key is the observed remote IP; an IPv4-mapped IPv6 address maps to +the same key as its IPv4 form. Per-IP rate and byte state is shared by every +association from that source. Normal reconnects reuse retained rate state, so +reconnecting alone does not refill a depleted bucket. The source table itself +has a hard bound and evicts only the oldest inactive entry, preventing the +limiter from becoming a source-churn memory attack. + +Admission is non-queueing above the application bounds. The server stops +starting new transport accepts while all global association slots are in use. +After accept, an association must obtain both its global and per-IP share. A +connection may own at most `max_channels_per_connection` active DataChannels, +and every admitted channel must also own one of the global `max_channels` +permits. Excess channels are closed and terminate the offending association. +The v4 protocol expects persistent channels, so an association is closed when +its last application channel ends rather than retaining a stale connection +slot for a hypothetical channel reopen. + +Connection and channel handlers are children of bounded `JoinSet`s rather +than detached tasks. Their semaphore permits and source counters are RAII +guards. Normal shutdown drains connection tasks for five seconds, then aborts +and joins any remainder; closing a connection also aborts and joins its +remaining channel tasks. A channel waiting for its next frame has a 60-second +idle deadline. Partial-frame reads and all response writes have total, +size-scaled transfer deadlines, so slow senders and readers cannot retain work +or byte reservations indefinitely. + +A request obtains a global work permit only after its first message arrives, +so an idle persistent channel does not consume a request slot. The permit is +held through frame assembly, processing, and response transmission. The +ML-KEM/ML-DSA session handshake consumes the same global, per-IP, and +per-connection rate tokens and the same work permits as an RPC; otherwise +channel churn would provide an unmetered public-key-cryptography path. All +three rate controls are constant-space token buckets with a one-second burst. +Capacity or rate rejection closes the channel or association without sending +an error response that would amplify attacker traffic. + +Frame memory is reserved atomically against both the source and listener byte +budgets. Once an outer prefix declares a valid length, the complete frame is +reserved before the listener accepts a slow body. Reservations include +ciphertext/plaintext overlap during authenticated decryption, parsed request +content retained during processing, response serialization and encryption, +and bytes held through response writes. `GET_CHUNK` reserves the maximum chunk +size before asking storage to allocate the result, then shrinks to the actual +size. Outbound frames send the four-byte prefix and bounded payload fragments +without allocating another full-frame copy. Every reservation is released on +success, rejection, cancellation, task abort, or protocol error. + ### Stable addresses and transport certificates The canonical direct address form is: @@ -611,6 +690,9 @@ The earlier feature-gated WebTransport PoC has been replaced by the - a persistent reliable ordered application DataChannel, bounded 16-KiB messages, declared-length reassembly, and browser `bufferedAmount` backpressure; +- an independent node-side resource envelope with strict global, per-IP, and + per-association connection/channel/request/rate/byte bounds, owned handler + tasks, and deadline-bounded frame reads and response writes; - a bounded browser connection pool that reuses authenticated DataChannels across every lookup, quote, and record in one complete upload or download; - a Rust/WASM random-access reader that resolves the public root DataMap, @@ -668,7 +750,7 @@ 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 +On 2026-09-03 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 @@ -677,6 +759,14 @@ 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. +Node-side resource tests additionally cover fail-fast headroom invariants, +per-IP association isolation, IPv4-mapped IPv6 normalization, token-bucket +refill, preservation of source rate state across reconnects, bounded inactive +source state, global/per-source byte ceilings, rollback after failed global +reservation, and RAII release. The devnet workflow exercises the same limits +while transferring real encrypted chunks. The adversarial browser and fleet +tests listed under Validation remain promotion requirements. + 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. @@ -787,8 +877,9 @@ round-trip tests. 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. +- Origin is policy input, not client authentication. Public listeners enforce + independent per-IP/session request, channel, rate, and byte quotas; origin + does not bypass or replace those controls. - The post-quantum handshake authenticates the node to the browser, not the browser user to the node. Client authority remains method-specific; for paid storage it comes from the normal wallet signature and payment proof. @@ -822,7 +913,9 @@ The decision advances beyond PoC only after all of the following are covered: 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. + reconnect churn, task cleanup, and global/per-client byte quotas. Tests must + also show that one source at each configured ceiling leaves another source + admissible. - UDP-mux regression tests cover source-port reuse: a binding request carrying a new ICE credential must override a stale address mapping, while binding responses and non-STUN traffic continue to use the selected address mapping. diff --git a/src/config.rs b/src/config.rs index f84c910d..e49fb8bc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -184,6 +184,50 @@ pub struct WebRtcDirectConfig { #[serde(default = "default_webrtc_direct_max_connections")] pub max_connections: usize, + /// Maximum simultaneously accepted browser sessions from one source IP. + /// + /// This must be lower than [`Self::max_connections`] so one public source + /// cannot occupy every listener slot. + #[serde(default = "default_webrtc_direct_max_connections_per_ip")] + pub max_connections_per_ip: usize, + + /// Maximum simultaneously active `DataChannels` on one browser session. + #[serde(default = "default_webrtc_direct_max_channels_per_connection")] + pub max_channels_per_connection: usize, + + /// Maximum simultaneously active `DataChannels` across the listener. + /// + /// Every admitted channel owns one handler task, so this is also the hard + /// global channel-task bound. + #[serde(default = "default_webrtc_direct_max_channels")] + pub max_channels: usize, + + /// Maximum requests being read or processed across the listener. + #[serde(default = "default_webrtc_direct_max_concurrent_requests")] + pub max_concurrent_requests: usize, + + /// Token-bucket request rate across the listener, in requests per second. + #[serde(default = "default_webrtc_direct_max_requests_per_second")] + pub max_requests_per_second: usize, + + /// Token-bucket request rate for one source IP, in requests per second. + #[serde(default = "default_webrtc_direct_max_requests_per_second_per_ip")] + pub max_requests_per_second_per_ip: usize, + + /// Token-bucket request rate for one browser session, in requests per + /// second. + #[serde(default = "default_webrtc_direct_max_requests_per_second_per_connection")] + pub max_requests_per_second_per_connection: usize, + + /// Maximum bytes reserved by frames being assembled, decrypted, or sent + /// across the listener. + #[serde(default = "default_webrtc_direct_max_in_flight_bytes")] + pub max_in_flight_bytes: usize, + + /// Maximum in-flight frame bytes attributable to one source IP. + #[serde(default = "default_webrtc_direct_max_in_flight_bytes_per_ip")] + pub max_in_flight_bytes_per_ip: usize, + /// Maximum JSON request-header size, in bytes. /// /// Binary PUT content has a separate [`crate::ant_protocol::MAX_CHUNK_SIZE`] @@ -200,6 +244,16 @@ impl Default for WebRtcDirectConfig { advertised_addr: None, certificate_path: None, max_connections: default_webrtc_direct_max_connections(), + max_connections_per_ip: default_webrtc_direct_max_connections_per_ip(), + max_channels_per_connection: default_webrtc_direct_max_channels_per_connection(), + max_channels: default_webrtc_direct_max_channels(), + max_concurrent_requests: default_webrtc_direct_max_concurrent_requests(), + max_requests_per_second: default_webrtc_direct_max_requests_per_second(), + max_requests_per_second_per_ip: default_webrtc_direct_max_requests_per_second_per_ip(), + max_requests_per_second_per_connection: + default_webrtc_direct_max_requests_per_second_per_connection(), + max_in_flight_bytes: default_webrtc_direct_max_in_flight_bytes(), + max_in_flight_bytes_per_ip: default_webrtc_direct_max_in_flight_bytes_per_ip(), max_request_bytes: default_webrtc_direct_max_request_bytes(), } } @@ -213,6 +267,42 @@ const fn default_webrtc_direct_max_connections() -> usize { 32 } +const fn default_webrtc_direct_max_connections_per_ip() -> usize { + 4 +} + +const fn default_webrtc_direct_max_channels_per_connection() -> usize { + 2 +} + +const fn default_webrtc_direct_max_channels() -> usize { + 32 +} + +const fn default_webrtc_direct_max_concurrent_requests() -> usize { + 16 +} + +const fn default_webrtc_direct_max_requests_per_second() -> usize { + 256 +} + +const fn default_webrtc_direct_max_requests_per_second_per_ip() -> usize { + 32 +} + +const fn default_webrtc_direct_max_requests_per_second_per_connection() -> usize { + 16 +} + +const fn default_webrtc_direct_max_in_flight_bytes() -> usize { + 64 * 1024 * 1024 +} + +const fn default_webrtc_direct_max_in_flight_bytes_per_ip() -> usize { + 16 * 1024 * 1024 +} + const fn default_webrtc_direct_max_request_bytes() -> usize { 64 * 1024 } diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 80e3c322..896a7c84 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -16,12 +16,12 @@ 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, + accept_pq_session, decode_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 parking_lot::{Mutex, RwLock}; use saorsa_core::identity::NodeIdentity; use saorsa_core::{DHTNode, KnownReachability, MultiAddr, P2PNode, PeerId}; use saorsa_transport::webrtc_direct::{ @@ -33,10 +33,11 @@ use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use std::time::{Duration, SystemTime}; -use tokio::sync::Semaphore; -use tokio::task::JoinHandle; +use std::time::{Duration, Instant, SystemTime}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio::task::{JoinHandle, JoinSet}; use tokio_util::sync::CancellationToken; const PROTOCOL_VERSION: u16 = 4; @@ -45,9 +46,19 @@ 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 SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); 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; +const TRACKED_SOURCE_MULTIPLIER: usize = 4; +const MIN_TRACKED_SOURCES: usize = 64; +const CONNECTION_CAPACITY_ERROR: &str = "global connection capacity exhausted"; +const SOURCE_CONNECTION_CAPACITY_ERROR: &str = "source connection capacity exhausted"; +const CHANNEL_CAPACITY_ERROR: &str = "global DataChannel capacity exhausted"; +const REQUEST_CAPACITY_ERROR: &str = "global request capacity exhausted"; +const REQUEST_RATE_ERROR: &str = "request rate limit exceeded"; +const GLOBAL_BYTE_CAPACITY_ERROR: &str = "global in-flight byte capacity exhausted"; +const SOURCE_BYTE_CAPACITY_ERROR: &str = "source in-flight byte capacity exhausted"; /// Filename containing the node's canonical browser bootstrap address. /// @@ -143,6 +154,327 @@ impl BrowserEndpointCatalog { } } +/// Fixed-capacity token bucket with a one-second burst allowance. +/// +/// The bucket is deliberately constant-space: source churn must not turn the +/// request limiter itself into a memory-exhaustion surface. +struct RequestRateBucket { + rate_per_second: u128, + token_units: u128, + last_refill: Instant, +} + +impl RequestRateBucket { + fn new(rate_per_second: usize) -> Self { + let rate_per_second = rate_per_second as u128; + Self { + rate_per_second, + token_units: rate_per_second.saturating_mul(1_000_000_000), + last_refill: Instant::now(), + } + } + + fn allow(&mut self, now: Instant) -> bool { + let elapsed = now.saturating_duration_since(self.last_refill); + self.last_refill = now; + let capacity = self.rate_per_second.saturating_mul(1_000_000_000); + let refill = elapsed.as_nanos().saturating_mul(self.rate_per_second); + self.token_units = self.token_units.saturating_add(refill).min(capacity); + if self.token_units < 1_000_000_000 { + return false; + } + self.token_units -= 1_000_000_000; + true + } +} + +/// Atomic byte budget and an RAII reservation within it. +/// +/// A custom counter is used instead of a semaphore because WebRTC frames grow +/// incrementally and the accounting must resize without queueing an unbounded +/// number of waiters. +struct ByteBudget { + limit: usize, + in_use: AtomicUsize, +} + +impl ByteBudget { + fn new(limit: usize) -> Self { + Self { + limit, + in_use: AtomicUsize::new(0), + } + } + + fn try_acquire( + self: &Arc, + amount: usize, + error: &'static str, + ) -> ServerResult { + self.in_use + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(amount) + .filter(|next| *next <= self.limit) + }) + .map_err(|_| error.to_string())?; + Ok(ByteReservation { + budget: Arc::clone(self), + amount, + error, + }) + } + + #[cfg(test)] + fn in_use(&self) -> usize { + self.in_use.load(Ordering::Acquire) + } +} + +struct ByteReservation { + budget: Arc, + amount: usize, + error: &'static str, +} + +impl ByteReservation { + fn try_grow(&mut self, amount: usize) -> ServerResult<()> { + self.budget + .in_use + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(amount) + .filter(|next| *next <= self.budget.limit) + }) + .map_err(|_| self.error.to_string())?; + self.amount += amount; + Ok(()) + } + + fn shrink(&mut self, amount: usize) { + let released = amount.min(self.amount); + self.amount -= released; + self.budget.in_use.fetch_sub(released, Ordering::AcqRel); + } +} + +impl Drop for ByteReservation { + fn drop(&mut self) { + self.budget.in_use.fetch_sub(self.amount, Ordering::AcqRel); + } +} + +struct InFlightByteReservation { + source: ByteReservation, + global: ByteReservation, +} + +impl InFlightByteReservation { + fn try_grow(&mut self, amount: usize) -> ServerResult<()> { + self.source.try_grow(amount)?; + if let Err(error) = self.global.try_grow(amount) { + self.source.shrink(amount); + return Err(error); + } + Ok(()) + } + + fn resize(&mut self, amount: usize) -> ServerResult<()> { + if amount > self.source.amount { + self.try_grow(amount - self.source.amount) + } else { + let released = self.source.amount - amount; + self.source.shrink(released); + self.global.shrink(released); + Ok(()) + } + } +} + +struct TrackedBytes { + bytes: Vec, + reservation: InFlightByteReservation, +} + +impl TrackedBytes { + fn reserve_length(&mut self, length: usize) -> ServerResult<()> { + self.reservation.resize(length)?; + if self.bytes.capacity() < length { + self.bytes.reserve_exact(length - self.bytes.len()); + } + Ok(()) + } +} + +struct SourceQuota { + request_rate: Mutex, + bytes: Arc, +} + +struct SourceEntry { + active_connections: usize, + last_seen: Instant, + quota: Arc, +} + +#[derive(Default)] +struct SourceAdmissionState { + sources: HashMap, +} + +/// Admission and accounting shared by every association on one listener. +struct ListenerResources { + connection_limit: Arc, + channel_limit: Arc, + request_limit: Arc, + global_request_rate: Mutex, + global_bytes: Arc, + source_state: Mutex, + max_connections_per_ip: usize, + max_requests_per_second_per_ip: usize, + max_requests_per_second_per_connection: usize, + max_in_flight_bytes_per_ip: usize, + max_tracked_sources: usize, +} + +impl ListenerResources { + fn new(config: &WebRtcDirectConfig) -> Arc { + Arc::new(Self { + connection_limit: Arc::new(Semaphore::new(config.max_connections)), + channel_limit: Arc::new(Semaphore::new(config.max_channels)), + request_limit: Arc::new(Semaphore::new(config.max_concurrent_requests)), + global_request_rate: Mutex::new(RequestRateBucket::new(config.max_requests_per_second)), + global_bytes: Arc::new(ByteBudget::new(config.max_in_flight_bytes)), + source_state: Mutex::new(SourceAdmissionState::default()), + max_connections_per_ip: config.max_connections_per_ip, + max_requests_per_second_per_ip: config.max_requests_per_second_per_ip, + max_requests_per_second_per_connection: config.max_requests_per_second_per_connection, + max_in_flight_bytes_per_ip: config.max_in_flight_bytes_per_ip, + max_tracked_sources: config + .max_connections + .saturating_mul(TRACKED_SOURCE_MULTIPLIER) + .max(MIN_TRACKED_SOURCES), + }) + } + + fn try_admit_connection( + self: &Arc, + remote_addr: SocketAddr, + ) -> ServerResult { + let global = Arc::clone(&self.connection_limit) + .try_acquire_owned() + .map_err(|_| CONNECTION_CAPACITY_ERROR.to_string())?; + let ip = canonical_source_ip(remote_addr.ip()); + let source = { + let mut state = self.source_state.lock(); + if !state.sources.contains_key(&ip) && state.sources.len() >= self.max_tracked_sources { + let eviction = state + .sources + .iter() + .filter(|(_, entry)| entry.active_connections == 0) + .min_by_key(|(_, entry)| entry.last_seen) + .map(|(ip, _)| *ip); + let Some(eviction) = eviction else { + return Err(CONNECTION_CAPACITY_ERROR.to_string()); + }; + state.sources.remove(&eviction); + } + + let entry = state.sources.entry(ip).or_insert_with(|| SourceEntry { + active_connections: 0, + last_seen: Instant::now(), + quota: Arc::new(SourceQuota { + request_rate: Mutex::new(RequestRateBucket::new( + self.max_requests_per_second_per_ip, + )), + bytes: Arc::new(ByteBudget::new(self.max_in_flight_bytes_per_ip)), + }), + }); + if entry.active_connections >= self.max_connections_per_ip { + return Err(SOURCE_CONNECTION_CAPACITY_ERROR.to_string()); + } + entry.active_connections += 1; + entry.last_seen = Instant::now(); + Arc::clone(&entry.quota) + }; + let context = Arc::new(ConnectionResources { + listener: Arc::clone(self), + source, + request_rate: Mutex::new(RequestRateBucket::new( + self.max_requests_per_second_per_connection, + )), + }); + Ok(ConnectionAdmission { + listener: Arc::clone(self), + ip, + context, + _global: global, + }) + } + + fn release_connection(&self, ip: IpAddr) { + let mut state = self.source_state.lock(); + if let Some(entry) = state.sources.get_mut(&ip) { + entry.active_connections = entry.active_connections.saturating_sub(1); + entry.last_seen = Instant::now(); + } + } +} + +struct ConnectionResources { + listener: Arc, + source: Arc, + request_rate: Mutex, +} + +impl ConnectionResources { + fn try_admit_request(&self) -> ServerResult { + let permit = Arc::clone(&self.listener.request_limit) + .try_acquire_owned() + .map_err(|_| REQUEST_CAPACITY_ERROR.to_string())?; + let now = Instant::now(); + if !self.source.request_rate.lock().allow(now) + || !self.request_rate.lock().allow(now) + || !self.listener.global_request_rate.lock().allow(now) + { + return Err(REQUEST_RATE_ERROR.to_string()); + } + Ok(permit) + } + + fn try_reserve_bytes(&self, amount: usize) -> ServerResult { + let source = self + .source + .bytes + .try_acquire(amount, SOURCE_BYTE_CAPACITY_ERROR)?; + let global = self + .listener + .global_bytes + .try_acquire(amount, GLOBAL_BYTE_CAPACITY_ERROR)?; + Ok(InFlightByteReservation { source, global }) + } +} + +struct ConnectionAdmission { + listener: Arc, + ip: IpAddr, + context: Arc, + _global: OwnedSemaphorePermit, +} + +impl Drop for ConnectionAdmission { + fn drop(&mut self) { + self.listener.release_connection(self.ip); + } +} + +fn canonical_source_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or(IpAddr::V6(ip), IpAddr::V4), + IpAddr::V4(ip) => IpAddr::V4(ip), + } +} + /// A running browser listener and the endpoint clients use to reach it. pub struct WebRtcDirectServer { /// Direct endpoint with its certificate pin embedded in the multiaddress. @@ -191,7 +523,7 @@ pub async fn spawn( endpoint: browser_endpoint.clone(), endpoint_catalog, }); - let connection_limit = Arc::new(Semaphore::new(config.max_connections)); + let resources = ListenerResources::new(config); info!( bind = %local_addr, @@ -201,7 +533,7 @@ pub async fn spawn( ); let task = tokio::spawn(async move { - serve_webrtc(listener, state, connection_limit, shutdown).await; + serve_webrtc(listener, state, resources, shutdown).await; }); dht.set_supplemental_self_addresses(vec![browser_endpoint.multiaddr.clone()]) .await; @@ -223,9 +555,84 @@ async fn persist_browser_endpoint(root_dir: &Path, endpoint: &BrowserEndpoint) - } fn validate_webrtc_config(config: &WebRtcDirectConfig) -> Result<()> { - if config.max_connections == 0 { + for (name, value) in [ + ("max_connections", config.max_connections), + ("max_connections_per_ip", config.max_connections_per_ip), + ( + "max_channels_per_connection", + config.max_channels_per_connection, + ), + ("max_channels", config.max_channels), + ("max_concurrent_requests", config.max_concurrent_requests), + ] { + if value == 0 || value > Semaphore::MAX_PERMITS { + return Err(Error::Config(format!( + "webrtc_direct.{name} must be between 1 and {}", + Semaphore::MAX_PERMITS + ))); + } + } + for (name, value) in [ + ("max_requests_per_second", config.max_requests_per_second), + ( + "max_requests_per_second_per_ip", + config.max_requests_per_second_per_ip, + ), + ( + "max_requests_per_second_per_connection", + config.max_requests_per_second_per_connection, + ), + ("max_in_flight_bytes", config.max_in_flight_bytes), + ( + "max_in_flight_bytes_per_ip", + config.max_in_flight_bytes_per_ip, + ), + ] { + if value == 0 { + return Err(Error::Config(format!( + "webrtc_direct.{name} must be greater than zero" + ))); + } + } + if config.max_connections_per_ip >= config.max_connections { return Err(Error::Config( - "webrtc_direct.max_connections must be greater than zero".to_string(), + "webrtc_direct.max_connections_per_ip must be lower than max_connections".to_string(), + )); + } + let source_channel_ceiling = config + .max_connections_per_ip + .checked_mul(config.max_channels_per_connection) + .ok_or_else(|| { + Error::Config("webrtc_direct per-IP DataChannel ceiling overflows usize".to_string()) + })?; + if source_channel_ceiling >= config.max_channels { + return Err(Error::Config( + "webrtc_direct max_connections_per_ip * max_channels_per_connection must be lower than max_channels" + .to_string(), + )); + } + if source_channel_ceiling >= config.max_concurrent_requests { + return Err(Error::Config( + "webrtc_direct max_connections_per_ip * max_channels_per_connection must be lower than max_concurrent_requests" + .to_string(), + )); + } + if config.max_requests_per_second_per_connection > config.max_requests_per_second_per_ip { + return Err(Error::Config( + "webrtc_direct.max_requests_per_second_per_connection must not exceed max_requests_per_second_per_ip" + .to_string(), + )); + } + if config.max_requests_per_second_per_ip >= config.max_requests_per_second { + return Err(Error::Config( + "webrtc_direct.max_requests_per_second_per_ip must be lower than max_requests_per_second" + .to_string(), + )); + } + if config.max_in_flight_bytes_per_ip >= config.max_in_flight_bytes { + return Err(Error::Config( + "webrtc_direct.max_in_flight_bytes_per_ip must be lower than max_in_flight_bytes" + .to_string(), )); } if config.max_request_bytes == 0 || config.max_request_bytes > MAX_RESPONSE_HEADER_BYTES { @@ -288,33 +695,55 @@ fn advertised_addr(config: &WebRtcDirectConfig, local_addr: SocketAddr) -> Resul Ok(local_addr) } +#[allow(clippy::significant_drop_tightening)] async fn serve_webrtc( mut listener: WebRtcDirectListener, state: Arc, - connection_limit: Arc, + resources: Arc, shutdown: CancellationToken, ) { + let mut connection_tasks = JoinSet::new(); loop { let connection = tokio::select! { + biased; () = shutdown.cancelled() => break, - connection = listener.accept() => connection, + completed = connection_tasks.join_next(), if !connection_tasks.is_empty() => { + if let Some(Err(error)) = completed { + warn!(%error, "WebRTC Direct connection task failed"); + } + continue; + } + // Do not perform another ICE/DTLS/SCTP accept while every + // application connection slot is occupied. The transport's + // pending-association queue remains bounded, and completed + // sessions release a permit before this branch becomes eligible. + connection = listener.accept(), if resources.connection_limit.available_permits() > 0 => connection, }; match connection { Ok(connection) => { let remote_addr = connection.remote_addr(); - let 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"); + let admission = match resources.try_admit_connection(remote_addr) { + Ok(admission) => admission, + Err(error) => { + debug!(remote = %remote_addr, %error, "Rejected WebRTC Direct connection"); + if let Err(close_error) = connection.close().await { + debug!(remote = %remote_addr, %close_error, "Failed to close rejected connection"); + } + continue; } - continue; }; + let connection_resources = Arc::clone(&admission.context); 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 + connection_tasks.spawn(async move { + let _admission = admission; + if let Err(error) = handle_connection( + connection, + connection_state, + connection_resources, + connection_shutdown, + ) + .await { debug!(remote = %remote_addr, "WebRTC Direct connection ended: {error}"); } @@ -328,69 +757,128 @@ async fn serve_webrtc( if let Err(error) = listener.close().await { debug!("WebRTC Direct listener close failed: {error}"); } + let drained = tokio::time::timeout(SHUTDOWN_DRAIN_TIMEOUT, async { + while let Some(result) = connection_tasks.join_next().await { + if let Err(error) = result { + debug!(%error, "WebRTC Direct connection task failed during shutdown"); + } + } + }) + .await; + if drained.is_err() { + warn!("WebRTC Direct connection tasks did not drain before shutdown deadline"); + connection_tasks.abort_all(); + while connection_tasks.join_next().await.is_some() {} + } info!("ADR-0009 WebRTC Direct stopped"); } async fn handle_connection( mut connection: WebRtcDirectConnection, state: Arc, + resources: Arc, shutdown: CancellationToken, ) -> ServerResult<()> { - loop { - let channel = tokio::select! { - () = shutdown.cancelled() => return Ok(()), - result = connection.accept_data_channel() => { - result.map_err(|error| format!("DataChannel accept failed: {error}"))? + let remote_addr = connection.remote_addr(); + let mut channel_tasks = JoinSet::new(); + let outcome = loop { + let accepted = tokio::select! { + biased; + () = shutdown.cancelled() => break Ok(()), + completed = channel_tasks.join_next(), if !channel_tasks.is_empty() => { + if let Some(Err(error)) = completed { + debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed"); + } + // The v4 protocol uses persistent channels; it has no channel + // reopen/continuation handshake. Once the last channel ends, + // close the association promptly instead of retaining a stale + // per-IP connection slot while waiting for another channel. + if channel_tasks.is_empty() { + break Ok(()); + } + continue; + } + result = connection.accept_data_channel() => result, + }; + let channel = match accepted { + Ok(channel) => channel, + Err(error) => break Err(format!("DataChannel accept failed: {error}")), + }; + if channel_tasks.len() >= state.config.max_channels_per_connection { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); } + break Err("per-connection DataChannel capacity exhausted".to_string()); + } + let Ok(channel_permit) = Arc::clone(&resources.listener.channel_limit).try_acquire_owned() + else { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); + } + break Err(CHANNEL_CAPACITY_ERROR.to_string()); }; - let state = Arc::clone(&state); - tokio::spawn(async move { - if let Err(error) = handle_webrtc_channel(channel, state).await { - debug!("WebRTC Direct DataChannel ended: {error}"); + let channel_state = Arc::clone(&state); + let channel_resources = Arc::clone(&resources); + channel_tasks.spawn(async move { + let _channel_permit = channel_permit; + if let Err(error) = + handle_webrtc_channel(&channel, channel_state, channel_resources).await + { + debug!(remote = %remote_addr, channel = channel.id(), "WebRTC Direct DataChannel ended: {error}"); + } + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, channel = channel.id(), %error, "Failed to close WebRTC Direct DataChannel"); } }); + }; + + if let Err(error) = connection.close().await { + debug!(remote = %remote_addr, %error, "Failed to close WebRTC Direct connection"); } + channel_tasks.abort_all(); + while channel_tasks.join_next().await.is_some() {} + outcome } +#[allow(clippy::significant_drop_tightening)] async fn handle_webrtc_channel( - channel: WebRtcDataChannel, + channel: &WebRtcDataChannel, state: Arc, + resources: 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 pq_session = establish_pq_session(channel, &state, &resources).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(()); - } - }; + let admitted = match read_webrtc_request( + channel, + state.config.max_request_bytes, + &mut pq_session, + &resources, + ) + .await + { + Ok(request) => request, + Err(error) if is_quiet_channel_close(&error) => return Ok(()), + Err(error) => { + let response = Response::error(0, "invalid_request", error); + write_webrtc_response(channel, &mut pq_session, &response, None, &resources) + .await?; + return Ok(()); + } + }; + let AdmittedRequest { + request, + content, + _request_permit, + _in_flight_bytes, + } = admitted; if request.version != PROTOCOL_VERSION { let response = Response::error( request.id, @@ -400,7 +888,7 @@ async fn handle_webrtc_channel( request.version ), ); - write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; + write_webrtc_response(channel, &mut pq_session, &response, None, &resources).await?; continue; } @@ -411,69 +899,129 @@ async fn handle_webrtc_channel( "authentication_required", "HELLO must initialize this encrypted WebRTC session first".to_string(), ); - write_webrtc_response(&channel, &mut pq_session, &response, &[]).await?; + write_webrtc_response(channel, &mut pq_session, &response, None, &resources).await?; continue; } - let (response, content) = process_request(request, content, &state).await; + let (response, content) = process_request(request, content, &state, &resources).await?; if is_hello && matches!(&response.status, ResponseStatus::Ok) { hello_completed = true; } write_webrtc_response( - &channel, + channel, &mut pq_session, &response, - content.as_deref().unwrap_or_default(), + content.as_ref(), + &resources, ) .await?; } } +fn is_quiet_channel_close(error: &str) -> bool { + matches!( + error, + "DataChannel closed" + | "request idle timeout" + | "request frame timed out" + | REQUEST_CAPACITY_ERROR + | REQUEST_RATE_ERROR + | GLOBAL_BYTE_CAPACITY_ERROR + | SOURCE_BYTE_CAPACITY_ERROR + ) || error.starts_with("PQ session:") +} + async fn establish_pq_session( channel: &WebRtcDataChannel, state: &ServerState, + resources: &ConnectionResources, ) -> ServerResult { - let client_hello = read_pq_payload( + let first_message = receive_first_message(channel, "PQ client hello idle timeout").await?; + // The post-quantum handshake is deliberately charged to the same work and + // rate envelopes as an RPC. Otherwise a source could churn channels and + // force unmetered ML-KEM/ML-DSA work without ever sending a request. + let _handshake_permit = resources.try_admit_request()?; + let client_hello = read_pq_payload_after_first( + first_message, channel, PQ_CLIENT_HELLO_BYTES, - "PQ client hello idle timeout", "PQ client hello timed out", + resources, ) .await?; let peer_id = *state.p2p.peer_id().to_bytes(); let public_key = state.identity.public_key().as_bytes(); let (server_accept, session) = - accept_pq_session(&client_hello, &peer_id, public_key, |transcript| { + accept_pq_session(&client_hello.bytes, &peer_id, public_key, |transcript| { state .identity .sign(transcript) .map(|signature| signature.as_bytes().to_vec()) }) .map_err(|error| format!("PQ session: {error}"))?; - write_pq_payload(channel, &server_accept).await?; + write_pq_payload(channel, &server_accept, resources).await?; Ok(session) } +struct AdmittedRequest { + request: Request, + content: Vec, + _request_permit: OwnedSemaphorePermit, + _in_flight_bytes: InFlightByteReservation, +} + async fn read_webrtc_request( channel: &WebRtcDataChannel, max_header_bytes: usize, pq_session: &mut PqSession, -) -> ServerResult<(Request, Vec)> { + resources: &ConnectionResources, +) -> ServerResult { + let first_message = receive_first_message(channel, "request idle timeout").await?; + // Admission happens as soon as a client starts a frame. Idle persistent + // channels consume neither request-rate tokens nor request worker slots. + let request_permit = resources.try_admit_request()?; let max_plaintext_bytes = 4 + max_header_bytes + MAX_CHUNK_SIZE; - let encrypted = read_pq_payload( + let mut encrypted = read_pq_payload_after_first( + first_message, channel, max_plaintext_bytes + PQ_ENCRYPTED_OVERHEAD_BYTES, - "request idle timeout", "request frame timed out", + resources, ) .await?; + let encrypted_len = encrypted.bytes.len(); + // AEAD opening briefly holds ciphertext and plaintext at once. Reserve the + // second buffer before asking the cryptographic layer to allocate it. + encrypted.reservation.try_grow(encrypted_len)?; let frame = pq_session - .open(&encrypted) + .open(&encrypted.bytes) .map_err(|error| format!("PQ session: {error}"))?; - parse_webrtc_request(&frame, max_header_bytes) + let TrackedBytes { + bytes: encrypted_bytes, + mut reservation, + } = encrypted; + drop(encrypted_bytes); + reservation.resize(frame.len())?; + + let (request, content_offset) = parse_webrtc_request(&frame, max_header_bytes)?; + let content_len = frame.len() - content_offset; + let accounted_request_bytes = frame.len(); + // serde owns the parsed header and the body copy below owns the content. + // Account the copy while the complete plaintext frame is still live, then + // retain one frame-sized reservation for the parsed request's lifetime. + reservation.try_grow(content_len)?; + let content = frame[content_offset..].to_vec(); + drop(frame); + reservation.resize(accounted_request_bytes)?; + Ok(AdmittedRequest { + request, + content, + _request_permit: request_permit, + _in_flight_bytes: reservation, + }) } -fn parse_webrtc_request(frame: &[u8], max_header_bytes: usize) -> ServerResult<(Request, Vec)> { +fn parse_webrtc_request(frame: &[u8], max_header_bytes: usize) -> ServerResult<(Request, usize)> { if frame.len() < 4 { return Err("request prefix is incomplete".to_string()); } @@ -510,66 +1058,102 @@ fn parse_webrtc_request(frame: &[u8], max_header_bytes: usize) -> ServerResult<( frame.len() )); } - Ok((request, frame[content_offset..].to_vec())) + Ok((request, content_offset)) } -async fn read_pq_payload( +async fn receive_first_message( 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()) + let message = tokio::time::timeout(REQUEST_IDLE_TIMEOUT, channel.receive()) .await .map_err(|_| idle_timeout_message.to_string())? .map_err(|error| format!("DataChannel message read failed: {error}"))?; - if first_message.is_empty() { + if message.is_empty() { return Err("DataChannel closed".to_string()); } + Ok(message) +} + +async fn read_pq_payload_after_first( + first_message: Vec, + channel: &WebRtcDataChannel, + max_payload_bytes: usize, + frame_timeout_message: &str, + resources: &ConnectionResources, +) -> ServerResult { let frame_started = tokio::time::Instant::now(); let mut frame_deadline = frame_started + transfer_timeout(PQ_FRAME_PREFIX_BYTES); - let mut frame = Vec::new(); + let mut frame = TrackedBytes { + reservation: resources.try_reserve_bytes(first_message.len())?, + bytes: first_message, + }; 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 { + if frame.bytes.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) + expected_length = pq_frame_length(&frame.bytes, max_payload_bytes) .map_err(|error| format!("PQ session: {error}"))?; if let Some(length) = expected_length { + if frame.bytes.len() > length { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + // Reserve the complete declared frame before accepting a slow + // body. A sender cannot make many partial 4 MiB frames consume + // unaccounted memory during their transfer windows. + frame.reserve_length(length)?; frame_deadline = frame_started + transfer_timeout(length); } } if let Some(length) = expected_length { - if frame.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}")); + if frame.bytes.len() == length { + let payload_len = length - PQ_FRAME_PREFIX_BYTES; + frame.reservation.try_grow(payload_len)?; + let payload = decode_pq_frame(&frame.bytes, max_payload_bytes) + .map_err(|error| format!("PQ session: {error}"))?; + let TrackedBytes { + bytes: encoded_frame, + mut reservation, + } = frame; + drop(encoded_frame); + reservation.resize(payload.len())?; + return Ok(TrackedBytes { + bytes: payload, + reservation, + }); } } + + let message = tokio::time::timeout_at(frame_deadline, channel.receive()) + .await + .map_err(|_| frame_timeout_message.to_string())? + .map_err(|error| format!("DataChannel message read failed: {error}"))?; + if message.is_empty() { + return Err("DataChannel closed".to_string()); + } + let next_length = frame + .bytes + .len() + .checked_add(message.len()) + .ok_or_else(|| "PQ frame length overflow".to_string())?; + if next_length > max_frame_bytes + || expected_length.is_some_and(|length| next_length > length) + { + return Err("PQ frame contains bytes after its declared payload".to_string()); + } + if expected_length.is_none() { + frame.reserve_length(next_length)?; + } + frame.bytes.extend_from_slice(&message); } } @@ -577,31 +1161,67 @@ async fn write_webrtc_response( channel: &WebRtcDataChannel, pq_session: &mut PqSession, response: &Response, - content: &[u8], + content: Option<&TrackedBytes>, + resources: &ConnectionResources, ) -> ServerResult<()> { + // GET content carries its own reservation from before the storage read. + // This reservation accounts only the new header, plaintext, and ciphertext + // allocations made while encoding the response. + let content = content.map_or(&[][..], |tracked| tracked.bytes.as_slice()); + let mut reservation = resources.try_reserve_bytes(0)?; 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()); } + reservation.try_grow(header.len())?; 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()); + let plaintext_len = 4usize + .checked_add(header.len()) + .and_then(|length| length.checked_add(content.len())) + .ok_or_else(|| "response frame length overflow".to_string())?; + reservation.try_grow(plaintext_len)?; + let mut plaintext = Vec::with_capacity(plaintext_len); plaintext.extend_from_slice(&header_len.to_be_bytes()); plaintext.extend_from_slice(&header); plaintext.extend_from_slice(content); - let frame = pq_session + let encrypted_len = plaintext_len + .checked_add(PQ_ENCRYPTED_OVERHEAD_BYTES) + .ok_or_else(|| "encrypted response length overflow".to_string())?; + reservation.try_grow(encrypted_len)?; + let encrypted = pq_session .seal(&plaintext) .map_err(|error| format!("PQ session: {error}"))?; - write_pq_payload(channel, &frame).await + drop(plaintext); + reservation.resize(content.len() + header.len() + encrypted.len())?; + write_framed_pq_payload(channel, &encrypted).await +} + +async fn write_pq_payload( + channel: &WebRtcDataChannel, + payload: &[u8], + resources: &ConnectionResources, +) -> ServerResult<()> { + let _reservation = resources.try_reserve_bytes(payload.len())?; + write_framed_pq_payload(channel, payload).await } -async fn write_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) +async fn write_framed_pq_payload(channel: &WebRtcDataChannel, payload: &[u8]) -> ServerResult<()> { + let payload_len = u32::try_from(payload.len()) + .map_err(|_| "PQ session: payload length does not fit u32".to_string())?; + let framed_len = PQ_FRAME_PREFIX_BYTES + .checked_add(payload.len()) + .ok_or_else(|| "PQ response frame length overflow".to_string())?; + let deadline = tokio::time::Instant::now() + transfer_timeout(framed_len); + tokio::time::timeout_at(deadline, channel.send(&payload_len.to_be_bytes())) + .await + .map_err(|_| "response frame timed out".to_string())? + .map_err(|error| format!("response message write failed: {error}"))?; + for chunk in payload.chunks(WEBRTC_WRITE_CHUNK_BYTES) { + tokio::time::timeout_at(deadline, channel.send(chunk)) .await + .map_err(|_| "response frame timed out".to_string())? .map_err(|error| format!("response message write failed: {error}"))?; } Ok(()) @@ -611,21 +1231,22 @@ async fn process_request( request: Request, content: Vec, state: &ServerState, -) -> (Response, Option>) { + resources: &ConnectionResources, +) -> ServerResult<(Response, Option)> { if !matches!(&request.body, RequestBody::PutChunk { .. }) && !content.is_empty() { - return ( + return Ok(( 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(); - ( + Ok(( Response::ok( request.id, ResponseBody::Hello { @@ -644,30 +1265,30 @@ async fn process_request( 0, ), None, - ) + )) } RequestBody::FindNode { target, count } => { - process_find_node(request.id, target, count, state).await + Ok(process_find_node(request.id, target, count, state).await) + } + RequestBody::GetChunk { address } => { + process_get_chunk(request.id, address, state, resources).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 + Ok(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 - } + } => Ok(process_put_chunk( + request.id, + address, + *quote, + transaction_hash, + content, + state, + ) + .await), } } @@ -676,7 +1297,7 @@ async fn process_find_node( target: String, count: Option, state: &ServerState, -) -> (Response, Option>) { +) -> (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), @@ -738,26 +1359,34 @@ async fn process_get_chunk( request_id: u64, address: String, state: &ServerState, -) -> (Response, Option>) { + resources: &ConnectionResources, +) -> ServerResult<(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), + Err(error) => { + return Ok((Response::error(request_id, "invalid_address", error), None)); + } }; let Some(ant_protocol) = state.ant_protocol.as_ref() else { - return ( + return Ok(( Response::error( request_id, "storage_disabled", "chunk storage is disabled on this node".to_string(), ), None, - ); + )); }; - match ant_protocol.storage().get(&address_bytes).await { + // The storage API allocates its returned Vec internally, so reserve the + // largest permitted chunk before awaiting it. This closes the interval in + // which many concurrent GETs could materialize unaccounted full chunks. + let mut content_reservation = resources.try_reserve_bytes(MAX_CHUNK_SIZE)?; + let response = match ant_protocol.storage().get(&address_bytes).await { Ok(Some(content)) if content.len() <= MAX_CHUNK_SIZE => { let content_length = content.len(); - ( + content_reservation.resize(content_length)?; + Ok(( Response::ok( request_id, ResponseBody::Chunk { @@ -766,10 +1395,13 @@ async fn process_get_chunk( }, content_length, ), - Some(content), - ) + Some(TrackedBytes { + bytes: content, + reservation: content_reservation, + }), + )) } - Ok(Some(content)) => ( + Ok(Some(content)) => Ok(( Response::error( request_id, "oversize_chunk", @@ -779,17 +1411,18 @@ async fn process_get_chunk( ), ), None, - ), - Ok(None) => (Response::not_found(request_id, address), None), - Err(error) => ( + )), + Ok(None) => Ok((Response::not_found(request_id, address), None)), + Err(error) => Ok(( Response::error( request_id, "storage_error", format!("chunk read failed: {error}"), ), None, - ), - } + )), + }; + response } async fn process_quote_chunk( @@ -797,7 +1430,7 @@ async fn process_quote_chunk( address: String, size: u64, state: &ServerState, -) -> (Response, Option>) { +) -> (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), @@ -893,7 +1526,7 @@ async fn process_put_chunk( transaction_hash: String, content: Vec, state: &ServerState, -) -> (Response, Option>) { +) -> (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), @@ -1278,10 +1911,246 @@ struct ServerState { } #[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::significant_drop_tightening +)] mod tests { use super::*; + #[test] + fn default_resource_limits_preserve_headroom_for_other_sources() { + let config = WebRtcDirectConfig::default(); + validate_webrtc_config(&config).expect("default resource limits"); + + let source_channel_ceiling = + config.max_connections_per_ip * config.max_channels_per_connection; + assert!(config.max_connections_per_ip < config.max_connections); + assert!(source_channel_ceiling < config.max_channels); + assert!(source_channel_ceiling < config.max_concurrent_requests); + assert!(config.max_requests_per_second_per_ip < config.max_requests_per_second); + assert!(config.max_in_flight_bytes_per_ip < config.max_in_flight_bytes); + } + + #[test] + fn rejects_resource_limits_that_let_one_ip_exhaust_a_global_pool() { + let mut config = WebRtcDirectConfig::default(); + config.max_connections_per_ip = config.max_connections; + assert!(validate_webrtc_config(&config).is_err()); + + let mut config = WebRtcDirectConfig::default(); + config.max_channels = config.max_connections_per_ip * config.max_channels_per_connection; + assert!(validate_webrtc_config(&config).is_err()); + + let mut config = WebRtcDirectConfig::default(); + config.max_in_flight_bytes_per_ip = config.max_in_flight_bytes; + assert!(validate_webrtc_config(&config).is_err()); + } + + #[test] + fn per_ip_connection_limit_cannot_starve_another_source() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + let attacker: SocketAddr = "198.51.100.1:1000".parse().expect("attacker address"); + let honest: SocketAddr = "203.0.113.2:2000".parse().expect("honest address"); + let mut attacker_admissions = Vec::new(); + + for port in 0..config.max_connections_per_ip { + let mut address = attacker; + address.set_port(u16::try_from(port + 1).expect("test port")); + attacker_admissions.push( + resources + .try_admit_connection(address) + .expect("source share remains"), + ); + } + assert_eq!(attacker_admissions.len(), config.max_connections_per_ip); + assert_eq!( + resources.try_admit_connection(attacker).err().as_deref(), + Some(SOURCE_CONNECTION_CAPACITY_ERROR) + ); + let honest_admission = resources + .try_admit_connection(honest) + .expect("another source retains listener headroom"); + + drop(attacker_admissions.pop()); + resources + .try_admit_connection(attacker) + .expect("released source slot is reusable"); + drop(honest_admission); + } + + #[test] + fn ipv4_mapped_ipv6_cannot_bypass_source_accounting() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + let v4: SocketAddr = "192.0.2.44:1000".parse().expect("IPv4 address"); + let mapped: SocketAddr = "[::ffff:192.0.2.44]:2000".parse().expect("mapped address"); + let mut admissions = vec![resources + .try_admit_connection(v4) + .expect("first connection")]; + for _ in 1..config.max_connections_per_ip { + admissions.push( + resources + .try_admit_connection(mapped) + .expect("mapped source share"), + ); + } + assert_eq!(admissions.len(), config.max_connections_per_ip); + assert_eq!( + resources.try_admit_connection(mapped).err().as_deref(), + Some(SOURCE_CONNECTION_CAPACITY_ERROR) + ); + } + + #[test] + fn request_token_bucket_refills_without_growing_state() { + let mut bucket = RequestRateBucket::new(2); + let start = bucket.last_refill; + assert!(bucket.allow(start)); + assert!(bucket.allow(start)); + assert!(!bucket.allow(start)); + assert!(bucket.allow(start + Duration::from_millis(500))); + assert!(!bucket.allow(start + Duration::from_millis(500))); + assert!(bucket.allow(start + Duration::from_secs(1))); + } + + #[test] + fn per_ip_request_rate_leaves_other_sources_admissible() { + let config = WebRtcDirectConfig { + max_requests_per_second: 4, + max_requests_per_second_per_ip: 2, + max_requests_per_second_per_connection: 2, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let attacker = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("attacker")) + .expect("attacker connection"); + let honest = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("honest")) + .expect("honest connection"); + + assert!(attacker.context.try_admit_request().is_ok()); + assert!(attacker.context.try_admit_request().is_ok()); + assert_eq!( + attacker.context.try_admit_request().err().as_deref(), + Some(REQUEST_RATE_ERROR) + ); + assert!(honest.context.try_admit_request().is_ok()); + } + + #[test] + fn reconnecting_does_not_reset_the_source_request_bucket() { + let config = WebRtcDirectConfig { + max_requests_per_second: 100, + max_requests_per_second_per_ip: 1, + max_requests_per_second_per_connection: 1, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let address = "198.51.100.1:1000".parse().expect("source"); + let first = resources + .try_admit_connection(address) + .expect("first connection"); + assert!(first.context.try_admit_request().is_ok()); + drop(first); + + let replacement = resources + .try_admit_connection(address) + .expect("replacement connection"); + assert_eq!( + replacement.context.try_admit_request().err().as_deref(), + Some(REQUEST_RATE_ERROR) + ); + } + + #[test] + fn inactive_source_rate_state_has_a_hard_bound() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + for index in 0..resources.max_tracked_sources + 10 { + let third = u8::try_from(index / 254).expect("third octet"); + let host = u8::try_from(index % 254 + 1).expect("host octet"); + let address = SocketAddr::from((Ipv4Addr::new(198, 51, third, host), 1000)); + drop( + resources + .try_admit_connection(address) + .expect("sequential source"), + ); + } + assert_eq!( + resources.source_state.lock().sources.len(), + resources.max_tracked_sources + ); + } + + #[test] + fn byte_reservations_are_per_source_global_and_raii_released() { + let config = WebRtcDirectConfig { + max_in_flight_bytes: 256, + max_in_flight_bytes_per_ip: 128, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let attacker = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("attacker")) + .expect("attacker connection"); + let honest = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("honest")) + .expect("honest connection"); + + let attacker_bytes = attacker + .context + .try_reserve_bytes(128) + .expect("attacker source budget"); + assert_eq!(resources.global_bytes.in_use(), 128); + assert_eq!( + attacker.context.try_reserve_bytes(1).err().as_deref(), + Some(SOURCE_BYTE_CAPACITY_ERROR) + ); + let honest_bytes = honest + .context + .try_reserve_bytes(64) + .expect("another source retains byte headroom"); + assert_eq!(resources.global_bytes.in_use(), 192); + + drop(attacker_bytes); + drop(honest_bytes); + assert_eq!(resources.global_bytes.in_use(), 0); + assert_eq!(attacker.context.source.bytes.in_use(), 0); + assert_eq!(honest.context.source.bytes.in_use(), 0); + } + + #[test] + fn global_byte_rejection_rolls_back_the_source_reservation() { + let config = WebRtcDirectConfig { + max_in_flight_bytes: 100, + max_in_flight_bytes_per_ip: 90, + ..WebRtcDirectConfig::default() + }; + let resources = ListenerResources::new(&config); + let first = resources + .try_admit_connection("198.51.100.1:1000".parse().expect("first")) + .expect("first connection"); + let second = resources + .try_admit_connection("203.0.113.2:2000".parse().expect("second")) + .expect("second connection"); + let _first_bytes = first + .context + .try_reserve_bytes(60) + .expect("first reservation"); + + assert_eq!( + second.context.try_reserve_bytes(50).err().as_deref(), + Some(GLOBAL_BYTE_CAPACITY_ERROR) + ); + assert_eq!(second.context.source.bytes.in_use(), 0); + assert_eq!(resources.global_bytes.in_use(), 60); + } + #[test] fn derives_stable_high_port_from_native_port() { assert_eq!(automatic_webrtc_port(10_000), 42_768); From cddfff6fc3dbc40dd12c77cbeec5c21f5bfe928a Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:29:00 +0200 Subject: [PATCH 15/31] fix(webrtc): treat endpoint reachability as unverified --- ...rect-browser-clients-over-webrtc-direct.md | 58 +++++++-------- src/devnet.rs | 2 +- src/node.rs | 3 +- src/web_rtc.rs | 72 ++++++++----------- 4 files changed, 62 insertions(+), 73 deletions(-) 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 index 7e3717de..723e6cd1 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -371,9 +371,12 @@ TransportAddressRecord { 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. +Relay, Direct, Unverified, and Lan. WebRTC Direct currently has no relayed +address form, so every WebRTC endpoint is published as `Unverified`; its +reachability is determined by attempting the certificate-pinned browser +connection. Relay acquisition selects `Quic + Direct`; native dialing never +consumes WebRTC records and native QUIC reachability is never reused as WebRTC +evidence. The identifiers are numeric fields rather than serialized Rust enums and are never reused. `address` is a bounded, length-delimited payload that is decoded @@ -639,12 +642,14 @@ 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. +V2 address records carry a reachability field independently from transport +type, but WebRTC Direct currently has no relay transport. Its records are +therefore normalized to `Unverified` rather than borrowing the classification +of a native QUIC socket on another UDP port. One-hop browser `FIND_NODE` +responses expose these authenticated, self-contained endpoints and the browser +handles failed dials through its bounded negative-endpoint cache. If relayed +WebRTC is added later, it requires a distinct address form and selection policy +rather than overloading the direct address. Every storage node, or a sufficient storage-aware replica set, must expose a browser endpoint. Filtering native closest results to a sparse browser-only @@ -655,18 +660,11 @@ storage nodes. 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. +address, and this implementation has no WebRTC relay transport. An endpoint +that cannot be reached is simply a failed browser dial and is suppressed by +the negative-endpoint cache. Supporting NATed WebRTC nodes would require a +separate signaling and relay design; it is not represented by the current +reachability field. ### Implemented proof-of-concept slice @@ -675,6 +673,9 @@ The earlier feature-gated WebTransport PoC has been replaced by the - a separate Saorsa-owned WebRTC Direct UDP listener in `saorsa-transport` and a browser dialer built directly on `RTCPeerConnection`/`RTCDataChannel`; +- WebRTC endpoint publication as `Unverified`, independent of native QUIC + reachability, with actual availability determined by a certificate-pinned + browser dial; - credential-first STUN routing in the shared UDP mux, so a new association is not sent to a stale ICE agent when a browser reuses a source UDP port; - a generated and persisted DTLS certificate whose fingerprint remains stable @@ -730,9 +731,9 @@ below. 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. +every browser-enabled peer in its routing view before DHT publication has +converged. This catalog is development-only; production lookup uses only the +authenticated V2 DHT endpoint records. Local testnets may publish a runtime manifest because their loopback addresses and ephemeral ports are created for each test run. Production bootstrap must @@ -798,11 +799,10 @@ 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. +testnet's deliberate inbound-NAT rules are not reachable through the current +WebRTC Direct transport. Failed direct endpoints are cancelled after the +shared lookup grace period and suppressed by the browser client's negative +cache. After replacing that prototype with the compatibility-safe V2 address plane, a five-node headless-Chromium test again started with exactly one WebRTC seed. diff --git a/src/devnet.rs b/src/devnet.rs index adb9af11..8050d480 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -1022,7 +1022,7 @@ impl Devnet { .as_ref() .unwrap_or(&EvmNetwork::ArbitrumOne), self.shutdown.clone(), - Arc::clone(&self.browser_endpoint_catalog), + Some(Arc::clone(&self.browser_endpoint_catalog)), ) .await .map_err(|error| { diff --git a/src/node.rs b/src/node.rs index 2b7e1b1e..216d5778 100644 --- a/src/node.rs +++ b/src/node.rs @@ -561,7 +561,6 @@ impl RunningNode { 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, @@ -570,7 +569,7 @@ impl RunningNode { self.ant_protocol.clone(), &evm_network, self.shutdown.clone(), - endpoint_catalog, + None, ) .await { diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 896a7c84..39ff3176 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -23,7 +23,7 @@ use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; use parking_lot::{Mutex, RwLock}; use saorsa_core::identity::NodeIdentity; -use saorsa_core::{DHTNode, KnownReachability, MultiAddr, P2PNode, PeerId}; +use saorsa_core::{DHTNode, MultiAddr, P2PNode, PeerId}; use saorsa_transport::webrtc_direct::{ WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, MAX_DATA_CHANNEL_MESSAGE_SIZE, @@ -135,10 +135,9 @@ fn routed_local_ip(ipv4: bool) -> Option { /// 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. +/// Only the in-process devnet supplies this catalog. Independently deployed +/// nodes discover endpoints exclusively from authenticated DHT address +/// records. #[derive(Default)] pub struct BrowserEndpointCatalog { endpoints: RwLock>, @@ -491,7 +490,7 @@ pub async fn spawn( ant_protocol: Option>, evm_network: &evmlib::Network, shutdown: CancellationToken, - endpoint_catalog: Arc, + endpoint_catalog: Option>, ) -> Result { validate_webrtc_config(config)?; let certificate_path = certificate_path(config, root_dir); @@ -511,13 +510,13 @@ pub async fn spawn( 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()); - + if let Some(catalog) = endpoint_catalog.as_ref() { + catalog.insert(peer_id, browser_endpoint.clone()); + } let state = Arc::new(ServerState { config: config.clone(), identity, - p2p, + p2p: Arc::clone(&p2p), ant_protocol, payment: BrowserPaymentNetwork::from_evm_network(evm_network), endpoint: browser_endpoint.clone(), @@ -532,10 +531,9 @@ pub async fn spawn( "ADR-0009 WebRTC Direct listening" ); - let task = tokio::spawn(async move { - serve_webrtc(listener, state, resources, shutdown).await; - }); - dht.set_supplemental_self_addresses(vec![browser_endpoint.multiaddr.clone()]) + let task = tokio::spawn(serve_webrtc(listener, state, resources, shutdown)); + p2p.dht_manager() + .set_supplemental_self_addresses(vec![browser_endpoint.multiaddr.clone()]) .await; Ok(WebRtcDirectServer { endpoint: browser_endpoint, @@ -1311,13 +1309,11 @@ async fn process_find_node( .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; + let supplemental = dht.supplemental_addresses_for_peer(&node.peer_id).await; nodes.push(browser_node_from_dht( &node, &supplemental, - &state.endpoint_catalog, + state.endpoint_catalog.as_deref(), )); } ( @@ -1328,23 +1324,21 @@ async fn process_find_node( fn browser_node_from_dht( node: &DHTNode, - supplemental: &[(MultiAddr, KnownReachability)], - endpoint_catalog: &BrowserEndpointCatalog, + supplemental: &[MultiAddr], + endpoint_catalog: Option<&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() + .find(|address| { + address.is_webrtc_direct() && address.peer_id().is_some_and(|peer| peer == &node.peer_id) }) - .map(|(address, _)| address.clone()) + .cloned() .map(|multiaddr| BrowserEndpoint { multiaddr }); BrowserNode { - webrtc_direct: discovered_endpoint.or_else(|| endpoint_catalog.get(&node.peer_id)), + webrtc_direct: discovered_endpoint + .or_else(|| endpoint_catalog.and_then(|catalog| catalog.get(&node.peer_id))), peer_id: node.peer_id.to_hex(), native_addresses: addresses .into_iter() @@ -1907,7 +1901,7 @@ struct ServerState { ant_protocol: Option>, payment: BrowserPaymentNetwork, endpoint: BrowserEndpoint, - endpoint_catalog: Arc, + endpoint_catalog: Option>, } #[cfg(test)] @@ -2311,12 +2305,8 @@ mod tests { 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(), - ); + let supplemental = endpoint.multiaddr.clone(); + let browser_node = browser_node_from_dht(&node, std::slice::from_ref(&supplemental), None); assert_eq!(browser_node.webrtc_direct, Some(endpoint)); assert_eq!( @@ -2326,7 +2316,7 @@ mod tests { } #[test] - fn find_node_hides_relay_only_webrtc_endpoint() { + fn production_find_node_does_not_use_dev_endpoint_catalog() { let peer_id = PeerId::from_bytes([0x32; 32]); let endpoint = BrowserEndpoint::new( "203.0.113.10:42768".parse().expect("socket address"), @@ -2341,14 +2331,14 @@ mod tests { distance: None, reliability: 0.75, }; - let supplemental = (endpoint.multiaddr, KnownReachability::Relay); + let catalog = BrowserEndpointCatalog::default(); + catalog.insert(peer_id, endpoint.clone()); - let browser_node = browser_node_from_dht( - &node, - std::slice::from_ref(&supplemental), - &BrowserEndpointCatalog::default(), - ); + let browser_node = browser_node_from_dht(&node, &[], None); assert!(browser_node.webrtc_direct.is_none()); + + let devnet_node = browser_node_from_dht(&node, &[], Some(&catalog)); + assert_eq!(devnet_node.webrtc_direct, Some(endpoint)); } } From 56d12145b5c3058967bfd2fe0f6e1531b0efa29f Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:22:19 +0200 Subject: [PATCH 16/31] refactor(webrtc): consume shared browser wire contract --- Cargo.lock | 13 +- src/browser.rs | 162 +----------- src/devnet.rs | 6 +- src/lib.rs | 2 +- src/web_rtc.rs | 482 ++++++++++------------------------ tests/webrtc_direct_devnet.rs | 319 ++++++++++++---------- 6 files changed, 347 insertions(+), 637 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad28368b..fd1b7c42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,10 +885,7 @@ checksum = "dd13dd1f51e3e9b3e871673b7d52a65054c9431b0656f4fa9589098126c3dafd" dependencies = [ "blake3", "bytes", - "chacha20poly1305", "evmlib", - "fips203", - "getrandom 0.2.17", "hex", "postcard", "rmp-serde", @@ -5555,6 +5552,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" diff --git a/src/browser.rs b/src/browser.rs index 0dface37..2ce9bbfa 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -5,79 +5,12 @@ //! records: browsers sign EVM transactions locally and send only payment //! receipts to nodes. -use saorsa_core::{MultiAddr, PeerId, WebRtcCertificateHash, WebRtcDirectAddr}; +pub use ant_protocol::web_rtc::{BrowserEndpoint, BrowserPaymentNetwork, WebRtcDirectEndpoint}; 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 { @@ -120,30 +53,11 @@ pub struct BrowserChunkInfo { 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()), - } +pub(crate) fn browser_payment_network(network: &evmlib::Network) -> BrowserPaymentNetwork { + BrowserPaymentNetwork { + rpc_url: network.rpc_url().to_string(), + payment_token_address: format!("{:?}", network.payment_token_address()), + payment_vault_address: format!("{:?}", network.payment_vault_address()), } } @@ -188,67 +102,3 @@ impl BrowserDevnetManifest { } } } - -#[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/devnet.rs b/src/devnet.rs index 8050d480..fc16f0ed 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -31,7 +31,9 @@ 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}; +use crate::browser::{ + browser_payment_network, BrowserBootstrapNode, BrowserPaymentNetwork, BrowserPublicFile, +}; #[cfg(feature = "webrtc-direct")] use crate::config::WebRtcDirectConfig; #[cfg(feature = "webrtc-direct")] @@ -717,7 +719,7 @@ impl Devnet { .evm_network .as_ref() .unwrap_or(&EvmNetwork::ArbitrumOne); - BrowserPaymentNetwork::from_evm_network(network) + browser_payment_network(network) } #[cfg(feature = "webrtc-direct")] diff --git a/src/lib.rs b/src/lib.rs index 08225aa1..b20a2cd3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,7 +65,7 @@ pub use ant_protocol::{ }; pub use browser::{ BrowserBootstrapNode, BrowserChunkInfo, BrowserDevnetManifest, BrowserEndpoint, - BrowserPaymentNetwork, BrowserPublicFile, ParsedBrowserEndpoint, BROWSER_MANIFEST_VERSION, + BrowserPaymentNetwork, BrowserPublicFile, WebRtcDirectEndpoint, BROWSER_MANIFEST_VERSION, }; pub use client::{ compute_address, hex_node_id_to_encoded_peer_id, peer_id_to_xor_name, xor_distance, DataChunk, diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 39ff3176..994d6160 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -9,15 +9,21 @@ use crate::ant_protocol::{ ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, MAX_CHUNK_SIZE, }; -use crate::browser::{BrowserEndpoint, BrowserPaymentNetwork}; +use crate::browser::{browser_payment_network, BrowserEndpoint, BrowserPaymentNetwork}; use crate::config::WebRtcDirectConfig; use crate::error::{Error, Result}; use crate::logging::{debug, info, warn}; use crate::payment::{serialize_single_node_proof, PaymentProof}; use crate::storage::AntProtocol; use ant_protocol::web_rtc::{ - accept_pq_session, decode_pq_frame, pq_frame_length, transfer_timeout, PqSession, - PQ_CLIENT_HELLO_BYTES, PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_FRAME_PREFIX_BYTES, + accept_pq_session, decode_pq_frame, encode_response_frame, parse_request_header, + pq_frame_length, transfer_timeout, BrowserCommitmentArtifact, BrowserNode, + BrowserQuoteArtifact, BrowserRequest as Request, BrowserRequestBody as RequestBody, + BrowserResponse as Response, BrowserResponseBody as ResponseBody, + BrowserResponseStatus as ResponseStatus, PqSession, BROWSER_PROTOCOL_NAME, + BROWSER_PROTOCOL_VERSION, MAX_BROWSER_HEADER_BYTES, PQ_CLIENT_HELLO_BYTES, + PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_FRAME_PREFIX_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, + WEBRTC_WRITE_CHUNK_BYTES, }; use evmlib::common::{Amount, TxHash}; use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; @@ -26,9 +32,7 @@ use saorsa_core::identity::NodeIdentity; use saorsa_core::{DHTNode, 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}; @@ -40,14 +44,9 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::task::{JoinHandle, JoinSet}; 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 SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); -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; const TRACKED_SOURCE_MULTIPLIER: usize = 4; @@ -507,8 +506,14 @@ pub async fn spawn( 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)?; + let browser_endpoint = + BrowserEndpoint::new(advertised_addr, peer_id.to_bytes(), certificate_sha256) + .map_err(|error| Error::Config(error.to_string()))?; + let supplemental_endpoint = browser_endpoint.multiaddr.parse().map_err(|error| { + Error::Startup(format!( + "shared WebRTC endpoint codec produced an invalid transport address: {error}" + )) + })?; persist_browser_endpoint(root_dir, &browser_endpoint).await?; if let Some(catalog) = endpoint_catalog.as_ref() { catalog.insert(peer_id, browser_endpoint.clone()); @@ -518,7 +523,7 @@ pub async fn spawn( identity, p2p: Arc::clone(&p2p), ant_protocol, - payment: BrowserPaymentNetwork::from_evm_network(evm_network), + payment: browser_payment_network(evm_network), endpoint: browser_endpoint.clone(), endpoint_catalog, }); @@ -533,7 +538,7 @@ pub async fn spawn( let task = tokio::spawn(serve_webrtc(listener, state, resources, shutdown)); p2p.dht_manager() - .set_supplemental_self_addresses(vec![browser_endpoint.multiaddr.clone()]) + .set_supplemental_self_addresses(vec![supplemental_endpoint]) .await; Ok(WebRtcDirectServer { endpoint: browser_endpoint, @@ -633,9 +638,9 @@ fn validate_webrtc_config(config: &WebRtcDirectConfig) -> Result<()> { .to_string(), )); } - if config.max_request_bytes == 0 || config.max_request_bytes > MAX_RESPONSE_HEADER_BYTES { + if config.max_request_bytes == 0 || config.max_request_bytes > MAX_BROWSER_HEADER_BYTES { return Err(Error::Config(format!( - "webrtc_direct.max_request_bytes must be between 1 and {MAX_RESPONSE_HEADER_BYTES}" + "webrtc_direct.max_request_bytes must be between 1 and {MAX_BROWSER_HEADER_BYTES}" ))); } if config.advertised_addr.is_some_and(|addr| addr.port() == 0) { @@ -844,7 +849,7 @@ async fn handle_webrtc_channel( state: Arc, resources: Arc, ) -> ServerResult<()> { - if channel.label() != DATA_CHANNEL_LABEL { + if channel.label() != WEBRTC_DIRECT_DATA_CHANNEL { return Err(format!( "unsupported DataChannel label {:?}", channel.label() @@ -877,12 +882,12 @@ async fn handle_webrtc_channel( _request_permit, _in_flight_bytes, } = admitted; - if request.version != PROTOCOL_VERSION { + if request.version != BROWSER_PROTOCOL_VERSION { let response = Response::error( - request.id, + request.request_id, "unsupported_version", format!( - "protocol version {} is unsupported; expected {PROTOCOL_VERSION}", + "protocol version {} is unsupported; expected {BROWSER_PROTOCOL_VERSION}", request.version ), ); @@ -893,7 +898,7 @@ async fn handle_webrtc_channel( let is_hello = matches!(&request.body, RequestBody::Hello); if !is_hello && !hello_completed { let response = Response::error( - request.id, + request.request_id, "authentication_required", "HELLO must initialize this encrypted WebRTC session first".to_string(), ); @@ -1001,7 +1006,8 @@ async fn read_webrtc_request( drop(encrypted_bytes); reservation.resize(frame.len())?; - let (request, content_offset) = parse_webrtc_request(&frame, max_header_bytes)?; + let (request, content_offset) = + parse_request_header(&frame, max_header_bytes).map_err(|error| error.to_string())?; let content_len = frame.len() - content_offset; let accounted_request_bytes = frame.len(); // serde owns the parsed header and the body copy below owns the content. @@ -1019,46 +1025,6 @@ async fn read_webrtc_request( }) } -fn parse_webrtc_request(frame: &[u8], max_header_bytes: usize) -> ServerResult<(Request, usize)> { - 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, content_offset)) -} - async fn receive_first_message( channel: &WebRtcDataChannel, idle_timeout_message: &str, @@ -1166,25 +1132,15 @@ async fn write_webrtc_response( // This reservation accounts only the new header, plaintext, and ciphertext // allocations made while encoding the response. let content = content.map_or(&[][..], |tracked| tracked.bytes.as_slice()); - let mut reservation = resources.try_reserve_bytes(0)?; - 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()); - } - reservation.try_grow(header.len())?; - let header_len = u32::try_from(header.len()) - .map_err(|_| "response header length does not fit u32".to_string())?; - let plaintext_len = 4usize - .checked_add(header.len()) + let encode_reservation = 4usize + .checked_add(MAX_BROWSER_HEADER_BYTES.saturating_mul(2)) .and_then(|length| length.checked_add(content.len())) .ok_or_else(|| "response frame length overflow".to_string())?; - reservation.try_grow(plaintext_len)?; - let mut plaintext = Vec::with_capacity(plaintext_len); - plaintext.extend_from_slice(&header_len.to_be_bytes()); - plaintext.extend_from_slice(&header); - plaintext.extend_from_slice(content); - let encrypted_len = plaintext_len + let mut reservation = resources.try_reserve_bytes(encode_reservation)?; + let plaintext = encode_response_frame(response, content).map_err(|error| error.to_string())?; + reservation.resize(plaintext.len())?; + let encrypted_len = plaintext + .len() .checked_add(PQ_ENCRYPTED_OVERHEAD_BYTES) .ok_or_else(|| "encrypted response length overflow".to_string())?; reservation.try_grow(encrypted_len)?; @@ -1192,7 +1148,7 @@ async fn write_webrtc_response( .seal(&plaintext) .map_err(|error| format!("PQ session: {error}"))?; drop(plaintext); - reservation.resize(content.len() + header.len() + encrypted.len())?; + reservation.resize(content.len() + encrypted.len())?; write_framed_pq_payload(channel, &encrypted).await } @@ -1234,7 +1190,7 @@ async fn process_request( if !matches!(&request.body, RequestBody::PutChunk { .. }) && !content.is_empty() { return Ok(( Response::error( - request.id, + request.request_id, "unexpected_content", "only put_chunk accepts binary request content".to_string(), ), @@ -1246,9 +1202,9 @@ async fn process_request( let peer_id = state.p2p.peer_id().to_hex(); Ok(( Response::ok( - request.id, + request.request_id, ResponseBody::Hello { - protocol: PROTOCOL_NAME.to_string(), + protocol: BROWSER_PROTOCOL_NAME.to_string(), peer_id, max_chunk_size: MAX_CHUNK_SIZE, endpoint: state.endpoint.clone(), @@ -1266,20 +1222,20 @@ async fn process_request( )) } RequestBody::FindNode { target, count } => { - Ok(process_find_node(request.id, target, count, state).await) + Ok(process_find_node(request.request_id, target, count, state).await) } RequestBody::GetChunk { address } => { - process_get_chunk(request.id, address, state, resources).await + process_get_chunk(request.request_id, address, state, resources).await } RequestBody::QuoteChunk { address, size } => { - Ok(process_quote_chunk(request.id, address, size, state).await) + Ok(process_quote_chunk(request.request_id, address, size, state).await) } RequestBody::PutChunk { address, quote, transaction_hash, } => Ok(process_put_chunk( - request.id, + request.request_id, address, *quote, transaction_hash, @@ -1335,7 +1291,9 @@ fn browser_node_from_dht( && address.peer_id().is_some_and(|peer| peer == &node.peer_id) }) .cloned() - .map(|multiaddr| BrowserEndpoint { multiaddr }); + .map(|multiaddr| BrowserEndpoint { + multiaddr: multiaddr.to_string(), + }); BrowserNode { webrtc_direct: discovered_endpoint .or_else(|| endpoint_catalog.and_then(|catalog| catalog.get(&node.peer_id))), @@ -1477,7 +1435,7 @@ async fn process_quote_chunk( ) } }; - let artifact = match BrowserQuoteArtifact::from_quote( + let artifact = match browser_quote_from_quote( state.p2p.peer_id(), "e, commitment.as_deref(), @@ -1604,7 +1562,8 @@ fn build_payment_proof( quote: BrowserQuoteArtifact, transaction_hash: &str, ) -> ServerResult> { - let (peer_id, payment_quote, commitment) = quote.into_payment_quote(expected_content)?; + let (peer_id, payment_quote, commitment) = + payment_quote_from_browser_quote(quote, expected_content)?; let transaction_hash = TxHash::from_str(transaction_hash) .map_err(|error| format!("invalid EVM transaction hash: {error}"))?; let proof = PaymentProof { @@ -1644,254 +1603,92 @@ fn decode_32_byte_hex(value: &str) -> ServerResult<[u8; 32]> { 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, +fn browser_quote_from_quote( + peer_id: &PeerId, + quote: &PaymentQuote, + commitment: Option<&[u8]>, +) -> ServerResult { + let timestamp_secs = quote + .timestamp + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|error| format!("quote timestamp predates the Unix epoch: {error}"))? + .as_secs(); + let commitment = commitment.map(browser_commitment_from_bytes).transpose()?; + Ok(BrowserQuoteArtifact { + peer_id: peer_id.to_hex(), + content: hex::encode(quote.content.0), + timestamp_secs, + price: quote.price.to_string(), + rewards_address: format!("{:?}", quote.rewards_address), + public_key: hex::encode("e.pub_key), + signature: hex::encode("e.signature), + committed_key_count: quote.committed_key_count, + commitment_pin: quote.commitment_pin.map(hex::encode), + quote_hash: hex::encode(quote.hash()), + commitment, + }) } -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), +fn payment_quote_from_browser_quote( + artifact: BrowserQuoteArtifact, + expected_content: [u8; 32], +) -> ServerResult<([u8; 32], PaymentQuote, Option>)> { + let peer_id = decode_32_byte_hex(&artifact.peer_id)?; + let content = decode_32_byte_hex(&artifact.content)?; + if content != expected_content { + return Err("payment quote is for a different chunk address".to_string()); + } + let price = Amount::from_str(&artifact.price) + .map_err(|error| format!("payment quote has an invalid price: {error}"))?; + let rewards_address = RewardsAddress::from_str(&artifact.rewards_address) + .map_err(|error| format!("payment quote has an invalid rewards address: {error}"))?; + let public_key = hex::decode(&artifact.public_key) + .map_err(|error| format!("payment quote public key is not hexadecimal: {error}"))?; + let signature = hex::decode(&artifact.signature) + .map_err(|error| format!("payment quote signature is not hexadecimal: {error}"))?; + let commitment_pin = artifact + .commitment_pin + .as_deref() + .map(decode_32_byte_hex) + .transpose()?; + let timestamp = SystemTime::UNIX_EPOCH + .checked_add(Duration::from_secs(artifact.timestamp_secs)) + .ok_or_else(|| "payment quote timestamp is out of range".to_string())?; + let quote = PaymentQuote { + content: xor_name::XorName(content), + timestamp, + price, + rewards_address, + pub_key: public_key, + signature, + committed_key_count: artifact.committed_key_count, + commitment_pin, + }; + if hex::encode(quote.hash()) != artifact.quote_hash.to_ascii_lowercase() { + return Err("payment quote hash does not match its signed fields".to_string()); + } + let commitment = artifact + .commitment + .map(|artifact| { + hex::decode(artifact.encoded) + .map_err(|error| format!("commitment is not hexadecimal: {error}")) }) - } + .transpose()?; + Ok((peer_id, quote, commitment)) +} + +fn browser_commitment_from_bytes(encoded: &[u8]) -> ServerResult { + let commitment: ::ant_protocol::payment::commitment::StorageCommitment = + rmp_serde::from_slice(encoded) + .map_err(|error| format!("node generated an invalid commitment: {error}"))?; + Ok(BrowserCommitmentArtifact { + encoded: hex::encode(encoded), + root: hex::encode(commitment.root), + key_count: commitment.key_count, + sender_peer_id: hex::encode(commitment.sender_peer_id), + sender_public_key: hex::encode(commitment.sender_public_key), + signature: hex::encode(commitment.signature), + }) } struct ServerState { @@ -2194,8 +1991,8 @@ mod tests { ) .expect("valid request"); - assert_eq!(request.version, PROTOCOL_VERSION); - assert_eq!(request.id, 7); + assert_eq!(request.version, BROWSER_PROTOCOL_VERSION); + assert_eq!(request.request_id, 7); assert!(matches!(request.body, RequestBody::FindNode { .. })); } @@ -2269,7 +2066,7 @@ mod tests { let peer_id = PeerId::from_bytes([0x42; 32]); let endpoint = BrowserEndpoint::new( "203.0.113.7:11000".parse().expect("socket address"), - &peer_id, + peer_id.to_bytes(), [0x24; 32], ) .expect("browser endpoint"); @@ -2290,7 +2087,7 @@ mod tests { let peer_id = PeerId::from_bytes([0x31; 32]); let endpoint = BrowserEndpoint::new( "203.0.113.9:42768".parse().expect("socket address"), - &peer_id, + peer_id.to_bytes(), [0x52; 32], ) .expect("browser endpoint"); @@ -2305,7 +2102,10 @@ mod tests { reliability: 0.75, }; - let supplemental = endpoint.multiaddr.clone(); + let supplemental = endpoint + .multiaddr + .parse() + .expect("WebRTC Direct multiaddress"); let browser_node = browser_node_from_dht(&node, std::slice::from_ref(&supplemental), None); assert_eq!(browser_node.webrtc_direct, Some(endpoint)); @@ -2320,7 +2120,7 @@ mod tests { let peer_id = PeerId::from_bytes([0x32; 32]); let endpoint = BrowserEndpoint::new( "203.0.113.10:42768".parse().expect("socket address"), - &peer_id, + peer_id.to_bytes(), [0x53; 32], ) .expect("browser endpoint"); diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs index 6ecd73c3..72363a60 100644 --- a/tests/webrtc_direct_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -4,7 +4,8 @@ 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, + BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION, PQ_ENCRYPTED_OVERHEAD_BYTES, + PQ_SERVER_ACCEPT_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, }; use ant_protocol::MAX_CHUNK_SIZE; use bytes::Bytes; @@ -12,18 +13,13 @@ 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 saorsa_transport::webrtc_direct::{WebRtcDataChannel, WebRtcDirectClient}; 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] @@ -64,70 +60,104 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint .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?; + let mut seed_client = BrowserRpcClient::connect(&endpoint.endpoint).await?; + let (hello, hello_content) = seed_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 5, + "type": "hello", + }), + &[], + ) + .await?; assert_eq!(hello["status"], "ok"); - assert_eq!(hello["protocol"], "autonomi.web.poc.v4"); + assert_eq!(hello["protocol"], BROWSER_PROTOCOL_NAME); assert_eq!( hello["payment"]["rpc_url"].as_str(), Some(evm_testnet.to_network().rpc_url().as_str()) ); - assert_eq!(hello["peer_id"], parsed_endpoint.peer_id.to_hex()); + assert_eq!(hello["peer_id"], parsed_endpoint.peer_id); 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?; + let (closest, closest_content) = seed_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 6, + "type": "find_node", + "target": public_file.address, + "count": 20, + }), + &[], + ) + .await?; assert_eq!(closest["status"], "ok"); assert_eq!(closest["type"], "nodes"); assert_eq!(closest["target"], public_file.address); assert!(closest_content.is_empty()); - let discovered_peer = closest["nodes"] + let discovered = 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) + .and_then(|nodes| { + nodes.iter().find(|node| { + node["webrtc_direct"]["multiaddr"] + .as_str() + .is_some_and(|addr| addr != endpoint.endpoint.multiaddr) + }) }) - .ok_or_else(|| io::Error::other("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?; + .ok_or_else(|| io::Error::other("FIND_NODE returned no browser endpoint"))?; + let discovered_peer = discovered["peer_id"] + .as_str() + .ok_or_else(|| io::Error::other("FIND_NODE node omitted its peer ID"))?; + let download_endpoint: BrowserEndpoint = + serde_json::from_value(discovered["webrtc_direct"].clone())?; + let parsed_download = download_endpoint.parse().map_err(io::Error::other)?; + assert_eq!(parsed_download.peer_id, discovered_peer); + let mut download_client = BrowserRpcClient::connect(&download_endpoint).await?; + let (download_hello, _) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 7, + "type": "hello", + }), + &[], + ) + .await?; + assert_eq!(download_hello["peer_id"], discovered_peer); + let (next_hop, next_hop_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 8, + "type": "find_node", + "target": public_file.address, + "count": 20, + }), + &[], + ) + .await?; + assert_eq!(next_hop["status"], "ok"); + assert_eq!(next_hop["type"], "nodes"); + assert!(next_hop["nodes"] + .as_array() + .is_some_and(|nodes| !nodes.is_empty())); + assert!(next_hop_content.is_empty()); + let (header, data_map_bytes) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 9, + "type": "get_chunk", + "address": public_file.address, + }), + &[], + ) + .await?; assert_eq!(header["status"], "ok"); assert_eq!(header["type"], "chunk"); @@ -139,17 +169,17 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint 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?; + let (chunk_header, chunk_bytes) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": request_id, + "type": "get_chunk", + "address": chunk.dst_hash, + }), + &[], + ) + .await?; assert_eq!(chunk_header["status"], "ok"); assert_eq!(chunk_header["type"], "chunk"); encrypted_chunks.push(EncryptedChunk { @@ -161,18 +191,18 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint 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?; + let (quote_header, quote_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 50, + "type": "quote_chunk", + "address": upload_address, + "size": upload_content.len(), + }), + &[], + ) + .await?; assert_eq!(quote_header["status"], "ok"); assert_eq!(quote_header["type"], "storage_quote"); assert_eq!(quote_header["already_stored"], false); @@ -189,37 +219,42 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint .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?; + let (put_header, put_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 51, + "type": "put_chunk", + "address": upload_address, + "quote": quote, + "transaction_hash": format!("{transaction_hash:?}"), + }), + upload_content, + ) + .await?; assert_eq!(put_header["status"], "ok"); assert_eq!(put_header["type"], "chunk_stored"); assert_eq!(put_header["address"], upload_address); assert!(put_content.is_empty()); - let (uploaded_header, uploaded_content) = rpc( - &download_endpoint.endpoint, - json!({ - "version": 4, - "request_id": 52, - "type": "get_chunk", - "address": upload_address, - }), - &[], - ) - .await?; + let (uploaded_header, uploaded_content) = download_client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, + "request_id": 52, + "type": "get_chunk", + "address": upload_address, + }), + &[], + ) + .await?; assert_eq!(uploaded_header["status"], "ok"); assert_eq!(uploaded_content, upload_content); + assert!(seed_client.requests_sent() >= 2); + assert!(download_client.requests_sent() >= 6); + + download_client.close().await?; + seed_client.close().await?; devnet.shutdown().await?; Ok(()) @@ -231,43 +266,59 @@ fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, io::Err .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}")))?; +struct BrowserRpcClient { + client: WebRtcDirectClient, + pq_session: PqSession, + requests_sent: usize, +} + +impl BrowserRpcClient { + async fn connect(endpoint: &BrowserEndpoint) -> Result> { + let parsed = endpoint.parse().map_err(io::Error::other)?; + let direct_addr = WebRtcDirectAddr::new( + parsed.socket_addr().map_err(io::Error::other)?, + WebRtcCertificateHash::new(parsed.certificate_hash), + )?; + let client = WebRtcDirectClient::dial(&direct_addr, WEBRTC_DIRECT_DATA_CHANNEL) + .await + .map_err(|error| io::Error::other(format!("WebRTC Direct dial failed: {error}")))?; + let expected_peer_id = parsed.peer_id_bytes().map_err(io::Error::other)?; + let pq_session = establish_pq_session(client.data_channel(), &expected_peer_id).await?; + Ok(Self { + client, + pq_session, + requests_sent: 0, + }) } - let result = rpc_stream(client.data_channel(), &mut pq_session, request, content) + + async fn rpc( + &mut self, + request: Value, + content: &[u8], + ) -> Result<(Value, Vec), Box> { + let request_type = request["type"].as_str().unwrap_or("unknown").to_string(); + let result = rpc_stream( + self.client.data_channel(), + &mut self.pq_session, + request, + content, + ) .await .map_err(|error| { io::Error::other(format!("WebRTC Direct {request_type} RPC failed: {error}")) - }); - client.close().await?; - Ok(result?) + })?; + self.requests_sent += 1; + Ok(result) + } + + const fn requests_sent(&self) -> usize { + self.requests_sent + } + + async fn close(self) -> Result<(), Box> { + self.client.close().await?; + Ok(()) + } } async fn rpc_stream( From 4894d4f7b19fff7f7a65fe7fc217cbb12db3d7dd Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:41:05 +0200 Subject: [PATCH 17/31] fix(webrtc): drain active channels on shutdown --- src/web_rtc.rs | 112 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 994d6160..eb62a034 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -783,6 +783,7 @@ async fn handle_connection( shutdown: CancellationToken, ) -> ServerResult<()> { let remote_addr = connection.remote_addr(); + let channel_shutdown = shutdown.child_token(); let mut channel_tasks = JoinSet::new(); let outcome = loop { let accepted = tokio::select! { @@ -822,10 +823,16 @@ async fn handle_connection( }; let channel_state = Arc::clone(&state); let channel_resources = Arc::clone(&resources); + let handler_shutdown = channel_shutdown.clone(); channel_tasks.spawn(async move { let _channel_permit = channel_permit; - if let Err(error) = - handle_webrtc_channel(&channel, channel_state, channel_resources).await + if let Err(error) = handle_webrtc_channel( + &channel, + channel_state, + channel_resources, + handler_shutdown, + ) + .await { debug!(remote = %remote_addr, channel = channel.id(), "WebRTC Direct DataChannel ended: {error}"); } @@ -835,19 +842,27 @@ async fn handle_connection( }); }; + // Stop every handler before returning its storage/P2P state. Closing the + // association alone is not a sufficient wake-up guarantee for work that + // is currently inside an application request. + channel_shutdown.cancel(); if let Err(error) = connection.close().await { debug!(remote = %remote_addr, %error, "Failed to close WebRTC Direct connection"); } - channel_tasks.abort_all(); - while channel_tasks.join_next().await.is_some() {} + while let Some(result) = channel_tasks.join_next().await { + if let Err(error) = result { + debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed during shutdown"); + } + } outcome } -#[allow(clippy::significant_drop_tightening)] +#[allow(clippy::significant_drop_tightening, clippy::too_many_lines)] async fn handle_webrtc_channel( channel: &WebRtcDataChannel, state: Arc, resources: Arc, + shutdown: CancellationToken, ) -> ServerResult<()> { if channel.label() != WEBRTC_DIRECT_DATA_CHANNEL { return Err(format!( @@ -856,23 +871,39 @@ async fn handle_webrtc_channel( )); } - let mut pq_session = establish_pq_session(channel, &state, &resources).await?; + let mut pq_session = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = establish_pq_session(channel, &state, &resources) => result?, + }; let mut hello_completed = false; loop { - let admitted = match read_webrtc_request( - channel, - state.config.max_request_bytes, - &mut pq_session, - &resources, - ) - .await - { + let admitted_result = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = read_webrtc_request( + channel, + state.config.max_request_bytes, + &mut pq_session, + &resources, + ) => result, + }; + let admitted = match admitted_result { Ok(request) => request, Err(error) if is_quiet_channel_close(&error) => return Ok(()), Err(error) => { let response = Response::error(0, "invalid_request", error); - write_webrtc_response(channel, &mut pq_session, &response, None, &resources) - .await?; + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } return Ok(()); } }; @@ -891,7 +922,17 @@ async fn handle_webrtc_channel( request.version ), ); - write_webrtc_response(channel, &mut pq_session, &response, None, &resources).await?; + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } continue; } @@ -902,22 +943,39 @@ async fn handle_webrtc_channel( "authentication_required", "HELLO must initialize this encrypted WebRTC session first".to_string(), ); - write_webrtc_response(channel, &mut pq_session, &response, None, &resources).await?; + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + None, + &resources, + ) => result?, + } continue; } - let (response, content) = process_request(request, content, &state, &resources).await?; + let (response, content) = tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = process_request(request, content, &state, &resources) => result?, + }; if is_hello && matches!(&response.status, ResponseStatus::Ok) { hello_completed = true; } - write_webrtc_response( - channel, - &mut pq_session, - &response, - content.as_ref(), - &resources, - ) - .await?; + tokio::select! { + biased; + () = shutdown.cancelled() => return Ok(()), + result = write_webrtc_response( + channel, + &mut pq_session, + &response, + content.as_ref(), + &resources, + ) => result?, + } } } From 943fa75204c8b60220a30cc59c7b91180a7bc42b Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:13:25 +0200 Subject: [PATCH 18/31] chore: enforce the actual Rust 1.91 MSRV --- .github/workflows/ci.yml | 9 +++++++++ Cargo.toml | 2 +- README.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab7f31f5..e97c20dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,15 @@ env: FOUNDRY_VERSION: v1.7.1 jobs: + msrv: + name: Rust 1.91 MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.91.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets --all-features --locked + fmt: name: Format Check runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 479076be..948adb39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/WithAutonomi/ant-node" keywords = ["p2p", "decentralized", "quantum-safe", "post-quantum", "dht"] categories = ["network-programming", "cryptography"] -rust-version = "1.75" +rust-version = "1.91" [lib] name = "ant_node" diff --git a/README.md b/README.md index 477dcafc..b64a4fb3 100644 --- a/README.md +++ b/README.md @@ -862,7 +862,7 @@ ant-node ### Prerequisites -- Rust 1.75+ (for building from source) +- Rust 1.91+ (for building from source) - Linux, macOS, or Windows ### Build from Source From 2ebcc01f50b75a1490e1c18b4c59cafbe934f626 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:14:53 +0200 Subject: [PATCH 19/31] ci(webrtc): run the five-node direct devnet --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e97c20dd..155d576d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -108,6 +108,22 @@ jobs: - name: Run unit tests without logging run: cargo test --lib --no-default-features + browser-devnet: + name: WebRTC Direct five-node devnet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: ${{ env.FOUNDRY_VERSION }} + - name: Run persistent-session discovery and transfer test + run: >- + cargo test --test webrtc_direct_devnet + -- --ignored --test-threads=1 + security: name: Security Audit runs-on: ubuntu-latest From 228c4a661e30b7058ac7218838d7c5cce16395ff Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:15:10 +0200 Subject: [PATCH 20/31] docs(webrtc): state automated evidence precisely --- docs/WEBRTC_DIRECT_TESTNET.md | 15 ++++--- ...rect-browser-clients-over-webrtc-direct.md | 45 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md index 1b71aedf..20f1b419 100644 --- a/docs/WEBRTC_DIRECT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -187,10 +187,11 @@ 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. +The 2026-08-27 public smoke run used the former protocol v3 and headless +Chromium only. From one bootstrap address it traversed multiple independent +nodes, obtained four storage quotes from non-bootstrap closest nodes, +submitted one payment, and stored all four encrypted records. It is historical +connectivity evidence, not v4 or cross-browser acceptance evidence. Nodes +behind the testnet's deliberate inbound-NAT rules remain unreachable without +relayed WebRTC, so their 10-second DataChannel timeouts currently make this +smoke path slower than an all-public fleet. diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md index 723e6cd1..9b47688b 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -749,28 +749,39 @@ 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-03 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. +### Protocol v4 automated validation + +Node CI explicitly runs the otherwise ignored five-node WebRTC Direct devnet +integration test. Its native test adapter completes the ML-KEM/ML-DSA +handshake and encrypted `HELLO`, asks a seed for closest nodes, dials an +endpoint from that wire response, performs another lookup on the discovered +node, and keeps each encrypted DataChannel open across multiple requests. It +then downloads and reconstructs a public file, obtains a quote, submits a +payment proof and upload, and reads the result back. The dev-only in-memory +endpoint catalog helps nodes populate their lookup responses; the client no +longer chooses its download peer from that out-of-band catalog. + +This harness proves direct endpoint discovery, encrypted session reuse, and +the node request path. It does **not** execute the browser WASM iterative +lookup state machine. `ant-client` CI builds and lints the WASM target and runs +its generated bindings in Node, but browser-side iterative parity remains a +promotion requirement below. Shared `ant-protocol` unit tests additionally +cover record tampering and replay, wrong peer IDs, tampered node signatures, +invalid outer frames, and version mismatch. Node-side resource tests additionally cover fail-fast headroom invariants, per-IP association isolation, IPv4-mapped IPv6 normalization, token-bucket refill, preservation of source rate state across reconnects, bounded inactive source state, global/per-source byte ceilings, rollback after failed global -reservation, and RAII release. The devnet workflow exercises the same limits -while transferring real encrypted chunks. The adversarial browser and fleet -tests listed under Validation remain promotion requirements. - -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. +reservation, and RAII release. The devnet workflow transfers real encrypted +chunks, but it is not a browser resource-limit or fleet test. The adversarial +browser and fleet tests listed under Validation remain promotion requirements. + +There is currently no automated real-browser v4 flow in browser CI. The +historical smoke flow below ran only Chromium and used protocol v3. Therefore +Chrome, Firefox, and Safari interoperability against a matching deployed v4 +node fleet, along with cold bootstrap from the production compiled seed list, +remain unmet acceptance criteria rather than claimed results. ### Historical public Internet v3 smoke result From f08eed640d54668962b6b56994cada341031ca18 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:22:35 +0200 Subject: [PATCH 21/31] chore: satisfy current stable Clippy --- src/bin/ant-node/cli.rs | 24 ++++++++++++----------- src/node.rs | 2 +- src/payment/verifier.rs | 14 +++++++------- src/replication/commitment.rs | 2 +- src/replication/commitment_state.rs | 4 ++-- src/replication/config.rs | 17 +++++++--------- src/replication/mod.rs | 30 +++++++++++++---------------- src/replication/neighbor_sync.rs | 12 ++++++------ src/replication/paid_list.rs | 2 +- src/replication/recent_provers.rs | 2 +- src/replication/scheduling.rs | 2 +- src/replication/subtree.rs | 2 +- src/replication/types.rs | 4 ++-- src/upgrade/apply.rs | 2 +- src/upgrade/monitor.rs | 10 ++++------ src/upgrade/release_cache.rs | 8 ++++---- src/upgrade/rollout.rs | 6 +++--- src/web_rtc.rs | 2 +- tests/e2e/integration_tests.rs | 2 +- tests/e2e/merkle_payment.rs | 2 +- tests/e2e/subtree_audit_testnet.rs | 2 +- tests/e2e/testnet.rs | 4 ++-- tests/webrtc_direct_devnet.rs | 2 +- 23 files changed, 75 insertions(+), 82 deletions(-) diff --git a/src/bin/ant-node/cli.rs b/src/bin/ant-node/cli.rs index dcde4a4b..64cba912 100644 --- a/src/bin/ant-node/cli.rs +++ b/src/bin/ant-node/cli.rs @@ -372,39 +372,41 @@ mod tests { use std::net::{IpAddr, Ipv4Addr}; #[test] - fn webrtc_direct_port_overrides_the_default_bind_port() { - let cli = Cli::try_parse_from(["ant-node", "--webrtc-direct-port", "45000"]) - .expect("parse WebRTC Direct port"); - let (config, _) = cli.into_config().expect("build node config"); + fn webrtc_direct_port_overrides_the_default_bind_port() -> Result<(), Box> + { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-direct-port", "45000"])?; + let (config, _) = cli.into_config()?; assert!(config.webrtc_direct.enabled); assert_eq!(config.webrtc_direct.bind.port(), 45_000); + Ok(()) } #[test] - fn webrtc_direct_port_overrides_only_the_explicit_bind_port() { + fn webrtc_direct_port_overrides_only_the_explicit_bind_port( + ) -> Result<(), Box> { let cli = Cli::try_parse_from([ "ant-node", "--webrtc-direct-bind", "127.0.0.1:40000", "--webrtc-direct-port", "45000", - ]) - .expect("parse WebRTC Direct bind and port"); - let (config, _) = cli.into_config().expect("build node config"); + ])?; + let (config, _) = cli.into_config()?; assert_eq!( config.webrtc_direct.bind.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST) ); assert_eq!(config.webrtc_direct.bind.port(), 45_000); + Ok(()) } #[test] - fn webrtc_port_alias_is_supported() { - let cli = Cli::try_parse_from(["ant-node", "--webrtc-port", "45000"]) - .expect("parse WebRTC port alias"); + fn webrtc_port_alias_is_supported() -> Result<(), Box> { + let cli = Cli::try_parse_from(["ant-node", "--webrtc-port", "45000"])?; assert_eq!(cli.webrtc_direct_port, Some(45_000)); + Ok(()) } } diff --git a/src/node.rs b/src/node.rs index 216d5778..002ed28d 100644 --- a/src/node.rs +++ b/src/node.rs @@ -387,7 +387,7 @@ impl NodeBuilder { if let Ok(cache_dir) = upgrade_cache_dir() { monitor = monitor.with_release_cache(ReleaseCache::new( cache_dir, - std::time::Duration::from_secs(3600), + std::time::Duration::from_hours(1), )); } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index ec8fa25f..29c1905a 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -2725,7 +2725,7 @@ impl PaymentVerifier { /// at which point a second leader can race for the same pool (see /// [`InflightGuard::drop`]). At steady state the pool cache and pool /// signature verification gate keep this rare in practice. - const CLOSENESS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(240); + const CLOSENESS_LOOKUP_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(4); /// Width of the storer's authoritative network lookup, in peers. /// @@ -5740,7 +5740,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Create a quote that's 25 hours old (exceeds 24-hour max) - let old_timestamp = SystemTime::now() - Duration::from_secs(25 * 3600); + let old_timestamp = SystemTime::now() - Duration::from_hours(25); let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5770,7 +5770,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Create a quote with a timestamp 1 hour in the future - let future_timestamp = SystemTime::now() + Duration::from_secs(3600); + let future_timestamp = SystemTime::now() + Duration::from_hours(1); let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5831,7 +5831,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Quote 360 seconds in the future — exceeds 300s tolerance - let future_timestamp = SystemTime::now() + Duration::from_secs(360); + let future_timestamp = SystemTime::now() + Duration::from_mins(6); let quote = make_fake_quote(xorname, future_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -5861,7 +5861,7 @@ mod tests { let rewards_addr = RewardsAddress::new([1u8; 20]); // Quote 23 hours old — within 24h max age - let old_timestamp = SystemTime::now() - Duration::from_secs(23 * 3600); + let old_timestamp = SystemTime::now() - Duration::from_hours(23); let quote = make_fake_quote(xorname, old_timestamp, rewards_addr); let mut peer_quotes = Vec::new(); @@ -7574,7 +7574,7 @@ mod tests { // failure mode from the trace in the doc comment will return. assert_eq!( PaymentVerifier::CLOSENESS_LOOKUP_TIMEOUT, - std::time::Duration::from_secs(240), + std::time::Duration::from_mins(4), "CLOSENESS_LOOKUP_TIMEOUT must be 240s; if changing this, update \ the iteration trace in the doc comment and re-validate on a \ fresh testnet" @@ -8166,7 +8166,7 @@ mod tests { let built = test_built_commitment(5); let commitment = built.commitment().clone(); let pin = built.hash(); - let ttl = std::time::Duration::from_secs(3 * 3600); + let ttl = std::time::Duration::from_hours(3); let now = std::time::Instant::now(); // Fresh AND matching pin -> resolves to the commitment. diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs index 9bbfd3ee..cd7271ad 100644 --- a/src/replication/commitment.rs +++ b/src/replication/commitment.rs @@ -372,7 +372,7 @@ pub fn verify_path( let mut cur = *leaf; let mut i = leaf_index; for sibling in path { - cur = if i % 2 == 0 { + cur = if i.is_multiple_of(2) { node_hash(&cur, sibling) } else { node_hash(sibling, &cur) diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index 8c7b2840..a49985b3 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -334,7 +334,7 @@ const MAX_RETAINED_GOSSIPED_SLOTS: usize = 16; /// stops being gossiped the pruner reclaims it promptly. At /// `RETAINED_GOSSIPED_COMMITMENTS = 2` this is `(2 + 1) ×` the 1 h rotation /// interval = 3 h. -pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_secs(3 * 3600); +pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_hours(3); /// Extra answerability margin applied ONLY when reloading retention after a /// restart (ADR-0004 A1). A gossip-stamp refresh in the last persist window may @@ -344,7 +344,7 @@ pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_secs(3 * 36 /// which is harmless — it only makes the responder answer a little longer, and a /// data-deleter still fails the round-2 slice challenge). Sized well above the /// persist interval + gossip cadence, far below the TTL. -const RESTART_STAMP_GRACE: Duration = Duration::from_secs(5 * 60); +const RESTART_STAMP_GRACE: Duration = Duration::from_mins(5); /// One persisted retention slot (ADR-0004 A1): the signed commitment, its /// committed key set (so the tree can be rebuilt without re-reading chunks), and diff --git a/src/replication/config.rs b/src/replication/config.rs index 488b8899..6cb7ea3f 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -846,13 +846,13 @@ pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; /// cooldown. Finer than the cooldown itself so a monetized commitment is /// first-audited promptly after its peer's window reopens; the retry just /// re-checks a small per-peer map, so the tick is cheap. -pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_secs(60); +pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_mins(1); /// Interval for the cumulative first-audit scheduler observability summary. /// /// Deliberately low frequency: this is intended for fleet-level Elasticsearch /// aggregation without recreating the high-volume logging load it measures. -pub const FIRST_AUDIT_SUMMARY_INTERVAL: Duration = Duration::from_secs(5 * 60); +pub const FIRST_AUDIT_SUMMARY_INTERVAL: Duration = Duration::from_mins(5); /// ADR-0004: max monetized-pin events the first-audit drainer drains from its /// channel per wake before it must run the audit-launch phase. @@ -875,7 +875,7 @@ pub const FIRST_AUDIT_DRAIN_BATCH: usize = 64; /// [`FIRST_AUDIT_BUDGET_BURST`] more in the first hour); steady-state demand /// sits far below it because nomination is paid-pin-only and re-nominations /// are suppressed by [`FIRST_AUDIT_PEER_REAUDIT_INTERVAL`]. -pub const FIRST_AUDIT_LAUNCH_INTERVAL: Duration = Duration::from_secs(5 * 60); +pub const FIRST_AUDIT_LAUNCH_INTERVAL: Duration = Duration::from_mins(5); /// ADR-0004 Amendment 2: token-bucket capacity for monetized first-audit /// launches. @@ -918,7 +918,7 @@ pub const FIRST_AUDIT_LAUNCH_JITTER_MAX: Duration = Duration::from_secs(30); /// only, so no gossip-lottery audit can ever cover them). Kept comfortably /// inside the 3h answerability TTL so a re-nomination after the window still /// lands in-window. Gossip-lottery re-audits are unaffected. -pub const FIRST_AUDIT_PEER_REAUDIT_INTERVAL: Duration = Duration::from_secs(2 * 3600); +pub const FIRST_AUDIT_PEER_REAUDIT_INTERVAL: Duration = Duration::from_hours(2); /// ADR-0004 Amendment 2: committed-count jump that overrides the per-peer /// re-audit window, as a ratio (`new > old * NUM / DEN`, integer math). @@ -1433,10 +1433,7 @@ mod tests { #[test] fn default_prune_hysteresis_is_three_days() { let config = ReplicationConfig::default(); - assert_eq!( - config.prune_hysteresis_duration, - Duration::from_secs(3 * 24 * 60 * 60) - ); + assert_eq!(config.prune_hysteresis_duration, Duration::from_hours(72)); } #[test] @@ -2000,8 +1997,8 @@ mod tests { #[test] fn scenario_31_audit_cadence_within_jitter_bounds() { let config = ReplicationConfig { - audit_tick_interval_min: Duration::from_secs(600), - audit_tick_interval_max: Duration::from_secs(1200), + audit_tick_interval_min: Duration::from_mins(10), + audit_tick_interval_max: Duration::from_mins(20), ..ReplicationConfig::default() }; diff --git a/src/replication/mod.rs b/src/replication/mod.rs index c9367996..594437b8 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -402,7 +402,7 @@ impl FirstAuditLimiter { /// call at ENQUEUE time — suppressed nominations never occupy pending /// slots — without disturbing LRU recency. fn window_allows(&self, peer: &PeerId, key_count: u32, now: Instant) -> bool { - self.recent.peek(peer).map_or(true, |prev| { + self.recent.peek(peer).is_none_or(|prev| { now.saturating_duration_since(prev.launched_at) >= config::FIRST_AUDIT_PEER_REAUDIT_INTERVAL || first_audit_count_jump(prev.key_count, key_count) @@ -558,7 +558,7 @@ async fn open_first_audit_reservation( /// needs to be comfortably past the next tick. fn first_audit_far_future() -> Instant { Instant::now() - .checked_add(Duration::from_secs(3600)) + .checked_add(Duration::from_hours(1)) .unwrap_or_else(Instant::now) } @@ -1541,7 +1541,7 @@ const BOOTSTRAP_STATE_SNAPSHOT_INTERVAL_SECS: u64 = 60; /// (`quote_ts ≈ now`), far from either bound. The gossip-lottery path (which pins /// the responder's OWN freshly-gossiped root) is the clock-skew-immune backstop. /// 30 min dwarfs any realistic honest skew while leaving a wide audit window. -const MONETIZED_AUDIT_SKEW_MARGIN: Duration = Duration::from_secs(30 * 60); +const MONETIZED_AUDIT_SKEW_MARGIN: Duration = Duration::from_mins(30); /// ADR-0004 A1 (guardrail A): whether a monetized pin's SIGNED `quote_ts` lands /// inside the answerability window relative to `now`, so first-auditing it cannot @@ -1568,7 +1568,7 @@ fn quote_within_audit_window(quote_ts: SystemTime, now: SystemTime) -> bool { /// per gossip message. This rate limit caps the verify-per-peer rate /// at 1/min, which is comfortably above the legitimate gossip cadence /// (the 10-20 min neighbor-sync round on each peer). -const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_secs(60); +const COMMITMENT_SIG_VERIFY_MIN_INTERVAL: Duration = Duration::from_mins(1); /// Hard cap on the size of `last_commitment_by_peer`. /// @@ -9440,9 +9440,8 @@ fn cooldown_allows_audit(map: &mut HashMap, peer: &PeerId, now: /// so this is only an optimization and never the security boundary. fn cooldown_would_allow(map: &HashMap, peer: &PeerId, now: Instant) -> bool { let cooldown = Duration::from_secs(config::AUDIT_ON_GOSSIP_COOLDOWN_SECS); - map.get(peer).map_or(true, |&last| { - now.saturating_duration_since(last) >= cooldown - }) + map.get(peer) + .is_none_or(|&last| now.saturating_duration_since(last) >= cooldown) } /// The gossip-audit launch decision in ONE place so the ordering is shared @@ -11609,7 +11608,7 @@ mod tests { limiter.commit_launch(peer, 100, base); // A rotated pin with a similar count inside the window is dropped... - let soon = base + Duration::from_secs(60); + let soon = base + Duration::from_mins(1); assert_eq!( limiter.assess(&peer, 100, soon, 0), LimiterVerdict::WindowDeduped @@ -11688,7 +11687,7 @@ mod tests { // horizon. let future = now .checked_add(MONETIZED_AUDIT_SKEW_MARGIN) - .and_then(|t| t.checked_add(Duration::from_secs(60))) + .and_then(|t| t.checked_add(Duration::from_mins(1))) .expect("future"); assert!(!quote_answerable_through_nominal_jitter(future, now)); } @@ -11714,7 +11713,7 @@ mod tests { let dead_quote = SystemTime::now() .checked_sub(GOSSIP_ANSWERABILITY_TTL) - .and_then(|t| t.checked_sub(Duration::from_secs(60))) + .and_then(|t| t.checked_sub(Duration::from_mins(1))) .expect("past wall time"); let stale_high = MonetizedPinEvent { peer, @@ -12017,7 +12016,7 @@ mod tests { let dead_quote = SystemTime::now() .checked_sub(GOSSIP_ANSWERABILITY_TTL) - .and_then(|t| t.checked_sub(Duration::from_secs(60))) + .and_then(|t| t.checked_sub(Duration::from_mins(1))) .expect("past wall time"); scheduler.enqueue( MonetizedPinEvent { @@ -12716,17 +12715,14 @@ mod tests { let now = SystemTime::now(); // Fresh (just quoted) and small future/past skew -> audited. assert!(quote_within_audit_window(now, now)); + assert!(quote_within_audit_window(now + Duration::from_mins(1), now)); assert!(quote_within_audit_window( - now + Duration::from_secs(60), - now - )); - assert!(quote_within_audit_window( - now - Duration::from_secs(3600), + now - Duration::from_hours(1), now )); // Far future (badly-skewed / replayed) -> skipped. assert!(!quote_within_audit_window( - now + MONETIZED_AUDIT_SKEW_MARGIN + Duration::from_secs(60), + now + MONETIZED_AUDIT_SKEW_MARGIN + Duration::from_mins(1), now )); // Older than the window -> skipped (pin may have aged out). diff --git a/src/replication/neighbor_sync.rs b/src/replication/neighbor_sync.rs index 3ab9cab6..8e1d4d01 100644 --- a/src/replication/neighbor_sync.rs +++ b/src/replication/neighbor_sync.rs @@ -608,7 +608,7 @@ mod tests { .last_sync_times .insert(peer_id_from_byte(3), Instant::now()); - let cooldown = Duration::from_secs(3600); // 1 hour + let cooldown = Duration::from_hours(1); // 1 hour let batch = select_sync_batch(&mut state, 2, cooldown); // Peer 1 and peer 3 should be skipped (removed from order). @@ -668,7 +668,7 @@ mod tests { .last_sync_times .insert(peer_id_from_byte(2), Instant::now()); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); let batch = select_sync_batch(&mut state, 4, cooldown); assert!(batch.is_empty()); @@ -806,7 +806,7 @@ mod tests { let peers: Vec = (1..=8).map(peer_id_from_byte).collect(); let mut state = NeighborSyncState::new_cycle(peers); let batch_size = 4; - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark peers 2 and 4 as recently synced (on cooldown). state @@ -1019,7 +1019,7 @@ mod tests { peer_id_from_byte(3), ]; let mut state = NeighborSyncState::new_cycle(peers); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark peer 2 as recently synced. state @@ -1105,7 +1105,7 @@ mod tests { let peer_count: u8 = 10; let peers: Vec = (1..=peer_count).map(peer_id_from_byte).collect(); let mut state = NeighborSyncState::new_cycle(peers); - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); // Mark all peers as recently synced. for i in 1..=peer_count { @@ -1309,7 +1309,7 @@ mod tests { // The preserved cooldowns cause peers 1,2 to be removed, leaving // only peer 3 selected. - let cooldown = Duration::from_secs(3600); + let cooldown = Duration::from_hours(1); let batch = select_sync_batch(&mut new_state, 3, cooldown); assert_eq!( batch, diff --git a/src/replication/paid_list.rs b/src/replication/paid_list.rs index f65172c1..d71bf059 100644 --- a/src/replication/paid_list.rs +++ b/src/replication/paid_list.rs @@ -1129,7 +1129,7 @@ mod tests { // cannot represent the backdated time (the claim-age assertion is // skipped in that case since the subtraction itself proves nothing // about production behaviour). - let grace_plus_margin = grace_period + std::time::Duration::from_secs(3600); + let grace_plus_margin = grace_period + std::time::Duration::from_hours(1); let first_seen = Instant::now() .checked_sub(grace_plus_margin) .unwrap_or_else(Instant::now); diff --git a/src/replication/recent_provers.rs b/src/replication/recent_provers.rs index e10be92c..63f3f4be 100644 --- a/src/replication/recent_provers.rs +++ b/src/replication/recent_provers.rs @@ -61,7 +61,7 @@ pub const MAX_PROVERS_PER_KEY: usize = 16; /// before re-audit is required. 40 min comfortably covers one audit /// cycle on the average peer while still requiring re-proof inside the /// rotation window. -pub const PROVER_ENTRY_TTL: Duration = Duration::from_secs(40 * 60); +pub const PROVER_ENTRY_TTL: Duration = Duration::from_mins(40); /// One cached prover entry: who proved the key, when, and against which /// commitment. diff --git a/src/replication/scheduling.rs b/src/replication/scheduling.rs index 79724291..2f1bb581 100644 --- a/src/replication/scheduling.rs +++ b/src/replication/scheduling.rs @@ -2413,7 +2413,7 @@ mod tests { let key = xor_name_from_byte(0x01); queues.add_pending_verify(key, test_entry(1)); - let evicted = queues.evict_stale(Duration::from_secs(3600)); + let evicted = queues.evict_stale(Duration::from_hours(1)); assert!( evicted.is_empty(), "fresh entry should not be reported as evicted" diff --git a/src/replication/subtree.rs b/src/replication/subtree.rs index 2fc8e2ee..a50a628f 100644 --- a/src/replication/subtree.rs +++ b/src/replication/subtree.rs @@ -153,7 +153,7 @@ fn sqrt_floor(key_count: u32) -> u32 { let mut y = x.div_ceil(2); while y < x { x = y; - y = (x + n / x) / 2; + y = u64::midpoint(x, n / x); } // x == floor(sqrt(n)) here. let ceil = if x.saturating_mul(x) == n { x } else { x + 1 }; diff --git a/src/replication/types.rs b/src/replication/types.rs index 040572e5..c96c69e5 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -1474,7 +1474,7 @@ mod tests { let peer = peer_id_from_byte(9); let mut state = NeighborSyncState::new_cycle(vec![peer]); let first_seen = Instant::now(); - let grace = Duration::from_secs(60); + let grace = Duration::from_mins(1); assert_eq!( state.observe_bootstrap_claim(peer, first_seen, grace), @@ -1503,7 +1503,7 @@ mod tests { let peer = peer_id_from_byte(10); let mut state = NeighborSyncState::new_cycle(vec![peer]); let first_seen = Instant::now(); - let grace = Duration::from_secs(60); + let grace = Duration::from_mins(1); let _ = state.observe_bootstrap_claim(peer, first_seen, grace); diff --git a/src/upgrade/apply.rs b/src/upgrade/apply.rs index 7af8c7d7..897bd8eb 100644 --- a/src/upgrade/apply.rs +++ b/src/upgrade/apply.rs @@ -52,7 +52,7 @@ impl AutoApplyUpgrader { current_version, client: reqwest::Client::builder() .user_agent(concat!("ant-node/", env!("CARGO_PKG_VERSION"))) - .timeout(std::time::Duration::from_secs(300)) + .timeout(std::time::Duration::from_mins(5)) .build() .unwrap_or_else(|_| reqwest::Client::new()), binary_cache: None, diff --git a/src/upgrade/monitor.rs b/src/upgrade/monitor.rs index b6d6b0a3..e366a398 100644 --- a/src/upgrade/monitor.rs +++ b/src/upgrade/monitor.rs @@ -296,7 +296,7 @@ impl UpgradeMonitor { let is_new_version = self .pending_upgrade_version .as_ref() - .map_or(true, |v| *v != info.version); + .is_none_or(|v| *v != info.version); if is_new_version { // New version detected - start rollout timer @@ -564,9 +564,7 @@ fn select_upgrade_from_releases( release_notes: release.body.clone(), }; - let should_replace = best - .as_ref() - .map_or(true, |b| candidate.version > b.version); + let should_replace = best.as_ref().is_none_or(|b| candidate.version > b.version); if should_replace { best = Some(candidate); @@ -894,10 +892,10 @@ mod tests { #[test] fn test_check_interval() { let monitor = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 24); - assert_eq!(monitor.check_interval(), Duration::from_secs(24 * 3600)); + assert_eq!(monitor.check_interval(), Duration::from_hours(24)); let monitor2 = UpgradeMonitor::new("test/repo".to_string(), UpgradeChannel::Stable, 6); - assert_eq!(monitor2.check_interval(), Duration::from_secs(6 * 3600)); + assert_eq!(monitor2.check_interval(), Duration::from_hours(6)); } /// Test 11: Process release - upgrade available diff --git a/src/upgrade/release_cache.rs b/src/upgrade/release_cache.rs index af1496bc..e2d967e8 100644 --- a/src/upgrade/release_cache.rs +++ b/src/upgrade/release_cache.rs @@ -289,7 +289,7 @@ mod tests { #[test] fn test_write_read_roundtrip() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); cache.write("owner/repo", &sample_releases()).unwrap(); @@ -314,7 +314,7 @@ mod tests { #[test] fn test_wrong_repo_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); cache.write("owner/repo", &sample_releases()).unwrap(); @@ -324,7 +324,7 @@ mod tests { #[test] fn test_corrupted_file_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); fs::write(cache.cache_file(), "not valid json!!!").unwrap(); @@ -334,7 +334,7 @@ mod tests { #[test] fn test_missing_file_returns_none() { let tmp = TempDir::new().unwrap(); - let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_secs(300)); + let cache = ReleaseCache::new(tmp.path().to_path_buf(), Duration::from_mins(5)); assert!(cache.read_if_valid("owner/repo").is_none()); } diff --git a/src/upgrade/rollout.rs b/src/upgrade/rollout.rs index 0a391863..e0e9ba0c 100644 --- a/src/upgrade/rollout.rs +++ b/src/upgrade/rollout.rs @@ -179,7 +179,7 @@ mod tests { let delay = rollout.calculate_delay(); // Should be between 0 and 24 hours - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); assert!(rollout.is_enabled()); } @@ -254,7 +254,7 @@ mod tests { let rollout = StagedRollout::new(&large_id, 24); let delay = rollout.calculate_delay(); - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); } /// Test 9: Empty node ID handled @@ -264,7 +264,7 @@ mod tests { let delay = rollout.calculate_delay(); // Should still produce a valid delay - assert!(delay <= Duration::from_secs(24 * 3600)); + assert!(delay <= Duration::from_hours(24)); } /// Test 10: Distribution test - ensure delays are spread across window diff --git a/src/web_rtc.rs b/src/web_rtc.rs index eb62a034..c548dbdf 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -45,7 +45,7 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio_util::sync::CancellationToken; const MAX_FIND_NODE_RESULTS: usize = 20; -const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_mins(1); const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); const AUTOMATIC_PORT_MIN: u32 = 32_768; const AUTOMATIC_PORT_COUNT: u32 = 65_536 - AUTOMATIC_PORT_MIN; diff --git a/tests/e2e/integration_tests.rs b/tests/e2e/integration_tests.rs index 4c11d7d1..8369d445 100644 --- a/tests/e2e/integration_tests.rs +++ b/tests/e2e/integration_tests.rs @@ -90,7 +90,7 @@ async fn test_custom_network_config() { node_count: 7, bootstrap_count: 2, spawn_delay: Duration::from_millis(100), - stabilization_timeout: Duration::from_secs(60), + stabilization_timeout: Duration::from_mins(1), ..Default::default() }; diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs index 9a004db4..168f9074 100644 --- a/tests/e2e/merkle_payment.rs +++ b/tests/e2e/merkle_payment.rs @@ -615,7 +615,7 @@ async fn test_attack_merkle_pay_yourself_fabricated_pool() -> Result<(), Box= 1 || s.timed_out >= 1 || s.failed >= 1 }) .await; diff --git a/tests/e2e/testnet.rs b/tests/e2e/testnet.rs index a281f5ea..89c332f0 100644 --- a/tests/e2e/testnet.rs +++ b/tests/e2e/testnet.rs @@ -1522,7 +1522,7 @@ impl TestNetwork { /// Returns an error if any node's bootstrap does not complete within /// the timeout. async fn wait_for_replication_bootstrap(&self) -> Result<()> { - const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120); + const BOOTSTRAP_TIMEOUT: Duration = Duration::from_mins(2); for node in &self.nodes { if let Some(ref engine) = node.replication_engine { @@ -1719,7 +1719,7 @@ impl TestNetwork { /// Returns an error if node creation, startup, or bootstrap fails. pub async fn add_node(&mut self) -> Result { const DHT_WARMUP_QUERIES: usize = 10; - const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120); + const BOOTSTRAP_TIMEOUT: Duration = Duration::from_mins(2); let index = self.nodes.len(); diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs index 72363a60..43dad20d 100644 --- a/tests/webrtc_direct_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -80,7 +80,7 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint assert_eq!(hello["peer_id"], parsed_endpoint.peer_id); assert_eq!( hello["endpoint"]["multiaddr"], - endpoint.endpoint.multiaddr.to_string() + endpoint.endpoint.multiaddr.clone() ); assert!(hello_content.is_empty()); From ff5ee68df4e1f0b43e9dff928a2d8995f6052f9e Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:13:12 +0200 Subject: [PATCH 22/31] refactor(webrtc): consume Saorsa profile crate --- Cargo.lock | 22 ++++++++++++++++++- Cargo.toml | 4 +++- ...rect-browser-clients-over-webrtc-direct.md | 22 ++++++++++--------- src/browser.rs | 2 +- src/web_rtc.rs | 20 ++++++++--------- tests/webrtc_direct_devnet.rs | 10 ++++----- 6 files changed, 52 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd1b7c42..a685abd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -856,6 +856,7 @@ dependencies = [ "saorsa-core", "saorsa-pqc 0.5.1", "saorsa-transport", + "saorsa-webrtc", "self-replace", "self_encryption", "semver 1.0.28", @@ -5355,6 +5356,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "saorsa-webrtc" +version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=da3a6934d85eb3fb63d7f489c362abee158d6ce8#da3a6934d85eb3fb63d7f489c362abee158d6ce8" +dependencies = [ + "base64", + "blake3", + "chacha20poly1305", + "fips203", + "fips204", + "getrandom 0.2.17", + "hex", + "serde", + "serde_bytes", + "serde_json", + "thiserror 2.0.18", + "zeroize", +] + [[package]] name = "schannel" version = "0.1.29" diff --git a/Cargo.toml b/Cargo.toml index 948adb39..5c7f12b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ path = "src/bin/ant-devnet/main.rs" # musl Linux builds (and tends to beat glibc's allocator too). mimalloc = "0.1" -# Wire protocol — the single version-pin shared with ant-client. +# Native application wire protocol shared with ant-client. # Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples # through here automatically; we keep a direct saorsa-core dep for # node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which @@ -40,6 +40,8 @@ mimalloc = "0.1" # (the rc-2026.4.2 branch) so Cargo can unify the wire types here # with ant-protocol's re-exports. ant-protocol = "2.3.5" +# Portable WebRTC Direct profile shared by the node and browser WASM adapter. +saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "da3a6934d85eb3fb63d7f489c362abee158d6ce8" } # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.27.3" 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 index 9b47688b..b89d9a36 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -176,12 +176,14 @@ 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. +validated endpoint/address integration. The standalone `saorsa-webrtc` crate +in the transport workspace owns the portable post-quantum handshake, +encrypted-record layer, browser RPC schema, outer framing, address codec, and +transfer limits. `ant-core` owns the runtime-neutral client algorithms and the +browser WASM facade; `ant-node` owns the bounded browser RPC adapter. The two +sides use the same Rust protocol implementation, while the browser transport +adapter calls `RTCPeerConnection` directly through Web APIs. `ant-protocol` +remains independent of the selected carrier transport. 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, @@ -241,7 +243,7 @@ slot for a hypothetical channel reopen. Connection and channel handlers are children of bounded `JoinSet`s rather than detached tasks. Their semaphore permits and source counters are RAII guards. Normal shutdown drains connection tasks for five seconds, then aborts -and joins any remainder; closing a connection also aborts and joins its +and joins any remainder; closing a connection cancels and joins its remaining channel tasks. A channel waiting for its next frame has a 60-second idle deadline. Partial-frame reads and all response writes have total, size-scaled transfer deadlines, so slow senders and readers cannot retain work @@ -601,7 +603,7 @@ 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` +The browser and node adapters also consume the same `saorsa-webrtc` post-quantum session and framing module. Cryptographic transcript construction, key derivation, sequence handling, record authentication, and frame bounds are not reimplemented in JavaScript or separately in `ant-node`. Existing native @@ -683,7 +685,7 @@ The earlier feature-gated WebTransport PoC has been replaced by the - 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` +- a protocol v4 browser session backed by the shared `saorsa-webrtc` post-quantum session v1, which performs ephemeral ML-KEM-768 key establishment, authenticates the transcript and ANT peer ID with ML-DSA-65, derives direction-separated keys, and protects every later application frame @@ -765,7 +767,7 @@ This harness proves direct endpoint discovery, encrypted session reuse, and the node request path. It does **not** execute the browser WASM iterative lookup state machine. `ant-client` CI builds and lints the WASM target and runs its generated bindings in Node, but browser-side iterative parity remains a -promotion requirement below. Shared `ant-protocol` unit tests additionally +promotion requirement below. Shared `saorsa-webrtc` unit tests additionally cover record tampering and replay, wrong peer IDs, tampered node signatures, invalid outer frames, and version mismatch. diff --git a/src/browser.rs b/src/browser.rs index 2ce9bbfa..160fb3c6 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -5,7 +5,7 @@ //! records: browsers sign EVM transactions locally and send only payment //! receipts to nodes. -pub use ant_protocol::web_rtc::{BrowserEndpoint, BrowserPaymentNetwork, WebRtcDirectEndpoint}; +pub use saorsa_webrtc::{BrowserEndpoint, BrowserPaymentNetwork, WebRtcDirectEndpoint}; use serde::{Deserialize, Serialize}; /// Version of the local browser bootstrap manifest. diff --git a/src/web_rtc.rs b/src/web_rtc.rs index c548dbdf..b63950da 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -2,7 +2,7 @@ //! //! 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 +//! in `saorsa-webrtc` uses ML-KEM-768, ML-DSA-65, and ChaCha20-Poly1305 to bind //! the node identity and protect every browser RPC without libp2p or Noise. use crate::ant_protocol::{ @@ -15,7 +15,15 @@ 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::{ +use evmlib::common::{Amount, TxHash}; +use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; +use parking_lot::{Mutex, RwLock}; +use saorsa_core::identity::NodeIdentity; +use saorsa_core::{DHTNode, MultiAddr, P2PNode, PeerId}; +use saorsa_transport::webrtc_direct::{ + WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, +}; +use saorsa_webrtc::{ accept_pq_session, decode_pq_frame, encode_response_frame, parse_request_header, pq_frame_length, transfer_timeout, BrowserCommitmentArtifact, BrowserNode, BrowserQuoteArtifact, BrowserRequest as Request, BrowserRequestBody as RequestBody, @@ -25,14 +33,6 @@ use ant_protocol::web_rtc::{ PQ_ENCRYPTED_OVERHEAD_BYTES, PQ_FRAME_PREFIX_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, }; -use evmlib::common::{Amount, TxHash}; -use evmlib::{EncodedPeerId, PaymentQuote, ProofOfPayment, RewardsAddress}; -use parking_lot::{Mutex, RwLock}; -use saorsa_core::identity::NodeIdentity; -use saorsa_core::{DHTNode, MultiAddr, P2PNode, PeerId}; -use saorsa_transport::webrtc_direct::{ - WebRtcCertificate, WebRtcDataChannel, WebRtcDirectConnection, WebRtcDirectListener, -}; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs index 43dad20d..9de71e63 100644 --- a/tests/webrtc_direct_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -2,11 +2,6 @@ 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, - BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION, PQ_ENCRYPTED_OVERHEAD_BYTES, - PQ_SERVER_ACCEPT_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, -}; use ant_protocol::MAX_CHUNK_SIZE; use bytes::Bytes; use evmlib::common::{Amount, QuoteHash}; @@ -14,6 +9,11 @@ use evmlib::wallet::Wallet; use evmlib::RewardsAddress; use saorsa_transport::transport::{WebRtcCertificateHash, WebRtcDirectAddr}; use saorsa_transport::webrtc_direct::{WebRtcDataChannel, WebRtcDirectClient}; +use saorsa_webrtc::{ + decode_pq_frame, encode_pq_frame, pq_frame_length, PqClientHandshake, PqSession, + BROWSER_PROTOCOL_NAME, BROWSER_PROTOCOL_VERSION, PQ_ENCRYPTED_OVERHEAD_BYTES, + PQ_SERVER_ACCEPT_BYTES, WEBRTC_DIRECT_DATA_CHANNEL, WEBRTC_WRITE_CHUNK_BYTES, +}; use self_encryption::{DataMap, EncryptedChunk}; use serde_json::{json, Value}; use std::error::Error; From 24b46ee630158ebe631cc1aada2e01430b2f9206 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:25:28 +0200 Subject: [PATCH 23/31] chore(deps): keep browser transport out of ant-protocol --- Cargo.lock | 2 ++ Cargo.toml | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a685abd1..4e037c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5368,10 +5368,12 @@ dependencies = [ "fips204", "getrandom 0.2.17", "hex", + "postcard", "serde", "serde_bytes", "serde_json", "thiserror 2.0.18", + "tiny-keccak", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 5c7f12b9..59622f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,10 +35,6 @@ mimalloc = "0.1" # node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which # Cargo unifies with ant-protocol's version constraint. # -# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. -# Until then, the git pin tracks the matching saorsa-core lineage -# (the rc-2026.4.2 branch) so Cargo can unify the wire types here -# with ant-protocol's re-exports. ant-protocol = "2.3.5" # Portable WebRTC Direct profile shared by the node and browser WASM adapter. saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "da3a6934d85eb3fb63d7f489c362abee158d6ce8" } From bb7d7741df0cbd18611e172a8c3957a3ea0a1483 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:26:10 +0200 Subject: [PATCH 24/31] docs(webrtc): record neutral protocol boundary --- .../adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index b89d9a36..d3278092 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -183,7 +183,9 @@ transfer limits. `ant-core` owns the runtime-neutral client algorithms and the browser WASM facade; `ant-node` owns the bounded browser RPC adapter. The two sides use the same Rust protocol implementation, while the browser transport adapter calls `RTCPeerConnection` directly through Web APIs. `ant-protocol` -remains independent of the selected carrier transport. +remains independent of the selected carrier transport; this design uses its +released `2.3.1` API without WebRTC-specific source, feature, or dependency +changes. The native ML-KEM/ML-DSA transport remains the node-to-node transport and is not downgraded or replaced. The WebRTC listener has independent connection, From d757795cb8bc605230e4ffd8eb007bd184975b61 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:20:26 +0200 Subject: [PATCH 25/31] chore(deps): align rebased browser support stack --- Cargo.lock | 1439 +++++++++++++++++++++++++++++----------------------- 1 file changed, 799 insertions(+), 640 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e037c02..cd235ce8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,9 +54,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -109,9 +109,9 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.35" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b5cc30538e90795a57647bef8d8864aad6e8d86190617009b4ef8d8b647b49a" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" dependencies = [ "alloy-primitives", "num_enum", @@ -137,12 +137,12 @@ dependencies = [ "either", "k256", "once_cell", - "rand 0.8.6", + "rand 0.8.8", "secp256k1 0.30.0", "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -178,15 +178,15 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "alloy-core" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62ddde5968de6044d67af107ad835bc0069a7ca245870b94c5958a7d8712b184" +checksum = "fa2d25cf04344ea5eeb47e0cd21c794e646a029959bf5700bd8c05342c0353fe" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -197,9 +197,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a475bb02d9cef2dbb99065c1664ab3fe1f9352e21d6d5ed3f02cdbfc06ed1abc" +checksum = "201b9e973fe90b2effd9ab356d4f2a46ab56046ba9d46f163367553e72c045b0" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -208,7 +208,7 @@ dependencies = [ "itoa", "serde", "serde_json", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -221,14 +221,14 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "alloy-eip2930" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -246,7 +246,7 @@ dependencies = [ "alloy-rlp", "borsh", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -260,7 +260,7 @@ dependencies = [ "borsh", "once_cell", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -316,9 +316,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c36c9d7f9021601b04bfef14a4b64849f6d73116a4e91e071d7fbfe10247901" +checksum = "1dba4e59c3581a39e03e0b0b4a46ee9c41315b5d843b1e903271952b32bedb7c" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -337,7 +337,7 @@ dependencies = [ "http", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -364,7 +364,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -394,34 +394,35 @@ dependencies = [ "alloy-signer-local", "k256", "libc", - "rand 0.8.6", + "rand 0.8.8", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", ] [[package]] name = "alloy-primitives" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4885c1409b6936c4898e646ef58baf6ec54edaf6d8179f79df805a7b85b7cf3e" +checksum = "ce7b00f0cb42c66ec353076ded1dff1fbf818f6e0e26c40c8a8456c04483fca4" dependencies = [ "alloy-rlp", "bytes", "cfg-if", "const-hex", "derive_more", + "fixed-cache", "foldhash", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "k256", "keccak-asm", "paste", "proptest", - "rand 0.9.4", + "rand 0.9.5", "rapidhash", "ruint", "rustc-hash", @@ -464,7 +465,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -490,7 +491,7 @@ checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -569,7 +570,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -595,7 +596,7 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -610,48 +611,48 @@ dependencies = [ "alloy-signer", "async-trait", "k256", - "rand 0.8.6", - "thiserror 2.0.18", + "rand 0.8.8", + "thiserror 2.0.20", ] [[package]] name = "alloy-sol-macro" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840128ed2b2971d6d4668a553fe403a82683d3acc646c73e75887e7157408033" +checksum = "c64558980fb038cd34b4285ec2b36a2a8bd8d4ddd13b3f6e42d97cb5ee29938e" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63ec265e5d65d725175f6ca7711c970824c90ef9c0d1f1973711d4150ee612dd" +checksum = "1ffb0e793abdbaea9d01259493c8272c3af295d03389e70a2acdf53b57ad1edf" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.14.0", - "proc-macro-error2", + "indexmap 2.14.1", + "proc-macro-error3", "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-macro-input" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89bf01077f18650876cfa682eb1f949967b5cde03f1a51c955c469d2c9b4aa67" +checksum = "32c2c0ec8425d9663dac939ba75750f17d2933d2f020814b4f8fde4a60da6d26" dependencies = [ "alloy-json-abi", "const-hex", @@ -661,25 +662,25 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857b470ecdd2ed38beaf82ad1a38c516a8ff75266750f38b9eeed001d575241b" +checksum = "96b37db6a7ad8170596345f864522f789cc7af093f516d6cdf34bbdcfba8adab" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "alloy-sol-types" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384cf252de0db2dec52821eac037a7f57e2aa33fe5b900ce6fe39973402341f1" +checksum = "1f40e33a0f588dde548c3b6767a71e61b593421a8c1b253b9cf1441dc7cf7ab5" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -695,14 +696,14 @@ checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" dependencies = [ "alloy-json-rpc", "auto_impl", - "base64", + "base64 0.22.1", "derive_more", "futures", "futures-utils-wasm", "parking_lot", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tower", "tracing", @@ -738,7 +739,7 @@ dependencies = [ "nybbles", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -751,14 +752,14 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -850,11 +851,11 @@ dependencies = [ "parking_lot", "postcard", "proptest", - "rand 0.8.6", + "rand 0.8.8", "reqwest", "rmp-serde", "saorsa-core", - "saorsa-pqc 0.5.1", + "saorsa-pqc", "saorsa-transport", "saorsa-webrtc", "self-replace", @@ -866,7 +867,7 @@ dependencies = [ "sha2", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-test", "tokio-util", @@ -899,9 +900,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -1023,7 +1024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1033,7 +1034,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1071,7 +1072,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1084,7 +1085,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1141,7 +1142,7 @@ checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1151,7 +1152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1161,7 +1162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1171,7 +1172,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -1181,15 +1182,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.8", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.8" @@ -1224,7 +1219,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -1236,7 +1231,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -1248,7 +1243,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -1260,7 +1255,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1282,18 +1277,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1317,7 +1312,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http", "log", "url", @@ -1331,7 +1326,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1342,9 +1337,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -1352,9 +1347,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -1372,7 +1367,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link 0.2.1", @@ -1400,6 +1395,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -1441,21 +1442,20 @@ dependencies = [ [[package]] name = "bitcoin-consensus-encoding" -version = "1.0.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" dependencies = [ "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", ] [[package]] name = "bitcoin-internals" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" -dependencies = [ - "hex-conservative 0.3.2", -] +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" [[package]] name = "bitcoin-io" @@ -1473,7 +1473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.2", + "hex-conservative 0.2.3", ] [[package]] @@ -1484,9 +1484,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -1505,16 +1505,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -1555,9 +1554,9 @@ dependencies = [ [[package]] name = "blst" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", @@ -1567,9 +1566,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -1578,15 +1577,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1633,9 +1632,9 @@ checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -1645,9 +1644,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1673,9 +1672,9 @@ dependencies = [ [[package]] name = "c-kzg" -version = "2.1.7" +version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6648ed1e4ea8e8a1a4a2c78e1cda29a3fd500bc622899c340d8525ea9a76b24a" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" dependencies = [ "blst", "cc", @@ -1697,9 +1696,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1727,9 +1726,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -1744,12 +1743,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -1793,9 +1792,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -1803,9 +1802,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -1815,14 +1814,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -1846,7 +1845,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1884,9 +1883,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -1989,9 +1988,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -2013,9 +2012,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -2139,7 +2138,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2163,7 +2162,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2174,7 +2173,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2193,9 +2192,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "deflate64" @@ -2203,6 +2202,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" @@ -2270,7 +2300,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2292,7 +2322,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] @@ -2375,19 +2405,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "objc2", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -2399,6 +2429,43 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "dtls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser 9.0.0", + "hmac", + "log", + "p256", + "p384", + "pem 3.0.6", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", + "x25519-dalek", + "x509-parser 0.16.0", +] + [[package]] name = "dunce" version = "1.0.5" @@ -2426,31 +2493,6 @@ dependencies = [ "spki", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "educe" version = "0.6.0" @@ -2460,14 +2502,14 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -2508,22 +2550,22 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "enum-ordinalize" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -2535,7 +2577,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2564,7 +2606,7 @@ dependencies = [ "ant-merkle", "exponential-backoff", "hex", - "rand 0.8.6", + "rand 0.8.8", "rmp-serde", "serde", "serde_with", @@ -2586,19 +2628,20 @@ dependencies = [ [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fastrlp" @@ -2650,9 +2693,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fips203" @@ -2690,6 +2733,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -2697,19 +2750,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.6", + "rand 0.8.8", "rustc-hex", "static_assertions", ] [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2757,9 +2811,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -2772,9 +2826,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2782,15 +2836,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -2799,38 +2853,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2919,9 +2973,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "group" @@ -2936,9 +2990,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.16" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -2946,7 +3000,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.1", "slab", "tokio", "tokio-util", @@ -3022,7 +3076,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "byteorder", "heed-traits", "heed-types", @@ -3056,9 +3110,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3068,18 +3122,18 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" dependencies = [ "arrayvec", ] [[package]] name = "hex-conservative" -version = "0.3.2" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ "arrayvec", ] @@ -3125,9 +3179,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -3135,9 +3189,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -3145,9 +3199,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -3164,18 +3218,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -3213,7 +3267,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3224,7 +3278,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -3242,7 +3296,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3256,9 +3310,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3270,9 +3324,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3283,9 +3337,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3297,16 +3351,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3317,15 +3372,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3400,7 +3455,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3422,9 +3477,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3444,29 +3499,30 @@ dependencies = [ [[package]] name = "interceptor" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ab04c530fd82e414e40394cabe5f0ebfe30d119f10fe29d6e3561926af412e" +checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98" dependencies = [ "async-trait", "bytes", + "futures", "log", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "rtcp", "rtp", "thiserror 1.0.69", "tokio", "waitgroup", "webrtc-srtp", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -3507,6 +3563,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -3519,7 +3628,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -3534,7 +3643,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3553,7 +3662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3568,9 +3677,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -3602,12 +3711,12 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -3653,9 +3762,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -3674,9 +3783,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -3689,9 +3798,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lmdb-master-sys" @@ -3715,9 +3824,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -3763,7 +3872,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3787,9 +3896,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -3829,6 +3938,15 @@ name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -3836,9 +3954,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -3864,7 +3982,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -3908,9 +4026,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -3953,7 +4071,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3994,7 +4112,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -4011,7 +4129,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -4077,9 +4195,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] name = "p256" @@ -4140,7 +4258,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4201,7 +4319,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -4222,9 +4350,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -4256,7 +4384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.6", + "rand 0.8.8", ] [[package]] @@ -4269,7 +4397,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4307,7 +4435,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4334,9 +4462,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" @@ -4367,6 +4495,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -4382,9 +4519,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -4430,36 +4567,36 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -4472,9 +4609,9 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec 0.8.0", - "bitflags 2.13.0", + "bitflags 2.13.1", "num-traits", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -4502,8 +4639,8 @@ dependencies = [ "quinn-udp 0.5.15", "rustc-hash", "rustls", - "socket2", - "thiserror 2.0.18", + "socket2 0.6.5", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -4511,9 +4648,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -4526,7 +4663,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4541,7 +4678,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -4554,16 +4691,16 @@ checksum = "76150b617afc75e6e21ac5f39bc196e80b65415ae48d62dbef8e2519d040ce42" dependencies = [ "cfg_aliases", "libc", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4588,9 +4725,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4600,9 +4737,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4615,7 +4752,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4718,7 +4855,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem", + "pem 3.0.6", "ring", "rustls-pki-types", "time", @@ -4728,11 +4865,11 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ - "pem", + "pem 4.0.0", "ring", "rustls-pki-types", "time", @@ -4746,7 +4883,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -4768,34 +4905,34 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4805,9 +4942,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4826,7 +4963,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http", @@ -4912,28 +5049,28 @@ dependencies = [ [[package]] name = "rtcp" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8306430fb118b7834bbee50e744dc34826eca1da2158657a3d6cbc70e24c2096" +checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec" dependencies = [ "bytes", "thiserror 1.0.69", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "rtp" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e68baca5b6cb4980678713f0d06ef3a432aa642baefcbfd0f4dd2ef9eb5ab550" +checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023" dependencies = [ "bytes", "memchr", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "serde", "thiserror 1.0.69", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -4956,8 +5093,8 @@ dependencies = [ "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.6", - "rand 0.9.4", + "rand 0.8.8", + "rand 0.9.5", "rlp", "ruint-macro", "serde_core", @@ -4973,9 +5110,9 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" @@ -5022,7 +5159,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -5031,9 +5168,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -5068,9 +5205,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -5116,9 +5253,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -5186,14 +5323,14 @@ dependencies = [ "once_cell", "parking_lot", "postcard", - "rand 0.8.6", + "rand 0.8.8", "saorsa-dht-lookup", "saorsa-pqc", "saorsa-transport", "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -5210,49 +5347,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "saorsa-pqc" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d4bae22bfc65b379efcaae0c9ec5075916a79c05e97d595a4b78fb8ff6545b" -dependencies = [ - "aead", - "aes-gcm", - "anyhow", - "blake3", - "bytes", - "chacha20poly1305", - "curve25519-dalek", - "ed25519-dalek", - "fips203", - "fips204", - "fips205", - "futures", - "hkdf", - "hmac", - "hpke", - "libc", - "log", - "pbkdf2", - "postcard", - "rand 0.8.6", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "rayon", - "serde", - "serde_json", - "sha2", - "sha3 0.10.9", - "subtle", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "wide", - "x25519-dalek", - "zeroize", -] - [[package]] name = "saorsa-pqc" version = "0.5.1" @@ -5276,7 +5370,7 @@ dependencies = [ "log", "pbkdf2", "postcard", - "rand 0.8.6", + "rand 0.8.8", "rand_chacha 0.3.1", "rand_core 0.6.4", "rayon", @@ -5285,7 +5379,7 @@ dependencies = [ "sha2", "sha3 0.10.9", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -5301,7 +5395,7 @@ dependencies = [ "anyhow", "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "blake3", "bytes", "chrono", @@ -5313,7 +5407,7 @@ dependencies = [ "futures-util", "hex", "igd-next", - "indexmap 2.14.0", + "indexmap 2.14.1", "keyring", "libc", "lru-slab", @@ -5322,9 +5416,9 @@ dependencies = [ "parking_lot", "pin-project-lite", "quinn-udp 0.6.1", - "rand 0.8.6", + "rand 0.8.8", "rcgen 0.13.2", - "rcgen 0.14.8", + "rcgen 0.14.10", "regex", "reqwest", "rustc-hash", @@ -5338,10 +5432,10 @@ dependencies = [ "serde_json", "serde_yaml", "slab", - "socket2 0.5.10", - "stun", + "socket2 0.6.5", + "stun 0.7.0", "system-configuration", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tinyvec", "tokio", @@ -5361,7 +5455,7 @@ name = "saorsa-webrtc" version = "0.1.0" source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=da3a6934d85eb3fb63d7f489c362abee158d6ce8#da3a6934d85eb3fb63d7f489c362abee158d6ce8" dependencies = [ - "base64", + "base64 0.22.1", "blake3", "chacha20poly1305", "fips203", @@ -5372,7 +5466,7 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "tiny-keccak", "zeroize", ] @@ -5400,9 +5494,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -5418,11 +5512,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdp" -version = "0.7.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a526161f474ae94b966ba622379d939a8fe46c930eebbadb73e339622599d5" +checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6" dependencies = [ - "rand 0.8.6", + "rand 0.9.5", "substring", "thiserror 1.0.69", "url", @@ -5450,7 +5544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", ] @@ -5462,7 +5556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes", - "rand 0.9.4", + "rand 0.9.5", "secp256k1-sys 0.11.0", ] @@ -5490,7 +5584,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5530,7 +5624,7 @@ dependencies = [ "bytes", "chacha20poly1305", "hex", - "rand 0.8.6", + "rand 0.8.8", "rand_chacha 0.3.1", "rayon", "serde", @@ -5566,9 +5660,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5586,29 +5680,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5637,18 +5731,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.1", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -5657,14 +5752,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5673,7 +5768,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "itoa", "ryu", "serde", @@ -5712,14 +5807,14 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -5754,7 +5849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", ] [[package]] @@ -5804,15 +5899,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version 0.4.1", "simdutf8", @@ -5838,9 +5933,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", ] @@ -5856,9 +5951,19 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5866,9 +5971,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -5907,17 +6012,36 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea256fb46a13f9204e9dee9982997b2c3097db175a9fddaa8350310d03c4d5a3" dependencies = [ - "base64", + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.8.8", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util 0.10.0", +] + +[[package]] +name = "stun" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b" +dependencies = [ + "base64 0.22.1", "crc", "lazy_static", "md-5", - "rand 0.8.6", + "rand 0.9.5", "ring", "subtle", "thiserror 1.0.69", "tokio", "url", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -5954,9 +6078,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -5965,14 +6100,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.6.0" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec005042c7d952febc1a3ef5b0f6674e9054aa836877a31c90b20e25b3d31744" +checksum = "4c6415502cd1e9ed58b3ceb415164b812d5572757b1a6f0e280ae806723c1fab" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6001,7 +6136,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6010,7 +6145,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6066,11 +6201,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -6081,25 +6216,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -6115,9 +6250,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -6137,9 +6272,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -6156,9 +6291,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -6166,9 +6301,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -6181,9 +6316,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -6191,27 +6326,27 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -6219,9 +6354,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -6242,14 +6377,15 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -6290,7 +6426,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "serde", "serde_spanned", "toml_datetime 0.6.11", @@ -6300,23 +6436,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -6346,7 +6482,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -6390,7 +6526,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -6403,7 +6539,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6477,23 +6613,23 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "turn" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0044fdae001dd8a1e247ea6289abf12f4fcea1331a2364da512f9cd680bbd8cb" +checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "futures", "log", "md-5", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "ring", - "stun", + "stun 0.9.0", "thiserror 1.0.69", "tokio", "tokio-util", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -6526,6 +6662,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6599,9 +6741,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6675,9 +6817,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -6688,9 +6830,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -6698,9 +6840,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6708,22 +6850,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -6744,9 +6886,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -6764,63 +6906,61 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] [[package]] name = "webrtc" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30367074d9f18231d28a74fab0120856b2b665da108d71a12beab7185a36f97b" +checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad" dependencies = [ "arc-swap", "async-trait", "bytes", - "cfg-if", + "dtls", "hex", "interceptor", "lazy_static", "log", - "pem", + "pem 3.0.6", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "rcgen 0.13.2", "regex", "ring", "rtcp", "rtp", - "rustls", "sdp", "serde", "serde_json", "sha2", "smol_str", - "stun", + "stun 0.9.0", "thiserror 1.0.69", - "time", "tokio", "turn", + "unicase", "url", "waitgroup", "webrtc-data", - "webrtc-dtls", "webrtc-ice", "webrtc-mdns", "webrtc-media", "webrtc-sctp", "webrtc-srtp", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "webrtc-data" -version = "0.10.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec93b991efcd01b73c5b3503fa8adba159d069abe5785c988ebe14fcf8f05d1" +checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea" dependencies = [ "bytes", "log", @@ -6828,62 +6968,24 @@ dependencies = [ "thiserror 1.0.69", "tokio", "webrtc-sctp", - "webrtc-util", -] - -[[package]] -name = "webrtc-dtls" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c9b89fc909f9da0499283b1112cd98f72fec28e55a54a9e352525ca65cd95c" -dependencies = [ - "aes", - "aes-gcm", - "async-trait", - "bincode", - "byteorder", - "cbc", - "ccm", - "der-parser 9.0.0", - "hkdf", - "hmac", - "log", - "p256", - "p384", - "pem", - "portable-atomic", - "rand 0.8.6", - "rand_core 0.6.4", - "rcgen 0.13.2", - "ring", - "rustls", - "sec1", - "serde", - "sha1", - "sha2", - "subtle", - "thiserror 1.0.69", - "tokio", - "webrtc-util", - "x25519-dalek", - "x509-parser 0.16.0", + "webrtc-util 0.12.0", ] [[package]] name = "webrtc-ice" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348b28b593f7709ac98d872beb58c0009523df652c78e01b950ab9c537ff17d" +checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d" dependencies = [ "arc-swap", "async-trait", "crc", "log", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "serde", "serde_json", - "stun", + "stun 0.9.0", "thiserror 1.0.69", "tokio", "turn", @@ -6891,40 +6993,40 @@ dependencies = [ "uuid", "waitgroup", "webrtc-mdns", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "webrtc-mdns" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6dfe9686c6c9c51428da4de415cb6ca2dc0591ce2b63212e23fd9cccf0e316b" +checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace" dependencies = [ "log", "socket2 0.5.10", "thiserror 1.0.69", "tokio", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "webrtc-media" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e153be16b8650021ad3e9e49ab6e5fa9fb7f6d1c23c213fd8bbd1a1135a4c704" +checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773" dependencies = [ "byteorder", "bytes", - "rand 0.8.6", + "rand 0.9.5", "rtp", "thiserror 1.0.69", ] [[package]] name = "webrtc-sctp" -version = "0.11.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5faf3846ec4b7e64b56338d62cbafe084aa79806b0379dff5cc74a8b7a2b3063" +checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f" dependencies = [ "arc-swap", "async-trait", @@ -6932,17 +7034,17 @@ dependencies = [ "crc", "log", "portable-atomic", - "rand 0.8.6", + "rand 0.9.5", "thiserror 1.0.69", "tokio", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] name = "webrtc-srtp" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771db9993712a8fb3886d5be4613ebf27250ef422bd4071988bf55f1ed1a64fa" +checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123" dependencies = [ "aead", "aes", @@ -6958,7 +7060,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "tokio", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -6976,7 +7078,27 @@ dependencies = [ "log", "nix 0.26.4", "portable-atomic", - "rand 0.8.6", + "rand 0.8.8", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "webrtc-util" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.9.5", "thiserror 1.0.69", "tokio", "winapi", @@ -7030,7 +7152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -7042,7 +7164,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -7054,8 +7176,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -7064,7 +7199,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] @@ -7077,7 +7212,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7088,7 +7223,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7109,7 +7244,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -7122,6 +7257,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -7131,6 +7275,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -7299,9 +7452,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -7314,9 +7467,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -7371,7 +7524,7 @@ dependencies = [ "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", ] @@ -7387,9 +7540,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xmltree" @@ -7407,7 +7560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd9dddecfdbc7c17ae93da6d28a5a9c4f5564abe7b735d2530c7a159b6b55e8" dependencies = [ "hex", - "rand 0.8.6", + "rand 0.8.8", "rand_core 0.6.4", "serde", "serde_test", @@ -7461,28 +7614,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.53" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.53" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -7502,7 +7655,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -7523,14 +7676,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -7539,9 +7692,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -7550,13 +7703,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.4", ] [[package]] @@ -7576,12 +7729,12 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.14.0", + "indexmap 2.14.1", "lzma-rs", "memchr", "pbkdf2", "sha1", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "xz2", "zeroize", @@ -7589,11 +7742,17 @@ dependencies = [ "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" @@ -7618,9 +7777,9 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] From f2527a71f33e2c66f58e1aad6a01a8a2515fe093 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:55:05 +0200 Subject: [PATCH 26/31] fix: timeout idle WebRTC associations --- ...rect-browser-clients-over-webrtc-direct.md | 11 +- src/web_rtc.rs | 215 ++++++++++++++---- 2 files changed, 174 insertions(+), 52 deletions(-) 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 index d3278092..f5cca956 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -235,7 +235,13 @@ limiter from becoming a source-churn memory attack. Admission is non-queueing above the application bounds. The server stops starting new transport accepts while all global association slots are in use. After accept, an association must obtain both its global and per-IP share. A -connection may own at most `max_channels_per_connection` active DataChannels, +newly admitted association has 15 seconds to open its first DataChannel. This +deadline covers the remaining ICE, DTLS, SCTP, and DataChannel establishment +work after transport accept; it gives the browser's 10-second dial deadline +modest server-side headroom. An association that misses the deadline is closed, +releasing both admission shares, so peers that never open an application +channel cannot retain listener capacity indefinitely. A connection may own at +most `max_channels_per_connection` active DataChannels, and every admitted channel must also own one of the global `max_channels` permits. Excess channels are closed and terminate the offending association. The v4 protocol expects persistent channels, so an association is closed when @@ -930,7 +936,8 @@ The decision advances beyond PoC only after all of the following are covered: excessive channels, slow readers, connection floods, request amplification, reconnect churn, task cleanup, and global/per-client byte quotas. Tests must also show that one source at each configured ceiling leaves another source - admissible. + admissible, and that an association which opens no DataChannel times out and + releases its global and per-IP admission shares. - 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. diff --git a/src/web_rtc.rs b/src/web_rtc.rs index b63950da..209d4ff5 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -34,6 +34,7 @@ use saorsa_webrtc::{ WEBRTC_WRITE_CHUNK_BYTES, }; use std::collections::HashMap; +use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -45,6 +46,9 @@ use tokio::task::{JoinHandle, JoinSet}; use tokio_util::sync::CancellationToken; const MAX_FIND_NODE_RESULTS: usize = 20; +// Browser dials use a 10-second channel-open timeout. Give successful clients +// modest server-side headroom while bounding associations that never open one. +const FIRST_DATA_CHANNEL_TIMEOUT: Duration = Duration::from_secs(15); const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_mins(1); const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); const AUTOMATIC_PORT_MIN: u32 = 32_768; @@ -53,6 +57,7 @@ const TRACKED_SOURCE_MULTIPLIER: usize = 4; const MIN_TRACKED_SOURCES: usize = 64; const CONNECTION_CAPACITY_ERROR: &str = "global connection capacity exhausted"; const SOURCE_CONNECTION_CAPACITY_ERROR: &str = "source connection capacity exhausted"; +const FIRST_DATA_CHANNEL_TIMEOUT_ERROR: &str = "DataChannel opening timed out"; const CHANNEL_CAPACITY_ERROR: &str = "global DataChannel capacity exhausted"; const REQUEST_CAPACITY_ERROR: &str = "global request capacity exhausted"; const REQUEST_RATE_ERROR: &str = "request rate limit exceeded"; @@ -785,61 +790,70 @@ async fn handle_connection( let remote_addr = connection.remote_addr(); let channel_shutdown = shutdown.child_token(); let mut channel_tasks = JoinSet::new(); - let outcome = loop { - let accepted = tokio::select! { - biased; - () = shutdown.cancelled() => break Ok(()), - completed = channel_tasks.join_next(), if !channel_tasks.is_empty() => { - if let Some(Err(error)) = completed { - debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed"); - } - // The v4 protocol uses persistent channels; it has no channel - // reopen/continuation handshake. Once the last channel ends, - // close the association promptly instead of retaining a stale - // per-IP connection slot while waiting for another channel. - if channel_tasks.is_empty() { - break Ok(()); - } - continue; - } - result = connection.accept_data_channel() => result, - }; - let channel = match accepted { - Ok(channel) => channel, - Err(error) => break Err(format!("DataChannel accept failed: {error}")), - }; - if channel_tasks.len() >= state.config.max_channels_per_connection { - if let Err(error) = channel.close().await { - debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); - } - break Err("per-connection DataChannel capacity exhausted".to_string()); + + let outcome = 'connection: { + let first_channel = + match wait_for_first_data_channel(&shutdown, FIRST_DATA_CHANNEL_TIMEOUT, async { + connection + .accept_data_channel() + .await + .map_err(|error| format!("DataChannel accept failed: {error}")) + }) + .await + { + Ok(Some(channel)) => channel, + Ok(None) => break 'connection Ok(()), + Err(error) => break 'connection Err(error), + }; + if let Err(error) = start_data_channel_task( + first_channel, + &mut channel_tasks, + &state, + &resources, + &channel_shutdown, + remote_addr, + ) + .await + { + break 'connection Err(error); } - let Ok(channel_permit) = Arc::clone(&resources.listener.channel_limit).try_acquire_owned() - else { - if let Err(error) = channel.close().await { - debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); - } - break Err(CHANNEL_CAPACITY_ERROR.to_string()); - }; - let channel_state = Arc::clone(&state); - let channel_resources = Arc::clone(&resources); - let handler_shutdown = channel_shutdown.clone(); - channel_tasks.spawn(async move { - let _channel_permit = channel_permit; - if let Err(error) = handle_webrtc_channel( - &channel, - channel_state, - channel_resources, - handler_shutdown, + + loop { + let accepted = tokio::select! { + biased; + () = shutdown.cancelled() => break Ok(()), + completed = channel_tasks.join_next(), if !channel_tasks.is_empty() => { + if let Some(Err(error)) = completed { + debug!(remote = %remote_addr, %error, "WebRTC Direct DataChannel task failed"); + } + // The v4 protocol uses persistent channels; it has no channel + // reopen/continuation handshake. Once the last channel ends, + // close the association promptly instead of retaining a stale + // per-IP connection slot while waiting for another channel. + if channel_tasks.is_empty() { + break Ok(()); + } + continue; + } + result = connection.accept_data_channel() => result, + }; + let channel = match accepted { + Ok(channel) => channel, + Err(error) => break Err(format!("DataChannel accept failed: {error}")), + }; + if let Err(error) = start_data_channel_task( + channel, + &mut channel_tasks, + &state, + &resources, + &channel_shutdown, + remote_addr, ) .await { - debug!(remote = %remote_addr, channel = channel.id(), "WebRTC Direct DataChannel ended: {error}"); - } - if let Err(error) = channel.close().await { - debug!(remote = %remote_addr, channel = channel.id(), %error, "Failed to close WebRTC Direct DataChannel"); + break Err(error); } - }); + } }; // Stop every handler before returning its storage/P2P state. Closing the @@ -857,6 +871,64 @@ async fn handle_connection( outcome } +async fn wait_for_first_data_channel( + shutdown: &CancellationToken, + timeout: Duration, + accept: impl Future>, +) -> ServerResult> { + tokio::select! { + biased; + () = shutdown.cancelled() => Ok(None), + result = tokio::time::timeout(timeout, accept) => result.map_or_else( + |_| Err(FIRST_DATA_CHANNEL_TIMEOUT_ERROR.to_string()), + |result| result.map(Some), + ), + } +} + +async fn start_data_channel_task( + channel: WebRtcDataChannel, + channel_tasks: &mut JoinSet<()>, + state: &Arc, + resources: &Arc, + channel_shutdown: &CancellationToken, + remote_addr: SocketAddr, +) -> ServerResult<()> { + if channel_tasks.len() >= state.config.max_channels_per_connection { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); + } + return Err("per-connection DataChannel capacity exhausted".to_string()); + } + let Ok(channel_permit) = Arc::clone(&resources.listener.channel_limit).try_acquire_owned() + else { + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, %error, "Failed to close excess DataChannel"); + } + return Err(CHANNEL_CAPACITY_ERROR.to_string()); + }; + let channel_state = Arc::clone(state); + let channel_resources = Arc::clone(resources); + let handler_shutdown = channel_shutdown.clone(); + channel_tasks.spawn(async move { + let _channel_permit = channel_permit; + if let Err(error) = handle_webrtc_channel( + &channel, + channel_state, + channel_resources, + handler_shutdown, + ) + .await + { + debug!(remote = %remote_addr, channel = channel.id(), "WebRTC Direct DataChannel ended: {error}"); + } + if let Err(error) = channel.close().await { + debug!(remote = %remote_addr, channel = channel.id(), %error, "Failed to close WebRTC Direct DataChannel"); + } + }); + Ok(()) +} + #[allow(clippy::significant_drop_tightening, clippy::too_many_lines)] async fn handle_webrtc_channel( channel: &WebRtcDataChannel, @@ -1798,6 +1870,49 @@ mod tests { assert!(validate_webrtc_config(&config).is_err()); } + #[tokio::test] + async fn first_data_channel_timeout_releases_connection_admission() { + let config = WebRtcDirectConfig::default(); + let resources = ListenerResources::new(&config); + let remote_addr: SocketAddr = "198.51.100.1:1000".parse().expect("remote address"); + let shutdown = CancellationToken::new(); + + let result = { + let _admission = resources + .try_admit_connection(remote_addr) + .expect("connection admission"); + assert_eq!( + resources.connection_limit.available_permits(), + config.max_connections - 1 + ); + wait_for_first_data_channel( + &shutdown, + Duration::from_millis(10), + std::future::pending::>(), + ) + .await + }; + + assert_eq!( + result.err().as_deref(), + Some(FIRST_DATA_CHANNEL_TIMEOUT_ERROR) + ); + assert_eq!( + resources.connection_limit.available_permits(), + config.max_connections + ); + assert_eq!( + resources + .source_state + .lock() + .sources + .get(&remote_addr.ip()) + .expect("tracked source") + .active_connections, + 0 + ); + } + #[test] fn per_ip_connection_limit_cannot_starve_another_source() { let config = WebRtcDirectConfig::default(); From fa6c02f39d9dd9343d77f42cb1a1a8925f005d4d Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 7 Sep 2026 12:10:42 +0200 Subject: [PATCH 27/31] fix(browser): keep verification RPC URLs private Advertise chain ID and payment contracts in protocol v5 and browser manifest v6. Resolve custom chain IDs privately at listener startup and cover credential privacy with encrypted HELLO and manifest regressions. --- .github/workflows/ci.yml | 2 + Cargo.lock | 2 +- Cargo.toml | 2 +- docs/WEBRTC_DIRECT_TESTNET.md | 50 ++++-- ...rect-browser-clients-over-webrtc-direct.md | 35 ++-- src/bin/ant-devnet/main.rs | 2 +- src/browser.rs | 95 +++++++++- src/devnet.rs | 11 +- src/web_rtc.rs | 7 +- tests/webrtc_direct_devnet.rs | 165 +++++++++++++++++- 10 files changed, 322 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 155d576d..5e4dd940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,8 @@ jobs: version: ${{ env.FOUNDRY_VERSION }} - name: Run unit tests run: cargo test --lib --features test-utils + - name: Run browser RPC privacy regressions + run: cargo test --test webrtc_direct_devnet - name: Run e2e tests run: cargo test --test e2e --features test-utils -- --test-threads=1 - name: Run v12 storage-bound audit attack PoCs diff --git a/Cargo.lock b/Cargo.lock index cd235ce8..02b63191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5453,7 +5453,7 @@ dependencies = [ [[package]] name = "saorsa-webrtc" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=da3a6934d85eb3fb63d7f489c362abee158d6ce8#da3a6934d85eb3fb63d7f489c362abee158d6ce8" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=79fe1d8aca0e3434c41a4f19af65ab098aac102a#79fe1d8aca0e3434c41a4f19af65ab098aac102a" dependencies = [ "base64 0.22.1", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 59622f65..fe56d002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ mimalloc = "0.1" # ant-protocol = "2.3.5" # Portable WebRTC Direct profile shared by the node and browser WASM adapter. -saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "da3a6934d85eb3fb63d7f489c362abee158d6ce8" } +saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "79fe1d8aca0e3434c41a4f19af65ab098aac102a" } # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.27.3" diff --git a/docs/WEBRTC_DIRECT_TESTNET.md b/docs/WEBRTC_DIRECT_TESTNET.md index 20f1b419..d3b66c5f 100644 --- a/docs/WEBRTC_DIRECT_TESTNET.md +++ b/docs/WEBRTC_DIRECT_TESTNET.md @@ -3,7 +3,7 @@ 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. +`ant-client-browser-sdk` repository, with Rust/WASM from `ant-client-web-support`. ## Start the node testnet @@ -48,33 +48,43 @@ 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. +is a disposable local Anvil key for browser upload testing. HELLO and the browser +manifest contain only `chain_id`, `payment_token_address`, and +`payment_vault_address`. Local Anvil uses chain ID 31337. Neither the verification +RPC URL nor the funded key is included in browser metadata. + +Browser protocol v5 and browser manifest v6 require matching node, Rust/WASM +client, and SDK versions. The application or wallet owns its payment provider; +no browser RPC setting is needed on the node. For a custom EVM network, the node +privately resolves `eth_chainId` from its verification RPC when starting the +browser listener. Failure to resolve the chain ID fails listener startup. +Built-in Arbitrum networks use their known chain IDs. Clients compare payment +chain and contract identities, so they can use a different provider for the same +network. ## Start the browser client -In `ant-client-web-support/web`: +In `ant-client-browser-sdk`: ```bash npm ci +ANT_CLIENT_DIR=../ant-client-web-support npm run sync:wasm 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 +Open the URL printed by Vite. Paste a node's WebRTC Direct multiaddress from +the browser manifest and click **Connect**. To upload, choose a file, paste the +Anvil JSON-RPC URL and funded private key printed by ant-devnet, and use +**Pay and upload**. 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 +and public payment proof to nodes. The result address is placed into the download field automatically. -Use **Download and save file** to fetch the public DataMap and every encrypted +Use **Download and save** 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 +For a browser-supported video, use **Stream as media** 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 @@ -83,7 +93,7 @@ through the manifest server or another gateway. ## Automated verification ```bash -cargo test --test webrtc_direct_devnet -- --ignored +cargo test --locked --test webrtc_direct_devnet -- --include-ignored ``` This starts Anvil and the five-node network, self-encrypts and publishes a @@ -93,6 +103,10 @@ 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. +The same suite checks that encrypted HELLO and browser manifests omit a custom +verification URL containing dummy credentials and API keys, and that invalid +chain-ID responses fail without exposing provider details. + ## LAN testing Use `--host ` to advertise the literal LAN address: @@ -108,7 +122,7 @@ cargo run --bin ant-devnet -- \ ``` Expose the client dev server on the LAN with `npm run dev -- --host 0.0.0.0` -and change its manifest URL to +and obtain a bootstrap address from `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. @@ -141,8 +155,8 @@ 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 +Start `ant-client-browser-sdk`, paste that address into the demo, and use +**Connect**. 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, @@ -191,7 +205,7 @@ The 2026-08-27 public smoke run used the former protocol v3 and headless Chromium only. From one bootstrap address it traversed multiple independent nodes, obtained four storage quotes from non-bootstrap closest nodes, submitted one payment, and stored all four encrypted records. It is historical -connectivity evidence, not v4 or cross-browser acceptance evidence. Nodes +connectivity evidence, not v5 or cross-browser acceptance evidence. Nodes behind the testnet's deliberate inbound-NAT rules remain unreachable without relayed WebRTC, so their 10-second DataChannel timeouts currently make this smoke path slower than an all-public fleet. diff --git a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md index f5cca956..e0f99b63 100644 --- a/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md +++ b/docs/adr/ADR-0009-direct-browser-clients-over-webrtc-direct.md @@ -244,7 +244,7 @@ channel cannot retain listener capacity indefinitely. A connection may own at most `max_channels_per_connection` active DataChannels, and every admitted channel must also own one of the global `max_channels` permits. Excess channels are closed and terminate the offending association. -The v4 protocol expects persistent channels, so an association is closed when +The v5 protocol expects persistent channels, so an association is closed when its last application channel ends rather than retaining a stale connection slot for a hypothetical channel reopen. @@ -527,8 +527,8 @@ 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 +DHT protocol. The application protocol name is `autonomi.web.poc.v5`, its +DataChannel label is `autonomi.web.v5`, 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, @@ -550,9 +550,18 @@ has its own independently checked wire version 1. The initial methods are: handler, including content-address and on-chain payment verification. - `PING`: optional liveness method after the proof of concept. +HELLO payment metadata contains only the EVM `chain_id`, payment-token address, +and payment-vault address. It never includes the node operator's verification +RPC URL, which may contain credentials or API keys. Built-in networks supply +known chain IDs; custom networks resolve `eth_chainId` privately during listener +startup and fail startup if resolution fails. Browser manifest v6 carries the +same public identity. Applications and wallets select their own RPC providers, +verify their chain before paying, and require upload nodes to agree on the chain +and both contracts. This schema change requires protocol v5 on both sides. + 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: +association. Protocol v5 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 @@ -693,7 +702,7 @@ The earlier feature-gated WebTransport PoC has been replaced by the - native `saorsa-transport` and `saorsa-core::MultiAddr` support for canonical, literal-IP `/webrtc-direct/certhash/.../p2p/...` addresses with exactly one fingerprint and no DNS form; -- a protocol v4 browser session backed by the shared `saorsa-webrtc` +- a protocol v5 browser session backed by the shared `saorsa-webrtc` post-quantum session v1, which performs ephemeral ML-KEM-768 key establishment, authenticates the transcript and ANT peer ID with ML-DSA-65, derives direction-separated keys, and protects every later application frame @@ -731,7 +740,7 @@ 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 +connection-establishment profile and the v5 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. @@ -759,7 +768,7 @@ pre-populates the devnet payment cache for those addresses, while content-address verification, DHT responsibility, payment-cache admission, LMDB storage, and verified reads remain active. -### Protocol v4 automated validation +### Protocol v5 automated validation Node CI explicitly runs the otherwise ignored five-node WebRTC Direct devnet integration test. Its native test adapter completes the ML-KEM/ML-DSA @@ -787,9 +796,9 @@ reservation, and RAII release. The devnet workflow transfers real encrypted chunks, but it is not a browser resource-limit or fleet test. The adversarial browser and fleet tests listed under Validation remain promotion requirements. -There is currently no automated real-browser v4 flow in browser CI. The +There is currently no automated real-browser v5 flow in browser CI. The historical smoke flow below ran only Chromium and used protocol v3. Therefore -Chrome, Firefox, and Safari interoperability against a matching deployed v4 +Chrome, Firefox, and Safari interoperability against a matching deployed v5 node fleet, along with cold bootstrap from the production compiled seed list, remain unmet acceptance criteria rather than claimed results. @@ -798,7 +807,7 @@ remain unmet acceptance criteria rather than claimed results. 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. +implementation and must be repeated with matching v5 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 @@ -906,8 +915,8 @@ round-trip tests. 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 +- Application protocol v5 requires matching browser and node deployments; + older plaintext v3 and encrypted v4 peers deliberately fail closed. Native QUIC nodes and existing `ant-core`/`ant-cli` callers are unaffected. ## Validation @@ -928,7 +937,7 @@ The decision advances beyond PoC only after all of the following are covered: 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. + sent to a v5 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. diff --git a/src/bin/ant-devnet/main.rs b/src/bin/ant-devnet/main.rs index 2ceefec8..48c0c7ed 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -138,7 +138,7 @@ async fn main() -> color_eyre::Result<()> { network_id, created_at.clone(), devnet.browser_endpoints(), - devnet.browser_payment_network(), + devnet.browser_payment_network().await?, vec![public_file], )) } else { diff --git a/src/browser.rs b/src/browser.rs index 160fb3c6..849f9086 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -9,7 +9,7 @@ pub use saorsa_webrtc::{BrowserEndpoint, BrowserPaymentNetwork, WebRtcDirectEndp use serde::{Deserialize, Serialize}; /// Version of the local browser bootstrap manifest. -pub const BROWSER_MANIFEST_VERSION: u16 = 5; +pub const BROWSER_MANIFEST_VERSION: u16 = 6; /// A bootstrap node that a browser can authenticate and contact directly. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -53,12 +53,71 @@ pub struct BrowserChunkInfo { pub src_size: usize, } -pub(crate) fn browser_payment_network(network: &evmlib::Network) -> BrowserPaymentNetwork { - BrowserPaymentNetwork { - rpc_url: network.rpc_url().to_string(), +/// Resolve the public payment identity without exposing the verifier's RPC URL. +#[cfg(any(feature = "webrtc-direct", test))] +pub(crate) async fn browser_payment_network( + network: &evmlib::Network, +) -> crate::Result { + let chain_id = match network { + evmlib::Network::ArbitrumOne => 42_161, + evmlib::Network::ArbitrumSepoliaTest => 421_614, + evmlib::Network::Custom(_) => payment_chain_id(network.rpc_url()).await?, + }; + Ok(BrowserPaymentNetwork { + chain_id, payment_token_address: format!("{:?}", network.payment_token_address()), payment_vault_address: format!("{:?}", network.payment_vault_address()), + }) +} + +#[cfg(any(feature = "webrtc-direct", test))] +async fn payment_chain_id(rpc_url: &reqwest::Url) -> crate::Result { + // Neither provider errors nor response bodies may escape into errors: they + // can contain the operator's credentials, API keys, or internal addresses. + let unavailable = || { + crate::Error::Config( + "could not resolve the custom EVM chain ID for browser payment metadata".to_string(), + ) + }; + let invalid = || { + crate::Error::Config("custom EVM RPC returned an invalid eth_chainId response".to_string()) + }; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| unavailable())?; + let mut response = client + .post(rpc_url.clone()) + .json( + &serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "eth_chainId", "params": []}), + ) + .send() + .await + .map_err(|_| unavailable())? + .error_for_status() + .map_err(|_| unavailable())?; + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| unavailable())? { + if body.len() + chunk.len() > 4096 { + return Err(invalid()); + } + body.extend_from_slice(&chunk); } + let response: serde_json::Value = serde_json::from_slice(&body).map_err(|_| invalid())?; + if response["jsonrpc"] != "2.0" || response["id"] != 1 || response.get("error").is_some() { + return Err(invalid()); + } + let hex = response["result"] + .as_str() + .and_then(|value| value.strip_prefix("0x")) + .filter(|hex| { + !hex.is_empty() + && (hex.len() == 1 || !hex.starts_with('0')) + && hex.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + .ok_or_else(invalid)?; + u64::from_str_radix(hex, 16).map_err(|_| invalid()) } /// Local-devnet handoff consumed by the browser application. @@ -76,7 +135,7 @@ pub struct BrowserDevnetManifest { pub created_at: String, /// Direct node endpoints available as initial browser contacts. pub endpoints: Vec, - /// Public payment contracts and RPC used by browser uploads. + /// Public payment chain and contracts used by browser uploads. pub payment: BrowserPaymentNetwork, /// Immutable files published when the devnet started. pub files: Vec, @@ -102,3 +161,29 @@ impl BrowserDevnetManifest { } } } + +#[cfg(test)] +mod tests { + use super::browser_payment_network; + + #[tokio::test] + async fn builtin_payment_identity_contains_only_chain_and_contracts( + ) -> Result<(), Box> { + for (network, chain_id) in [ + (evmlib::Network::ArbitrumOne, 42_161), + (evmlib::Network::ArbitrumSepoliaTest, 421_614), + ] { + let identity = browser_payment_network(&network).await?; + assert_eq!(identity.chain_id, chain_id); + assert_eq!( + serde_json::to_value(identity)?, + serde_json::json!({ + "chain_id": chain_id, + "payment_token_address": format!("{:?}", network.payment_token_address()), + "payment_vault_address": format!("{:?}", network.payment_vault_address()), + }) + ); + } + Ok(()) + } +} diff --git a/src/devnet.rs b/src/devnet.rs index fc16f0ed..fa53a814 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -710,16 +710,21 @@ impl Devnet { Ok(published) } - /// Public EVM configuration advertised to direct browser clients. + /// Public EVM chain and contracts advertised to direct browser clients. + /// + /// # Errors + /// + /// Returns `DevnetError::Config` if a custom RPC cannot report its chain ID. #[cfg(feature = "webrtc-direct")] - #[must_use] - pub fn browser_payment_network(&self) -> BrowserPaymentNetwork { + pub async fn browser_payment_network(&self) -> Result { let network = self .config .evm_network .as_ref() .unwrap_or(&EvmNetwork::ArbitrumOne); browser_payment_network(network) + .await + .map_err(|error| DevnetError::Config(error.to_string())) } #[cfg(feature = "webrtc-direct")] diff --git a/src/web_rtc.rs b/src/web_rtc.rs index 209d4ff5..457706e2 100644 --- a/src/web_rtc.rs +++ b/src/web_rtc.rs @@ -497,6 +497,7 @@ pub async fn spawn( endpoint_catalog: Option>, ) -> Result { validate_webrtc_config(config)?; + let payment = browser_payment_network(evm_network).await?; let certificate_path = certificate_path(config, root_dir); let certificate = load_or_generate_certificate(&certificate_path).await?; let certificate_sha256 = certificate @@ -528,7 +529,7 @@ pub async fn spawn( identity, p2p: Arc::clone(&p2p), ant_protocol, - payment: browser_payment_network(evm_network), + payment, endpoint: browser_endpoint.clone(), endpoint_catalog, }); @@ -2160,7 +2161,7 @@ mod tests { #[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}"#, + r#"{"version":5,"request_id":7,"content_length":0,"type":"find_node","target":"0000000000000000000000000000000000000000000000000000000000000000","count":20}"#, ) .expect("valid request"); @@ -2200,7 +2201,7 @@ mod tests { 3, ); let value = serde_json::to_value(response).expect("serialize response"); - assert_eq!(value["version"], 4); + assert_eq!(value["version"], BROWSER_PROTOCOL_VERSION); assert_eq!(value["request_id"], 42); assert_eq!(value["status"], "ok"); assert_eq!(value["content_length"], 3); diff --git a/tests/webrtc_direct_devnet.rs b/tests/webrtc_direct_devnet.rs index 9de71e63..245c3c85 100644 --- a/tests/webrtc_direct_devnet.rs +++ b/tests/webrtc_direct_devnet.rs @@ -19,6 +19,165 @@ use serde_json::{json, Value}; use std::error::Error; use std::io; use std::str::FromStr; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +struct MockChainRpc { + url: String, + task: tokio::task::JoinHandle>, +} + +impl MockChainRpc { + async fn new(body: String) -> io::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = format!( + "http://dummy-user:dummy-password@{}/v2/dummy-path-key?api_key=dummy-query-key", + listener.local_addr()? + ); + let task = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await?; + let mut request = Vec::new(); + loop { + if socket.read_buf(&mut request).await? == 0 { + return Err(io::Error::other("truncated RPC request")); + } + if let Some(end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..end]); + let length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .ok_or_else(|| io::Error::other("missing RPC request length"))?; + if request.len() >= end + 4 + length { + let payload: Value = serde_json::from_slice(&request[end + 4..])?; + assert_eq!(payload["method"], "eth_chainId"); + break; + } + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(response.as_bytes()).await?; + } + }); + Ok(Self { url, task }) + } + + fn network(&self) -> evmlib::Network { + evmlib::Network::new_custom( + &self.url, + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + ) + } +} + +impl Drop for MockChainRpc { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[serial_test::serial] +async fn encrypted_hello_and_manifest_never_disclose_verification_rpc() -> Result<(), Box> +{ + let rpc = + MockChainRpc::new(json!({"jsonrpc":"2.0", "id":1, "result":"0x7a69"}).to_string()).await?; + let temp = tempfile::tempdir()?; + let mut config = DevnetConfig::minimal(); + config.node_count = 2; + config.bootstrap_count = 1; + config.base_port = 0; + config.webrtc_direct = true; + config.data_dir = temp.path().join("rpc-privacy-devnet"); + config.spawn_delay = std::time::Duration::from_millis(20); + config.evm_network = Some(rpc.network()); + let mut devnet = Devnet::new(config).await?; + devnet.start().await?; + let endpoints = devnet.browser_endpoints(); + let endpoint = endpoints + .first() + .ok_or_else(|| io::Error::other("missing browser endpoint"))?; + let payment = devnet.browser_payment_network().await?; + let manifest = ant_node::BrowserDevnetManifest::new( + "rpc-privacy".to_string(), + "2026-09-07T00:00:00Z".to_string(), + endpoints.clone(), + payment, + vec![], + ); + let mut client = BrowserRpcClient::connect(&endpoint.endpoint).await?; + let (hello, content) = client + .rpc( + json!({ + "version": BROWSER_PROTOCOL_VERSION, "request_id": 1, "type": "hello", + }), + &[], + ) + .await?; + client.close().await?; + devnet.shutdown().await?; + assert_eq!(hello["status"], "ok"); + assert!(content.is_empty()); + assert_eq!( + hello["payment"], + json!({ + "chain_id": 31337, + "payment_token_address": "0x1111111111111111111111111111111111111111", + "payment_vault_address": "0x2222222222222222222222222222222222222222", + }) + ); + assert_eq!(hello["payment"], serde_json::to_value(&manifest.payment)?); + let serialized = serde_json::to_string(&(hello, manifest))?; + for private in [ + "rpc_url", + "dummy-user", + "dummy-password", + "dummy-path-key", + "dummy-query-key", + &rpc.url, + ] { + assert!( + !serialized.contains(private), + "browser metadata disclosed {private}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn invalid_chain_identity_fails_without_exposing_provider_response( +) -> Result<(), Box> { + let temp = tempfile::tempdir()?; + for body in [ + json!({"jsonrpc":"2.0", "id":1, "result":"0x01"}), + json!({"jsonrpc":"2.0", "id":1, "result":"0x10000000000000000"}), + json!({"jsonrpc":"2.0", "id":2, "result":"0x1"}), + json!({"jsonrpc":"2.0", "id":1, "error":{"message":"dummy-private-url"}}), + ] { + let rpc = MockChainRpc::new(body.to_string()).await?; + let mut config = DevnetConfig::minimal(); + config.data_dir = temp.path().join("invalid-rpc"); + config.evm_network = Some(rpc.network()); + let mut devnet = Devnet::new(config).await?; + let error = devnet + .browser_payment_network() + .await + .err() + .ok_or_else(|| io::Error::other("accepted invalid chain identity"))?; + assert!(!error.to_string().contains("dummy")); + assert!(!error.to_string().contains(&rpc.url)); + devnet.shutdown().await?; + } + Ok(()) +} #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "starts a five-node local network"] @@ -73,10 +232,8 @@ async fn seeded_public_file_downloads_and_paid_uploads_over_direct_node_endpoint .await?; assert_eq!(hello["status"], "ok"); assert_eq!(hello["protocol"], BROWSER_PROTOCOL_NAME); - assert_eq!( - hello["payment"]["rpc_url"].as_str(), - Some(evm_testnet.to_network().rpc_url().as_str()) - ); + assert_eq!(hello["payment"]["chain_id"], 31337); + assert!(hello["payment"].get("rpc_url").is_none()); assert_eq!(hello["peer_id"], parsed_endpoint.peer_id); assert_eq!( hello["endpoint"]["multiaddr"], From 62265763f4d5efa39ee0b3df4f91c652f0d71de1 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:34:12 +0200 Subject: [PATCH 28/31] fix(replication): capture admission outcomes on Rust 1.91 --- src/replication/mod.rs | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 594437b8..8e87b2ae 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -3931,23 +3931,21 @@ impl ReplicationEngine { let q = queues.read().await; q.pending_keys().into_iter().collect() }; - let admission_futures = completed.iter().map(|(_, outcome)| async { - match outcome { - Some(outcome) if !outcome.response.bootstrapping => Some( - admission::admit_hints( - &self_id, - &outcome.response.replica_hints, - &outcome.response.paid_hints, - &p2p, - &config, - &storage, - &paid_list, - &pending_keys, - ) - .await, - ), - _ => None, - } + let admission_futures = completed.iter().map(async |(_, outcome)| match outcome { + Some(outcome) if !outcome.response.bootstrapping => Some( + admission::admit_hints( + &self_id, + &outcome.response.replica_hints, + &outcome.response.paid_hints, + &p2p, + &config, + &storage, + &paid_list, + &pending_keys, + ) + .await, + ), + _ => None, }); let admitted = join_all(admission_futures).await; From 52305e12508d455826a770c5c406ba3e35d9c474 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:35:13 +0200 Subject: [PATCH 29/31] ci(browser): check feature gates without test configuration --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e4dd940..67f44794 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,10 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 with: version: ${{ env.FOUNDRY_VERSION }} + # Compile without cfg(test) too: browser unit tests can otherwise hide + # unused helpers whose production callers require webrtc-direct. + - name: Check library without default features + run: cargo check --lib --no-default-features --locked - name: Run unit tests without logging run: cargo test --lib --no-default-features From 59e33e974aa829429d8ef52c1be091641b87bfe6 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:43:42 +0200 Subject: [PATCH 30/31] fix(webrtc): pin bounded pending-association cleanup --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02b63191..a620bcf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5308,7 +5308,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.27.3" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=7b546f05b13f06ded8a24cffa89e4091b937c234#7b546f05b13f06ded8a24cffa89e4091b937c234" dependencies = [ "anyhow", "async-trait", @@ -5341,7 +5341,7 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=915f3f1050006d96569c0efb6402828a01176e27#915f3f1050006d96569c0efb6402828a01176e27" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=7b546f05b13f06ded8a24cffa89e4091b937c234#7b546f05b13f06ded8a24cffa89e4091b937c234" dependencies = [ "futures-core", "futures-util", @@ -5390,7 +5390,7 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.36.3" -source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=44b3292b3ffa49e6048f2816f9ea5c38c6804815#44b3292b3ffa49e6048f2816f9ea5c38c6804815" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=5687db03e31384f35dff1785dedc09a5a1655a2b#5687db03e31384f35dff1785dedc09a5a1655a2b" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index fe56d002..281e7024 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -200,8 +200,8 @@ webrtc-direct = [ ] [patch.crates-io] -saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "915f3f1050006d96569c0efb6402828a01176e27" } -saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "44b3292b3ffa49e6048f2816f9ea5c38c6804815" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "7b546f05b13f06ded8a24cffa89e4091b937c234" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "5687db03e31384f35dff1785dedc09a5a1655a2b" } [profile.release] lto = true From f71275402a8a2b84331eadb07ab908ed6b985c37 Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:07:02 +0200 Subject: [PATCH 31/31] chore(deps): pin reviewed transport and core for browser nodes --- Cargo.lock | 8 ++++---- Cargo.toml | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a620bcf2..c1d750a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5308,7 +5308,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.27.3" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=7b546f05b13f06ded8a24cffa89e4091b937c234#7b546f05b13f06ded8a24cffa89e4091b937c234" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=a87ebd641627aaabd5f5aea7b0d1c002c9f03915#a87ebd641627aaabd5f5aea7b0d1c002c9f03915" dependencies = [ "anyhow", "async-trait", @@ -5341,7 +5341,7 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=7b546f05b13f06ded8a24cffa89e4091b937c234#7b546f05b13f06ded8a24cffa89e4091b937c234" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=a87ebd641627aaabd5f5aea7b0d1c002c9f03915#a87ebd641627aaabd5f5aea7b0d1c002c9f03915" dependencies = [ "futures-core", "futures-util", @@ -5390,7 +5390,7 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.36.3" -source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=5687db03e31384f35dff1785dedc09a5a1655a2b#5687db03e31384f35dff1785dedc09a5a1655a2b" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=d2147379ae45ecccc28fb8313ec1d42efac407f6#d2147379ae45ecccc28fb8313ec1d42efac407f6" dependencies = [ "anyhow", "async-trait", @@ -5453,7 +5453,7 @@ dependencies = [ [[package]] name = "saorsa-webrtc" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=79fe1d8aca0e3434c41a4f19af65ab098aac102a#79fe1d8aca0e3434c41a4f19af65ab098aac102a" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=d2147379ae45ecccc28fb8313ec1d42efac407f6#d2147379ae45ecccc28fb8313ec1d42efac407f6" dependencies = [ "base64 0.22.1", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 281e7024..dce9fe1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ mimalloc = "0.1" # ant-protocol = "2.3.5" # Portable WebRTC Direct profile shared by the node and browser WASM adapter. -saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "79fe1d8aca0e3434c41a4f19af65ab098aac102a" } +saorsa-webrtc = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "d2147379ae45ecccc28fb8313ec1d42efac407f6" } # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.27.3" @@ -200,8 +200,8 @@ webrtc-direct = [ ] [patch.crates-io] -saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "7b546f05b13f06ded8a24cffa89e4091b937c234" } -saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "5687db03e31384f35dff1785dedc09a5a1655a2b" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "a87ebd641627aaabd5f5aea7b0d1c002c9f03915" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "d2147379ae45ecccc28fb8313ec1d42efac407f6" } [profile.release] lto = true