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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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", ]