Layer 7 · The DIG Node peer network
Canonical references:
dig-gossip(the peer transport, discovery, and gossip layer — TLS-WebSocket peers,peer_id = SHA-256(TLS SPKI DER), address manager, introducer + peer-exchange),dig-relay(the rendezvous / hole-punch coordinator / circuit relay serving theRelayMessagewire),dig-nat(theconnect(peer)NAT-traversal ladder),dig-dht(the Kademlia DHT with provider records that locate which peers hold content), anddig-constants(DIG_RELAY_URL). This layer is how DIG Nodes find and reach each other; the dig RPC is what they speak once connected.
This is the normative anchor for DIG Node ↔ Node communication. Every peer-facing crate — dig-nat, dig-relay, dig-gossip, dig-dht, and dig-node — conforms to the contracts below. Where a statement is a wire contract it is fixed at the field/byte level; a conforming reimplementation must reproduce it exactly.
The thesis: authenticated peers, direct when possible, relay only as a last resort
- Every peer link is mutually authenticated. All node↔node traffic runs over mutual-TLS (mTLS). A peer's identity is the hash of its TLS public key; there is no unauthenticated peer channel.
- Direct paths are preferred. A node tries, in a fixed order, to open a direct connection (already reachable → UPnP → NAT-PMP → PCP → relay-coordinated hole-punch) before it ever relays. When any direct path succeeds, no relay is used.
- The relay is the last resort, never the trust anchor.
relay.dig.netbridges bytes only when every direct strategy fails. It forwards opaque, end-to-end-authenticated payloads by peer id and can read none of them.
The relay has four distinct roles
relay.dig.net is not a single service — it fills four separate roles, three of which are low-bandwidth signalling. Keeping them distinct matters because only the fourth carries a peer's data stream:
| # | Role | Bandwidth | What the relay does | Where |
|---|---|---|---|---|
| 1 | STUN server | tiny | Answers a Binding request so a node learns its public reflexive IP:port (RFC 5389). | §3 |
| 2 | Introducer | small | Registers a node's presence and returns known-peer lists for rendezvous/discovery. | §4a |
| 3 | Hole-punch signalling | small | Brokers a hole punch between two NAT'd peers — relays their candidate-address exchange and coordinates the simultaneous-open timing — after which the peers connect directly. The relay carries only the coordination messages, never the data. | §5 |
| 4 | Relayed (TURN-like) transport | full | Proxies all of a peer connection's data when no direct path exists. High-bandwidth, last resort only. | §6 |
Roles 1–3 are how the relay helps two peers connect directly; role 4 is the only one where the peer's stream flows through the relay. A node always prefers role 3 (broker a direct link) over role 4 (proxy the whole stream), because role 3 costs the relay almost nothing while role 4 consumes real bandwidth. Whatever the tier, once connected the link is mTLS with peer_id = SHA-256(SPKI) (§1) — the relay is never the trust anchor.
0 · The two RPC tiers — mTLS peer/control vs anonymous public read
A DIG Node's RPC is served on two distinct tiers with two distinct authentication models, because two different callers need it: other nodes (which present a client certificate) and browsers/agents (which cannot present a client certificate but must still be able to READ content). One dig-node process serves both; the tier a method lives on is a frozen contract every serve-layer and every RPC client conforms to.
| PEER / CONTROL tier | PUBLIC READ tier | |
|---|---|---|
| Purpose | node↔node: discovery, DHT, PEX, availability-for-sync, PUSH/WRITE, config/control | browser/agent content retrieval only |
| Auth | mutual TLS — client cert REQUIRED; peer_id = SHA-256(TLS SPKI DER) (§1); write routes additionally per-request BLS-signed (§21.9) | none — anonymous, no client cert, no bearer for reads |
| Transport | dig-nat mTLS mux (peer RPC + DHT + PEX streams) on the P2P port DIG_PEER_PORT (default 9444); the §21 authenticated HTTPS write routes | plain HTTPS JSON-RPC, CORS-enabled, on the public read listener (network-wide: rpc.dig.net) |
| Browser-reachable | No — an anonymous caller cannot open it | Yes — this is the browser/agent read path |
| Mutation / identity | write, peer, config, control all live here | read-only — no mutation, no peer/config method reachable |
| Integrity trust | mTLS peer identity | client-side self-verification (merkle inclusion proof + chain-anchored root pin) — the server is untrusted (Verification & provenance) |
| Miss behavior | method-specific error | decoy-on-miss, never 404 (blind host) |
The boundary invariant
Two rules, jointly, are the contract:
- No peer / write / config / control method is reachable without mTLS. Every method that mutates state, exchanges peer information, moves the DHT/PEX, or reconfigures the node is served only on the mTLS peer/control tier. On the anonymous public-read listener these methods do not exist — an anonymous caller that names one receives
-32601(method not found), the same as any unimplemented method. A node MUST NOT honor a write/peer/control method on a connection that did not complete the mTLS handshake. - Content read requires no mTLS. The read methods —
dig.getContentand the read side ofdig.getProof/dig.getCapsule/dig.getManifest/dig.getMetadata, plus the §21 GET routes (content/proof/roots/descriptor) — are served anonymously so a browser works. They are read-only: they never mutate, never reveal peer/config state, and never accept a write.
Which tier every method sits on is enumerated in §7a.
Why this is secure
Splitting authentication by tier does not weaken the network, because the public tier is read-only and client-verified:
- The read server is already untrusted. Every read is verified client-side against the CHIP-0035 chain-anchored root (
dig.getContentstreaming contract) — a merkle inclusion proof under the on-chain root plus per-chunk AES-256-GCM-SIV authentication. A malicious or anonymous read server can only serve bytes that either verify (correct content) or fail the proof/tag (detected, discarded). Anonymity of the reader changes nothing: the content is public ciphertext, keyed byretrieval_key = SHA-256(URN), and the AES key is derived client-side and never sent. There is nothing to authenticate a reader for. - A miss is indistinguishable. A content miss returns a deterministic decoy, byte-shaped like a hit, never a 404 — so anonymous access leaks no presence/absence oracle.
- Everything that could harm the network stays mTLS-gated. Advancing a root (PUSH/WRITE), announcing a provider record, injecting peers, or reconfiguring the node all require the client certificate (and, for writes, a per-request BLS signature). An attacker with no certificate can read public ciphertext and nothing more — exactly the capability a browser needs and no more.
- The two never blur. Because the anonymous listener simply does not implement the peer/write/control methods, there is no "forgot to check auth" path: an unauthenticated write is not a rejected request, it is a nonexistent method.
How one process serves both
A dig-node runs two listeners:
- The mTLS peer/control listener binds the P2P port (
DIG_PEER_PORT, default 9444) with rustlsCERT_REQUIRED; it carries the dig-nat mux (peer RPC, DHT, PEX). The §21 authenticated write/push routes (transport & push) are the control tier's HTTPS face (per-request BLS auth, middleware order auth-THEN-rate-limit). - The public read listener serves the anonymous JSON-RPC read subset over plain HTTPS with
Access-Control-Allow-Origin: *, no credentials,OPTIONS→ 204. On the public network this listener isrpc.dig.net. A dig-node run purely for a local consumer keeps this listener on loopback (127.0.0.1, default read port 9778); a node that wants to serve the wider network exposes the read listener on its public interface (TLS-fronted, asrpc.dig.netis).
The recommended endpoint split (design-first call): the public read tier is the same JSON-RPC endpoint shape as the network profile — one POST endpoint speaking JSON-RPC 2.0 — but the anonymous serve layer answers only the read subset; the peer/write/control methods return -32601 there. This reuses the existing node-profile / dig.methods gating (an agent already gates on dig.methods rather than assuming one uniform surface) instead of inventing a parallel protocol, and it is exactly what a browser already speaks to rpc.dig.net. The peer/control tier is not a JSON-RPC-over-HTTPS surface at all — it is the dig-nat mTLS mux — so it cannot be reached by an anonymous HTTP client even by accident.
rpc.dig.net IS the public read tierToday rpc.dig.net (the dig RPC network profile) is exactly this anonymous, CORS-enabled, decoy-on-miss, client-verified public read tier. This section names the tier the browser has always used and states the invariant that keeps the peer/write/control surface off it.
Browser specifics — fetch + verify without mTLS
A browser (or an agent with no client certificate) reads content like this, needing no mTLS at any step:
- CORS. The public read listener answers a cross-origin
POSTwithAccess-Control-Allow-Origin: *and no credentials (Access-Control-Allow-Credentialsis never set — the content is public ciphertext, there is nothing to send credentials for), and answers theOPTIONSpreflight with204. So a page on any origin canfetch()the read endpoint directly. - Read.
POSTJSON-RPCdig.getContent(windowed byoffset/length) to the public read endpoint; reassemble the ciphertext bytotal_length(streaming contract). No client certificate, no bearer token. - Verify — the server is untrusted. Verify the first window's
inclusion_proofagainst the caller-supplied chain-anchored root (the CHIP-0035 singleton's current on-chainmetadata.root_hash, resolved independently of the serving node), then split bychunk_lensand AES-256-GCM-SIV-open each chunk with the client-derived key. A wrong/forged byte fails the proof or the authentication tag and is rejected. This is the same read-crypto every DIG read client runs; the anonymity of the transport does not relax it.
Because the read tier is CORS-* + anonymous, the exact same fetch works from a web page, a service worker, the extension, the SDK, or a headless agent — none of which can present a client certificate.
1 · Peer identity + mTLS
A peer is identified by the SHA-256 of the DER-encoded SubjectPublicKeyInfo of the certificate it presents in the TLS handshake:
peer_id = SHA-256( SubjectPublicKeyInfo DER ) // 32 bytes
peer_idis aBytes32— 32 raw bytes, rendered as 64 lower-case hex on any text surface (dig-gossiptypes/peer.rs:pub type PeerId = Bytes32,peer_id_from_tls_spki_der).- The hashed input is the full
SubjectPublicKeyInfoASN.1 sequence (algorithm identifier + subject public-key bit string) lifted from the peer's X.509 leaf certificate — not the bare public-key bit string. Both sides recover the other'speer_idfrom the certificate exchanged during the handshake, so identity is bound to key material: impersonation requires the private key.
mTLS is mandatory
All peer-to-peer connections use mutual TLS over a WebSocket (wss://). This is a hard requirement — plaintext and server-only TLS are never accepted for a peer link.
- Both endpoints present a certificate. The dialing peer presents its cert to the listener; the listener presents its cert to the dialer. Each derives the other's
peer_idfrom the presented cert. - Self-signed node certificates are expected. Peer identity is verified by the
peer_idhash, not by a certificate authority — the CA chain is not the trust root here, the key hash is. A node generates its certificate on first run and reuses it thereafter. - The listener requires a client certificate (
CERT_REQUIRED). A peer that presents no certificate, or whose TLS handshake fails, is dropped — there is no fallback to a weaker transport. - After the TLS handshake, peers exchange a
Handshakecarrying thenetwork_id(the network genesis challenge as lower-case hex), the protocol version, the node's declared listen port, and its node type + capabilities. Anetwork_idmismatch, or a protocol version below the minimum-compatible floor, ends the connection. TheHandshakeis the Chia-streamable message (big-endian) — this layer speaks the Chia peer protocol, not a bespoke framing. - Unauthenticated peer traffic is rejected. A message received before a completed mTLS handshake +
Handshakeexchange is not processed.
A node's link to the relay is a standard server-authenticated wss:// connection (the relay presents a TLS certificate; the node does not present one to the relay). Peer↔peer identity is never delegated to the relay — end-to-end payloads carried over the relay remain authenticated by the peer protocol itself, so a relay cannot forge a peer.
Identity rotation
A node MAY rotate its network identity (regenerate its certificate, hence its peer_id) on an interval to reduce long-term linkability. Rotation is a network-layer concern only — it is independent of any consensus/validator identity and does not disturb address-book entries, which are keyed by IP:port, not by peer_id.
2 · Connection establishment — the NAT-traversal ladder
A node reaches a peer through a single abstract operation — connect(peer) — which attempts the strategies below in order and returns the first that yields a working, mTLS-authenticated link. This ordered ladder is the contract dig-nat implements; every strategy above "relayed" produces a direct peer link (no relay in the data path).
| # | Strategy | What it is | Relay's role | Result |
|---|---|---|---|---|
| a | DIRECT | The peer is already reachable — publicly routable, or a port is forwarded to it. Dial its advertised address. | none | Direct link |
| b | UPnP / IGD | Ask the local gateway (Internet Gateway Device) to map an external port to the node via UPnP, then advertise the mapped address. | none | Direct link |
| c | NAT-PMP | Request a port mapping from the gateway via NAT Port Mapping Protocol. | none | Direct link |
| d | PCP | Request a mapping via the Port Control Protocol (RFC 6887), NAT-PMP's successor. | none | Direct link |
| e | RELAY-COORDINATED HOLE PUNCH | Neither peer is directly reachable: the relay signals only — it relays the candidate-address exchange and coordinates a simultaneous open so both sides punch through their NATs. The data stream then flows peer-to-peer (see §5). | signalling only (low bandwidth) | Direct link |
| f | RELAYED / TURN transport | Every direct strategy failed: the relay proxies all of the connection's data as an untrusted bridge (see §6). | carries the data (high bandwidth) | Relayed link |
Rules:
- Attempt in order; stop at the first success. A node does not skip ahead to the relay while an earlier, cheaper strategy can still yield a direct path.
- Prefer hole-punch signalling (e) over full relaying (f). Both involve the relay, but they are not the same tier: in (e) the relay only brokers the introduction and the stream goes peer-to-peer, whereas in (f) the relay carries every byte. A node falls to (f) only when the hole punch of (e) fails — this saves relay bandwidth, since a brokered direct link costs the relay almost nothing. A successful (e) is a direct link (strategy result "Direct"), authenticated by the same mTLS
peer_idas every other tier. - Strategies (b)–(d) run once at startup / on address change, not per connection: they establish inbound reachability so future dials land as (a) DIRECT for peers dialing this node. A node that obtains a stable external mapping via UPnP/NAT-PMP/PCP advertises it as a candidate address so peers dial it directly.
- Candidate addresses. A node advertises the set of addresses at which it may be reachable — its configured/observed listen address, any UPnP/NAT-PMP/PCP-mapped external address, and its STUN-derived reflexive address (§3). Peers dial candidates most-direct-first and IPv6-first (§2a).
- Reflexive discovery precedes hole-punch. Before requesting a hole-punch a node learns its public reflexive
IP:portvia STUN (§3) and supplies it as theexternal_addrin the coordination exchange.
2a · Address family — IPv6-first, IPv4-fallback
All peer communication is IPv6-first, with IPv4 used only as a fallback. This applies wherever a node binds, advertises, or dials a peer address.
- Bind — dual-stack. A node's peer listener binds the IPv6 unspecified address
[::]as a dual-stack socket, so the single socket accepts both native IPv6 connections and IPv4 connections (via IPv4-mapped-IPv6) on the same port. It does not bind an IPv4-only wildcard. - Advertise — IPv6 first. A node advertises its real, directly-dialable candidate addresses ordered IPv6-first: a routable (global-unicast) IPv6 address precedes the IPv4 fallback. The wildcard bind address is never advertised (it is not dialable). A node with no routable address advertises no direct candidate and is reached via the relay-coordinated tiers (§2e/f).
- Dial — happy-eyeballs, IPv6 preferred. When dialing a peer with several candidate addresses, a node tries the peer's IPv6 candidate(s) first and falls back to IPv4 only when IPv6 fails or times out. The full candidate list is preserved through the dial path so every family is tried in IPv6-first order — not collapsed to one address.
IPv4 remains a fully supported fallback (many networks are still IPv4-only); it is the fallback, never the default, wherever IPv6 is possible.
3 · STUN — reflexive address discovery
relay.dig.net also serves as a STUN server (RFC 5389 Binding request/response) so a node behind NAT learns the public IP:port its traffic appears to originate from — its reflexive address.
- Endpoint. The STUN Binding service is co-located with the relay at
relay.dig.net, served on the standard STUN port3478(RFC 5389). A node sends a STUN Binding request and reads its reflexive transport address from theXOR-MAPPED-ADDRESSattribute of the Binding success response. A node derives the STUN host from its configured relay endpoint (DIG_RELAY_URL), so pointing a node at a private relay also points its STUN at that host. - Feeds candidate advertisement. The reflexive address discovered here is added to the node's candidate-address set (§2) and supplied as the
external_addrin a hole-punch request (§5). It is how a NAT'd node tells a peer where to punch to. - Advisory, not authenticated. A reflexive address is a hint used to attempt a direct path; a peer link is trusted only after the mTLS handshake over it succeeds (§1). STUN never grants trust — it only tells a node where it appears to live.
4 · Peer discovery — introducer + gossip
A node fills its address book from two complementary sources. Both yield candidate peers to dial; neither is a trust anchor (every dialed peer is authenticated by mTLS).
4a · Introducer (via the relay)
relay.dig.net acts as an introducer: nodes connected to the same relay can enumerate each other, and a node can register itself so others discover it. This uses the relay RelayMessage wire (§6):
- Ask for peers. Send
get_peers(optionally scoped to anetwork_id); the relay replies withpeers, a list ofRelayPeerInfo(each:peer_id,network_id,protocol_version,connected_at,last_seen). - Register presence. A node that holds a relay reservation (
register, §6) is itself returned to other nodes'get_peers— registration is the introducer advertisement. - Live notifications. While registered, a node receives
peer_connected/peer_disconnectedfor same-network peers, so its view stays fresh without polling.
A node MAY additionally use a dedicated introducer over the peer protocol (RequestPeersIntroducer → RespondPeersIntroducer, a peer_list of TimestampedPeerInfo{host, port, timestamp}), and register with it via register_peer{ip, port, node_type} → register_ack{success}.
4b · Gossip peer-exchange (node ↔ node)
Nodes also ask each other for peers, so discovery does not depend on any single rendezvous. Over an established peer link a node exchanges:
RequestPeers(no fields) →RespondPeerscarrying apeer_listofTimestampedPeerInfo{host, port, timestamp}(Chia-streamable, big-endian). Received lists are bounded (per-response and lifetime caps) and merged into the address book.
The peer RPC methods in §7 expose this same peer-exchange over the node's JSON-RPC surface, so an agent or a non-gossip client can drive discovery through the documented node profile. The continuous, incremental, first-hand peer gossip that keeps address books fresh is PEX (§4d) — it generalizes this snapshot exchange with an anti-flood, delta-based model over both an mTLS mux stream and the relay (RLY-008).
4c · Content discovery — the DHT
§4a and §4b find peers; the DHT finds which peers hold a specific piece of content. It is a Kademlia distributed hash table whose provider records map a content key to the peer_ids that hold that content. A node consults the DHT to locate holders before it fetches: it looks up the content, gets back the holders' peer_ids and candidate addresses, then confirms and fetches from them with dig.getAvailability + dig.fetchRange. The DHT locates peers; the NAT ladder reaches them and the peer RPC moves the bytes.
Every node both serves the DHT (holds a slice of the routing table and of the global provider records, and answers lookups) and advertises its own held inventory as provider records, so content is findable without any central index.
The keyspace — one 256-bit XOR metric for nodes and content
Kademlia places nodes and content in a single 256-bit keyspace and measures closeness by XOR distance (Maymounkov & Mazières). DIG maps into it as follows — a frozen contract every implementation reproduces:
-
A node's key IS its
peer_id.peer_id = SHA-256(TLS SubjectPublicKeyInfo DER)(§1) is already a uniform 256-bit value, so the DHT node id and the peer id are one and the same. -
A content key is
SHA-256(domain-tag ‖ canonical bytes)over a fixed, domain-separated byte encoding. The one-byte domain tag makes the three granularities distinct points even when they share astore_id, so a store-level record and a resource-level record never collide:Content Tag Canonical bytes hashed Answers store 0x01store_id(32 B)does a peer serve this store? root / capsule 0x02store_id ‖ root(64 B)does a peer have this generation store_id:root?resource 0x03store_id ‖ root ‖ retrieval_key(96 B)does a peer have this resource in the capsule? All hashes are the raw 32-byte forms in the fixed field order shown; the leading tag byte is part of the frozen key derivation and is never renumbered. These granularities match the
dig.getAvailabilityhas_store / has_root / has_resource shapes, so a lookup and an availability check speak of the same content. -
Distance is XOR.
d(a, b) = a XOR b, compared big-endian (smaller = closer). A key's routing-table bucket index is255 − leading_zeros(distance)— the position of the most-significant set bit, i.e. the length of the shared prefix with this node's id. This gives 256 k-buckets, least-recently-seen ordered with the standard ping-and-replace eviction (long-lived nodes resist eviction). One iterative lookup engine (α-parallel, converging on thekclosest peers) serves bothfind_nodeandfind_providers.
The DHT RPC — a distinct framed wire
The DHT RPC is not a dig.* JSON-RPC 2.0 method. It rides an authenticated dig-nat mTLS stream (§1): each RPC opens a logical stream and writes a u32 big-endian length prefix + a type-tagged JSON body — byte-identical framing to the dig-nat / relay control messages (§6), so a node speaks one framing across the whole peer network. The framed body is bounded (a length prefix over the cap is rejected, never allocated). There are exactly four methods:
| Method | Request | Response |
|---|---|---|
find_node | { "type":"find_node", "target":"<64hex>" } | { "type":"nodes", "nodes":[Contact] } — the k peers the responder knows closest to target |
find_providers | { "type":"find_providers", "content_key":"<64hex>" } | { "type":"providers", "providers":[ProviderRecord], "closer":[Contact] } — providers held locally plus the k closer peers |
add_provider | { "type":"add_provider", "record":ProviderRecord } | { "type":"add_provider_ok" } — the record was accepted + stored |
ping | { "type":"ping", "nonce":<uint> } | { "type":"pong", "nonce":<uint> } — liveness; the responder echoes the nonce |
A responder that cannot answer returns the error envelope { "type":"error", "code":<uint>, "message":<str> } — advisory: a lookup treats it like an unreachable peer and walks on. find_providers always returns closer contacts (even when providers are already found), because more providers may live nearer the key — this is what lets an iterative lookup keep converging.
The two wire shapes:
Contact = { "peer_id":"<64hex>",
"addresses":[ { "host":str, "port":uint,
"kind":"direct"|"mapped"|"reflexive"|"relay" } ] }
ProviderRecord = { "content_key":"<64hex>",
"provider_peer_id":"<64hex>",
"addresses":[ { "host":str, "port":uint, "kind":… } ],
"expires_at":<unix-seconds> }
The addresses[] shape (and the kind tokens direct/mapped/reflexive/relay, most-direct-first) is byte-compatible with the L7 dig.getPeers addresses (§7), so a returned Contact or ProviderRecord drops straight into a dial target for the NAT ladder. content_key is the 64-hex content key derived above; provider_peer_id is the holder's peer_id.
Provider-record lifecycle — soft state, TTL'd + republished
A provider record is soft state, not a permanent entry, so an offline holder ages out automatically. These rules are normative:
- Announce on hold. When a node gains content it serves, it PUTs a
ProviderRecord(viaadd_provider) at theknodes closest to that content key — binding the content key to its ownpeer_idand candidate addresses. - Absolute expiry.
expires_atis set tonow + TTLin absolute Unix seconds. A record at or after itsexpires_atis treated as absent. - Republish before expiry. The holder re-announces (a fresh record with a new
expires_at) on an interval strictly shorter than the TTL, so its records never expire while it is online. - Withdraw on removal. A node that no longer holds content stops announcing it; the record then ages out on its TTL (no explicit delete is required).
- GC drops the expired. A responder discards expired records on read and does not return them.
- Inbound RPC populates the routing table bidirectionally. On every inbound DHT RPC, the responder folds the mTLS-verified caller (its
Contact) into its own routing table — every request is evidence the caller is alive, so a node that queries you teaches you about itself. The caller identity MUST come from the authenticated transport, never from a field the caller sets.
How a node uses the DHT
- On content-want (a user asks for
store_id,store_id:root, or a specific resource): derive the matching content key, runfind_providers, then reach each returned provider over the NAT ladder and fetch viadig.getAvailability+dig.fetchRange. The DHT is step 1 of the multi-source download — it finds the candidate holders the download then fans out across. - On inventory-change (the node gains or loses content it serves):
add_providerfor each new content key, and stop announcing what it no longer holds. Run republish on the configured interval. - Bootstrap the routing table from existing discovery — the gossip peer pool (§4b) or the relay introducer (§4a) — then a self-lookup (
find_nodeon the node's own id) fills the table. The DHT never hard-depends on a live relay.