<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://maninak.com/</id>
    <title>Kostis Maninakis</title>
    <updated>2026-09-23T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <author>
        <name>Kostis Maninakis</name>
        <uri>https://maninak.com/</uri>
    </author>
    <link rel="alternate" href="https://maninak.com/blog/"/>
    <link rel="self" href="https://maninak.com/atom.xml"/>
    <subtitle>Kostis Maninakis on code, peer-to-peer software, entrepreneurship, life and anything in between.</subtitle>
    <icon>https://maninak.com/favicon.ico</icon>
    <rights>© Konstantinos Maninakis</rights>
    <entry>
        <title type="html"><![CDATA[Git Is Distributed. Your Project Isn’t.]]></title>
        <id>https://maninak.com/blog/git-is-distributed-your-project-isnt/</id>
        <link href="https://maninak.com/blog/git-is-distributed-your-project-isnt/"/>
        <updated>2026-09-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Git escaped centralization. Everything we built on top of git walked right back into it.]]></summary>
        <content type="html"><![CDATA[<p>The Grove Build Log, chapter 0: in which I explain what I have gotten myself into.</p><p>So. You use git. Of course you do, you found your way here, and you strike me as the kind of person who commits early and often. Then you already know the famous thing about git, the one every tutorial opens with: git is <strong>distributed</strong>. Every clone is a full copy. Full history, every branch, the works. No central server, no single point of failure. Linus built it so a thousand kernel hackers could collaborate without asking one blessed machine for permission.</p><p>Now humor me for a minute. Open one of your repos on GitHub. Look at the issues. The pull requests, the review threads, the labels, the stars, your very username and everything it has ever said. If GitHub vanished tonight, which of those would you still have tomorrow morning?</p><p>I’ll wait…</p><p>The code, obviously. Every clone has the code. Everything else, the entire <em>social life</em> of your project, its memory of every decision and every argument and every “fixed in #482”, lives in exactly one place, on someone else’s computer. We took the most successful distributed version-control system ever shipped… and we all pointed it at the same building.</p><figure><p><em>Two drawings side by side. On the left, git as designed: six clones linked to one another, every copy reachable from every other, with no special node among them. On the right, git as practiced: the same six clones, each wired to a single box in the middle, and the only path from any clone to any other runs through it.</em> <a href="https://maninak.com/blog/git-is-distributed-your-project-isnt/">See the drawing.</a></p><figcaption><code>git remote -v</code>. Git as designed, on the left. Git as practiced, on the right.</figcaption></figure><h2 id="the-great-escape-to-another-server">The great escape (to another server)</h2><p>That contradiction sat wrong with me for years, and I’m clearly not alone: a whole genre of software exists to get out of it. Every project below saw the same problem I did, they just took different forks in the road. (I’m sorry, I’ll let myself out… ^^ )</p><p><strong>The baseline: <a href="https://github.com/" rel="nofollow">GitHub</a> and <a href="https://gitlab.com/" rel="nofollow">GitLab</a>.</strong> This is not a “GitHub bad” post, I’ve spent most of my career happily inside both. It’s just that your account is a row in someone else’s database, and the row isn’t yours. Now and then that bites. A takedown lands on the wrong repo. An anti-fraud system locks the wrong org. A sanctions policy fences off a country and thousands of developers wake up locked out of their own history. Genuinely rare. Until it’s you.</p><p><strong>Escape one: become the server.</strong> <a href="https://about.gitea.com/" rel="nofollow">Gitea</a>, <a href="https://forgejo.org/" rel="nofollow">Forgejo</a>, <a href="https://codeberg.org/" rel="nofollow">Codeberg</a>. Self-hosting is the classic move: put the forge on hardware you control, and no company can close your account. Except now you <em>are</em> the server. The uptime, the backups, the spam, the migrations at 2am, all yours. And for everyone who contributes, the center of truth is still one machine, it just has your name on it.</p><p><strong>Escape two: teach the servers to talk.</strong> <a href="https://codeberg.org/forgejo-contrib/federation" rel="nofollow">Forgejo has been building federation</a> on ActivityPub, the protocol behind Mastodon. Your issues live on your instance, mine live on mine, and the instances trade mail so our projects can collaborate across the fence. It’s good, hard work and I hope it lands well. But your account and your project still live on an instance. Fewer eggs per basket, more baskets… still baskets.</p><p><strong>Escape three: make the user portable.</strong> <a href="https://tangled.org/" rel="nofollow">Tangled</a> is a young forge built on <a href="https://atproto.com/" rel="nofollow">atproto</a>, the protocol under Bluesky. It’s VC-backed, and its clever trick is portable identity: fall out with your host and you can move without losing your name or your social graph. A real step forward. Your repos and their social layer still live on hosted infrastructure, though.</p><figure><p><em>Four drawings in a row, labelled GitHub, self-hosted, federated and Tangled. The outline around each one is a different shape, but inside every outline is the identical picture: four peers wired to a single box in the middle, and the box is the only thing joining them.</em> <a href="https://maninak.com/blog/git-is-distributed-your-project-isnt/">See the drawing.</a></p><figcaption>Four buildings. One floor plan.</figcaption></figure><blockquote>Git escaped centralization. Everything we built on top of git walked right back into it.</blockquote><p>At some point I stopped asking “which server should hold my project?” and started asking why a <em>distributed</em> version control system needs an appointed keeper at all.</p><h2 id="the-project-as-the-sovereign-thing">The project as the sovereign thing</h2><p>That question has an answer, and it’s called <a href="https://radicle.xyz/" rel="nofollow">Radicle</a>. When I first understood it properly, it rearranged my head.</p><p>Radicle is a peer-to-peer code collaboration network built on plain git. Your identity isn’t a username in someone’s database, it’s a cryptographic keypair that you generate and hold. Nobody issues it, so nobody can revoke it. Issues, patches (Radicle’s pull requests) and discussions are signed data stored <em>inside git itself</em>, versioned and replicated just like your commits, so the social layer finally travels with the code.</p><p>Nodes gossip about repositories and replicate the ones they care about. Who speaks for a project is written into the project: its maintainers are listed, as public keys, in a document inside the repository. Seeds exist, but they just carry copies and have no say over the project. Delete every copy on every seed and any peer holding the project can re-seed it whole, issues and all. And there’s no account to ban. The project itself is the sovereign thing, and machines just hold copies of it.</p><figure><p><em>Four peers drawn as small squares, one at each corner of a loose rectangle, with a dot inside every one of them. They are joined to their neighbours and one pair is joined straight across the middle. Nothing encloses them and there is nothing in the middle for them to be wired to.</em> <a href="https://maninak.com/blog/git-is-distributed-your-project-isnt/">See the drawing.</a></p><figcaption>Same four peers. The box surrounding them is gone, and everybody has their own full copy of the project.</figcaption></figure><p>I’m not a drive-by tourist here, by the way. These days my Radicle work lives at <a href="https://radicle.tools/" rel="nofollow">radicle.tools</a>, but it started before that. I built the <a href="https://marketplace.visualstudio.com/items?itemName=radicle-ide-plugins-team.radicle" rel="nofollow">Radicle VS Code extension</a>, which I still maintain, led the development of Radicle Planning Boards, and sat in the Radworks Product Org around the time <a href="https://radicle.garden/" rel="nofollow">Garden</a> was getting started. That’s multiple years in, long enough to earn some opinions.</p><p>And the one that wouldn’t leave me alone: Radicle had a web problem, and that web problem had exactly two doors.</p><h2 id="the-two-doors">The two doors</h2><p><strong>Door one: install things.</strong> Run a node and drive it from the CLI. Or run <a href="https://radicle.xyz/desktop" rel="nofollow">Radicle Desktop</a>. Or use the <a href="https://marketplace.visualstudio.com/items?itemName=radicle-ide-plugins-team.radicle" rel="nofollow">VS Code extension</a> (I built that door; I am fond of that door). Good doors, I use them daily. But every one of them starts with <em>install</em>, and that’s where most of the internet politely turns around and leaves.</p><p><strong>Door two: the browser.</strong> The stock web <a href="https://app.radicle.xyz/" rel="nofollow">Explorer</a> is read-only. You can look at a repo through it, but you can’t touch it. The team’s hosted product, <a href="https://radicle.garden/" rel="nofollow">radicle.garden</a>, lets you touch it, and credit where due: sign up with an email and a credit card and you can open issues, comment, react and review patches, right from the browser. No install.</p><p>Now look at how that works. Garden generates a Radicle keypair for you and keeps it in a keystore on Garden’s servers. When you write, Garden signs the change with that key, on your behalf. That’s a reasonable trade, and for plenty of people the right one. But the key that proves a change is really yours, the one thing Radicle made sovereign, sits in someone else’s keystore.</p><p>Both doors rest on the same, perfectly reasonable assumption. So either you install the real thing, or somebody’s server does it for you, key and all.</p><p>I got stuck on that assumption. <em>A browser tab can’t do the real thing.</em></p><p>… Says who?</p><h2 id="the-node-in-the-tab-for-real">The node. In the tab. For real.</h2><p><a href="https://radicle.tools/grove" rel="nofollow">Grove</a> is a Radicle node that runs inside a browser tab. Not a web client politely asking some server’s API to do git things on your behalf.</p><figure><p><em>On the left, one browser tab drawn as a window, holding three things: a stack of git objects, a signing key inside a dashed boundary it never crosses, and the protocol engine. A single thin line labelled relay runs from the tab to the right, where five peers are wired to some of their neighbours and not to all of them. There is no server in the middle of that mesh, and no building anywhere in the picture.</em> <a href="https://maninak.com/blog/git-is-distributed-your-project-isnt/">See the drawing.</a></p><figcaption>The whole node, inside the tab. The relay is drawn as a line because all it does is move bytes.</figcaption></figure><p>When Grove clones a repository, real git objects land on your own disk, in the browser’s private file storage (a.k.a. <a href="https://web.dev/articles/origin-private-file-system" rel="nofollow">OPFS</a>). An actual git object database, actual refs, yours. <em>[Fun fact: no browser ships a way to debug OPFS, and the couple of extensions that claim to do so never worked for me, so much of Grove was built on a filesystem I could not see. Full chapter later.]</em></p><p>Grove generates a signing key that never leaves your browser. When you open an issue or publish a patch, <em>you</em> sign it, with your key. Nobody signs on your behalf. Nobody can.</p><p>It speaks the actual binary Radicle protocol, the same handshake, gossip and replication stream a native node speaks. And it verifies everything it clones by itself, signature by signature, because there’s no server whose word it could take instead.</p><p>And because it’s a web app, it’s just a URL. Open it on your phone and you have a real Radicle peer in your pocket. Open it on the locked-down work machine where you’ll never get approval to install a node (IT said no; IT always says no), same thing.</p><p>It works offline too. The repo is on your disk, and so are its issues and patches, so there’s nothing to be “down”. Read, write, commit and review a patch on the plane, merge it even, and sync when the wheels touch.</p><p>One catch, before someone in the comments points it out: a browser can’t open a raw TCP socket (probably wise, all things considered), and Radicle nodes speak TCP. So between the tab and the wider network sits a thin relay that pumps bytes between a WebSocket and a TCP connection. “Aha,” you say, “so there IS a server.”</p><p>There’s a pipe. One file, about two hundred lines. It holds no state and no keys. It can’t author a change as you, forge a signature or decide which branch is canonical. The node in your tab verifies everything the relay hands it, so a lying relay only wastes its own bandwidth. Don’t like mine? Run it yourself, straight from the repo:</p><pre><code><span><span>npm</span><span> run</span><span> relay
</span></span></code></pre><p>Or skip mine entirely: if a Radicle node already runs on your machine, one line of <a href="https://github.com/vi/websocat" rel="nofollow">websocat</a> bridges your tab straight to it:</p><pre><code><span><span>websocat</span><span> -b</span><span> ws-l:127.0.0.1:8080</span><span> tcp:127.0.0.1:8776
</span></span></code></pre><p>It’s a dumb pipe on purpose, and I find it fitting that the one server left in the picture is the one that can’t forge anything, given that everyone calls the incumbents “forges”.</p><p>And when the peer on the other side is another Grove, the two tabs connect directly, browser to browser, and a repository replicates with no relay and no seed anywhere in the data path. Two servers are still involved today, at the edges: a rendezvous service where the two tabs find each other, and a public STUN server that tells a tab its own address. Neither one carries a byte of the repository, and neither can forge one.</p><p>(There is also Taiga, a Radicle network explorer I am building alongside Grove, but it can have its own chapter later.)</p><h2 id="what-you-are-signing-up-for">What you are signing up for</h2><p>Grove is in <strong>alpha</strong>, and this series is written from the workshop floor, sawdust included. Take cloning a real production repository into a tab. For weeks it didn’t clone at all: it would grind for ages and then crash. The first time it made it all the way through, it took all night. All. Night. Today that same repo clones in about three seconds. In between came a pile of confident theories that got challenged, measured and proven wrong one by one, and publishing your own wrong turns is, as far as I can tell, the only way to write a build log worth reading.</p><p>The upcoming chapter one has a title, which so far is the most finished part of it: <strong>“I Put a Whole Git Node in a Browser Tab. It Fought Back.”</strong> One repo’s issue list getting parsed <em>overnight</em>, four wrong theories, three bugs in one trench coat and a good look under Grove’s hood. It joins <a href="https://maninak.com/blog/series/grove-build-log/">this series</a> very soon. Come watch me argue with a browser.</p><hr><blockquote><p>Everything above is one craftsman’s read of the forge landscape. I fork code, not people.</p></blockquote>]]></content>
        <author>
            <name>Kostis Maninakis</name>
            <uri>https://maninak.com/</uri>
        </author>
        <category label="radicle"/>
        <category label="grove"/>
        <category label="git"/>
        <category label="peer-to-peer"/>
        <category label="code-forge"/>
        <published>2026-09-23T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Vulnerability disclosure: Radicle nodes send private repositories in cleartext]]></title>
        <id>https://maninak.com/blog/radicle-cleartext-transport-vulnerability/</id>
        <link href="https://maninak.com/blog/radicle-cleartext-transport-vulnerability/"/>
        <updated>2026-09-23T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[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.]]></summary>
        <content type="html"><![CDATA[<p><a href="https://radicle.dev/" rel="nofollow">Radicle’s</a> wire protocol has no published byte-level spec as of writing this; it’s all in the source code, baby. And in my deranged <a href="https://maninak.com/blog/series/grove-build-log/">quest to run a Radicle node inside a browser tab</a> I’ve spent a whole spring and summer that felt like a full year reading <a href="https://app.radicle.at/nodes/seed.radicle.at/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" rel="nofollow">heartwood’s source</a> line by line and rewriting it in TypeScript, byte for byte. My first transport implementation did what <a href="https://noiseprotocol.org/noise.html" rel="nofollow">Noise</a> 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.</p><h2 id="the-security-vulnerability">The security vulnerability</h2><p>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.</p><p>As it turns out, after the handshake, everything the two nodes say to each other goes over the plain TCP socket <em>unencrypted</em>: 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 <code>r</code> of <code>rad</code>. Anyone on the network path between two nodes (an ISP, a transit provider, a relay) can read all of it.</p><p>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 <a href="https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure" rel="nofollow">coordinated disclosure</a> lifts today in synchronization with an <a href="https://radicle.dev/2026/09/23/disclosure-of-vulnerability-in-network-protocol" rel="nofollow">announcement on radicle.dev</a>. 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.</p><h2 id="what-you-can-do-today">What you can do today</h2><ul><li><strong>Tunnel between machines you control.</strong> 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.</li><li><strong>Treat anything already fetched over clearnet as publicly exposed.</strong> If a private repository ever held a password, token or key and you fetched it over the default transport, rotate that secret.</li><li><strong>Reach <code>.onion</code> and <code>.i2p</code> peers through a local daemon.</strong> <code>radicle-node</code> 1.10.3 can send connections to onion peers through the SOCKS port of a Tor daemon you run. In <code>~/.radicle/config.json</code>:<pre><code><span><span>{
</span></span><span><span>  &quot;node&quot;</span><span>: {
</span></span><span><span>    &quot;onion&quot;</span><span>: { </span><span>&quot;mode&quot;</span><span>: </span><span>&quot;proxy&quot;</span><span>, </span><span>&quot;address&quot;</span><span>: </span><span>&quot;127.0.0.1:9050&quot;</span><span> }
</span></span><span><span>  }
</span></span><span><span>}
</span></span></code></pre><br><code>9050</code> is Tor’s default SOCKS port; use yours. <code>node.i2p</code> takes the same shape, pointed at your i2p router’s SOCKS port. Both default to <code>&quot;mode&quot;: &quot;drop&quot;</code>, 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 in <code>node.externalAddresses</code> and have the node listen only on the local address the service forwards to (for example <code>127.0.0.1:8776</code>), because a node listening on a public address accepts clearnet connections.</li><li><strong>Don’t set <code>node.proxy</code> expecting confidentiality.</strong> <code>node.proxy</code> is 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 until <code>node.onion</code> is set as above.</li></ul><h2 id="status">Status</h2><ul><li><strong>Affected:</strong> every <code>radicle-node</code> release 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.</li><li><strong>Fix:</strong> none released. <a href="https://radicle.zulipchat.com/#narrow/channel/369274-General/topic/Towards.20Radicle.202.2E0.2E0" rel="nofollow">Radicle 2.0 (unreleased)</a> 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.</li><li><strong>Private repositories:</strong> 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.</li><li><strong>Official disclosure:</strong> the <a href="https://radicle.dev/2026/09/23/disclosure-of-vulnerability-in-network-protocol" rel="nofollow">Radicle team’s announcement</a>, with their timeline and remediation.</li></ul><h2 id="why-i-was-digging-in-heartwood">Why I was digging in heartwood</h2><p>I am building <a href="https://radicle.tools/grove" rel="nofollow">Grove</a>, 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.</p><p>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 <a href="https://noiseprotocol.org/noise.html" rel="nofollow">Noise</a>, and it covers only the handshake. Everything after it (the framing, the message types, the gossip, the fetch) exists only as Rust in <a href="https://app.radicle.at/nodes/seed.radicle.at/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5" rel="nofollow">heartwood</a>. The fetch path alone is roughly 5,000 lines spread over three crates, tests not counted.</p><p>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.</p><p><code>rad</code>, in ASCII, right after the handshake. I didn’t believe it at first. The other node wasn’t encrypting anything.</p><h2 id="the-root-cause">The root cause</h2><p>Radicle opens every connection with a Noise XK handshake, three messages long:</p><ol><li><strong>The dialing node sends a fresh, single-use public key</strong> and mixes in a Diffie-Hellman against the other node’s long-term public key, which it already knows. 32 bytes out.</li><li><strong>The answering node sends a fresh key of its own</strong>, and a second Diffie-Hellman. 32 bytes back.</li><li><strong>The dialing node sends its own long-term public key</strong>, encrypted under what the two now share. 48 bytes out: the 32-byte key plus a 16-byte authentication tag.</li></ol><p>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. <code>noise-framework</code>, 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.</p><p>When message three lands, both sides derive a pair of symmetric keys, one per direction. Noise calls this the <strong>split</strong>, and it’s why the handshake exists: from here on, everything either side says is supposed to be encrypted under those keys.</p><figure><p><em>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.</em> <a href="https://maninak.com/blog/radicle-cleartext-transport-vulnerability/">See the drawing.</a></p><figcaption>Only the third message wears a sleeve. The fourth, sent after the split, has none.</figcaption></figure><p>In <code>radicle-node</code>, the split happens: <code>noise-framework</code> 0.4.1 derives both cipher states and stores them. Then nothing reads them.</p><p>Start with writing, since that’s what puts bytes on the wire. The node wraps every connection in a <code>Protocol</code>, which holds a handshake state machine and the socket under it. Its <code>write</code> checks one thing first:</p><pre><code><span><span>// crates/radicle-node/src/reactor/session.rs, Radicle 1.9.1, trimmed
</span></span><span><span>impl</span><span>&lt;</span><span>M</span><span>:</span><span> StateMachine</span><span>, </span><span>S</span><span>:</span><span> Session</span><span>&gt; </span><span>Write</span><span> for</span><span> Protocol</span><span>&lt;</span><span>M</span><span>, </span><span>S</span><span>&gt; {
</span></span><span><span>    fn</span><span> write</span><span>(</span><span>&amp;mut</span><span> self</span><span>, buf</span><span>:</span><span> &amp;</span><span>[</span><span>u8</span><span>]) </span><span>-&gt;</span><span> io</span><span>::</span><span>Result</span><span>&lt;</span><span>usize</span><span>&gt; {
</span></span><span><span>        if</span><span> self</span><span>.</span><span>state</span><span>.</span><span>is_complete</span><span>() </span><span>||</span><span> !</span><span>self</span><span>.</span><span>session</span><span>.</span><span>is_established</span><span>() {
</span></span><span><span>            log</span><span>::</span><span>trace!</span><span>(target</span><span>:</span><span> M</span><span>::</span><span>NAME</span><span>, </span><span>&quot;Passing writing to inner session&quot;</span><span>);
</span></span><span><span>            return</span><span> self</span><span>.</span><span>session</span><span>.</span><span>write</span><span>(buf);
</span></span><span><span>        }
</span></span><span><span>        // ... handshake acts ...
</span></span><span><span>    }
</span></span><span><span>}
</span></span></code></pre><p><code>buf</code> 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 <code>state</code> to <code>machine</code>; the trimming above also drops a trace line that sits directly above the condition in both versions.</p><p>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.</p><p>The bug is that one condition. <code>!self.session.is_established()</code> means the connection underneath isn’t set up yet, so there’s nothing to encrypt. <code>self.state.is_complete()</code> 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.</p><p>To be fair, I can see how this happened. <code>Protocol</code> is generic, and the node uses it for two different things:</p><pre><code><span><span>pub</span><span> type</span><span> NoiseSession</span><span>&lt;</span><span>E</span><span>, </span><span>D</span><span>, </span><span>S</span><span>&gt; </span><span>=</span><span> Protocol</span><span>&lt;</span><span>NoiseState</span><span>&lt;</span><span>E</span><span>, </span><span>D</span><span>&gt;, </span><span>S</span><span>&gt;;
</span></span><span><span>pub</span><span> type</span><span> Socks5Session</span><span>&lt;</span><span>S</span><span>&gt; </span><span>=</span><span> Protocol</span><span>&lt;socks5</span><span>::</span><span>Socks5</span><span>, </span><span>S</span><span>&gt;;
</span></span></code></pre><p>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 <code>is_complete()</code>, opposite meaning. Only the SOCKS5 half of that ever got implemented.</p><h2 id="the-fix">The fix</h2><p>As I understand it, the Noise side should look more like this:</p><pre><code><span><span>fn</span><span> write</span><span>(</span><span>&amp;mut</span><span> self</span><span>, buf</span><span>:</span><span> &amp;</span><span>[</span><span>u8</span><span>]) </span><span>-&gt;</span><span> io</span><span>::</span><span>Result</span><span>&lt;</span><span>usize</span><span>&gt; {
</span></span><span><span>    if</span><span> !</span><span>self</span><span>.</span><span>session</span><span>.</span><span>is_established</span><span>() {
</span></span><span><span>        return</span><span> self</span><span>.</span><span>session</span><span>.</span><span>write</span><span>(buf); </span><span>// nothing negotiated yet, nothing to encrypt
</span></span><span><span>    }
</span></span><span><span>    if</span><span> self</span><span>.</span><span>state</span><span>.</span><span>is_complete</span><span>() {
</span></span><span><span>        // A Noise transport message is capped at 65535 bytes, and 16 of them go to the
</span></span><span><span>        // authentication tag that proves nobody altered it, so a longer write leaves as
</span></span><span><span>        // several messages, each one framed so the far side knows where it ends.
</span></span><span><span>        for</span><span> chunk </span><span>in</span><span> buf</span><span>.</span><span>chunks</span><span>(</span><span>65535</span><span> -</span><span> 16</span><span>) {
</span></span><span><span>            let</span><span> ciphertext </span><span>=</span><span> self</span><span>.</span><span>state</span><span>.</span><span>encrypt</span><span>(chunk)</span><span>?</span><span>; </span><span>// chunk.len() + 16 bytes
</span></span><span><span>            self</span><span>.</span><span>session</span><span>.</span><span>write_all</span><span>(</span><span>&amp;</span><span>(ciphertext</span><span>.</span><span>len</span><span>() </span><span>as</span><span> u16</span><span>)</span><span>.</span><span>to_be_bytes</span><span>())</span><span>?</span><span>;
</span></span><span><span>            self</span><span>.</span><span>session</span><span>.</span><span>write_all</span><span>(</span><span>&amp;</span><span>ciphertext)</span><span>?</span><span>;
</span></span><span><span>        }
</span></span><span><span>        return</span><span> Ok</span><span>(buf</span><span>.</span><span>len</span><span>());
</span></span><span><span>    }
</span></span><span><span>    // ... handshake acts ...
</span></span><span><span>}
</span></span></code></pre><p>Here’s the catch: <code>self.state.encrypt</code> doesn’t exist. And this is the part that honestly surprised me. <code>noise-framework</code> does compute the split, and it stores both ciphers right there on <code>NoiseState::Active</code>, as <code>sending_cipher</code> and <code>receiving_cipher</code>. Each one is a <code>CipherState</code> with a perfectly working <code>encrypt_with_ad</code> and <code>decrypt_with_ad</code>, in the same file. The encryptor exists. The keys exist. They’re just sitting there.</p><p>What’s missing is a way to get at them. <code>NoiseState</code> gives you exactly two accessors, <code>get_handshake_hash</code> and <code>get_remote_static_key</code>, and neither hands out the ciphers. So when the node asks “is the handshake done?”, this is everything it gets back:</p><pre><code><span><span>fn</span><span> artifact</span><span>(</span><span>&amp;</span><span>self</span><span>) </span><span>-&gt;</span><span> Option</span><span>&lt;</span><span>Self</span><span>::</span><span>Artifact</span><span>&gt; {
</span></span><span><span>    self</span><span>.</span><span>get_handshake_hash</span><span>()</span><span>.</span><span>map</span><span>(</span><span>|</span><span>hh</span><span>|</span><span> NoiseArtifact</span><span> {
</span></span><span><span>        handshake_hash</span><span>:</span><span> hh,
</span></span><span><span>        remote_static_key</span><span>:</span><span> self</span><span>.</span><span>get_remote_static_key</span><span>(),
</span></span><span><span>    })
</span></span><span><span>}
</span></span></code></pre><p><code>is_complete()</code> just means that artifact exists. In other words, the node decides the handshake succeeded by collecting the two things that are <em>not</em> the keys, and the keys stay right where the split left them, unused. (Calling <code>advance</code> 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.)</p><p>Now, to be fair, the keys weren’t actually locked away. <code>NoiseState</code> is a public enum and both cipher fields are public, so <code>radicle-node</code> could have destructured <code>NoiseState::Active</code> and called <code>encrypt_with_ad</code> itself, in the <code>impl_noise</code> block of <code>session.rs</code> 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 (<code>encrypt_with_ad</code> bumps its counter unchecked, and the <code>rekey</code> it exposes is never called for you). The clean fix is adding that transport phase to <code>noise-framework</code>, but nothing stopped <code>radicle-node</code> from doing the same in its own <code>impl_noise</code> in the meantime.</p><p>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, <strong>an unfixed node reading a fixed node’s stream sees garbage, and vice versa.</strong> 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.</p><p>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.</p><h2 id="what-an-observer-sees-on-the-wire">What an observer sees on the wire</h2><p>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 <code>72 61 64 01</code>: ASCII <code>rad</code> 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, <code>/radicle:1.9.1/</code>, at 312, followed by the <code>rad</code> of the next frame. No other node’s address appears anywhere in this range.</p><figure><pre><code>00000000  xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx  |................|
00000010  xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx xx  |................|
00000020  72 61 64 01 03 41 20 00 02 b0 87 2f fa 5b ca 36  |rad..A ..../.[.6|
00000030  7c 32 6d 5e b2 1e ab 5d ee 71 fd 04 83 cc f5 2d  ||2m^...].q.....-|
00000040  89 1d 11 64 c8 51 a8 fa 1e 7b 70 1e 51 63 96 53  |...d.Q...{p.Qc.S|
00000050  75 c0 2a fd c9 c3 04 0e 74 94 bf f4 4a 72 fe 08  |u.*.....t...Jr..|
00000060  52 e0 41 05 06 eb 4f 99 61 a7 4c 20 fe 3b a0 a3  |R.A...O.a.L .;..|
00000070  78 7f cf d9 ce 1d 05 e1 ad 31 4b eb 22 a6 6d 6f  |x........1K.&quot;.mo|
00000080  f4 12 36 09 66 9e b0 2e 01 01 00 00 00 00 00 00  |..6.f...........|
00000090  00 01 00 00 01 9e 81 f7 5b 25 14 69 72 69 73 2e  |........[%.iris.|
000000a0  72 61 64 69 63 6c 65 2e 6e 65 74 77 6f 72 6b 00  |radicle.network.|
000000b0  03 03 14 69 72 69 73 2e 72 61 64 69 63 6c 65 2e  |...iris.radicle.|
000000c0  6e 65 74 77 6f 72 6b e5 98 05 3c 69 72 69 73 6d  |network...&lt;irism|
000000d0  69 35 70 79 35 73 73 62 61 65 36 72 6a 73 7a 68  |i5py5ssbae6rjszh|
000000e0  79 67 70 64 70 66 34 6f 77 78 67 73 66 72 76 32  |ygpdpf4owxgsfrv2|
000000f0  62 6d 33 77 78 68 6e 36 79 68 63 77 77 75 61 2e  |bm3wxhn6yhcwwua.|
00000100  62 33 32 2e 69 32 70 e5 98 04 44 51 28 80 68 cc  |b32.i2p...DQ(.h.|
00000110  95 6b 12 8c 7e 06 b4 f1 c0 63 1d 23 aa f7 4d 23  |.k..~....c.#..M#|
00000120  bc cc 4c 77 c4 ea 74 8c 17 a5 d5 95 03 e5 98 00  |..Lw..t.........|
00000130  00 00 00 00 00 00 01 0f 2f 72 61 64 69 63 6c 65  |......../radicle|
00000140  3a 31 2e 39 2e 31 2f 72 61 64 01 03 80 00 ff ea  |:1.9.1/rad......|</code></pre><figcaption>Inbound bytes from iris.radicle.network on 2026-06-24, immediately after the Noise handshake. The blanked bytes are the handshake key material.</figcaption></figure><p>You don’t need a decoder to read that. The frame opens with <code>rad</code> 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 <code>iris.radicle.network</code> 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 <code>/radicle:1.9.1/</code>, which is how I knew what version I was talking to. The next frame starts right after and declares itself 65,514 bytes long.</p><p>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.</p><p>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.</p><p>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.</p><p>A <code>rad clone</code> of one of my own repositories, from my node to my seed, with <code>tcpdump</code> on the interface: 1,522 packets. The inbound stream is 3.6 MB and opens with the same <code>72 61 64 01</code>. About two thirds in, Git starts talking, and here are both sides, read straight off the wire:</p><pre><code>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...@....
</code></pre><p>That’s Git’s pkt-line format: four hex digits of length, then the content. <code>agent=radicle-fetch/0.21.1</code> names the crate that does the fetching, not <code>radicle-node</code>, the one the advisory covers. The node that sent it logs <code>Version 1.10.3 (7e1cb406b)</code>, the newest release. The object my node asked for with <code>want</code> is the one the seed advertised for <code>refs/rad/id</code>, so the two halves match up. Then <code>PACK</code>, version 2, sixteen objects.</p><p>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.</p><h2 id="telling-the-team">Telling the team</h2><p>On 2026-06-24 I reported the finding privately to the Radicle team. They asked for a <code>.pcap</code> 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 <code>rad clone</code> 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.</p><p>We agreed on a coordinated disclosure with a roughly three-month embargo, modeled on the team’s <a href="https://radicle.dev/2026/03/30/disclosure-of-vulnerability-in-signed-references" rel="nofollow">signed-references disclosure in March</a>. 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.</p><p>The team first prototyped a fix on the <code>snow</code> Noise library, but then decided to replace the transport altogether. Radicle 2.0 was <a href="https://radicle.zulipchat.com/#narrow/channel/369274-General/topic/Towards.20Radicle.202.2E0.2E0" rel="nofollow">announced</a> in July and is planned to run on <a href="https://iroh.computer/" rel="nofollow">iroh</a>, which in practice means <a href="https://en.wikipedia.org/wiki/QUIC" rel="nofollow">QUIC</a> with TLS via <a href="https://github.com/rustls/rustls" rel="nofollow">rustls</a>. 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.</p><h2 id="grove-has-the-same-wire">Grove has the same wire</h2><p>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.</p><p>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.</p><p>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.</p><p>Who sees what, then:</p><ul><li><strong>Between your browser and the relay:</strong> the relay speaks <code>wss://</code>, so an observer on that hop sees TLS and nothing else.</li><li><strong>The relay operator:</strong> 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.</li></ul><p>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.</p><h2 id="follow-the-grove-build-log">Follow the Grove build log</h2><p>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 <a href="https://radicle.tools/#early-access" rel="nofollow">early-access</a> and try it first-hand.</p><p>You can also follow this crazy journey from its <a href="https://maninak.com/blog/git-is-distributed-your-project-isnt/">0th post</a> 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.</p><hr><p><strong>About me.</strong> 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: <a href="mailto:maninak@pm.me">get in touch</a>.</p>]]></content>
        <author>
            <name>Kostis Maninakis</name>
            <uri>https://maninak.com/</uri>
        </author>
        <category label="radicle"/>
        <category label="security"/>
        <category label="grove"/>
        <category label="peer-to-peer"/>
        <category label="noise-protocol"/>
        <published>2026-09-23T00:00:00.000Z</published>
    </entry>
</feed>