Skip to content

Architecture

Address Derivation

Endpoints are derived, not announced. A client computes today's address for a cell from a secret and the clock; the cell computes the same address independently. Nobody publishes anything. There is no DNS record to seize, no static address in the binary to scan for.

The formula

address = ipv6_prefix || truncate(HMAC(cell_secret, domain_tag || bucket || slot))
Input Role
ipv6_prefix The fixed network portion — a /64 (or wider) the cell controls.
cell_secret The client's credential for this cell. The only secret here.
domain_tag Domain separation, so a cell secret can never collide with output from another use.
bucket The time window — which hour it is. From the clock.
slot Which of the cell's concurrent addresses — enumeration, 0..slot_count.

truncate takes the leading host-width bits of the HMAC output and uses them directly as the host portion of the address. Host width = 128 − prefix_length (a /64 gives 64 bits). HMAC-SHA256 produces 256 bits, so even a /48 uses less than a third of the output.

The grid: two independent dimensions

bucket and slot are unrelated. One is time; one is fan-out. Together they form a grid, and at any instant a cell binds the whole live sub-grid:

                 slot 0   slot 1   slot 2   ...   slot 29
bucket 12344    [addr]   [addr]   [addr]    ...   [addr]    ← previous hour (skew)
bucket 12345    [addr]   [addr]   [addr]    ...   [addr]    ← current hour
bucket 12346    [addr]   [addr]   [addr]    ...   [addr]    ← next hour (skew)

With skew 1 and 30 slots, that is 90 addresses bound simultaneously. Rows roll over every hour; columns stay put. The time-bucket module produces the row numbers; this derivation fills in each cell of the grid.

  • Slots give resilience: one address being blocked or blackholed does not take the cell down — 29 others are live.
  • Buckets give rotation and clock tolerance: the skew rows are what let a client with a slightly wrong clock still land on a bound address.

They are independent dials at different layers. Slot count is a per-cell resilience decision; it has nothing to do with how many cells a client holds.

Two operations people confuse

The credential is the cell secret, never an address

  • Unbinding an address — a cell stops listening on one endpoint. The client can still derive it; it just finds nothing there. An availability action, not revocation.
  • Rotating the cell secret — invalidates every address that secret ever produced, past and future, instantly. This is real revocation, and it is how a client goes from N cells to N−1 in under a minute.

Because every address is HMAC(cell_secret, …), rotating the secret produces an entirely disjoint address sequence. A client holding the old secret cannot derive a single valid address anymore. You are not deleting addresses; you are invalidating the thing that generates them.

An address is a rendezvous point, not a credential

This is the most important framing on the page. In TOTP, the derived code is a credential — holding it proves you know the secret. Here it is not. Anyone who learns today's address may connect to it, and will fail the Noise handshake, because authentication chains to the root key and has nothing to do with how the endpoint was discovered.

Consequences:

  • No rate limiting on derivation — there is nothing to brute-force that helps.
  • No single-use enforcement — addresses are reused within a bucket by design.
  • Learning an address is not a compromise — it is a scanning shortcut, and scanning a /64 is infeasible anyway (2⁶⁴ host addresses).

Secrecy of the address buys scan-resistance and unlinkability, not authentication. Keep those two roles apart permanently — conflating "hard to find" with "authenticated" is exactly the class of composition error that kills protocols.

Why HMAC truncation is safe

HMAC is a pseudorandom function: its output is computationally indistinguishable from random, so any subset of its bits is too. Taking the leading 64 bits leaks nothing about cell_secret — you would have to invert HMAC to work backward, and truncation discards information rather than exposing it.

Collisions are a non-issue: by the birthday bound you would need ~2³² addresses in a single /64 before a 50% collision chance. Dozens of slots is nothing. (A property test still asserts distinctness — and skips the handful of reserved interface IDs if a derivation ever lands on one.)

Read next: the time buckets that produce the bucket input — the module currently under construction.