Browser members: Noise beside TLS, CORS on the mesh surface, the JS SDK in a page - #516
Conversation
A browser cannot run libp2p TLS: its TLS stack presents no client certificate and hides the server's, and the libp2p handshake needs both. Noise, a handshake over X25519 and ChaCha20-Poly1305 with the identity key signing the static key, it can run in JavaScript. Routers and nodes now offer TLS first and accept Noise; a peer that has TLS lands on it as before, and the connections a browser makes, to the router and relayed through it to a node, are Noise. Both bind the connection to the peer ID. TestNoiseOnlyPeer is what a browser is on the wire: a Go peer with Noise alone joins through the router, is relayed to a sam-node, completes the node's auth handshake and reaches its MCP service; with Noise removed from the node the relayed dial fails. The docs no longer say TLS is the only security protocol. FIPS is not claimed: a build for it would offer TLS alone, and nothing asks for one.
A member running in a browser enrolls, refreshes and reads keys, /info and policy from a page whose origin is not the control plane's, and the browser asks the control plane first whether that page may. The mesh protocol's endpoints (/info, /keys, /enroll, /enroll/status, /register, /refresh, /policies) now answer the preflight and mark their responses for any origin. They authenticate by what the request carries, a token in the body or a biscuit as a bearer, never by a cookie, so a page on another origin can send nothing a program could not send already. The operator plane (/admin, /user), which is cookie-authenticated, and the router lease get no such header.
The core of the SDK, identity, enrollment, credential and refresh, no longer reaches for node:crypto and Buffer. Ed25519 comes from @noble/curves, the arithmetic libp2p itself uses in a browser, with RFC 8032 verification as Go crypto/ed25519 and node:crypto apply it. Hex, standard base64 and base64url come from uint8arrays, named by alphabet and padding so each matches the Go decoder that reads it. Both packages were already installed through libp2p; they are now declared because the SDK imports them directly. A signature made here verifies with node:crypto and the other way round, and the SDK integration tests still enroll, refresh and sync policy against the Go control plane.
There was a problem hiding this comment.
Code Review
This pull request introduces browser support for the JS SDK, enabling agents to run directly in browser pages. Key changes include adding CORS support to the control plane, enabling Noise security transport on nodes and routers, refactoring state storage to use IndexedDB in browsers, and implementing a runtime-neutral HTTP/1.1 codec over libp2p streams to replace Node-specific modules. Feedback on the changes highlights a potential runtime TypeError in browser environments when invoking custom fetch implementations due to incorrect this binding, as well as a security vulnerability in the custom HTTP/1.1 chunk size parser that could permit HTTP Request Smuggling due to lax validation.
3777685 to
b23d29e
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces support for running the JS SDK in a browser page by adding a CORS handler to the control plane, enabling the Noise security protocol across Go, JS, and Python hosts, and implementing a runtime-neutral HTTP/1.1 codec. It also splits platform-specific concerns such as state storage (using IndexedDB in browsers) and transports. Feedback on these changes highlights a potential Denial of Service (DoS) vulnerability in readLine due to a bypassable line length limit check, a path traversal vulnerability in hasDotSegment from unchecked URL-encoded dot segments, and TypeScript compilation issues caused by using Uint8Array as a generic type in bytes.ts and http1.ts.
The client that calls a service on a peer and the ingress that answers for this member's agent framed their HTTP/1.1 with Node's http module over a Duplex bridge. They now use a codec of their own on the libp2p stream (http1.ts): Content-Length and chunked bodies in and out, a response without either read to the end of the stream, heads bounded at 64 KiB, the body of a streaming Response written as chunks as the agent produces them so an SSE event reaches the caller before the next. Nothing in that path needs Node any more; a member in a browser calls and answers the same way. An endpoint answered by a Node request listener (an Express app with the A2A SDK's handlers) still needs Node's own parser to build the IncomingMessage it expects; that path moves to libp2p-http-node.ts and shares admission (admitIngress) with the neutral one, so authorization is written once. Unit tests feed the codec byte by byte; the ingress tests cover a fetch handler with a streaming body and Node's own client reading the chunked response; the SDK integration tests exercise the Go and Python callers against it.
What differs between the two runtimes now sits in four small modules
under src/platform, each with a .browser.ts twin of the same exports,
and package.json's "browser" field points a bundler at the twins:
transports Node: TCP and WebSocket with TLS.
Browser: WebSocket with Noise (@chainsafe/libp2p-noise),
since a page cannot run libp2p's TLS; routers and nodes
accept Noise beside TLS.
state Node: a directory of owner-only files, written atomically.
Browser: an IndexedDB database named after the location.
A token file path is refused; the page passes the value.
wasm Node: biscuit-wasm instantiated by hand from disk.
Browser: the package's own entry, resolved by the bundler
as wasm-pack's bundler target expects.
ingress Node: the ingress that also serves a Node request listener.
Browser: the runtime-neutral one.
mesh.ts keeps its identity and credential through a StateStore instead
of node:fs, host.ts takes transports and encrypters from the platform,
biscuit.ts loads its module through it. Nothing else changed for Node:
the same files are read and written, the same transports dialled.
scripts/bundle-browser.mjs bundles dist/index.js for a browser with
esbuild (a devDependency), resolving the .wasm import the way a page's
bundler would, and fails when the browser graph reaches a node: module,
so the split is checked, not assumed.
sdk/js/examples/browser is the Echo agent of a2a-agent.ts in a page: it enrolls with a join token, joins the router, answers a2a://agent with a fetch handler around the A2A SDK's JsonRpcTransportHandler (the verified peer as the A2A user), calls another agent by peer ID, and keeps its identity in IndexedDB so a reload resumes the same peer without a token. tests/ui/browser-sdk.spec.js drives that page in Chromium against bin/sam-one twice: directly, and behind a TLS-terminating edge reached by name, the topology `sam-one --tunnel` leaves a page in, where the router is advertised as wss. In both a Node member calls the page's agent through the relay and the page calls a Node agent. The page runs on an origin of its own, so enrollment goes through the control plane's CORS answer, and the connection it makes is Noise over ws or wss. run.sh builds the SDK, its examples and the page before Playwright. Three things the page found: the Node and Python SDKs must accept Noise beside TLS, or a relayed connection from a browser member has no security protocol in common with them; a browser's fetch refuses to run as a method of another object, so the control plane client wraps it; and js-libp2p in a browser skips loopback and plain ws:// addresses by default, which is what sam-one on the same machine advertises, so the host's gater judges peers, not address shapes.
The SDK design and the Native SDKs guide said the SDKs run on Node.js and CPython only and listed a browser build as a non-goal. The JS SDK now runs in a page, so the guide gains an "In a browser" section (how the page reaches the router, wss behind a TLS edge, CORS on the control plane, IndexedDB state, a handler instead of a listener, how to bundle and where the test is), the design records what is in place (Noise beside TLS on every member, CORS on the mesh protocol's endpoints, the JS codec and platform modules, the browser example and its test) and the npm page points at the guide.
The A2A ingress in both SDKs refuses a request whose path has a "." or ".." segment, since policy is decided on the /<type>/<name> prefix and a backend could resolve such a segment across it. The check read the raw target, where "%2e%2e" is not ".."; a URL parser, and the backend behind the agent, read it as one (WHATWG URL, path state). The check now sees what they see: each segment with %2e (any case) read as a dot. The Go ingress checks the decoded URL.Path and was not affected.
b23d29e to
0b85250
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces browser support for the JS SDK, enabling browser-based agents to join the mesh over WebSocket and Noise, with state persisted in IndexedDB. To achieve this, Node-specific dependencies (like node:crypto and node:http) have been replaced with platform-neutral implementations, including a custom HTTP/1.1 codec over libp2p streams and the addition of the Noise security protocol to Go hosts and Python/JS SDKs. Feedback on the changes suggests ensuring that the bodyStream generator is properly closed upon cancellation to prevent resource leaks, preserving the original Content-Length header for HEAD responses in compliance with RFC 9110, and providing a fallback error when throwing an abort signal reason that might be undefined.
…eader Three things from review. A HEAD response carried Content-Length: 0 whatever the agent declared; it now carries the head a GET would get (RFC 9110 section 9.3.2), the agent's Content-Length if it set one and none otherwise, and a body the agent built for HEAD anyway is cancelled, not sent. Cancelling the body stream of a response closes the generator reading the libp2p stream, after the stream itself is torn down so a pending read cannot block it. A fetch that fails after its signal fired throws the signal's reason, falling back to the error in hand when a runtime leaves the reason unset. Also, the authorizer rendered biscuit-wasm's refusals, which are plain objects, as "[object Object]"; a CI failure of the authorizer tests showed nothing else. It now renders them as JSON, as biscuit.ts already does, and the denial test pins that the failed check is named.
A member of the mesh can now be a browser page. Seven commits, each standing on its own:
libp2p.Security(noise.ID, noise.New)after TLS onsam-routerandsam-node. TLS first: Go peers and the Node/Python SDKs land on it. Noise is what a browser can speak; both bind the connection to the peer ID.TestNoiseOnlyPeerjoins a Noise-only host through the router and reaches a node's service over the relay; removing Noise from the node makes the relayed dial fail. No newgo.modentry (flynn/noisewas already indirect). Docs (networking concept, SDK design, host comments) no longer say TLS only./info,/keys,/enroll,/enroll/status,/register,/refresh,/policiesanswer a preflight and setAccess-Control-Allow-Origin: *. They authenticate by what the request carries (token in the body, biscuit as a bearer), never by a cookie, so a page on another origin can send nothing a program could not already./admin/*,/user/*(cookie-authenticated) and/routers/leaseget no such header;TestMeshSurfaceCORSpins both sides.@noble/curves(what libp2p uses in a browser), RFC 8032 verification as Go and node:crypto apply it; hex/base64/base64url fromuint8arrays, named by alphabet and padding to match each Go decoder. Both were already installed through libp2p and are now declared. Cross-checked against node:crypto in both directions.http1.ts: Content-Length, chunked, until-close, heads capped at 64 KiB) replaces Node'shttpover a Duplex forsession.fetch()/fetchOverStreamand for thehandler/urlingress; a streamingResponsebody goes out chunk by chunk, so SSE works. The Nodelistener(Express) path keeps Node's parser inlibp2p-http-node.tsand shares admission (admitIngress), so authorization is written once. Codec unit tests feed it byte by byte; the ingress tests add a fetch handler with a streaming body and Node's own client reading the chunked response.src/platformwith.browser.tstwins mapped bypackage.json'sbrowserfield: transports (TCP+WS with TLS then Noise / WS with Noise via@chainsafe/libp2p-noise), state (owner-only files / IndexedDB), the biscuit WASM loader, the ingress.scripts/bundle-browser.mjs(esbuild, devDependency) bundles for a page and fails if the browser graph reaches anode:module.sdk/js/examples/browseris the Echo agent in a page (A2A SDK'sJsonRpcTransportHandlerbehind a fetch handler, the verified peer as the A2A user, IndexedDB state so a reload resumes the same peer without a token).tests/ui/browser-sdk.spec.jsdrives it in Chromium againstbin/sam-onedirectly and behind a TLS-terminating edge reached by name (thesam-one --tunneltopology, router advertised aswss): the page enrolls cross-origin, joins overws/wss+ Noise (verified:enc: "/noise",wss://localhost:<edge>), a Node member calls the page's agent through the relay, the page calls a Node agent.run.shbuilds the SDK, examples and the page;make ui-testruns it. Three fixes the page surfaced: the Node and Python SDKs must also accept Noise beside TLS (else a relayed connection from a browser member has no security protocol in common); a browser'sfetchmust not be stored as a method; js-libp2p in a browser denies loopback and plainws://addresses by default, which is what sam-one on the same machine advertises, so the host's gater judges peers, not address shapes.sdk/README.mdand the guide; the npm page points at the guide.Decisions: CORS
*on the mesh surface only; a hand-written codec rather than a dependency; Node keepsnode:httpforlistener/url; TLS first and Noise accepted everywhere; FIPS parked until a concrete customer need.Validation:
make build,go vet ./...,hack/lint.sh(0 issues),go test ./...(incl.TestNoiseOnlyPeer,TestMeshSurfaceCORS,TestNativeSDKsMesh,TestNativeSDKA2A,TestNativeSDKExamples,TestSDKCanaryScript,TestStandaloneSDKAgents*),npm test(77),npm run build/examples,npm cifrom the lockfile,pytest(82),hack/verify-sdk-generated.sh,hack/verify-secrets.sh,make ui-test(20 passed, both browser topologies included).