Network
Onion routing and cells
Every frame on every link is a fixed 2048, 4096 or 8192-byte cell, each hop's layer is sealed under a fresh ephemeral X25519 key, and the padding one relay writes is bound into the next hop's authenticated data.
What onion routing is here
A message addressed through UniNet's onion layer is wrapped in one AES-256-GCM layer per relay hop. Each layer's key comes from a fresh ephemeral X25519 exchange against that relay's published onion key, so relay i can open layer i and nothing else. Opening layer i reveals exactly one fact: the UNP address of relay i+1.
The problem this addresses is that encrypting a request end to end still leaves the request's shape intact. A relay that cannot read a payload can still see how large it is, how it shrinks as it crosses the path, and which frames arrive together, and any one of those is enough to stitch the hops of one circuit back into a single flow. The construction below answers the shape, not the content: it makes every frame on every link the same size, and it authenticates the padding as well as the ciphertext.
The cell
Every frame, in either direction, is exactly the tier's cell size. There is no variable-length frame anywhere in the format.
offset 0 : version (1 byte, = 1)
offset 1 : reserved (1 byte, 0; doubles as the frame-kind byte)
offset 2 : ct_len (u16 LE, this hop's ciphertext length)
offset 4 : ephemeral_pub (32 bytes, X25519, fresh for this layer)
offset 36 : ciphertext (ct_len bytes, AES-256-GCM)
offset 36 + ct_len .. end : filler (deterministic)
The cleartext header is 36 bytes. Inside a layer, a forwarding hop's plaintext header is cmd(1) | next_hop_unp(32) | next_ephemeral_pub(32) | next_ct_len(2), 67 bytes, and the terminal hop's is cmd(1) | payload_len(4), 5 bytes. One hop of forwarding therefore costs 67 + 16 = 83 bytes: its header plus its AEAD tag.
Three commands exist. Forward carries the next hop's address, Deliver marks a real payload for the last hop, and Drop marks a cover packet the last hop discards without forwarding.
The usable-payload figures below are derived from those constants rather than written down separately: a tier's usable payload is whatever is left in the cell after the frame header, the terminal header and tag, and 83 bytes for each hop above the terminal one. Hop count, cell size and TTL are stated per tier.
| | Standard | Private | HighSecurity | | --- | --- | --- | --- | | Relay hops | 1 | 2 | 3 | | Cell size | 2048 B | 4096 B | 8192 B | | Usable forward payload | 1991 B | 3956 B | 7969 B | | Usable reply payload | 1872 B | 3821 B | 7818 B | | Descriptor TTL | 3600 s | 900 s | 300 s | | Cover traffic | off | 5 per 10 s | 15 per 5 s |
Padding is not wrapped around a variable payload. The terminal layer's body is a fixed-length field that the payload is written into and then resized to fill, so a one-byte payload and a full-cell payload produce byte-for-byte identically sized frames on every link. A cover packet's body is CSPRNG bytes rather than zeros, so even the plaintext the last hop sees is not a constant pattern.
Hop count is exact, not a minimum. Building a packet for a tier with the wrong number of hops is refused rather than adjusted, as is a path containing the same relay twice, a payload larger than the tier's capacity, and a hop whose public key is non-contributory.
One ephemeral key per hop
The origin performs a separate X25519 exchange against each hop's published onion key, for every layer of every packet. From the resulting shared secret it derives that layer's AEAD key and nonce with BLAKE3 key derivation over shared || ephemeral_pub || hop_static_pub, under two distinct context strings: one for the key, one for the nonce, of which the leading 12 bytes are used.
Binding the derivation to both public keys means a shared secret can never be reused under a different pairing. Because the ephemeral key is fresh per layer per packet, the derived key is used for exactly one AEAD operation ever, which is why the nonce is derived rather than transmitted. A fixed-per-key nonce cannot repeat when the key never repeats, and not sending it removes 12 attacker-controlled bytes from every frame.
A relay's onion key is separate from its consensus signing key. Compromising the onion key lets the holder read layers that relay could already read; it does not let them sign. There is no forward secrecy across the relay's static key, because the ephemeral half of the exchange is the client's, so a rotation ring keeps the previous secret live for a grace window after a rotation and frames already sealed to the retired key continue to open.
The filler, and why it sits inside the AAD
Peeling an AES-GCM layer shrinks the ciphertext by exactly 83 bytes. If relays simply forwarded what was left, link sizes would step down along the circuit and a passive observer could stitch the hops of one circuit together by size alone. Instead each relay re-pads the frame it forwards back to the full cell size, with filler derived from its own shared secret: BLAKE3 key derivation over that 32-byte secret under a per-block context string, concatenated until the required length is reached.
The filler is deterministic on purpose. The origin holds every hop's shared secret, so it can predict the exact bytes each relay will write, and it binds them into the next hop's associated data when it seals that hop's layer. The AAD for hop i is:
aad_i = version(1) | reserved(1) | ct_len_i (u16 LE) | ephemeral_pub_i (32) | filler_i
That is the entire cleartext header of the frame hop i will receive, followed by the filler region of that same frame. filler_i is i × 83 bytes long: the origin's own frame carries no filler and exactly fills the cell, and each relay adds one layer's worth. The filler in hop i's frame is written by hop i−1 and derived from hop i−1's shared secret, because hop i−1 is the party that writes it.
The consequence is the point of the mechanism. The classic tagging attack, where a hostile entry relay flips bits in a region nothing authenticates and a colluding relay further down reads them back to confirm the two are on the same circuit, becomes an AEAD authentication failure at the very next hop. A relay that alters either the header or the filler does not open a covert channel; it kills the frame. The channel is also confined to adjacent hops, because a forwarding relay regenerates the whole filler region from its own secret rather than passing the previous hop's bytes through, so marks set by one relay never survive past the next one. Adjacent relays can signal to each other by virtue of being adjacent, and no construction changes that.
hop 0 hop 1 hop 2 (HighSecurity)
ct_len 8156 8073 7990
filler 0 83 166
header 36 36 36
total 8192 8192 8192
Single-use reply blocks
The return leg cannot be built the same way. On the forward leg the origin knows the whole path and each hop removes a layer. On the return leg the party holding the payload is the destination, which must not learn the path at all, so the direction of the layering flips. The origin pre-builds a routing header it can no longer read into, ships it to the destination inside the already-sealed forward payload, and each return hop adds a layer to the payload rather than removing one. Only the origin, which generated every shared secret, can unwrap the result. This is the Sphinx SURB construction.
A reply frame is the same cell size as a forward frame of the same tier, split into a fixed routing region and a fixed payload region:
offset 0 : version (1 byte, = 1)
offset 1 : kind (1 byte, = 1, reply)
offset 2 : route_ct_len (u16 LE)
offset 4 : payload_offset (u16 LE)
offset 6 : reserved (2 bytes, must be 0)
offset 8 : ephemeral_pub (32 bytes; on the final link, the SURB tag)
offset 40 : route_ct | route_filler | payload_ct | payload_filler
Byte 1 is the frame-kind discriminator, and it is the same byte the forward format has always required to be zero. The two formats are therefore distinguishable in one byte, and the forward parser rejects a reply frame outright rather than misparsing it.
The routing region shrinks by 83 bytes per hop and is re-padded exactly as the forward leg is, with a domain-separated filler so one shared secret can never produce the same bytes in two different roles. The payload region moves the other way: it absorbs one AEAD tag per return hop plus one for the destination's own seal, and its filler shrinks by a tag each time a hop seals. A return hop uses the same shared secret twice, once to open its routing layer and once to seal the payload, under two different derivation contexts, because reusing one AES-GCM key and nonce across both would be nonce reuse.
What each party holds is bounded by construction. The destination receives the entry relay's address, the opaque routing prefix, a seal key and nonce, and filler; it learns the return path's length, which the block states, and nothing else — not the origin, not any hop, not any hop's key. No return hop ever sees the reply in the clear, including the first one, because the destination's own seal is under a key only the destination and the origin share. The origin's store of outstanding blocks is single-use, TTL-bounded and capacity-bounded: taking the keys removes them, so a replayed reply frame finds nothing and is dropped.
Two properties of this leg are weaker than the forward leg, and both are inherent to reply blocks. Tampering is detected only at the origin, because the hops seal rather than open and so have nothing to check; the circuit dies rather than leaking. And the last return hop necessarily learns the origin's address, because somebody has to deliver the frame. Running the return path as the reverse of the forward path makes that hop the relay that was already the origin's peer, but the builder cannot see the forward path, so the pairing is the caller's choice rather than an enforced property.
A reply block is also a bearer token. Whoever holds one can send exactly one reply to the origin, and its seal key is what keeps the first return hop from reading the reply, so it belongs inside the payload already sealed end to end to the serving node rather than in any layer a relay can open.
What this does not address
- Relay diversity, in practice. The selection policy forbids two hops of one circuit from sharing an operator, an autonomous system or a network prefix, and all three constraints are on by default. They are evaluated over a separate attribution map, deliberately kept off the relay's self-declared record, and two relays conflict on a field only when both have that field recorded. Nothing in the workspace populates that map outside tests, so every relay counts as its own operator, no pair ever conflicts, and a three-hop path can be three relays run by one party. The strict mode that refuses unplaceable relays is off by default, because enabling it before attribution exists empties the candidate set rather than tightening it.
- A global passive adversary, or one holding both ends. Fixed cells raise the cost of correlating flows. They do not defeat an observer watching every link at once, and they do not defeat a party that controls both the entry and the exit of one circuit. This is a low-latency design, not a mixnet. Diversity, not cryptography, is the defence against holding both ends, which is what makes the previous bullet load-bearing.
- Cover traffic, in practice. The per-tier cover rates in the table are parameters, and a scheduler that turns them into emissions exists. Nothing outside the crate's own tests calls it, so no node emits cover packets.
- The return path, in practice. The reply construction is a library. Nothing outside the crate's own tests builds a reply block, and no handler in the node binary dispatches on the frame-kind byte, so a reply frame never reaches a relay over a network link. On the live path the exit acknowledges the terminal payload and stops there rather than opening a return circuit.
- Forwarding, in practice. A relay forwards a peeled frame only to a UNP address its operator placed in a relay directory, and nothing in the node populates that directory. The node's own onion directory holds one entry, its own descriptor, and building a circuit additionally needs a relay pool the node constructs as absent.
- Timing. Padding equalises size, not time. Request and response latency, inter-packet gaps and burst structure all still carry information, and a reply is a second flow correlated in time with its request.
- The tier itself. Cell sizes are uniform within a tier, not across tiers, so the size of a frame identifies which of the three tiers produced it.
- A relay's knowledge of its own position.
ct_lentravels in the clear and steps down by one layer's overhead per hop, so a receiving relay can compute how many hops remain. That is visible to the two adjacent relays and not to an observer of the link, and it reveals neither endpoint. - The single relay of the
Standardtier. One hop means that relay is both entry and exit and does see both the client and the destination. It hides the destination from the network path and pads sizes; it does not hide the client from the relay. It is never substituted for a stronger tier, and a stronger tier that cannot be satisfied is refused rather than shortened. - The exit relay's view of the payload. The last hop sees the plaintext it delivers. End-to-end payload confidentiality is the tunnel's job; the onion layers protect metadata.
- The directory. The map from relays to onion keys is local and unsigned. Whoever fills a client's directory chooses which relays that client can use and under which keys, and can therefore publish keys it holds and read every layer addressed to them. Freshness is handled — descriptors carry a publication time and entries of unknown age are pruned as stale — but agreement on which keys are canonical is a different problem, and it is not approximated here.
- Denial of service. A relay can drop frames. Nothing here prevents that; it only prevents dropping from being informative.
Treat the properties above as design intent to read and test against the source, not as controls to cite.
How it composes
This page describes the routing layer in isolation. What each hop of a UNP path learns — the property these cells exist to enforce — and the resolver that sits above them are on private networking.
The address a peeled layer reveals is a UNP address, which binds to an identity holding a key. Relays forward without resolving it, and reaching a service over a circuit confers nothing on its own: a request that arrives is still authorized against delegated authority carried with the request.
The assumptions this construction makes about relay operators, and the positions an adversary would need to hold to defeat it, are collected in the threat model.
What is not finished
Relay diversity is not enforced. Relay attribution is never populated, so every relay counts as its own operator and all hops on a path may in fact belong to one party.
The confidential resolver is an interface with no backend, and the node starts without one, so a request cannot be resolved to a service across nodes. Read the addressing and routing pages as design you can test locally, not as a deployable private-hosting path.
Hardware-backed confidential computing is not implemented. If your threat model includes the operator running your workload, UniNet does not solve that today.