From 5d3991073ae4fecf934b3f9a8b182753907dd373 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/13] feat: add browser-enabled WebTransport testnet --- Cargo.lock | 153 +++- 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, 2248 insertions(+), 49 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 a2796216..b72fd2dc 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]] @@ -840,11 +855,12 @@ dependencies = [ "saorsa-core", "saorsa-pqc 0.5.1", "self-replace", + "self_encryption", "semver 1.0.28", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "tar", "tempfile", "thiserror 2.0.18", @@ -855,6 +871,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "wtransport", "xor_name", "zip", ] @@ -1508,6 +1525,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" @@ -1787,6 +1825,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" @@ -2087,7 +2131,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", ] @@ -2175,7 +2219,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", ] @@ -2187,6 +2231,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", ] @@ -2318,7 +2363,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2544,7 +2589,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", ] @@ -2556,7 +2601,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", ] @@ -2988,12 +3033,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" @@ -3438,7 +3489,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3845,6 +3896,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" @@ -3987,7 +4044,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -4778,6 +4835,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4970,7 +5028,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5011,7 +5069,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5211,6 +5269,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" @@ -5398,6 +5478,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" @@ -6717,6 +6808,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 2e396350..043bffb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,12 @@ page_size = "0.6" # Protocol serialization postcard = { version = "1.1.3", features = ["use-std"] } +# 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" @@ -166,6 +172,11 @@ name = "poc_price_floor_live" path = "tests/poc_price_floor_live.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. @@ -176,6 +187,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 55a34717..07df25a1 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 @@ -77,12 +100,11 @@ pub struct Cli { #[arg(long, conflicts_with = "enable_evm")] pub evm_network: Option, - /// Serve the manifest over a read-only HTTP API on this port (binds - /// 0.0.0.0). Any LAN device can then GET - /// `http://:/api/devnet-manifest.json` (and `/api/info`) — - /// no file copying. Open CORS. Suggested: 8088. Requires `--host` (the API - /// advertises a LAN URL, so a loopback-only devnet would be misleading). - #[arg(long, requires = "host", value_parser = clap::value_parser!(u16).range(1..))] + /// Serve native and browser manifests over a read-only HTTP API. + /// + /// Without `--host` it binds 127.0.0.1. With `--host` it binds 0.0.0.0 + /// and advertises that LAN address. Open CORS. Suggested: 25000. + #[arg(long, value_parser = clap::value_parser!(u16).range(1..))] pub serve_port: Option, } @@ -99,6 +121,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. @@ -111,11 +134,11 @@ mod tests { "--evm-network", "arbitrum-sepolia", "--serve-port", - "8088", + "25000", ]); assert_eq!(cli.host, Some(Ipv4Addr::new(192, 168, 1, 100))); assert_eq!(cli.evm_network.as_deref(), Some("arbitrum-sepolia")); - assert_eq!(cli.serve_port, Some(8088)); + assert_eq!(cli.serve_port, Some(25_000)); } /// A non-IPv4 `--host` is rejected by clap's value parser. @@ -124,18 +147,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 a55a0fa0..194d1bea 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,22 +107,57 @@ 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, &mut config).await?; 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()); @@ -123,8 +167,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."); @@ -134,6 +188,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 @@ -216,8 +335,10 @@ fn serve_manifest_api( host: Option, manifest: &DevnetManifest, manifest_json: String, + browser_manifest: Option<&BrowserDevnetManifest>, + browser_manifest_json: Option, ) -> color_eyre::Result<()> { - let host_ip = host.map_or_else(local_ip_guess, |i| i.to_string()); + let host_ip = host.map_or_else(|| "127.0.0.1".to_string(), |i| i.to_string()); let evm_block = manifest.evm.as_ref().map_or(serde_json::Value::Null, |e| { let loopback = e.rpc_url.contains("127.0.0.1") || e.rpc_url.contains("localhost"); serde_json::json!({ @@ -236,37 +357,40 @@ fn serve_manifest_api( }) }); let bootstrap = serde_json::to_value(&manifest.bootstrap)?; + let browser_manifest_url = + browser_manifest.map(|_| format!("http://{host_ip}:{port}/api/browser-manifest.json")); + let public_files = browser_manifest.map_or_else(Vec::new, |browser| browser.files.clone()); let info = serde_json::json!({ "host_ip": host_ip, "manifest_url": format!("http://{host_ip}:{port}/api/devnet-manifest.json"), + "browser_manifest_url": browser_manifest_url, "node_count": manifest.node_count as u64, "bootstrap": bootstrap, + "public_files": public_files, "evm": evm_block, }); let info_json = serde_json::to_string_pretty(&info)?; // Bind synchronously so a failure (e.g. the port is already in use) // propagates to the caller instead of the devnet silently coming up // without its manifest API. - let listener = std::net::TcpListener::bind(("0.0.0.0", port)).map_err(|e| { - color_eyre::eyre::eyre!("failed to bind manifest API on 0.0.0.0:{port}: {e}") + let bind_ip = host.map_or(std::net::Ipv4Addr::LOCALHOST, |_| { + std::net::Ipv4Addr::UNSPECIFIED + }); + let listener = std::net::TcpListener::bind((bind_ip, port)).map_err(|e| { + color_eyre::eyre::eyre!("failed to bind manifest API on {bind_ip}:{port}: {e}") })?; ant_node::logging::info!( - "manifest API on http://0.0.0.0:{port}/api/devnet-manifest.json (+ /api/info)" + "manifest API on http://{host_ip}:{port}/api/devnet-manifest.json (+ /api/info)" ); - spawn_manifest_server(listener, manifest_json, info_json); + if browser_manifest.is_some() { + ant_node::logging::info!( + "browser app manifest: http://{host_ip}:{port}/api/browser-manifest.json" + ); + } + spawn_manifest_server(listener, manifest_json, info_json, browser_manifest_json); Ok(()) } -/// Best-effort primary LAN IP (src of the default route) for the info endpoint. -fn local_ip_guess() -> String { - std::net::UdpSocket::bind("0.0.0.0:0") - .and_then(|s| { - s.connect("1.1.1.1:80")?; - Ok(s.local_addr()?.ip().to_string()) - }) - .unwrap_or_else(|_| "127.0.0.1".to_string()) -} - /// Run a tiny read-only HTTP server on `listener` (its own thread) exposing the /// manifest over the LAN. GET-only, open CORS; hand-rolled HTTP/1.1 so there's /// no new dependency. Connections are handled **inline, one at a time** — the @@ -278,6 +402,7 @@ fn spawn_manifest_server( listener: std::net::TcpListener, manifest_json: String, info_json: String, + browser_manifest_json: Option, ) { std::thread::spawn(move || { use std::io::{Read, Write}; @@ -295,11 +420,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 6c301549..6651b887 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 // ============================================================================= @@ -169,6 +180,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 { @@ -191,6 +211,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(), + ], } } } @@ -274,6 +300,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 { @@ -294,6 +324,8 @@ pub struct Devnet { shutdown: CancellationToken, state: Arc>, health_monitor: Option>, + #[cfg(feature = "webtransport-poc")] + browser_endpoint_catalog: Arc, } impl Devnet { @@ -304,6 +336,7 @@ impl Devnet { /// Returns `DevnetError::Config` if the configuration is invalid (e.g. bootstrap /// count exceeds node count or port range overflow). /// Returns `DevnetError::Io` if the data directory cannot be created. + #[allow(clippy::too_many_lines)] pub async fn new(mut config: DevnetConfig) -> Result { if config.bootstrap_count >= config.node_count { return Err(DevnetError::Config( @@ -342,6 +375,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 { @@ -350,6 +447,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(), + ), }) } @@ -402,6 +503,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); @@ -450,6 +560,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); @@ -541,6 +846,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, }) } @@ -592,6 +901,7 @@ impl Devnet { )) } + #[allow(clippy::too_many_lines)] async fn start_node(&mut self, mut node: DevnetNode) -> Result<()> { debug!("Starting node {} on port {}", node.index, node.port); *node.state.write().await = NodeState::Starting; @@ -629,6 +939,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 089d2f2c..67155041 100644 --- a/src/node.rs +++ b/src/node.rs @@ -87,6 +87,15 @@ impl NodeBuilder { Self::validate_production_rewards_address(&self.config)?; + #[cfg(not(feature = "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(); @@ -206,6 +215,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)), }; @@ -465,6 +476,9 @@ pub struct RunningNode { replication_engine: Option, /// Protocol message routing background task. protocol_task: Option>, + /// ADR-0009 experimental browser listener task. + #[cfg(feature = "webtransport-poc")] + webtransport_task: Option>, /// Exit code requested by a successful upgrade (-1 = no upgrade exit pending). upgrade_exit_code: Arc, } @@ -525,6 +539,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}"); @@ -693,6 +728,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 a67e8bb8..83a082ee 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1104,6 +1104,18 @@ impl PaymentVerifier { self.cache.insert(xorname); } + /// Mark startup content as prepaid for the in-process browser devnet. + /// + /// This remains crate-private and feature-gated: it is used only by + /// [`crate::devnet::Devnet::publish_public_file`] before that local devnet + /// is handed to a browser. The subsequent PUT still traverses the normal + /// address, responsibility, payment-cache, storage, and read-verification + /// checks. + #[cfg(feature = "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 459ae941ec475c3887f353fcc7c15ab883bb80ad 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/13] 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 b72fd2dc..21b2ab08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", "wtransport", "xor_name", "zip", @@ -4968,8 +4969,6 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454529f8a72b4cf22f7d9c3b009ad9d4ba78520e11f6444ae460795d55c002da" dependencies = [ "anyhow", "async-trait", @@ -5083,12 +5082,11 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3284026c300f642077315b782462b22558e24d621265a8deeae23287b1c5542" dependencies = [ "anyhow", "async-trait", "aws-lc-rs", + "base64", "blake3", "bytes", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 043bffb3..c41dd8be 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" @@ -191,6 +192,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 6651b887..680ce45c 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -569,10 +569,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 36d87086c13dfed6ce210ed2ae8219c2e84c03dd 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/13] 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 | 55 +- 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(+), 91 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 07df25a1..d0790eec 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). @@ -167,6 +171,7 @@ mod tests { let cli = Cli::parse_from([ "ant-devnet", "--webtransport", + "--enable-evm", "--webtransport-base-port", "22000", "--public-file", @@ -175,4 +180,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 194d1bea..ce312f7d 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -116,8 +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(cli.evm_network.as_deref(), cli.enable_evm, &mut config).await?; + let ResolvedEvm { + manifest: evm_info, + local_testnet: _local_evm_testnet, + } = resolve_evm_info(cli.evm_network.as_deref(), cli.enable_evm, &mut config).await?; let mut devnet = Devnet::new(config).await?; devnet.start().await?; @@ -135,6 +137,7 @@ async fn main() -> color_eyre::Result<()> { network_id, created_at.clone(), devnet.browser_endpoints(), + devnet.browser_payment_network(), vec![public_file], )) } else { @@ -257,11 +260,18 @@ 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, 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, @@ -278,12 +288,15 @@ async fn resolve_evm_info( "Using external EVM network {net_name}: rpc={rpc_url} token={token_addr} vault={vault_addr}" ); config.evm_network = Some(network); - Ok(Some(DevnetEvmInfo { - rpc_url, - wallet_private_key: String::new(), - payment_token_address: token_addr, - payment_vault_address: vault_addr, - })) + Ok(ResolvedEvm { + manifest: Some(DevnetEvmInfo { + rpc_url, + wallet_private_key: String::new(), + payment_token_address: token_addr, + payment_vault_address: vault_addr, + }), + local_testnet: None, + }) } else if enable_evm { ant_node::logging::info!("Starting local Anvil blockchain for EVM payment enforcement..."); let testnet = evmlib::testnet::Testnet::new() @@ -312,18 +325,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 680ce45c..823b80f6 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")] @@ -677,6 +677,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; @@ -986,6 +998,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 67155041..63979cbb 100644 --- a/src/node.rs +++ b/src/node.rs @@ -543,10 +543,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 49f0273c72e8bba8280c3ccc18de89d31ec00e4c 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/13] chore(webtransport): lock shared DHT lookup crate --- Cargo.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 21b2ab08..ebbc9f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4983,6 +4983,7 @@ dependencies = [ "parking_lot", "postcard", "rand 0.8.6", + "saorsa-dht-lookup", "saorsa-pqc 0.5.1", "saorsa-transport", "serde", @@ -4996,6 +4997,10 @@ dependencies = [ "wyz", ] +[[package]] +name = "saorsa-dht-lookup" +version = "0.1.0" + [[package]] name = "saorsa-pqc" version = "0.4.2" From 1d1ab2f6834045ecfc84a7b3092430539caad65d 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/13] feat(webrtc): replace WebTransport browser support --- Cargo.lock | 673 +++++++++++++++--- 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, 1841 insertions(+), 1174 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 ebbc9f70..634b7ee1 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,13 +854,14 @@ dependencies = [ "rmp-serde", "saorsa-core", "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", @@ -871,8 +872,6 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", - "url", - "wtransport", "xor_name", "zip", ] @@ -911,6 +910,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" @@ -1186,13 +1194,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", @@ -1202,6 +1226,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" @@ -1428,6 +1464,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" @@ -1481,6 +1523,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" @@ -1623,6 +1674,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" @@ -1635,6 +1695,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" @@ -1826,12 +1898,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" @@ -2132,17 +2198,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", @@ -2220,7 +2301,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", ] @@ -2232,7 +2313,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", ] @@ -2293,7 +2373,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.0", "objc2", ] @@ -2364,7 +2444,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2 0.10.9", + "sha2", "subtle", "zeroize", ] @@ -2403,6 +2483,7 @@ dependencies = [ "generic-array", "group", "hkdf", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -2590,7 +2671,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", ] @@ -2602,7 +2683,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", ] @@ -2939,7 +3020,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", @@ -3034,18 +3115,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" @@ -3361,9 +3436,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" @@ -3490,7 +3586,7 @@ dependencies = [ "elliptic-curve", "once_cell", "serdect", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3677,12 +3773,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" @@ -3728,17 +3843,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]] @@ -3864,7 +3992,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", ] @@ -3881,7 +4009,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", @@ -3898,10 +4026,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" @@ -3909,7 +4040,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]] @@ -3954,8 +4085,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]] @@ -4045,7 +4190,7 @@ dependencies = [ "digest 0.10.7", "hmac", "password-hash", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -4058,6 +4203,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" @@ -4205,6 +4359,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" @@ -4310,7 +4470,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", @@ -4550,6 +4710,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" @@ -4560,8 +4734,8 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", - "yasna", + "x509-parser 0.18.1", + "yasna 0.6.0", ] [[package]] @@ -4570,7 +4744,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -4734,6 +4908,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.19.0" @@ -4820,7 +5020,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", @@ -5032,7 +5232,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5073,7 +5273,7 @@ dependencies = [ "rayon", "serde", "serde_json", - "sha2 0.10.9", + "sha2", "sha3 0.10.9", "subtle", "thiserror 2.0.18", @@ -5107,13 +5307,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", @@ -5128,6 +5329,7 @@ dependencies = [ "serde_yaml", "slab", "socket2 0.5.10", + "stun", "system-configuration", "thiserror 2.0.18", "time", @@ -5138,6 +5340,7 @@ dependencies = [ "tracing-subscriber", "unicode-width", "uuid", + "webrtc", "windows", "x25519-dalek", "zeroize", @@ -5182,6 +5385,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" @@ -5244,7 +5459,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", @@ -5481,17 +5696,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" @@ -5600,6 +5804,15 @@ dependencies = [ "serde", ] +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -5657,6 +5870,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" @@ -5738,7 +5979,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", ] @@ -6074,7 +6315,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", @@ -6203,6 +6444,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" @@ -6337,6 +6599,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" @@ -6469,6 +6740,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" @@ -6811,42 +7293,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" @@ -6868,18 +7314,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", @@ -6934,6 +7398,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 c41dd8be..bc1a7dc8 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" @@ -113,10 +112,9 @@ page_size = "0.6" # Protocol serialization postcard = { version = "1.1.3", features = ["use-std"] } -# 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] @@ -174,9 +172,9 @@ path = "tests/poc_price_floor_live.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"] @@ -189,8 +187,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 d0790eec..16afab71 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. @@ -125,7 +120,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. @@ -165,25 +160,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 ce312f7d..84f6aae8 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, @@ -126,8 +121,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) @@ -144,7 +139,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 { @@ -172,7 +167,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, @@ -191,7 +186,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 823b80f6..2be7b195 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; // ============================================================================= @@ -181,14 +181,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 { @@ -211,12 +208,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, } } } @@ -300,9 +293,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, } @@ -324,8 +317,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 { @@ -375,25 +368,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 @@ -413,28 +400,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(), )); } } @@ -447,10 +434,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()), }) } @@ -503,11 +488,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 ); } @@ -561,7 +546,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 @@ -584,19 +569,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 { @@ -678,7 +663,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 @@ -689,7 +674,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(); @@ -855,9 +840,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, }) } @@ -948,18 +933,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 )) })?; @@ -968,34 +953,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 @@ -1005,14 +978,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 63979cbb..8c02e382 100644 --- a/src/node.rs +++ b/src/node.rs @@ -87,11 +87,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(), )); } @@ -215,8 +215,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)), }; @@ -477,8 +477,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, } @@ -539,23 +539,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); } @@ -730,12 +732,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 83a082ee..d826b913 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -1111,7 +1111,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 4c0d31914f1bd9bec65860614986a20433499b5a Mon Sep 17 00:00:00 2001 From: Mick van Dijke <12992260+mickvandijke@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:10:56 +0200 Subject: [PATCH 06/13] chore: patch portable ant-protocol --- Cargo.lock | 224 +++++++++++++++++++++++++++++++---------------------- Cargo.toml | 1 + 2 files changed, 134 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 634b7ee1..910ca1bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,8 +879,6 @@ dependencies = [ [[package]] name = "ant-protocol" version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3081ee130dd45e8166bc8ac4d789aac17d143e2e7f54f1c3006503dc63cf3edb" dependencies = [ "blake3", "bytes", @@ -891,6 +889,7 @@ dependencies = [ "saorsa-core", "saorsa-pqc 0.5.1", "serde", + "tiny-keccak", "tokio", "tracing", ] @@ -2397,6 +2396,43 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "dtls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser 9.0.0", + "hmac", + "log", + "p256", + "p384", + "pem", + "portable-atomic", + "rand 0.9.4", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util 0.12.0", + "x25519-dalek", + "x509-parser 0.16.0", +] + [[package]] name = "dunce" version = "1.0.5" @@ -3442,22 +3478,23 @@ 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.4", "rtcp", "rtp", "thiserror 1.0.69", "tokio", "waitgroup", "webrtc-srtp", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -4910,28 +4947,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.4", "serde", "thiserror 1.0.69", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -5329,7 +5366,7 @@ dependencies = [ "serde_yaml", "slab", "socket2 0.5.10", - "stun", + "stun 0.7.0", "system-configuration", "thiserror 2.0.18", "time", @@ -5387,11 +5424,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.4", "substring", "thiserror 1.0.69", "url", @@ -5886,7 +5923,26 @@ dependencies = [ "thiserror 1.0.69", "tokio", "url", - "webrtc-util", + "webrtc-util 0.10.0", +] + +[[package]] +name = "stun" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b" +dependencies = [ + "base64", + "crc", + "lazy_static", + "md-5", + "rand 0.9.4", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util 0.12.0", ] [[package]] @@ -6446,9 +6502,9 @@ 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", @@ -6456,13 +6512,13 @@ dependencies = [ "log", "md-5", "portable-atomic", - "rand 0.8.6", + "rand 0.9.4", "ring", - "stun", + "stun 0.9.0", "thiserror 1.0.69", "tokio", "tokio-util", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -6495,6 +6551,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" @@ -6742,54 +6804,52 @@ dependencies = [ [[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", "portable-atomic", - "rand 0.8.6", + "rand 0.9.4", "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", @@ -6797,62 +6857,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.4", "serde", "serde_json", - "stun", + "stun 0.9.0", "thiserror 1.0.69", "tokio", "turn", @@ -6860,40 +6882,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.4", "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", @@ -6901,17 +6923,17 @@ dependencies = [ "crc", "log", "portable-atomic", - "rand 0.8.6", + "rand 0.9.4", "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", @@ -6927,7 +6949,7 @@ dependencies = [ "subtle", "thiserror 1.0.69", "tokio", - "webrtc-util", + "webrtc-util 0.12.0", ] [[package]] @@ -6951,6 +6973,26 @@ dependencies = [ "winapi", ] +[[package]] +name = "webrtc-util" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.9.4", + "thiserror 1.0.69", + "tokio", + "winapi", +] + [[package]] name = "wide" version = "0.7.33" diff --git a/Cargo.toml b/Cargo.toml index bc1a7dc8..11eb7f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,6 +194,7 @@ webrtc-direct = [ ] [patch.crates-io] +ant-protocol = { path = "../ant-protocol-web-support" } saorsa-core = { path = "../saorsa-core-web-support" } saorsa-transport = { path = "../saorsa-transport-web-support" } From 9c88e4aabdb39601c4c28d86f259fb8d3f20c90f 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 07/13] 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 84f6aae8..5fcaf884 100644 --- a/src/bin/ant-devnet/main.rs +++ b/src/bin/ant-devnet/main.rs @@ -193,7 +193,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 a654dca5c47a73da2cc0e139d33dd5baba57ffc9 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 08/13] 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 11eb7f47..c7fb5d84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,8 +112,8 @@ page_size = "0.6" # Protocol serialization postcard = { version = "1.1.3", features = ["use-std"] } -# 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 } @@ -177,7 +177,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 dev builds (`cargo build`, `cargo test`) get logging # automatically. Release builds strip it: @@ -186,8 +186,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 8c02e382..626bb887 100644 --- a/src/node.rs +++ b/src/node.rs @@ -541,10 +541,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 e383a42465a52d83b9cfcf4314f7256d86dbc7dd 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 09/13] 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 910ca1bf..29768c48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5237,6 +5237,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 c9abe3f4f451c029de981e83d1e9607629cd4e24 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 10/13] 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 a5dce780dd21ef39c1e654e695d5420044691a62 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 11/13] chore(deps): pin browser support draft stack --- Cargo.lock | 4 ++++ Cargo.toml | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29768c48..65bdd66f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,6 +879,7 @@ dependencies = [ [[package]] name = "ant-protocol" version = "2.3.1" +source = "git+https://github.com/WithAutonomi/ant-protocol.git?rev=41433e6d44a4602ce1918f62782415478f92bcdd#41433e6d44a4602ce1918f62782415478f92bcdd" dependencies = [ "blake3", "bytes", @@ -5206,6 +5207,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.26.4" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=710ea023a72c0de90a950c617cf286906a9fecd1#710ea023a72c0de90a950c617cf286906a9fecd1" dependencies = [ "anyhow", "async-trait", @@ -5237,6 +5239,7 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=710ea023a72c0de90a950c617cf286906a9fecd1#710ea023a72c0de90a950c617cf286906a9fecd1" dependencies = [ "futures-core", "futures-util", @@ -5328,6 +5331,7 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.35.3" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38#c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index c7fb5d84..0df87caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,9 +194,9 @@ webrtc-direct = [ ] [patch.crates-io] -ant-protocol = { path = "../ant-protocol-web-support" } -saorsa-core = { path = "../saorsa-core-web-support" } -saorsa-transport = { path = "../saorsa-transport-web-support" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol.git", rev = "41433e6d44a4602ce1918f62782415478f92bcdd" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "710ea023a72c0de90a950c617cf286906a9fecd1" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38" } [profile.release] lto = true From 525e4c0b48557a9eac4886cd4ec2cbbb12746831 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 12/13] feat(webrtc): require PQ browser sessions --- Cargo.lock | 6 +- Cargo.toml | 2 +- ...rect-browser-clients-over-webrtc-direct.md | 220 +++++++++++---- src/web_rtc.rs | 263 ++++++++++-------- tests/webrtc_direct_devnet.rs | 141 +++++++--- 5 files changed, 419 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65bdd66f..f02e6708 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -879,11 +879,14 @@ dependencies = [ [[package]] name = "ant-protocol" version = "2.3.1" -source = "git+https://github.com/WithAutonomi/ant-protocol.git?rev=41433e6d44a4602ce1918f62782415478f92bcdd#41433e6d44a4602ce1918f62782415478f92bcdd" +source = "git+https://github.com/WithAutonomi/ant-protocol.git?rev=4dad14b6947b6264e0b5982c976a385f9fdac9e0#4dad14b6947b6264e0b5982c976a385f9fdac9e0" dependencies = [ "blake3", "bytes", + "chacha20poly1305", "evmlib", + "fips203", + "getrandom 0.2.17", "hex", "postcard", "rmp-serde", @@ -893,6 +896,7 @@ dependencies = [ "tiny-keccak", "tokio", "tracing", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0df87caa..86549be0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,7 +194,7 @@ webrtc-direct = [ ] [patch.crates-io] -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol.git", rev = "41433e6d44a4602ce1918f62782415478f92bcdd" } +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol.git", rev = "4dad14b6947b6264e0b5982c976a385f9fdac9e0" } saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "710ea023a72c0de90a950c617cf286906a9fecd1" } saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38" } 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 51b152c36e2897d618cb1c0c46d689ae4c7165e3 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 13/13] feat(webrtc): adopt no-mutation direct v2 profile --- Cargo.lock | 6 +-- Cargo.toml | 4 +- ...rect-browser-clients-over-webrtc-direct.md | 38 +++++++++++-------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f02e6708..e9a4e490 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5211,7 +5211,7 @@ dependencies = [ [[package]] name = "saorsa-core" version = "0.26.4" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=710ea023a72c0de90a950c617cf286906a9fecd1#710ea023a72c0de90a950c617cf286906a9fecd1" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=2ed691c7cb49690c86beb445ee422f22a230ef9a#2ed691c7cb49690c86beb445ee422f22a230ef9a" dependencies = [ "anyhow", "async-trait", @@ -5243,7 +5243,7 @@ dependencies = [ [[package]] name = "saorsa-dht-lookup" version = "0.1.0" -source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=710ea023a72c0de90a950c617cf286906a9fecd1#710ea023a72c0de90a950c617cf286906a9fecd1" +source = "git+https://github.com/WithAutonomi/saorsa-core.git?rev=2ed691c7cb49690c86beb445ee422f22a230ef9a#2ed691c7cb49690c86beb445ee422f22a230ef9a" dependencies = [ "futures-core", "futures-util", @@ -5335,7 +5335,7 @@ dependencies = [ [[package]] name = "saorsa-transport" version = "0.35.3" -source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38#c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38" +source = "git+https://github.com/WithAutonomi/saorsa-transport.git?rev=2c6e23cd6cd4db79f475676e25af9334a044a343#2c6e23cd6cd4db79f475676e25af9334a044a343" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 86549be0..1b160e54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -195,8 +195,8 @@ webrtc-direct = [ [patch.crates-io] ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol.git", rev = "4dad14b6947b6264e0b5982c976a385f9fdac9e0" } -saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "710ea023a72c0de90a950c617cf286906a9fecd1" } -saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "c6d893bb1ce46cc7e7635b8f1dbc5e5257b67f38" } +saorsa-core = { git = "https://github.com/WithAutonomi/saorsa-core.git", rev = "2ed691c7cb49690c86beb445ee422f22a230ef9a" } +saorsa-transport = { git = "https://github.com/WithAutonomi/saorsa-transport.git", rev = "2c6e23cd6cd4db79f475676e25af9334a044a343" } [profile.release] lto = true diff --git a/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.