agent-uri-dht-libp2p
The networked Kademlia backend for agent:// discovery, over libp2p-kad.
agent-uri-dht defines what discovery means and ships an in-process index that implements it. This crate implements the same Dht trait against a real overlay: records live on other people's machines, reads cross the network, and every node that stores a record decides for itself whether it should.
It is a separate crate because libp2p is a large dependency and the core crate is useful without it. A caller that only derives DHT keys should not inherit a networking stack.
[]
= "0.5"
Starting a node
use ;
use ;
use Libp2pConfig;
use identity;
# async #
Registration, migration, refresh, deregistration, and lookup are the Dht trait's, unchanged from the simulator. Code written against SimulatedDht runs here.
How a registration is stored
This crate implements the sharded record model of SPECIFICATION.md §6.2, which every Kademlia deployment is required to use. The alternative, materializing a registration at its exact path and every ancestor so that a prefix query is one exact-key read, does not survive contact with a real overlay: libp2p-kad's wire limit is 16 KiB, which the spike in #72 measured at 1 to 27 registrations, against a broad ancestor key meant to hold an entire subtree.
The sharded model splits the two jobs one record was doing:
| key | holds | written by |
|---|---|---|
| identity | the agent's signed registration | the agent, once per write |
| page | pointers to agents beneath a path | every agent, at every ancestor |
| descriptor | how many pages a path is spread over | whoever widens it |
A pointer is about 74 bytes against a registration's 600, so a page holds roughly 220 agents rather than 27. That moves the ceiling; it does not remove one. Sharding does: a path's pointers spread across 2^level pages, and whichever publisher finds a full page raises the level for everyone.
The level is a power of two on purpose, and §6.1.1 requirement 5 makes that normative. With page = hash mod P, raising P moves nearly every existing publisher to a different page and every pointer already written becomes unreadable until its publisher happens to rewrite it. With page = hash & (2^level - 1), raising the level splits each page in two and leaves every existing pointer where a wider reader still looks. Growth is backward compatible, and a stale level costs coverage rather than correctness.
The key derivations are in §6.1.1 and their test vectors in Appendix B.4.1; keys.rs asserts them, because an implementation that derives one key differently sees none of this one's registrations and the failure looks like an empty namespace rather than a disagreement.
Cost of a lookup: one descriptor read, one page read per shard, and one read per agent found, which is what §6.4 costs the sharded model at. Each is one exact-key read, so each is still O(log N) hops.
What makes this safe under replication
Kademlia copies records between nodes verbatim, on its own schedule, in whichever direction fires first. A backend whose nodes edited what they stored, or preferred whatever arrived last, would have replication undoing writes.
Every value here is instead reconciled by a rule that does not depend on arrival order, and no node ever rewrites a value it accepts:
- identity records are immutable and signed; the later
(registered_at, sequence)wins - pointer pages union, keeping the later expiry per URI
- shard descriptors take the greater level
All three are commutative, associative, and idempotent, so replication is anti-entropy rather than a race. The same rules run on both sides of a read: a node applies them when a record arrives, and a reader applies them across every copy the overlay returned, which makes a lookup as fresh as the best-informed peer that replied.
What a node refuses to store
§6.2 requirement 4 says DHT nodes MUST verify attestations before storing records. Every value entering a node's store passes validation, via Kademlia's StoreInserts::FilterBoth, including values the node publishes itself. A node will not store a record whose signature does not cover it, whose key does not match its URI, or whose attestation names a different key than the record does.
The one place that requirement cannot be met literally is a trust root the node has no key for, and a node holds records for keys near it in the overlay, which is every trust root. AttestationPolicy is where that is decided rather than assumed:
| policy | unknown trust roots |
|---|---|
RequireVerified |
refused; the node serves only roots it was configured with |
VerifyKnownRoots (default) |
stored, but never a key change the node could not verify |
Unverified |
stored; for isolated tests |
Keeping a registration alive
Two things have to keep happening, and only one is Kademlia's job.
Surviving churn is the overlay's: nodes leave, the closest set changes, and records are copied to the new nodes automatically.
Surviving expiry is the agent's. A registration carries its own signed expires_at, and no node can extend it, because extending is a write and a write needs the agent's key. keep_alive holds the signing key and renews at half the TTL, which leaves the other half to retry in.
Known limits
- A node with no peers cannot publish. Kademlia counts remote acknowledgements, and a local store write is not one. Bootstrap first.
- Pointer pages are unauthenticated. Anyone can put any URI on any page. A lookup dereferences each pointer and checks the agent's own URI against the path queried, so an injected pointer costs a wasted read and nothing more.
- Shard growth is opportunistic. A page over its high-water mark is widened by whoever notices; until then it keeps accepting pointers.
Testing
Integration tests run a real multi-node overlay in one process, over loopback TCP with noise and yamux, covering discovery, migration, deregistration, paging, shard growth, churn, partition, and the attacks that #50 and #51 closed.
License
MIT OR Apache-2.0