YUME Internet freedom by FixCraft
On this page

Circuit 1

Status: normative contract for the codec in circuit/, the constructions in providers/circuit_crypto.* with their vectors, and the node service and client builder in runtime/circuit_node.* and runtime/circuit_client.*. yumed serves circuits over its cluster links, and yume builds them for a client with a circuits section (implementation status).

Purpose

A circuit carries one client’s streams through two or three servers of one operator’s cluster. The first server, the entry, knows the client but not the destinations. The last, the exit, reaches the destinations but does not know the client. A middle server knows only its two neighbours. The client encrypts each message once for every server on the path, and each server can remove only its own layer, so it reads only what is meant for it.

This protects against observers outside the servers and against any one server that has been broken into. It does not protect against the operator, who runs every server and can match what they see, or against an observer who watches both the client and the exit and matches the timing of their traffic. The exit sees each destination and any content not protected by TLS or another end-to-end layer.

A three-hop circuit and what each server knows The client applies three circuit layers. Each node removes its own. All three servers belong to one operator, who can correlate their observations. One operator's cluster three layers two layers on link one layer on link ordinary TCP Client knows chosen route Entry client and middle, not target Middle entry and exit, not client Exit target, not client identity Destination sees exit address
The client applies three circuit layers. Each node removes its own. All three servers belong to one operator, who can correlate their observations.
What each part does
Client
Chooses the entry, middle and exit from the signed routes view, verifies each hop handshake and applies one layer per hop.src/runtime/circuit_pool.cppsrc/runtime/circuit_client.cpp
Entry
Authenticates the client on its session and removes the entry layer. It knows the next hop and can infer the circuit length.src/runtime/circuit_node.cpp
Middle
Removes the middle layer and passes the remaining exit ciphertext over its outbound link. It learns its two neighbours.src/runtime/circuit_node.cpp
Exit
Removes the final layer, checks its egress policy and reaches the destination. It sees content the application did not encrypt.src/runtime/circuit_node.cppsrc/runtime/circuit_host.cpp
Destination
The circuit protects the path between client and exit. Application encryption has its own trust boundary.docs/protocol/CIRCUIT_1.md
Text version
+--------------------------------+
|  Client                        |
|  knows chosen route            |
+----------------+---------------+
                 |
                 v ==YUME==> three layers
+----------------+---------------+
|  Entry                         |
|  client and middle, not target |
+----------------+---------------+
                 |
                 v ==YUME==> two layers on link
+----------------+---------------+
|  Middle                        |
|  entry and exit, not client    |
+----------------+---------------+
                 |
                 v ==YUME==> one layer on link
+----------------+---------------+
|  Exit                          |
|  target, not client identity   |
+----------------+---------------+
                 |
                 v ordinary TCP
+----------------+---------------+
|  Destination                   |
|  sees exit address             |
+--------------------------------+

Conventions

The key words MUST, MUST NOT, SHOULD and MAY describe what the codec and constructions accept and produce. Integers are unsigned and big-endian. Decoders consume their whole input: missing bytes are truncated input and extra bytes are trailing data. Reserved fields, padding and filler MUST be zero. A receiver that finds any violation MUST end the circuit.

Circuit 1 is its own protocol with its own labels. It runs inside YTP/1 streams and shares no label or key with YTP/1, the relay channel or the cluster list.

A circuit is one YTP/1 packet stream on the client’s session to its entry and one on each link after it, so the YTP/1 stream identifies the circuit on that hop and cells carry no circuit number. The packet service keeps each cell a single record, and YTP/1 credit paces every hop. A node extends a circuit by opening a stream on its own outbound link to the next node, as cluster 1 requires. Ending the stream ends the circuit on that hop, and the stream’s close carries no reason.

A circuit has 1 to 3 hops. The hop’s depth is its position: 1 for the entry, which the client reaches over its own session, then 2 and 3.

Cells

A cell’s size is its bucket, one of three in each direction:

Direction Buckets in bytes
forward 512, 4096, 16311
backward 512, 4096, 16315
u8  version = 1
u8  command            1 CREATE, 2 CREATED, 3 RELAY
u16 body_length
u8  body[body_length]
u8  filler[bucket - 4 - body_length] = 0

A full cell of the largest bucket leaves the session or link it crosses as exactly one TLS record of 16384 bytes, the most TLS carries in one record. Around the cell go its YTP/1 PACKET record (a 12-byte frame header, the 16-byte post-AUTH envelope and the 16-byte AES-GCM tag), the carrier’s 12-byte envelope, one WebSocket frame header and one 9-byte HTTP/2 DATA frame header. Forward cells always leave the side that dialed, the client on its session and the extending node on its own link, and that side masks its WebSocket frames, whose headers are then 8 bytes instead of 4. So the forward bucket is 4 bytes smaller. Browsers fill records to that size in bulk transfers in both directions, where a cell a few bytes too large would pair every full record with a small one.

A receiver MUST refuse a cell whose size is not a bucket of its direction, another version, an unknown command, a command sent in the wrong direction, a body longer than the bucket allows or a nonzero filler byte. The command fixes the direction and the body:

  • CREATE goes forward, carries a CREATE body of 1638 bytes and MUST use the 4096-byte bucket.
  • CREATED goes backward, carries a hop handshake of 6327 bytes and MUST use the 16315-byte bucket.
  • RELAY carries 1 to 3 layers around a relay message. Its body is the bucket’s relay capacity plus 16 bytes for each layer, so its length MUST be that capacity plus 16, 32 or 48. The number of layers is the cell’s layer count.

The relay capacity of a bucket is the bucket less 52 bytes, the 4-byte cell header and room for three 16-byte layer tags: 460, 4044, and 16259 forward or 16263 backward. A relay message is padded to exactly this capacity before the first layer is applied, so a body’s length depends only on its bucket and layer count and no hop learns a message’s true length. A layer count does tell a hop how many layers remain.

The first cell a node sends forward on a circuit’s stream is CREATE, the first it receives back is CREATED, and every later cell in either direction is RELAY. A relay forwards a cell in the bucket it arrived in. A sender SHOULD use the smallest bucket that holds its message.

Handshake

The client runs one handshake with each hop, through the part of the circuit already built. Both sides use fresh X25519 and ML-KEM-1024 keys for every circuit. The hop signs with its composite Ed25519 and ML-DSA-87 identity, which the operator’s signed list pins, and the client proves nothing to any hop.

client handshake, 1636 bytes          hop handshake, 6327 bytes
u8  schema = 1                        u8  schema = 1
u8  reserved[3] = 0                   u8  reserved[3] = 0
u8  nonce[32]                         u8  x25519_public[32]
u8  x25519_public[32]                 u8  mlkem_ciphertext[1568]
u8  mlkem_public[1568]                u8  confirmation[32]
                                      u8  signature[4691]

The CREATE body is the hop’s depth, a reserved byte and the client handshake:

u8  depth              1, 2 or 3
u8  reserved = 0
u8  client_handshake[1636]

The client builds the handshake and knows each hop’s depth. The node that sends CREATE sets the depth: the client sets 1 on its own session, and a node extending a circuit sets its own depth plus one. A hop MUST refuse depth 1 on a link from another node and any depth but 1 on a client’s session.

The labels below are ASCII byte strings. Tagged means YUME’s length-prefixed encoding: every item, the label first, is written as a four-byte length and its bytes.

transcript = SHA-256(tagged("yume/circuit/1/transcript/v1",
                            hop_fingerprint[32], predecessor_fingerprint[32],
                            depth[1], client_handshake[1636],
                            hop_x25519_public[32], mlkem_ciphertext[1568]))
secret     = x25519_shared[32] || mlkem_shared[32]
key(label, n) = HKDF-SHA256(secret, salt = transcript, info = label, n bytes)
forward_key      = key("yume/circuit/1/forward-key/v1", 32)
backward_key     = key("yume/circuit/1/backward-key/v1", 32)
forward_iv       = key("yume/circuit/1/forward-iv/v1", 12)
backward_iv      = key("yume/circuit/1/backward-iv/v1", 12)
confirmation_key = key("yume/circuit/1/confirmation/v1", 32)
confirmation     = HMAC-SHA256(confirmation_key, transcript)
signed           = tagged("yume/circuit/1/hop-signature/v1",
                          transcript, confirmation)
signature        = Ed25519(signed) || ML-DSA-87(signed)

A fingerprint is the 32-byte composite identity digest of cluster 1, in binary. The predecessor fingerprint is the identity of the node the hop’s stream arrived from, as that link authenticated it, or 32 zero bytes at depth 1, where the predecessor is a client. Depth and predecessor MUST agree: zeros exactly at depth 1. The ML-DSA-87 signature uses the pure mode with an empty context, as YTP/1 does.

The hop decodes the client handshake, makes its X25519 key and encapsulates to the client’s ML-KEM key, derives the keys, and sends the hop handshake. It MUST refuse an all-zero X25519 shared secret and an ML-KEM key that fails OpenSSL’s input check.

The client decodes the hop handshake, decapsulates, derives the X25519 secret and the keys, verifies both halves of the signature with the identity it expects at that position, and then compares the confirmation with its own in constant time. It keeps no key and sends nothing on the circuit until all of these pass. The signed predecessor lets the client notice a node that routes the circuit through a node the client did not choose: the next hop signs that node’s fingerprint, not the one the client expects.

Layers

Every hop has one AES-256-GCM key and 12-byte IV for each direction. Forward is from the client toward the exit, backward the reverse.

nonce = iv XOR (0x00000000 || u64 counter)
aad   = tagged("yume/circuit/1/layer-aad/v1", direction[1], bucket[2])

The counter counts the cells of that hop and direction, starting at zero, and is never sent. Direction is 1 forward and 2 backward, and the bucket is the cell’s size as a u16. A layer’s ciphertext is its plaintext followed by the 16-byte tag.

Forward, the client pads the relay message to the relay capacity and applies the layers of the hops from the one it addresses back to the entry, the addressed hop’s innermost. Each hop removes its own layer. A hop that removes the last layer reads the relay message, and one that leaves layers forwards them in a RELAY cell of the same bucket. A cell with more than one layer at a hop that has no next hop ends the circuit.

Backward, a hop that sends a relay message applies its own layer to the padded message. Each hop before it adds its own layer to whatever arrives from its next hop, and the client removes them all. The layer count tells the client which hop sent the message. A hop MUST end the circuit rather than add a fourth layer.

A replayed, reordered, rebucketed or altered cell fails its tag, and a hop or client that finds a failed tag MUST end the circuit. A layer protects at most 2^32 cells in each direction, and the circuit then ends. Layer keys last as long as the circuit and are never rekeyed. The YTP/1 links underneath keep their own ratchets.

Relay messages

A relay message fills exactly the relay capacity:

u8  type
u8  flags = 0
u16 length
u32 stream
u8  payload[length]
u8  padding[capacity - 8 - length] = 0
Type Name Direction Stream Payload
1 BEGIN forward nonzero a destination, below
2 DATA both nonzero 1 or more bytes
3 END both nonzero one stream reason byte
4 STREAM_CREDIT both nonzero u32 increment from 1 to 2^30
5 EXTEND forward 0 next node’s fingerprint[32], client handshake[1636]
6 CONNECTED backward nonzero empty
7 EXTENDED backward 0 hop handshake[6327]
8 EXTEND_FAILED backward 0 one circuit reason byte
9 CIRCUIT_FAILED backward 0 one circuit reason byte

A decoder MUST refuse an unknown type, a type sent in the wrong direction, a stream of 0 where the table requires a nonzero one or the reverse, nonzero flags, a length that does not fit the capacity, a payload of the wrong shape and nonzero padding. The client picks stream numbers, and one stream’s messages carry the same number for its whole life.

The BEGIN payload is an address kind followed by the destination bytes of a YTP/1 OPEN. The destination is TCP and meets YTP/1’s destination rules:

u8  address_kind       1 IPv4, 2 IPv6, 3 DNS
u16 port               nonzero
u8  address[4]         IPv4
u8  address[16]        IPv6
u8  dns_length, u8 dns_name[dns_length]    DNS, canonical lowercase
Circuit reason Value Meaning
unreachable 1 No live link to the next node, or it refused the circuit
busy 2 A bound was reached, try another route later
timeout 3 The next node did not answer in time
protocol 4 A malformed cell or a failed check
refused 5 Depth, a loop or a node the sender does not know
closing 6 The node is stopping or its list has expired
Stream reason Value Meaning
done 0 The stream finished
policy 1 The exit’s egress policy refused the destination
connection refused 2 The destination refused the connection
unreachable 3 The destination could not be reached
name not found 4 The destination’s name did not resolve, or none of its addresses connected
timeout 5 Resolving or connecting took too long
resources 6 The exit reached a bound
protocol 7 A malformed message or a broken rule
closed 8 The other end closed the stream

Reasons name no node. A hop reports why a circuit failed only in its own layer, so no other hop can read it.

Streams

A stream starts when the client sends BEGIN. The exit answers CONNECTED once it has reached the destination, or END with the reason it could not. An exit that cannot tell a failed lookup from a failed connection answers name not found for a name and unreachable for an address. The client MAY send DATA, and END, right after BEGIN without waiting for CONNECTED, within the window below, so a request need not wait a circuit round trip. The exit holds that data until it has reached the destination and writes it there first, and discards it with the stream when it answers END. Messages for a stream the receiver has already ended are ignored, because they may have crossed its END.

Each end grants the other a window of 262144 bytes for every stream’s DATA when the stream starts and returns it with STREAM_CREDIT as it delivers the bytes, so neither end holds more of one stream than the window it granted. A receiver may grow a window by returning more than it delivered. A receiver MUST end the circuit when DATA exceeds the window it granted or when STREAM_CREDIT would let its sender hold more than 2^30 bytes.

END with reason done finishes one direction: its sender will send no more DATA, and the receiver delivers what it holds and then ends the stream toward its reader or destination. The stream is gone once both ends have sent done. END with any other reason ends both directions at once, also from an end that already sent done, and DATA after its sender’s END is a protocol error.

EXTEND_FAILED leaves the circuit open at the hop that sent it, so the client may extend to another node. CIRCUIT_FAILED comes from the hop that saw the circuit break. That hop stops forwarding and shuts down its backward write direction after the report, preserving queued cells for the previous hop to read. It drains crossed forward cells until the previous hop ends the stream, within the existing stalled-circuit timeout. A terminal abort can discard unread cells, so it is used when delivery fails or that timeout expires, rather than immediately after writing the report.

Vectors

src/circuit/testdata/circuit1_vectors.txt holds public synthetic vectors, written by src/circuit/testdata/generate_circuit_vectors.py, which uses only Python’s hashlib and hmac and the cryptography package’s X25519 and AES-GCM. They cover cell and relay message encodings, the BEGIN destination forms, the transcript, the key schedule, the confirmation, the signed bytes, the layer nonces and a three-hop cell in both directions layer by layer. Large values are given by their SHA-256. X25519 uses the key pair of RFC 7748 section 6.1. The file also lists negative vectors: a valid cell or message with one mutation and the error its decoder must report.

yume_circuit1_codec_test checks the encodings and negative vectors, and yume_circuit_crypto_test checks the constructions against the same file and runs real handshakes with freshly generated keys, including mutated and stripped signatures, ciphertexts, depths and predecessors. ML-KEM and the signatures have no vectors here, because their keys are random.

Limits

  • Timing is not hidden. An observer of both the client’s session and the exit’s connections can match them, and nodes forward cells without delay.
  • The entry learns how many hops a circuit has from its layer count and from the rounds of the build. Middle and exit learn nothing new from theirs with at most three hops.
  • The largest buckets fit YTP/1’s H2-duplex carrier framing. A carrier with other framing still carries valid cells, but a full cell would then no longer fill exactly one TLS record.
  • Layer tags cost 16 bytes per hop. A length-preserving layer would hide the layer count but lets a malicious entry mark cells for a colluding exit, and the construction that avoids that is not available in OpenSSL.
  • This page defines no route choice, bounds, consent or exits. The node service’s bounds and exits, and the client’s route choice and consent, are in cluster 1.