Vulnerability disclosure: Radicle nodes send private repositories in cleartext
I discovered that Radicle nodes send private repositories over the network in cleartext. This is the story of how I found out, what exactly is happening, and what to do now.
Radicle’s (opens in a new tab) wire protocol has no published byte-level spec as of writing this; it’s all in the source code, baby. And in my deranged quest to run a Radicle node inside a browser tab I’ve spent a whole spring and summer that felt like a full year reading heartwood’s source (opens in a new tab) line by line and rewriting it in TypeScript, byte for byte. My first transport implementation did what Noise (opens in a new tab) says and encrypted everything past the peer-to-peer handshake. But as it turned out, nobody was talking to me. With an encrypted communications channel, not a single real node could understand me in the wild, and when I nailed down the root cause I knew I was looking at a major vulnerability I had to report upstream immediately.
The security vulnerability
Radicle is a rare kind of code forge (the GitHub-shaped thing: repositories, issues, patches, reviews, etc) because it’s a peer-to-peer one. Nodes talk to each other directly, they gossip about repositories, and replicate Git data with no need for a central server. Every connection between two nodes starts with a Noise handshake, the same family of protocols WireGuard uses, which proves that the node on the other end holds the private key it claims to hold.
As it turns out, after the handshake, everything the two nodes say to each other goes over the plain TCP socket unencrypted: gossip, repository ids, the addresses of other nodes, and the Git objects themselves, private repositories included. The first inbound byte after the handshake, offset 32 of a live connection to a production seed, is the ASCII r of rad. Anyone on the network path between two nodes (an ISP, a transit provider, a relay) can read all of it.
This holds for every Radicle release ever made, up to and including 1.10.3, the latest at the time of writing. No release fixes it yet. The coordinated disclosure (opens in a new tab) lifts today in synchronization with an announcement on radicle.dev (opens in a new tab). A RustSec advisory may follow. This post is my side of it all: how I ran into it, how I made sense of it, and what followed once I realized what was going on.
What you can do today
- Tunnel between machines you control. If your team runs its own seed, put the hop between your laptops and that seed inside WireGuard, a VPN or an SSH tunnel. That protects the hop with nobody new in the middle, and it works today with any Radicle version.
- Treat anything already fetched over clearnet as publicly exposed. If a private repository ever held a password, token or key and you fetched it over the default transport, rotate that secret.
- Reach
.onionand.i2ppeers through a local daemon.radicle-node1.10.3 can send connections to onion peers through the SOCKS port of a Tor daemon you run. In~/.radicle/config.json:{ "node": { "onion": { "mode": "proxy", "address": "127.0.0.1:9050" } } }9050is Tor’s default SOCKS port; use yours.node.i2ptakes the same shape, pointed at your i2p router’s SOCKS port. Both default to"mode": "drop", which skips onion and i2p peers entirely. This only protects connections to onion and i2p peers: nothing in 1.10.3’s config refuses clearnet peers, so your node still talks to IP and DNS peers in cleartext. If you also run an onion service for inbound connections, publish it innode.externalAddressesand have the node listen only on the local address the service forwards to (for example127.0.0.1:8776), because a node listening on a public address accepts clearnet connections. - Don’t set
node.proxyexpecting confidentiality.node.proxyis a global proxy for IP and DNS peers. Pointed at Tor, it sends all your clearnet traffic, private repositories included, out through a Tor exit, where it is still cleartext and readable by the exit operator and anyone after it. It doesn’t enable onion peers either; those stay dropped untilnode.onionis set as above.
Status
- Affected: every
radicle-noderelease to date, up to and including 1.10.3. Those are Radicle’s release numbers; the crate itself is versioned 0.x (0.21.1 in Radicle 1.10.3). Every published version of it is affected, and none is patched. - Fix: none released. Radicle 2.0 (unreleased) (opens in a new tab) is supposed to replace the transport with QUIC and TLS. The v1.x and v2.x nodes won’t be able to connect to each other, so upgrading a node to 2.0 is expected to cut it off from the 1.x network.
- Private repositories: being private restricts who may fetch a repository from a seed, not what an observer on the path sees while it is fetched between allowed peers. If you have fetched, or still fetch, a private repository over clearnet, treat its contents as having crossed the network in the clear.
- Official disclosure: the Radicle team’s announcement (opens in a new tab), with their timeline and remediation.
Why I was digging in heartwood
I am building Grove (opens in a new tab), a Radicle node that runs inside a browser tab, on any device, with no server doing Git on its behalf. For that to be worth anything it has to speak the real protocol exactly, or the connection dies.
Nothing written down says what those bytes are. Radicle’s protocol guide and its RIPs explain the concepts, not the bytes. The one real specification in the stack is Noise (opens in a new tab), and it covers only the handshake. Everything after it (the framing, the message types, the gossip, the fetch) exists only as Rust in heartwood (opens in a new tab). The fetch path alone is roughly 5,000 lines spread over three crates, tests not counted.
The first version of Grove’s transport did what the Noise spec says: handshake, split, then encrypt every message under the derived keys. It couldn’t hold a connection to a real node. The handshake would complete and then everything fell apart. The Noise code was mine, so I assumed the bug was mine too, and hunted it there for a good while. Eventually I put Wireshark on the connection to see what the other side was actually sending me.
rad, in ASCII, right after the handshake. I didn’t believe it at first. The other node wasn’t encrypting anything.
The root cause
Radicle opens every connection with a Noise XK handshake, three messages long:
- The dialing node sends a fresh, single-use public key and mixes in a Diffie-Hellman against the other node’s long-term public key, which it already knows. 32 bytes out.
- The answering node sends a fresh key of its own, and a second Diffie-Hellman. 32 bytes back.
- The dialing node sends its own long-term public key, encrypted under what the two now share. 48 bytes out: the 32-byte key plus a 16-byte authentication tag.
If you know Noise you’d expect 48, 48 and 64 there. The spec ends every handshake message by encrypting its payload, which adds a 16-byte tag even when the payload is empty. noise-framework, the Noise crate Radicle uses, skips that on an empty payload, so each message is 16 bytes shorter. The counts above are what the wire actually carries.
When message three lands, both sides derive a pair of symmetric keys, one per direction. Noise calls this the split, and it’s why the handshake exists: from here on, everything either side says is supposed to be encrypted under those keys.
A sequence diagram of the Noise XK handshake between two nodes, the dialing node on the left and a Radicle seed on the right, with a lifeline hanging under each. Three handshake messages cross between them. The first, an ephemeral key of 32 bytes, goes left to right in bare ink. The second, the seed’s own ephemeral key of 32 bytes, comes back right to left, also bare. The third, the dialing node’s long-term key of 48 bytes, goes left to right drawn inside a sleeve, which is how this figure shows bytes that travel encrypted. A dashed line across the middle marks the split, the moment both sides hold the session keys. Below it a fourth message, a node announcement, comes back from the seed in bare ink with no sleeve around it at all.
In radicle-node, the split happens: noise-framework 0.4.1 derives both cipher states and stores them. Then nothing reads them.
Start with writing, since that’s what puts bytes on the wire. The node wraps every connection in a Protocol, which holds a handshake state machine and the socket under it. Its write checks one thing first:
// crates/radicle-node/src/reactor/session.rs, Radicle 1.9.1, trimmed
impl<M: StateMachine, S: Session> Write for Protocol<M, S> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.state.is_complete() || !self.session.is_established() {
log::trace!(target: M::NAME, "Passing writing to inner session");
return self.session.write(buf);
}
// ... handshake acts ...
}
}
buf is whatever the node has to say: a gossip message, a ref announcement, the Git pack of a private repository. The log message is the code’s own description: it hands the write to the inner session, the raw socket. The same condition is in the current code, with the state machine field renamed from state to machine; the trimming above also drops a trace line that sits directly above the condition in both versions.
Reading has the mirror image of the same problem, which is why two nodes still understand each other at all. The read side alone leaks nothing.
The bug is that one condition. !self.session.is_established() means the connection underneath isn’t set up yet, so there’s nothing to encrypt. self.state.is_complete() means the handshake has finished, the exact moment encryption should start. Too early and too late take the same branch, and both write in the clear.
To be fair, I can see how this happened. Protocol is generic, and the node uses it for two different things:
pub type NoiseSession<E, D, S> = Protocol<NoiseState<E, D>, S>;
pub type Socks5Session<S> = Protocol<socks5::Socks5, S>;
With SOCKS5 this is exactly what the spec asks for: once the proxy handshake is done, you get out of the way and pass the bytes through as they are. With Noise, the end of the handshake is where the real work starts, because from then on you encrypt everything. Same wrapper, same is_complete(), opposite meaning. Only the SOCKS5 half of that ever got implemented.
The fix
As I understand it, the Noise side should look more like this:
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if !self.session.is_established() {
return self.session.write(buf); // nothing negotiated yet, nothing to encrypt
}
if self.state.is_complete() {
// A Noise transport message is capped at 65535 bytes, and 16 of them go to the
// authentication tag that proves nobody altered it, so a longer write leaves as
// several messages, each one framed so the far side knows where it ends.
for chunk in buf.chunks(65535 - 16) {
let ciphertext = self.state.encrypt(chunk)?; // chunk.len() + 16 bytes
self.session.write_all(&(ciphertext.len() as u16).to_be_bytes())?;
self.session.write_all(&ciphertext)?;
}
return Ok(buf.len());
}
// ... handshake acts ...
}
Here’s the catch: self.state.encrypt doesn’t exist. And this is the part that honestly surprised me. noise-framework does compute the split, and it stores both ciphers right there on NoiseState::Active, as sending_cipher and receiving_cipher. Each one is a CipherState with a perfectly working encrypt_with_ad and decrypt_with_ad, in the same file. The encryptor exists. The keys exist. They’re just sitting there.
What’s missing is a way to get at them. NoiseState gives you exactly two accessors, get_handshake_hash and get_remote_static_key, and neither hands out the ciphers. So when the node asks “is the handshake done?”, this is everything it gets back:
fn artifact(&self) -> Option<Self::Artifact> {
self.get_handshake_hash().map(|hh| NoiseArtifact {
handshake_hash: hh,
remote_static_key: self.get_remote_static_key(),
})
}
is_complete() just means that artifact exists. In other words, the node decides the handshake succeeded by collecting the two things that are not the keys, and the keys stay right where the split left them, unused. (Calling advance again to get any further gets you a “handshake complete” error, which is how the Radicle team ran into the same wall from the other side.)
Now, to be fair, the keys weren’t actually locked away. NoiseState is a public enum and both cipher fields are public, so radicle-node could have destructured NoiseState::Active and called encrypt_with_ad itself, in the impl_noise block of session.rs that already imports the type. What the library doesn’t give you is a transport phase: an accessor for the ciphers, framing, and a guard against nonce exhaustion (encrypt_with_ad bumps its counter unchecked, and the rekey it exposes is never called for you). The clean fix is adding that transport phase to noise-framework, but nothing stopped radicle-node from doing the same in its own impl_noise in the meantime.
And whichever way you fix it, the wire changes. Every message gets 16 bytes longer, anything big gets chopped into pieces, and every piece needs a length header in front of it. Noise leaves that framing up to you, so someone has to pick one, and whatever they pick, an unfixed node reading a fixed node’s stream sees garbage, and vice versa. You can’t sneak that in gradually. It’s a breaking change to the Radicle protocol, and that’s why the fix ended up being a whole 2.0.
So what you get on the wire is authentication once, at the handshake, and zero confidentiality after that. The frames after the handshake carry no transport MAC either, so someone on the path can alter, drop or reorder them. What they can’t do is slip you tampered repository contents without it being noticed: announcements are signed, Git objects are content-addressed, and signed references sit on top of both. That’s why this disclosure is about confidentiality and not integrity.
What an observer sees on the wire
Once I knew what I was looking at, I made a clean capture. I dialed one of the Radicle team’s production seeds from Grove’s TypeScript stack, completed the handshake, and fed the inbound bytes to a plain hexdump. No decryption, no Grove decoder. Inbound byte 32, the first after the handshake, starts 72 61 64 01: ASCII rad and a protocol version byte. The seed’s alias sits at offset 155 as readable text, its network address at 179, and its user agent, /radicle:1.9.1/, at 312, followed by the rad of the next frame. No other node’s address appears anywhere in this range.
Packet capture of a connection from my node to iris.radicle.network. Its first 3 packets are the handshake, and look like random bytes. From byte 32 on nothing is encrypted: anyone on the network path can read iris.radicle.network, a 60-character I2P address, /radicle:1.9.1/ as plain text.
| Time (s) | From | Bytes | What |
|---|---|---|---|
| 0.058 | my node | 32 | handshake message one, a fresh single-use key (random-looking) |
| 0.104 | seed | 32 | handshake message two, a fresh single-use key (random-looking) |
| 0.111 | my node | 48 | handshake message three. Both sides now hold the derived keys (random-looking) |
| 0.157 | seed | 1,428 | the first thing the seed sends after the split, and it is in the clear (not encrypted) |
| 0.157 | my node | 12 | my node's first message after the split: a ping (not encrypted) |
| 0.157 | seed | 1,428 | the inventory frame that opened behind it, still going (not encrypted) |
| 0.157 | seed | 2,856 | and still going (not encrypted) |
| 4.587 | seed | 61,326 | 33 more packets: the rest of the inventory, a subscribe filter, a pong and one more node announcement, none of it encrypted (not encrypted) |
You don’t need a decoder to read that. The frame opens with rad and a version byte, names the stream it belongs to, and gives its length: 288 bytes. Inside is a node announcement, readable end to end: the seed’s public key, its signature, its alias iris.radicle.network in ASCII, the three addresses it wants to be reached on (a DNS name, an I2P address and a Tor onion), its proof-of-work nonce, and the agent string /radicle:1.9.1/, which is how I knew what version I was talking to. The next frame starts right after and declares itself 65,514 bytes long.
None of that is secret: a node announcement is public gossip, signed so it can travel through strangers. I just didn’t need a key to read it.
Then I fed the same bytes to Grove’s message decoder, to make sure the hexdump wasn’t flattering me. It parsed five frames straight off: the node announcement above; the 65,514-byte frame, an inventory announcement listing every repository the seed holds; the seed’s subscribe filter, 1,044 bytes; a pong, 8 bytes, answering the ping I had sent; and a second node announcement, 148 bytes, for another node the seed was passing along. 67,002 bytes, none of them encrypted. Encrypted, all of that would have been noise, in the other sense of the word.
That was gossip: routing metadata, repository ids, node addresses. Bad enough, but Radicle exists to move repositories, and Grove couldn’t drive a fetch yet. So I captured one with the stock tools.
A rad clone of one of my own repositories, from my node to my seed, with tcpdump on the interface: 1,522 packets. The inbound stream is 3.6 MB and opens with the same 72 61 64 01. About two thirds in, Git starts talking, and here are both sides, read straight off the wire:
my node, asking
0012 command=fetch
001f agent=radicle-fetch/0.21.1
0001 000e thin-pack
000e ofs-delta
0032 want 9d9ab6a49d68b1179aea39f80e5d2fa629169aef
0009 done
0000
the seed, answering
0039 9d9ab6a49d68b1179aea39f80e5d2fa629169aef refs/rad/id
000d packfile
0024 Enumerating objects: 16, done.
003c Total 16 (delta 0), reused 0 (delta 0), pack-reused 16
0aba PACK.........%x..P...@....
That’s Git’s pkt-line format: four hex digits of length, then the content. agent=radicle-fetch/0.21.1 names the crate that does the fetching, not radicle-node, the one the advisory covers. The node that sent it logs Version 1.10.3 (7e1cb406b), the newest release. The object my node asked for with want is the one the seed advertised for refs/rad/id, so the two halves match up. Then PACK, version 2, sixteen objects.
I inflated all sixteen straight out of the capture: two commits with author identity, timestamps and SSH signatures intact, six trees, five blobs and three offset-deltas. One blob is the repository’s identity document, with its name, description and delegate. Another is a patch revision, title and description readable. It’s all plaintext on the wire.
Telling the team
On 2026-06-24 I reported the finding privately to the Radicle team. They asked for a .pcap and I sent one, but by then they had already captured their own from a native node and were seeing the same thing. They then went a step further than I had at the time: they captured a rad clone and pulled the Git contents out of the stream. So the claim about repository contents doesn’t rest on my capture alone; the people who ship the node reproduced it independently.
We agreed on a coordinated disclosure with a roughly three-month embargo, modeled on the team’s signed-references disclosure in March (opens in a new tab). That one went out after the fix shipped in 1.7.0. This one goes out before a fix is released, and the team’s reason is to inform users that they should be careful with private repositories, since there is an increased risk of leaking sensitive information. For public repositories the team considers the situation bad but probably still workable. Radicle doesn’t self-assign CVEs; the vehicle is the team’s write-up, and a RustSec advisory may follow.
The team first prototyped a fix on the snow Noise library, but then decided to replace the transport altogether. Radicle 2.0 was announced (opens in a new tab) in July and is planned to run on iroh (opens in a new tab), which in practice means QUIC (opens in a new tab) with TLS via rustls (opens in a new tab). That gives confidentiality by construction, plus NAT traversal and multiplexing. To be clear, iroh was on the table long before me: the project had been discussing it since July 2025, about a year before my report, with a renewed push in the weeks before it. From what I was told though, my report lit a fire under the schedule and sealed the decision to go for a new major version. 2.0 isn’t out yet, and 1.x and 2.x nodes won’t be able to talk to each other; the team’s post has the timeline and the details. Oh, and the one thing I asked for was to be credited as the finder, which the team had already offered before I even got to ask.
Grove has the same wire
Before someone beats me to it in the comments: Grove speaks this same protocol, because speaking it is the entire point of Grove. When Grove talks to a native (heartwood) Radicle node, that connection is exactly as cleartext as everyone else’s. No better, no worse.
Two Groves talking to each other directly is a happier story. That connection runs over WebRTC, which encrypts with DTLS, so this vulnerability doesn’t apply to it.
Between a Grove tab and a native seed there’s one more piece, because no browser can open a raw TCP socket. So a dumb relay sits in the middle and pumps bytes from a WebSocket on one side into a TCP connection on the other. It doesn’t read or change anything; it just moves bytes. But the bytes after the handshake are cleartext, so whoever runs that relay can read them, private repositories included.
Who sees what, then:
- Between your browser and the relay: the relay speaks
wss://, so an observer on that hop sees TLS and nothing else. - The relay operator: TLS ends at the relay, so whoever runs it sees the stream. Run it yourself, or let a seed operator run it in front of their own seed, and nobody learns anything they didn’t already know, given that Radicle stores repositories unencrypted to begin with. A relay run by a stranger has to be trusted with the contents.
Grove will move to the encrypted transport when the network does, which needs iroh running inside a browser tab, something I haven’t tried yet. Until then, on the Radicle wire, Grove sends the same bytes the CLI does. Beyond the wire, Grove is planned to store your private repositories encrypted at rest.
Follow the Grove build log
As already hinted at, Grove is in active development, and as far as I know it is the first peer-to-peer code forge to ever run in a browser tab. Its source will be published soon enough. Until then, the curious among you can request early-access (opens in a new tab) and try it first-hand.
You can also follow this crazy journey from its 0th post onwards. Next week’s chapter is about how the node itself was built: putting a Git object store, a signing key and the Radicle protocol inside a browser tab, and what fought back.
About me. I am Kostis Maninakis, a software engineer lately working on peer-to-peer and Web-native systems. I take contract work on systems design, web applications and developer tooling: get in touch.
Replies
A selection from this post's inbox, quoted by hand. The words are their writers' own.
Reply by email to maninak@pm.me. Those as well as Bluesky (opens in a new tab) and Mastodon (opens in a new tab) posts about this blog post may show up here.