Skip to content

feat: add an ant-core-powered direct browser client - #186

Draft
mickvandijke wants to merge 61 commits into
mainfrom
web-support
Draft

feat: add an ant-core-powered direct browser client#186
mickvandijke wants to merge 61 commits into
mainfrom
web-support

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Sep 1, 2026

Copy link
Copy Markdown
Member

Summary

Adds a direct Autonomi browser client powered by ant-core compiled to WebAssembly.

The browser connects to storage nodes over WebRTC Direct, authenticates their ANT identities, establishes a fresh post-quantum application session, performs iterative closest-node lookup, verifies quotes, pays once, uploads content-addressed records, resolves public DataMaps, downloads/reconstructs files, and serves seekable media ranges without an application gateway.

Compatibility-sensitive behavior stays in Rust. JavaScript is limited to browser-owned boundaries: DOM interaction, file/save handles, worker and IndexedDB integration, service-worker messaging, and submitting an ant-core-verified payment plan through the wallet provider.

Companion node implementation: ant-node#220

ant-core architecture

  • Preserves the existing native ant-core and ant-cli API through the default native feature.
  • Adds a browser-wasm build that excludes Tokio networking, filesystem, daemon management, and native EVM providers.
  • Owns WebRTC peer connections and DataChannels in Rust through web-sys.
  • Parses canonical WebRTC Direct multiaddresses and pins the expected DTLS certificate fingerprint and ANT peer ID.
  • Uses Saorsa's shared transport-independent iterative lookup runner instead of a browser-specific Kademlia implementation.
  • Uses portable ant-protocol types for quote encoding, payment hashing, pricing, commitment verification, ML-DSA validation, transfer deadlines, and the complete encrypted session/record layer.
  • Shares native/browser transfer policy: adaptive scheduling, bounded in-flight bytes, four-of-seven quorum, fallback targets, whole-record retries, and payload-aware deadlines.
  • Uses the native self-encryption/DataMap implementation for upload, reconstruction, nested DataMaps, range decryption, and BLAKE3 verification.

The native facade is unchanged. Existing Rust desktop applications and ant-cli continue to use the native QUIC client without source changes; only browser-wasm selects the WebRTC adapter.

Post-quantum WebRTC application session

Browser protocol v4 replaces the former plaintext v3 RPC channel and standalone ML-DSA HELLO challenge:

  1. ant-core generates an ephemeral ML-KEM-768 encapsulation key after the ordered DataChannel opens.
  2. The node returns an ML-KEM ciphertext, ANT peer ID, ML-DSA-65 public key, and a signature over the domain-separated KEM transcript.
  3. ant-core verifies the expected peer ID, the BLAKE3 public-key binding, and the ML-DSA signature.
  4. Both sides derive independent client-to-server and server-to-client keys from the fresh KEM secret and transcript hash.
  5. Every later request and response, including HELLO metadata, is protected by ordered ChaCha20-Poly1305 records.

The handshake, key derivation, replay protection, sequence handling, outer framing, and bounds all come from the shared ant-protocol module. JavaScript contains no parallel cryptographic protocol.

WebRTC still supplies certificate-pinned DTLS, ICE, SCTP, and DataChannel transport. The application session protects RPC and chunk plaintext against later compromise of only the classical DTLS key exchange, but it does not hide transport metadata, lengths, timing, or make the WebRTC stack itself post-quantum secure.

Browser workflows

Paid uploads

  • Self-encrypts incrementally in a dedicated WASM worker.
  • Stages encrypted records in IndexedDB instead of retaining the plaintext and complete encrypted file in page memory.
  • Has ant-core locate targets, collect and verify quotes, calculate one payment plan, and store records using the shared native scheduling policy.
  • Uses one narrow wallet callback for approval and the batched payment transaction.
  • Never sends or persists the wallet key outside the page.
  • Cleans staged records after success or failure.

Public downloads

  • Accepts any public DataMap address, including files uploaded by ant-cli or another browser.
  • Fetches and authenticates the DataMap from closest nodes instead of requiring manifest metadata.
  • Resolves nested DataMaps and derives chunk metadata and file size in ant-core.
  • Downloads, decrypts, and BLAKE3-verifies the complete file in Rust.
  • Treats manifest filename, MIME type, and whole-file hash as optional metadata, not download authorization.

Random-access streaming

  • Exposes a bounded Rust BrowserFileReader.
  • Fetches and decrypts only records overlapping requested byte ranges.
  • Uses a thin same-origin service worker to translate media requests into standards-compliant 200/206 responses.
  • Supports disjoint seeks and suffix ranges used to locate MP4 metadata.
  • Keeps the page-owned authenticated WebRTC session; no service worker or manifest server contacts storage nodes.

Compatibility and rollout

  • Wire: browser protocol v4 requires a matching ant-node v4 listener. Plaintext v3 and encrypted v4 deliberately fail closed, so the browser client and node fleet must be redeployed together.
  • Native wire: unchanged. ant-cli and Rust desktop callers retain the existing QUIC transport and public ant-core API.
  • Storage: unchanged. Browser and native clients share the existing self-encrypted chunks and MessagePack DataMap representation; cross-client public addresses work.
  • Payment: unchanged. Quote, commitment, EVM hash, transaction, and payment-proof formats are shared with native clients.
  • API: adds browser-wasm bindings and browser-specific facade types without changing default native callers.
  • Dependency: pins ant-protocol commit 4dad14b6947b6264e0b5982c976a385f9fdac9e0.

Coordinated draft stack

Draft dependencies are pinned by immutable Git SHA so CI does not depend on sibling worktrees.

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Reason: the client implements a new public transport/RPC surface and performs quote, payment, and storage operations while deliberately preserving existing native and stored-data formats.

Test evidence

Current-head protocol-v4 validation:

  • cargo check -p ant-core --target wasm32-unknown-unknown --no-default-features --features browser-wasm
  • cargo clippy -p ant-core --target wasm32-unknown-unknown --no-default-features --features browser-wasm -- -D warnings
  • cargo test -p ant-core --lib browser::protocol::tests
    • 4 endpoint, framing, HELLO metadata, and SDP tests passed.
  • Generated browser SDK from this exact ant-core tree:
    • release wasm-pack build passed;
    • TypeScript build and typecheck passed;
    • 8 unit/generated-WASM tests passed.
  • Coordinated five-node integration against the pushed protocol revision:
    • ML-KEM/ML-DSA session establishment;
    • encrypted HELLO and iterative lookup;
    • public download;
    • signed quote and payment-proof handling;
    • paid upload and record read-back.
  • git diff --check

Earlier headless-Chromium and public-testnet results validated WebRTC connectivity, decentralized lookup, range streaming, and paid uploads under protocol v3. They are useful transport evidence but do not validate the new v4 record layer. A real browser run against a matching deployed v4 node fleet remains required.

New dependencies

Rust/WASM surface:

  • wasm-bindgen, wasm-bindgen-futures, web-sys, js-sys, serde-wasm-bindgen, gloo-timers, console_error_panic_hook, and browser-enabled getrandom.
  • saorsa-dht-lookup from saorsa-core#158.
  • Portable ant-protocol and pinned foundation draft revisions.

ADR

The browser architecture is covered by ADR-0003. The node-side transport and v4 cryptographic design are covered by ant-node ADR-0009.

Mitigation / rollback

Do not build or ship browser-wasm, and keep using the default native feature. Native ant-core, ant-cli, QUIC networking, and existing stored data remain available independently.

Remaining draft work

  • Attach the required Linear issue and confirm the proposed T3 tier.
  • Rebase onto current main.
  • Replace draft dependency pins with reviewed releases before merge.
  • Run protocol v4 in real Chrome, Firefox, and Safari against a matching deployed node fleet.
  • Add relayed WebRTC and independently cacheable signed bootstrap records for production reachability/hardening.
  • Complete-file download remains memory-bound; range streaming is the bounded path for large media.

@grumbach

grumbach commented Sep 8, 2026

Copy link
Copy Markdown
Member

Independent review of this PR at head dc8a2140, read as one stack together with saorsa-transport #160, saorsa-core #158 and ant-node #220. Everything below was verified against the source.

The quote path is the strongest part of the stack. The browser independently verifies the ML-DSA-65 quote signature, recomputes the EVM quote hash, binds peer and content, and checks the commitment sidecar envelope against its encoded form (ant-core/src/browser/payment.rs:76-155, 167-217), so a hostile node cannot make a browser pay against a hash the node will later reject. Every fetched record is BLAKE3-verified against the requested address. assert_upload_node (ant-core/src/browser/manifest.rs:120) refuses to upload to a node whose advertised chain ID, token or vault differs from the client's own. ResponseInbox bounds the whole response rather than per message, so draining cannot reset the budget, and caps tiny-message queue overhead separately.

The drift guards deserve a specific mention. The portable crate necessarily duplicates the quote signing layout, the price curve and the commitment encoding, and ant-core/src/browser/payment.rs:336-390 asserts Amount::from(calculate_price_wei(k)) == calculate_price(k), canonical_quote_bytes(..) == native_quote.bytes_for_sig(), encoded == rmp_serde::to_vec(&native_commitment) and quote_hash == native_quote.hash(). That is exactly the right set of checks.

This PR has never run CI

gh pr view 186 --json statusCheckRollup returns zero checks and gh run list --branch web-support returns nothing at all. Other PRs in this repo run CI normally, and saorsa-core #158 is equally conflicted with main yet ran its full suite, so a merge conflict is not a sufficient explanation. I could not work out the cause from outside the repo.

That matters more than it would for a browser-only change, because this is not a browser-only change. The new client_engine.rs is consumed by production native paths at ant-core/src/data/client/chunk.rs:380, ant-core/src/data/client/batch.rs:769, 856, 862 and ant-core/src/data/client/merkle.rs:548. The native chunk PUT loop was replaced wholesale and quote.rs lost 516 lines to the new policy modules.

I compared the extracted logic against the base line by line and found no reachable behavioural change to quote collection width, witness quorum, upper-median selection, already-stored majority, PUT ordering, payment amounts, retry counts and delays, or transfer-failure classification. The refactor looks faithful. But nothing executes it, including the drift guards above, and those guards are the reason a reader would trust the portable crate at all.

A browser upload can pay and lose the money

In ant-core/src/browser/wasm_transport.rs:2080-2110, after invoke_payment has broadcast the transaction, three separate ? paths return a bare String error and discard the transaction hash and the verified quotes:

  • payment.total_amount != expected_total
  • normalize_hex(transaction_hash) failure
  • store_prepared_records(...).await?

The public API's only recourse is to call it again, which re-quotes and pays a second time. load_record is also passed into store_prepared_records, so the staged bytes are only materialised and hash-checked after the money is spent, and validate_staged_file checks metadata only. A staged record that was evicted or truncated is discovered post-payment and no PUT is ever sent for it.

The native client is explicitly built to avoid this. ant-core/src/data/client/file.rs:2044, 2235, 2281 maintain a resumable receipt cache so that a mid-upload failure "leaves a resumable receipt" and a retry "hits the receipt before paying again". Grepping the whole ant-core/src/browser/ tree for "receipt" returns nothing. A recoverable paid state, or a resume-with-existing-proof entry point, would close this.

One hostile responder can suppress the trusted seeds

ant-core/src/browser/wasm_transport.rs:1145-1152 inserts every returned candidate carrying a webrtc_direct endpoint into the long-lived routing map, with no PQ handshake and no liveness check. And at ant-core/src/browser/wasm_transport.rs:885-891:

let mut initial_candidates = self.routing.borrow().values().cloned().collect::<Vec<_>>();
if initial_candidates.is_empty() {
    initial_candidates = join_all(seed_futures).await...;
}

Seeds are consulted only when the routing map is empty. One responder returning up to MAX_BROWSER_ROUTING_ENTRIES fabricated peers with syntactically valid endpoints keeps it non-empty for the lifetime of the client, so the configured seeds are never retried. The map is pruned by XOR distance to the last lookup's target (wasm_transport.rs:947), so choosing peers close to that target keeps the attacker's entries resident and evicts honest ones. Suppressed dead endpoints still count as non-empty, so a lookup can start with a full set of ineligible candidates, filter them all out, and fail without ever falling back to a seed.

Worth fixing before this ships

The compiled-in trust anchor does not exist. ant-node's ADR-0009 states that the web client contains a constant list of bootstrap MultiAddr values and that those entries are trust anchors. No such list exists here; BrowserNetworkCore::new (wasm_transport.rs:819) requires JavaScript to supply the seeds and rejects an empty set. That composes badly with parse_browser_manifest (browser/manifest.rs:61-118), which validates structure but gives no authenticity and no rollback protection: any well-formed version-6 object is accepted, created_at is unused, network_id need only be non-empty, and the payment-network check compares the node against values from that same manifest. A poisoned or replayed manifest can replace bootstrap identities, file addresses, chain ID, token and vault, and authentication then only proves you reached the attacker's chosen identities. The seed list was meant to be the anchor that prevents this.

Transfer deadlines use the adjustable wall clock. wasm_transport.rs:2846, 2873 and 3006 build every send and receive deadline from js_sys::Date::now(), while EndpointFailureCache correctly uses monotonic web_time::Instant. A forward clock adjustment mid-transfer gives the next fragment a zero timeout and fails a healthy operation; a backward one makes remaining_timeout_ms saturate at u32::MAX, turning a 180 second deadline into roughly 49.7 days. The "wasm-compatible endpoint clock" commit fixed the failure cache but not these.

The response deadline does not allow for node processing time. The shared module models transfer time only. The node's write deadline starts when it begins writing; the browser's starts when it begins waiting and is initialised from the request frame size (wasm_transport.rs:522, 551). For a small get_chunk that is ten seconds, and the browser only extends it after the length prefix arrives. A node spending longer than that reading up to 4 MiB from a loaded store loses the client before its first byte, keeps working, and the browser then suppresses that endpoint for thirty minutes. Over time this blacklists slow but honest nodes.

Staged descriptors are not tied back to the stored DataMap. wasm_transport.rs:2596-2626 checks only that the last record's address and size match the declared DataMap record. It does not decode that DataMap and compare chunks, file size or plaintext hash, so valid staged bytes with stale metadata produce a successful paid upload that returns a descriptor a later download rejects.

There is no spend ceiling. The policy accepts one to seven quotes and pays 3x the upper median with no caller-supplied maximum or deviation limit, and a quote's committed_key_count is signed by the quoting node itself, so validation proves consistency rather than truthfulness. This mirrors native behaviour, so it is not a new regression, but an automatic browser signer with no ceiling is a different risk profile from a human-driven CLI.

ADR-0003 documents protocol v4 while the code ships v5. Lines 68, 154 and 192 specify v4 and say v4 clients and node listeners must be deployed together. The shipped constants are BROWSER_PROTOCOL_VERSION = 5 and autonomi.web.poc.v5, and the protocol fails closed on mismatch, so an operator following the ADR would deploy a broken pair. ant-node's ADR-0009 correctly says v5.

Smaller things

  • uploadPublicFile (ant-core/src/browser.rs:32, 148) accepts a whole 1 GB buffer, copies it into Bytes and holds it alongside the encrypted record set and the JS boundary copies, so a user-selected file can OOM the tab rather than returning a size error. The staged API is bounded.
  • LookupQuery is implemented twice: ant-core/src/browser.rs:453 for the exported BrowserIterativeLookup facade, and wasm_transport.rs:1022 for the production BrowserNetworkLookupQuery with its pool, failure cache and endpoint handling. The test named "generated WASM drives Saorsa's complete shared iterative lookup" (wasm-tests/wasm.test.mjs:27) drives the facade with a JavaScript callback, not the production adapter. A correction applied to one adapter can silently miss the other.
  • BrowserNodeClient::connect() (wasm_transport.rs:2728) opens the DataChannel without sending HELLO, and hello() is a separate public method, but getChunk, quoteChunk and putChunk auto-connect without auto-HELLO. A caller using the low-level client directly gets authentication_required and a closed session. The higher-level paths do send HELLO first.
  • The pool's capacity-one wake channel (wasm_transport.rs:135, 188, 218) can drop a second try_send when two leases are released before the first waiter consumes its notification, leaving a waiter asleep while an evictable idle entry exists.
  • BROWSER_MANIFEST_VERSION is declared independently here and in ant-node. They agree today, and a one-sided bump compiles and rejects every manifest at runtime.
  • ant-core declares version = "0.5.1", which is already published on crates.io, and no rust-version while the dependency graph requires at least 1.88.

Test fidelity

wasm-tests/mock-webrtc.mjs:47 replaces RTCPeerConnection entirely and send() synchronously calls an in-process Rust BrowserTestNode, whose PutChunk handler (wasm_transport/test_utils.rs:216) records two fields and returns ChunkStored without validating payment, checking the address, or storing anything. So replicas == 4 in upload.test.mjs proves the client counted four mock acknowledgements. The test double also has no protocol-version gate, no HELLO-first state, no configured header limit, uses unwrap on malformed input, accepts raw find_node counts including zero, ignores the requested quote size, and returns the same bytes for every address.

These are good client-policy tests. They do not prove browser-to-node interoperability, and there is no test anywhere in the four repos that runs a real browser against a real node. The Playwright suite that did exist, web/e2e/webrtc-direct.spec.js plus its CI step, was removed in 189d351 and pointed at WithAutonomi/ant-browser-sdk, which is currently an empty repository, so the manual verification procedure in ant-node's runbook cannot be followed today either.

What I could not check

Nothing was built or run. Private key lifetime, wallet provider behaviour, IndexedDB durability, workers and service workers live in the separate SDK repo. The PR conflicts with main, so the eventual resolution was not reviewable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants