Tarnet - Reimplementing the Internet's archicture
So I took a very long pause from my other project kiln (CMake compatiable alternative) to work on tarnet. Tarnet in short is my re-imagination of GNUnet which I spent quite a lot of time in the past diving into. GNUnet VPN (Virtual Public Network) was a function I used extensivly to securily share services between my labs. However GNUnet has become quite unstable for me ever since GNUnet 0.20.0 and I never got it working well again. GNUnet truly has a lot of good ideas. Really good ideas. But the software is just not great.. Since AI is so capable in 2026 - I decide to rewrite my version with the help of AI. See my past posts on GNUnet. And let me nerd out in this post.
The project has been used by me for a while and I finally deemed it good enough to release.
NOTE: I DO NOT trust the secutiy of Tarnet with my life. Nor should you. It does the one thing I care about - exposing LAN services to the overlay and connection/link layer encryption. The rest of the feature set is me overengineering and see how far the combination of me, my ideas and AI can go.
And incendentally, in the case of AI taking over the world but humans still manage to survive. I hope Tarnet will enable an underground internet of some sort. Chain machines together, from hub to hub. Knowledge can be shared.
Unlike Tor and I2P. Tarnet is not designed as a darknet (whatever that means). Tarnet is designed to make machines always able to see each other under aribtrary network topology, with traffic encryption and authenticated by default then add all the anonymity features on top. This is roughly the same formula GNUnet is built on. But with several twists and turns.
Also, let me put my hacker hat on for a bit... decentralized networks are COOL. They are underfunded because the Internet wroks well enough. You know what? Let me try and do better. Each incrantion has problems. GNUnet has awesome ideasb but is unstable and underdeveloped. Tor and I2P is hell bent on being an anonymity network. Veilid has clean design but makes a node per-process. libp2p jsut works (awesome!) but is focused on P2P instead of anonymity and privacy.
Side note again: Read Tarnet's "decentralization" as "Internet as it was ment to be. No NAT, everyone has an IP, encryption at all layers, privacy at all layers" not "cryptocurrency, blockchain, tokens, smart contracts. global instance", ... decentralization.
Introduction
Most of Tarnet is designed after GNUnet. Including the identity system and operating semantics. To start a node, just run the tarnetd command.
❯ tarnetd
[2026-03-21T09:40:48Z INFO tarnetd] Loaded identity from /home/marty/.local/share/tarnet/identity.key
[2026-03-21T09:40:48Z INFO tarnet::transport::tcp] TCP listening on 0.0.0.0:7946
[2026-03-21T09:40:48Z INFO tarnetd] WebRTC enabled with 1 STUN servers
=== tarnetd ===
Address: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (redacted)
PeerId: 0000000000000000000000000000000000000000000000000000000000000000 (redacted)
Data: /home/marty/.local/share/tarnet
Config: /home/marty/.config/tarnet/tarnetd.toml
TCP: 0.0.0.0:7946
Public: not advertised
WebRTC: enabled (1 STUN servers)
Links: max 128 inbound, 48 outbound
Bandwidth: up unlimited / down unlimited
mDNS: enabled
IPC: /home/marty/.local/share/tarnet/sock
SOCKS5: 127.0.0.1:1080, [::1]:1080
Expose: /home/marty/.config/tarnet/services.d
Bootstrap: ["wss://0.bootstrap.tarnet.clehaxze.tw/tarnet"]
[2026-03-21T09:40:48Z INFO tarnet_expose::expose] Expose config dir: /home/marty/.config/tarnet/services.d
[2026-03-21T09:40:48Z INFO tarnet_socks::proxy] SOCKS5 proxy listening on 127.0.0.1:1080
[2026-03-21T09:40:48Z WARN tarnet_expose::expose] No services configured in /home/marty/.config/tarnet/services.d
[2026-03-21T09:40:48Z INFO tarnet_socks::proxy] SOCKS5 proxy listening on [::1]:1080
[2026-03-21T09:40:48Z INFO tarnetd] SOCKS5 proxy running on 127.0.0.1:1080
[2026-03-21T09:40:48Z INFO tarnet_expose::expose] Accepting connections...
[2026-03-21T09:40:48Z INFO tarnetd] SOCKS5 proxy running on [::1]:1080
[2026-03-21T09:40:48Z INFO tarnetd::ipc_server] IPC listening on /home/marty/.local/share/tarnet/sock
[2026-03-21T09:40:48Z INFO tarnetd::mdns] mDNS: registered as tarnet-10c4ef8d54fa4d8a
[2026-03-21T09:40:48Z INFO tarnet::node] Listening on ServiceId(ae45723edab487a8) port 0
[2026-03-21T09:40:48Z INFO tarnet::link] Link established (initiator) with PeerId(9ed995c3b0fa0fed)
[2026-03-21T09:40:48Z INFO tarnet::node] Bootstrap connected to PeerId(9ed995c3b0fa0fed) via wss://0.bootstrap.tarnet.clehaxze.tw/tarnet
[2026-03-21T09:40:48Z INFO tarnet::node] Link up: PeerId(9ed995c3b0fa0fed) (link_id=0)
Like GNUnet, Your identity on the network is not bound to your node. The PeerId is 99% just for traffic routing. Instead a seperate identity system exists. The default default user is created unconditionally on node startup. but you can create a new identity for yourself with the tarnet identity create command. The default signature scheme is Falcon + Ed25519. However, I do not claim Post Quantum security -- Falcon exists only because I need to ensure key format is extandable in the future and not requiring a big rewrite. Turns out the best way to ensure this expandability not breaking during development is to actually use it.
❯ tarnet identity create alice
Created identity 'alice': TBHB4H90Q6B7H0SEYEDT461AWP37H706V1MGRWZJSPJ1B9GX0K0G
Scheme: falcon_ed25519
Privacy: Public
Outbound hops: 1
With identity in hand. You can now do interesting things. For example on a server, drop this into ~/.config/tarnet/services.d/blog.toml and restart the daemon (or tarnet reload to trigger a reload).
# ~/.config/tarnet/services.d/blog.toml
name = "blog"
local = "127.0.0.1:4001" # Where the HTTP service is running
publish = true
identity = "alice"
port = 80
TNS (Tarnet Name System) does decentralized name resolution. On another machine, just add alice's zone to your config and you can see what alice's records:
❯ tarnet tns set alice zone TBHB4H90Q6B7H0SEYEDT461AWP37H706V1MGRWZJSPJ1B9GX0K0G
alice [private]
Now tarnet runs a SOCKS5 proxy on port 1080 by default. Connect to it and you can see alice's blog:
chromium -incognito --new-window "http://blog.alice" --proxy-server="SOCKS5://127.0.0.1:1080"

And if you ever want to run a hidden service (because why not.. just don't do bad things). Just set Alice's identity to hidden and requesting 3 node hops for all connection from and to alice. This setting works additivly. Both client and server can have their own privacy level. And the routing protocol enforces both settings at the same time. Alice creates and 3 node circut, Bob creats a 4 node one before they communicate. And of course, the underlying machine's ID are not shared with the network when privacy is hidden.
❯ tarnet identity update alice --privacy hidden --intro-points 3
Updated identity 'alice':
Privacy: Hidden { intro_points: 3 }
Outbound hops: 1
How Tarnet works
That's the very short, show and tell gist of what Tarnet can do. How does Tarnet work?
Like GNUnet, Tarnet has per host cryptographic id and allows creation of an arbitrary number of idenpendent identendies. Host whom wish to join the network must bootstrap off a known good host (good is important, your introduction is provided by that host). Your host then runs the entire join sequence, populate routing table, publish it's own existance on the DHT and you are on tarnet.
That's the short story. Now the long one.
Joining the network
Joining can be done by the --bootstrap flag when starting a new or via the bootstrap.peers section in config. Transport mode of tcp, ws/wss and webrtc is supported. The different transports each has their pros and cons. TCP is the simplest and works in LAN with 0 configuration (thanks to mDNS), websockets let you put your node behind a reverse proxy and WebRTC handles NAT punching. Either one you are connected through, they are treated as dumb pipes to get data from you to another host. Each bootstrap entry gets its own retry loop with exponential backoff capped at 60 seconds, so a single dead address never blocks the others.
Once the transport is up, both sides run a link handshake to authenticate each other and derive a session key. This handshake is not MITM proof iself. This however does not mean handshakes are not secure. Only the 1st one needs special out of band care (you need to make sure the link is secure, be it local wireing or trusting CAs). From the second one onwards, the node learns the cryptographic identity of the peer on the other side and can verify subsequent links. We'll get into the crypto in a later section. The interesting part is what happens after the link is established.
[2026-03-21T09:40:48Z INFO tarnet::link] Link established (initiator) with PeerId(9ed995c3b0fa0fed)
[2026-03-21T09:40:48Z INFO tarnet::node] Bootstrap connected to PeerId(9ed995c3b0fa0fed) via wss://0.bootstrap.tarnet.clehaxze.tw/tarnet
[2026-03-21T09:40:48Z INFO tarnet::node] Link up: PeerId(9ed995c3b0fa0fed) (link_id=0)
After the link is up, the new node and its bootstrap neighbor start trading routing information. Tarnet uses distance-vector routing under the hood (yes, distance-vector - I know, RIP is older than I am, but it is dead simple and bounded, it's the only way to deal with phtological network topolgies without node trust). Each route advertisement is signed by the advertising node and processed with split horizon and poison reverse. The routing table is bounded and uses fair-share eviction so a single chatty neighbor cannot fill up your table with routes pointing to itself. After a few seconds you have a partial view of the network reachable through your bootstrap peer, and your existence is already being gossipped outward through their next round of advertisements.
In parallel the node publishes its own signed Hello record into the DHT. The Hello carries the transports the node speaks, any globally routable addresses it wants to advertise, and the introducers it is willing to act as a rendezvous through. Other nodes that learn your PeerId from a routing advertisement (or from a TNS lookup) can fetch this record from the DHT to discover how to reach you directly. The Hello record is encrypted under a key derived from your PeerId, so storage nodes can enforce dedup-by-signer but cannot read the contents directly.
From here a couple of background tasks keep the node honest. A proactive outbound filler walks the routing table looking for peers you only know via relay (cost > 1) and tries to open direct links to them, picking candidates through DHT Hello lookups. If a peer's Hello says it speaks WebRTC and no direct link has been established, the node will try to establish a WebRTC direct connection to it, using the overlay network for signaling. And the Hello record itself is republished on a jittered interval so it doesn't expire out of the DHT. There is no explicit join command/phase. Creating the 1st link is the same as the 100th, Once you have a single good link, everything else - routes, peer records, transport upgrades - falls out of the maintenance loops.
Routing
The part I argued with myself the most about. How does a packet from your node actually find its way to a peer you've never directly talked to? And how to deal with annoyning network tolologies? Most importantly: Tarnet has two routing layers, and this section is only about one of them. The DHT (covered in the next section) does its own internal R5N-style routing to find records in keyspace. This section describes how plain connection traffic - gets pushed across the link graph from one peer to another - the bytes a tarnet connect stream actually carries. They share the same underlying links but answer completely different questions, and the two should not be conflated.
GNUnet uses R5N for both DHT lookups and general overlay routing, which fails when nodes from a single stright line. libp2p has it's own NAT punch. Tor uses a directory and assumes nodes are on the internet. I went the other direction for connection traffic and picked the dumbest thing that could possibly work: distance-vector. Yes, the same family as RIP. I know how this sounds. Hear me out.
Tarnet is an overlay. The links between nodes already abstract away the physical topology, so the "graph" the routing layer sees is small, mostly stable, and has a high branching factor. The convergence and count-to-infinity problems that make DV painful on big router networks barely show up here. And the implementation fits in a few hundred lines. Each node keeps a table of destination -> (next_hop, cost), with up to three alternate next-hops per destination for multipath and fast failover. Direct neighbors start at cost 1 (or higher if the link RTT is bad). Every advertised route adds 1 to the cost when imported.
Once a tick (with jitter), each node sends a signed route advertisement to every neighbor. The advertisement is just a list of (destination, cost) pairs from the local table, plus the advertiser's PeerId and a signature over the whole thing. Two pieces of textbook DV hygiene apply:
- Split horizon - don't tell a neighbor about routes you learned from them.
- Poison reverse - explicitly tell them those routes are at infinite cost, so they don't briefly learn it back from someone else and create a loop.
The routing table itself is bounded. When full, eviction picks the next-hop neighbor with the most entries and drops their oldest route - a fair-share scheme that prevents any single chatty neighbor from squatting on the whole table. Local lookups (e.g. "I'm trying to reach this peer right now") can always force eviction; passive advertisements can't push out a peer that's already at or below its fair share.
So far so good for nodes within a few hops of each other. But on a real network, your DV table will not have an entry for every peer - the table is bounded, advertisements are rate-limited, and far-away corners of the graph just don't reach you. For those cases there's a second mechanism: the route probe. When you try to reach a peer the DV table doesn't know about, the node fires a RouteProbe message with a nonce and a TTL into all of its neighbors. Each receiving node checks its own DV table; if it knows the target, it sends a RouteProbeFound reply back along the reverse path (tracked by the nonce). If it doesn't, it forwards the probe to one random non-sender neighbor and decrements the TTL. This is a classic expanding-ring search - if the first attempt times out, the originator retries with a TTL multiplied by 4, up to a few attempts. Duplicate nonces are suppressed so probes don't echo around forever.
When the reply comes back, the discovered route gets cached in the local DV table as if it had been learned normally, so the next packet to the same destination doesn't need to probe again. Effectively the probe is a lazy, on-demand patch over the holes in DV's worldview - DV handles the steady-state bulk, probes handle the long tail.
Also routes at the node level is circutized. Nodes in the middle does not know where the traffic is going, they only know where to forward. Multipath is also supported with reliabe mode connections automatically resend and congestion control applied to ensure reasonable performance.
DHT
The DHT is the part TNS, peer discovery, and hidden service rendezvous all sit on top of. The DHT deisgn in Tarnet is bascially GNUnet's R5N with BLAKE3 swapped in for SHA-512 and a couple of small implementation choices changed. To walk through it briefly:
Before diving in, the disambiguation from the previous section bears repeating. The DHT has its own routing layer, and it is not the same thing as the distance-vector routing to reach hosts described above. DV moves connection bytes between peers across the link graph. DHT queries toward keys in keyspace to directly connected machines. They run on the same underlying links, but the question "what's the next hop for traffic to PeerId X" and the question "what's the next hop for a query targeting key K" have nothing to do with each other, and the two layers are kept entirely separate in the implementation.
R5N does hybrid routing. A pure Kademlia lookup walks straight toward the key by always forwarding to the closest known peer at each hop. That's fast, but also predictable: a malicious node positioned near a popular key can intercept every query for that key and either drop them or forge replies. R5N's fix is to spend the first few hops doing a random walk, then switch to greedy convergence. By the time the query starts homing in on the target, it has already been at an unknown, unpredictable set of nodes - eclipsing the target now requires owning a slice of the whole graph, instead of just neighborhood around one key. Tarnet does the same: the first l2nse hops pick targets randomly, then switch to probabilistic-greedy selection biased toward k-closest.
l2nse is the log₂ network size estimate. GNUnet runs a dedicated NSE service that floods periodic beacons to derive this. Tarnet skips that and uses the standard Kademlia trick of reading log₂(N) directly off k-bucket occupancy, with a populated-bucket confidence bound so a fresh node with two peers doesn't conclude the network has 2^30 members out of sheer unluckiness. Without the pain of introducing more traffic on the overlay then nessearry.
Records on the DHT come in three flavours, all of them encrypted at rest so storage nodes cannot read what they're holding:
- Content-addressed records (
dht put "hello world"). DHT key isBLAKE3(BLAKE3(value))and the encryption key isBLAKE3(value). Same trick GNUnet's FS layer uses to protect stored content. The double hash makes storages nodes see the outer hash (the locator) but cannot derive the inner hash (the decryption key) from it. To retrieve, you have to already knowBLAKE3(value), which both locates and decrypts the record.
- Signed records. Tied to a publishing identity. Encryption key is
BLAKE3(dht_key), and since the querier has to know the DHT key to ask for the record at all, they trivially derive the decryption key. Storage nodes can see who signed (so they can enforce dedup and replacement-by-sequence-number, the way mainline does for BEP-44 mutable items) but cannot read the contents.
- Hello records. Per-peer records advertising transports and addresses. Same envelope as signed records, keyed off a DHT key derived from the peer's PeerId.
A couple of smaller details - like R5N each forwarded query carries a bloom filter of nodes already visited so peers don't waste hops re-forwarding to the same neighbors, but the bloom is advisory - if a peer is one of the k-closest to the target, the forwarder sends to it anyway, otherwise a malicious upstream could stuff the bloom with the actual k-closest set and route the query past the people who would have answered correctly. Hop limit and fan-out are computed per query from l2nse so small networks get tight queries and bigger ones get more parallelism without a config knob. Replication factor and parallelism are the Kademlia defaults (k = 20, alpha = 3).
That's basically it. R5N for the routing and the same GNUnet/Mainline tricks.
Identity
Identity is the "user" on Tarnet. Each identity's id is the hash of it's public key. For development and testing purposes, idententies support 2 key types. Ed25519 and Ed25519 + Falcon. QPC was and still is not a major design goal. But it's existance enables and ensures when the day come with we have a better algorithm with shorter keys/sig, it can be implemented without breaking wire format. We support PQC but it isn't mandatory - if a peer doesn't speak it, key exchange falls back to plain X25519.
An identity is a user. It is what you create with tarnet identity create alice, what you publish records under, what you put in TNS zones, and what other people use to talk to you. Each identity is its own keypair, its own ServiceId (a hash of the signing pubkey), and its own TNS zone. A node can host arbitrarily many of them, and connecting or publishing under one reveals nothing about the others - they live in entirely separate signature universes. The default identity is created on first run and cannot be deleted; everything else is up to you.
The ServiceId is the actual on-the-wire address for an identity, and it is rendered in Crockford base32, like TBHB4H90Q6B7H0SEYEDT461AWP37H706V1MGRWZJSPJ1B9GX0K0G to make thins easier on the human eye while being distinctive from the machine's PeerId which uses hex.
(The machine itself has a separate identity - the PeerId from the bootstrap section - which is what shows up in routing tables and link handshakes. PeerId is the wire-level "this box", ServiceId is the user-level "this is alice". Same hash construction, different domain string, different role. The two are deliberately decoupled so a single machine can host many users without leaking which users live where.)
Each identity also carries two pieces of routing policy: a privacy level and an outbound hop count. They are what turn the same key material into either a directly-reachable service or a hidden one.
A Public identity publishes a peer record into TNS mapping ServiceId -> PeerId, plus a Hello record in the DHT advertising its transports. Anyone who knows the ServiceId can resolve all the way down to "this machine, on this address, speaking this transport" and build a circuit straight to it. Fast, simple, but the link between your name and your machine is out in the open.
A Hidden identity publishes no peer record at all. Instead it picks a few introduction points - other tarnet nodes that agree to accept circuits on the identity's behalf via rendezvous - and publishes those under the ServiceId. A client connecting to a hidden identity builds a circuit to one of the intro points, the intro point hands the circuit off to the hidden node over a separate circuit it built, and the two halves are spliced together at a rendezvous. Neither side ever learns the other's PeerId or network location. Familiar territory if you've read the Tor onion service spec.
Privacy is set per-side and is additive. Alice with outbound_hops = 2 and Bob with intro_points = 3 means a 5-hop path between their machines, regardless of who initiated the connection. Either side can be public or hidden independently. The routing protocol enforces both settings simultaneously - whoever wants more hops, gets more hops.
❯ tarnet identity update alice --privacy hidden --intro-points 3
Updated identity 'alice':
Privacy: Hidden { intro_points: 3 }
Outbound hops: 1
The practical upshot is that you can run, on the same machine, a public identity hosting your blog and a hidden identity for something you'd rather not have tied back to you, and as long as you're disciplined about which identity you publish under, an observer staring at the DHT cannot link the two. The machine is one thing. The names are another.
Connections
The simplest way to see how connections work is to just open one. On one machine, listen on an identity (ro means reliable ordered):
❯ tarnet listen alice --port chat --mode ro
Listening on TBHB4H90Q6B7H0SEYEDT461AWP37H706V1MGRWZJSPJ1B9GX0K0G mode ReliableOrdered port 'chat'.
Waiting for connections... (Ctrl-C to quit)
On another machine, connect to it:
❯ tarnet connect alice.bob --port chat --mode ro
Connecting to alice.bob mode ReliableOrdered port 'chat'...
Connected to alice.bob. Type to send, Ctrl-C to quit.
That's the whole user-facing model. You listen as an identity, you connect to an identity, and the daemon takes care of finding a path. Both sides can be on completely different transports, behind NATs, on different continents, and the commands above don't change. Ports are just strings - there's no reserved port table, no /etc/services, just whatever name both sides agreed on (chat, ssh, http, whatever). Both sides have to agree on the delivery mode too; mismatched modes get rejected at setup time.
The connect target can be a ServiceId in Crockford base32, a TNS name (resolved through the recipient's zone), or - if you really want to talk to a specific machine instead of a named user - a raw PeerId in hex. More on that in a moment.
Underneath, a link between two nodes can ride on one of four transports:
- TCP - the obvious one. A boring TCP socket.
- WebSocket (
ws://orwss://) - useful for nodes behind reverse proxies, or when you want to expose a bootstrap node from inside Cloudflare or similar. The TLS layer doesn't really matter (but does give you a layer of CA provided verification); tarnet's link encryption runs on top regardless. - WebRTC data channels - the NAT-punching escape hatch. Two nodes that have no directly-routable address between them can still establish a direct WebRTC link by signaling through the overlay, then carry traffic peer-to-peer without paying for a relay hop.
Either transport you pick, Tarnet always speaks message frames over the link, same way WebSocket frames or QUIC datagrams work. A reliable-ordered channel on top of that feels like a TCP stream to the application, but the wire format is always frame-oriented. This makes the channel modes (reliable/unreliable, ordered/unordered) trivial to implement and means the framing layer doesn't have to guess message boundaries. It is possible to support UDP or other direct-radio transport. Tarnet comes with it's own congestion and ordering protocol to deal with packet drops and path lost already.
You can also listen with the machine's own PeerId as the address - no named identity involved. This is useful for diagnostics, for things that want to talk to "this physical box" rather than "this user", and as the addressing mode the machine-to-machine plumbing (DHT, route probes, intro point handoffs) uses internally.
The catch is that PeerId is just the machine address. There's no privacy level on it. Anyone who knows your PeerId can build a circuit straight to your node, and the routing layer will let them. If you want anonymity or onion-routed inbound, that's what hidden identities are for - listen on a ServiceId, set the privacy level, and the protocol will enforce it. PeerId is the back door for when you actually want to address the machine, and you should treat it that way.
TNS
TNS (Tarnet Name System) is the name resolution layer. To get this out of the way upfront: it is more or less a mirror of GNUnet's GNS, with the surface filed down to look more like DNS. Zones, delegation, record types, the cryptographic trick that makes records private on a public store - all of that is GNS. I rebuilt it because I wanted the same properties without the rest of the GNUnet stack hanging off it, and because the DNS-shaped surface is easier to explain to people who haven't read the GNS paper.
There is no root. DNS resolves example.com by walking down from a shared root - everyone agrees on what . means, then .com, then example.com. TNS doesn't have that. Resolution always starts from your acting identity. To resolve the name alice, you need a record in your zone that says "alice -> this ServiceId" owned by your current identity. Without that record, the name alice simply does not exist for you. There is no registrar to ask, no global lookup to fall back on, and no squatting because there's nothing global to squat.
That makes names subjective. Your alice and my alice can - and probably do - point to entirely different people. You can think of every TNS name as a petname: a private label you've chosen to attach to a key you trust. Once you've delegated a label to alice's zone, anything alice publishes under it (ssh.alice, blog.alice, ...) flows through your local view of who alice is. If alice rotates a subname, your resolver picks it up. If someone else publishes a zone calling themselves "alice", you never see it unless you choose to point at them.
The interesting part is making this private. Records have to live somewhere - in tarnet's case the DHT - and the DHT is made of other people's machines. You don't trust them, and you don't want them to be able to enumerate your zone, watch what names you're publishing, or see what they resolve to. So how do you put records into a public store and still get all of that?
The trick is the same trick most modern e2e systems use: derive the locator and the encryption key from a secret only the people who already know the name can compute.
Both the DHT key (where the record lives) and the symmetric key (which encrypts the record) are derived from (zone ServiceId, label) via BLAKE3 with different domain strings. Concretely:
dht_key = BLAKE3.derive_key("tarnet tns dht-key", zone_id || label)
encryption_key = BLAKE3.derive_key("tarnet tns enc-key", zone_id || label)
If you know alice's ServiceId and you know the label ssh, you can compute both: the 64-byte hash that locates the record on the DHT, and the 32-byte XChaCha20-Poly1305 key that decrypts it. If you don't know one of those, all you have is an opaque hash. The DHT nodes storing the record see a key and a ciphertext. They can't see the amount of nodes under Alice nor if lables belong to Alice.
However, though this protects you against enumeration, not against guessing. If somebody already knows your ServiceId and guesses that you probably have an ssh or @ label (a fair bet), they can derive the same DHT key and decryption key you can and pull the record out. The records are private the way an encrypted file with a known filename is private - the contents are sealed, but the existence of "the thing called ssh under alice" is computable by anyone who already had both halves. For most use cases that's the right tradeoff. The main pain of DNS servers knowning what you are looking for is solved. If you want a public record nobody can guess, put it under an identity that you don't share and point a private local record to it.
As a consequence of the design. ServiceId can be used as a global resolution address like the following shows. This is the only form of global addressing on Tarnet and you should share the ServiceId (or a stable ServiceId with delegations) so others can resolve into the same record.
tarnet tns resolve bob.TBHB4H90Q6B7H0SEYEDT461AWP37H706V1MGRWZJSPJ1B9GX0K0G
That covers confidentiality. Integrity is the other half of the problem, TNS must have zero trust in the actually-meaningful sense, peers can be evil. Each record set is signed by the zone owner's identity key before encryption, and the resolver verifies that signature after decrypting. A malicious storage node cannot forge a record - they don't have the zone's signing key. They cannot serve a stale or substituted record either, because the resolver checks the signature against the ServiceId it was already looking under, and ServiceId is itself a hash of the signing pubkey. The chain name -> zone -> ServiceId -> signing pubkey -> signature closes on itself with no outside trust required.
Query privacy gets the same treatment. The lookup that goes onto the wire is just the opaque DHT key. Reply routing uses ephemeral tokens so the relays carrying the answer back never learn who asked. A node sitting in the middle of your query path sees a 64-byte hash being asked about and a ciphertext coming back. It cannot tell what zone you were resolving, what label you wanted, or even - if it didn't originate the query - who is going to read the answer.
The end result is a DNS-shaped name system where storage lives on untrusted nodes, records can't be forged, and there's no root to point at. None of this is novel - GNS proved it works years ago - but it's nice to have it sitting next to the rest of tarnet instead of as a separate subsystem you have to opt into.
What can you do with Tarnet
A cool decentralized network is useless without actually doing stuff with it. The main application of tarnet is what I described the earliest - to expose LAN services to the overlay. So I can boot a machine anywhere, and have access to it from everywhere.
Tarnet `expose`
expose is the reason Tarnet is created initially. It is a small subsystem inside tarnetd that reads TOML files out of ~/.config/tarnet/services.d/, and for each one it opens a TCP or UDP socket to a local address and bridges it to a tarnet listener. Drop a file, reload the daemon, and a service that was only reachable from 127.0.0.1 is now reachable from any tarnet node that knows your identity.
A service file looks like this:
# ~/.config/tarnet/services.d/ssh.toml
local = "127.0.0.1:22"
protocol = "tcp" # "tcp" (default) or "udp"
publish = true # advertise via TNS
subdomain = "ssh" # TNS label; omit to publish at the zone apex
identity = "default" # which identity to publish under
port = 22 # The tarnet-side port to expose (can be non numeric,
# but usually is to IP compatiblity)
A few small things to note. The tarnet-side port name is taken from the filename by default, so ssh.toml becomes port ssh - for IP compatibility, you want to set a specific port number in port. The protocol field controls both how expose talks to the local socket and what channel mode it uses on the tarnet side: TCP services get reliable-ordered, UDP services get unreliable-unordered, so the semantics match end to end.
publish = true advertises the service through TNS under the chosen identity's zone, with subdomain controlling the label (omit it to publish at the zone apex). The publish can be withdrawn by setting it to false and wait for TTL. publish = false keeps the service private - the listener is still up, but nobody learns about it from TNS, acting more like /etc/services
Reloading via SIGHUP is supported, or tarnet reload if you don't want to dig up the PID:
$ tarnet reload
Reloaded.
Once the service is up, the other side reaches it with tarnet tarify, which is a torify-style wrapper. It runs whatever command you give it with an LD_PRELOAD that intercepts socket calls and reroutes them through the local SOCKS5 proxy, so the wrapped program ends up talking to tarnet without knowing anything about it. Hostnames are resolved as TNS names by the proxy on the way out.
$ tarnet tarify ssh alice
$ tarnet tarify curl http://blog.alice/
The wrapped program sees normal sockets. --identity picks which identity you appear as on the remote side (and which TNS zone is used for name resolution), so you can keep work and personal traffic separate without juggling configs:
$ tarnet tarify --identity work ssh internal-server
The catch is per the usual LD_PRELOAD problem: it only works with dynamically linked binaries on Linux, and any program that does its own DNS resolution outside of libc will sidestep the interception. For 99% of CLI tools - ssh, curl, git, psql, redis-cli, whatever - it just works.
I have a handful of machines spread across labs, friends' apartments, and a couple of cloud regions, may or maynot be behind a NAT. I do not want to run a VPN as managment them are a pain nor getting a real IP for all of them. Expose is what makes that work, and it is the one feature I would not give up.
SOCKS5 proxy
Both tarify and the previous SSH example actually go through the same thing: a SOCKS5 proxy that tarnetd runs on 127.0.0.1:1080 (and [::1]:1080) by default. tarify is just an LD_PRELOAD shim that points unmodified programs at it. Anything that already speaks SOCKS5 can skip the shim and talk to the proxy directly.
The proxy treats the SOCKS destination hostname as a TNS name. When something asks the proxy to connect to blog.alice, the proxy resolves blog.alice through TNS, builds a circuit to the resulting ServiceId, and bridges the stream over it. The application thinks it's talking to a hostname; the proxy is doing all the tarnet plumbing underneath. Both TCP (CONNECT) and UDP (UDP ASSOCIATE) are supported.
If a name doesn't resolve through TNS, the proxy refuses the connection by default - tarnet-only, no accidental clearnet leaks. You can flip allow_clearnet = true in tarnetd.toml if you want unresolved names to fall through to regular DNS, but that's an explicit opt-in.
The browser case is the obvious one:
chromium --proxy-server="SOCKS5://127.0.0.1:1080" "http://blog.alice"
But the same trick works for anything that knows the SOCKS5 protocol. SSH:
ssh -o ProxyCommand='nc -x 127.0.0.1:1080 %h %p' alice
curl:
curl --socks5-hostname 127.0.0.1:1080 http://api.alice/status
Important detail: use --socks5-hostname (or the equivalent option that make DNS goes through the proxy), --socks5 does DNS lookup locally pre-socket passthrough. The first form sends the hostname to the proxy and lets tarnet resolve it through TNS. The second form resolves the name locally via the system resolver first, which obviously cannot find alice in DNS and fails before tarnet ever sees the request. Every SOCKS5 client has both modes; pick the remote-resolution one.
Tarnet without Internet
Tarnet does not require the internet like Tor and I2P does, incendentally is also one of GNUnet's goal. If you plug two machines together with a crossover cable and both are running tarnetd, they will find each other and form a network with zero configuration thanks to mDNS. On startup, tarnetd registers itself as a _tarnet._tcp.local. service on the local network and simultaneously browses for other instances. When another tarnet node appears on the LAN, the mDNS resolver picks up its address and port. Then the usual handshake and initialization process takes place and the overlay is alive.
This works even without a DHCP server. Two machines connected via Ethernet with no router or DHCP will self-assign link-local addresses (169.254.x.x on IPv4, fe80:: on IPv6) through APIPA/SLAAC. mDNS operates over multicast on the local segment, so it works on these addresses. Tarnet's mDNS implementation does prefer non-link-local addresses when available (a real LAN IP is more useful than a 169.254 address), but it will fall back to whatever is there. IPv4 is preferred over IPv6 to avoid picking up duplicate connections through the same NIC.
The interesting consequence being network merges automagically. Say you have two air-gapped clusters, A and B, each running tarnet internally. The moment you connect a machine that has a link into both networks - be it a laptop with two NICs, a machine with an Ethernet cable into each cluster, shared switch - distance-vector routing and automatic route propergation kicks in. Routes from cluster A propagate to B and vice versa, DHT records merge, and the two networks become one. This is intended. Chain machines together, over whatever physical link is available, even if it's RS232 if you are willing to write a driver for it. Neighbors found, DV routing propagates and find paths, and services published with expose become reachable across the entire chain. The Internet just makes everyone one hop away, conveiant but not necessary.
Large network routing simulation
The existing Tarnet deployment is very small - just the machines I own. I am curious about how large of a network can the routing protocol handle realistically. So simulation was ran, I managed to simulate a 800 node tree-like network with some local links in between on my AMD 7950X3D desktop. Resulting in 99.4% of randomly selected pairs of nodes being able to reach each other.
Which I have reaons to believe the failing connections are from lack of CPU resource on my machine, leading to timeout. Instead of being genuine routing failure. Note that green lines = simulated physical connections. Red = pairs of node that fails to reach each other.
So yeah, that's tarnet for you. Code at the following link. And you can bootstrap off wss://0.bootstrap.tarnet.clehaxze.tw if you want to join the network.
I might find some more uses for it, have an idea for decentralize knowledge base. But for now, that's it. See ya.