2026-05-10 22:42:45 +02:00

1744 lines
52 KiB
Markdown

# SURBs in the libp2p MIX Implementation
This document is a deeper treatment of Single-Use Reply Blocks (SURBs) in the
vendored libp2p MIX implementation at
`nimbledeps/pkgs2/libp2p-*/libp2p/protocols/mix`.
It maps the implementation to the MIX spec, with special attention to the newer
SURB section in pull request 307:
- Published MIX LIP: https://lip.logos.co/ift-ts/raw/mix.html
- Pull request 307: https://github.com/logos-co/logos-lips/pull/307
- Pull request snapshot, Section 8.7: https://github.com/logos-co/logos-lips/blob/bfd845f11c5ee4edc1d425c7c4a2b941285fd9a3/docs/ift-ts/raw/mix.md#87-single-use-reply-blocks
The published LIP still contains older wording saying reply support is not
implemented yet. The pull request snapshot adds Section 8.7, which describes
SURB creation, use, reply processing, and reply recovery. This document compares
that newer SURB section with the current Nim implementation.
## High-Level Model
A SURB lets the destination side send a reply without learning the sender's
identity or return path. The sender precomputes a return-path Sphinx header,
embeds it in the forward message, and keeps the corresponding decryption
material locally. The exit node later uses the embedded SURB to package the
destination response as a MIX reply.
The current implementation uses SURBs for request/response flows such as Ping:
```text
sender
builds forward Sphinx packet
embeds N SURBs in the encrypted forward payload
stores SURB id -> reply credentials + incoming queue
|
v
mix path to exit
|
v
exit node
extracts SURBs
forwards request to destination protocol
reads destination response
sends the same response through each supplied SURB
|
v
return mix path(s)
|
v
sender
accepts first valid SURB reply
deletes credentials for all SURBs from that request
writes one recovered response into MixEntryConnection's incoming queue
```
The important operational point is that `numSurbs > 1` does not mean the
application receives multiple responses. In this implementation it means the
same response is sent over multiple independent return paths, and the first valid
reply wins.
## Relevant Types and Constants
The wire structures live mostly in `serialization.nim`.
```nim
const
k* = 16
r* = 5
t* = 6
AlphaSize* = 32
BetaSize* = ((r * (t + 1)) + 1) * k
GammaSize* = 16
HeaderSize* = AlphaSize + BetaSize + GammaSize
DelaySize* = 2
AddrSize* = (t * k) - DelaySize
PacketSize* = 4608
MessageSize* = PacketSize - HeaderSize - k
PayloadSize* = MessageSize + k
SurbSize* = HeaderSize + AddrSize + k
SurbLenSize* = 1
SurbIdLen* = k
```
With the current parameters:
- `HeaderSize = 624`
- `DelaySize = 2`
- `AddrSize = 94`
- `SurbSize = 734`
- `MessageSize = 3968`
- `PayloadSize = 3984`
`t * k` is the combined per-hop routing block space for address plus delay:
`6 * 16 = 96` bytes. The implementation reserves `DelaySize = 2` bytes for the
encoded per-hop delay, leaving `AddrSize = 94` bytes for the serialized hop
address.
That matches the pull request 307 Section 8.7.1 structure:
```text
SURB = hop_0 || header || reply_key
= 94 || 624 || 16
= 734 bytes
```
The implementation type is:
```nim
type Hop* = object
MultiAddress: seq[byte]
type
Secret* = seq[seq[byte]]
Key* = seq[byte]
SURBIdentifier* = array[SurbIdLen, byte]
SURB* = object
hop*: Hop
header*: Header
key*: Key
secret*: Opt[Secret]
```
`Hop` is the fixed-size routing address container used both for normal next-hop
routing and for `hop_0` in a SURB. Its serialized form is always `AddrSize`
bytes:
```nim
proc serialize*(hop: Hop): seq[byte] =
if hop.MultiAddress.len == 0:
return newSeq[byte](AddrSize)
doAssert len(hop.MultiAddress) == AddrSize
return hop.MultiAddress
```
An empty `Hop()` serializes as 94 zero bytes. The implementation uses that in
SURB construction for the final return-path routing block: zero address plus
zero delay means "this is not a normal forward destination"; the nonzero SURB
identifier placed after that block marks it as a reply.
Field mapping:
- `hop`: the spec term `hop_0`, the first hop on the return path.
- `header`: the spec tuple `(alpha_0, beta_0, gamma_0)`, the precomputed
Sphinx header.
- `key`: the spec term `k_tilde`, the reply key.
- `secret`: local-only per-hop shared secrets `s_0 ... s_{L-1}`. This should
not be distributed with the SURB.
The serialized SURB embedded into the forward message deliberately omits
`secret`:
```nim
let surbBytes =
surbs.mapIt(it.hop.serialize() & it.header.serialize() & it.key).concat()
```
## Where the Reply Queue Fits
The reply queue is not part of the Sphinx/SURB spec. It is an implementation
adapter that lets a `MixEntryConnection` look like a normal libp2p `Connection`
to client-side protocol code.
`MixEntryConnection` has:
```nim
type MixEntryConnection* = ref object of Connection
incoming: AsyncQueue[seq[byte]]
incomingFut: Future[void]
replyReceivedFut: Future[void]
cached: seq[byte]
```
When `expectReply = true`, construction creates the queue and starts one
background future:
```nim
instance.incoming = newAsyncQueue[seq[byte]]()
instance.replyReceivedFut = newFuture[void]()
let checkForIncoming = proc(): Future[void] {.async.} =
instance.cached = await instance.incoming.get()
instance.replyReceivedFut.complete()
instance.incomingFut = checkForIncoming()
```
When client code later reads from the connection, `readOnce` waits for
`replyReceivedFut` and then serves bytes from `cached`:
```nim
if s.cached.len == 0:
await s.replyReceivedFut
let toRead = min(nbytes, s.cached.len)
copyMem(pbytes, addr s.cached[0], toRead)
s.cached = s.cached[toRead ..^ 1]
```
The queue is populated by the sender-side `Reply` branch in
`handleMixMessages`, after the returned SURB packet has been recovered:
```nim
await connCred.incoming.put(deserialized.message)
```
So the reply queue is the bridge between:
- MIX/SURB packet processing, which eventually recovers `seq[byte]`; and
- libp2p protocol code, which expects to read those bytes from a `Connection`.
Current limitation: this is shaped for a single request/response. One
`replyReceivedFut` is completed once, and the first successful reply fills
`cached`. It is not a general multi-response stream abstraction.
## Sender-Side SURB Creation
SURB creation begins during a normal MIX send. `MixEntryConnection.write` calls:
```nim
let sendRes = await srcMix.anonymizeLocalProtocolSend(
instance.incoming, msg, codec, dest, numSurbs
)
```
Inside `anonymizeLocalProtocolSend`, after the forward path has selected an exit
node, the message is augmented with SURBs:
```nim
let msgWithSurbs = mixProto.prepareMsgWithSurbs(
incoming, msg, numSurbs, destination.peerId, exitPeerId
)
```
`prepareMsgWithSurbs` calls `buildSurbs`, then serializes them before the
application message:
```nim
proc prepareMsgWithSurbs(
mixProto: MixProtocol,
incoming: AsyncQueue[seq[byte]],
msg: seq[byte],
numSurbs: uint8 = 0,
destPeerId: PeerId,
exitPeerId: PeerId,
): Result[seq[byte], string] =
let surbs =
mixProto.buildSurbs(incoming, numSurbs, destPeerId, exitPeerId).valueOr:
return err(error)
serializeMessageWithSURBs(msg, surbs)
```
### `buildSurbs`: Outer Loop and Local State
`buildSurbs` is the outer loop. It creates `numSurbs` independent SURBs for this
one outgoing request. It is also where `buildSurb` is called:
```nim
proc buildSurbs(
mixProto: MixProtocol,
incoming: AsyncQueue[seq[byte]],
numSurbs: uint8,
destPeerId: PeerId,
exitPeerId: PeerId,
): Result[seq[SURB], string] =
var response: seq[SURB]
var igroup = SURBIdentifierGroup(members: initHashSet[SURBIdentifier]())
for _ in 0.uint8 ..< numSurbs:
var id: SURBIdentifier
hmacDrbgGenerate(mixProto.rng[], id)
let surb = ?mixProto.buildSurb(id, destPeerId, exitPeerId)
igroup.members.incl(id)
mixProto.connCreds[id] = ConnCreds(
igroup: igroup,
surbSecret: surb.secret.get(),
surbKey: surb.key,
incoming: incoming,
)
response.add(surb)
return ok(response)
```
In that loop:
- `id` is the random SURB identifier later embedded in the return-path header.
- `buildSurb(id, destPeerId, exitPeerId)` builds one complete return-path SURB.
- `connCreds[id]` stores the local-only recovery material for that SURB.
- `igroup` groups all SURB identifiers created for this request, so the first
valid reply can consume the whole group.
- `response` is the list of distributable SURBs that will be serialized into the
forward request payload.
The serialized layout is:
```text
num_surbs: 1 byte
SURB[0]: hop || header || key
SURB[1]: hop || header || key
...
application_message
```
This means even messages with no replies still carry a one-byte SURB count:
```text
0x00 || application_message
```
### Identifier and Reply Key
For each SURB, `buildSurbs` samples a distinct identifier before calling
`buildSurb`:
```nim
var id: SURBIdentifier
hmacDrbgGenerate(mixProto.rng[], id)
let surb = ?mixProto.buildSurb(id, destPeerId, exitPeerId)
```
That `id` is embedded into the terminal routing block of the SURB return path.
When a reply returns, the original sender extracts this identifier and uses it
to look up the stored recovery credentials in `connCreds`.
`createSURB`, which is called from inside `buildSurb`, samples the reply key:
```nim
var key = newSeqUninit[byte](k)
rng[].generate(key)
```
This maps to pull request 307 Section 8.7.2 Step 2: sample a unique SURB
identifier `id` and a reply key `k_tilde`.
There is no explicit collision check against existing `connCreds`; uniqueness is
probabilistic from the 16-byte random identifier.
### `buildSurb`: Return Path Selection
`buildSurb` constructs one return path per SURB and returns the distributable
SURB object:
```nim
method buildSurb*(
mixProto: MixProtocol,
id: SURBIdentifier,
destPeerId: PeerId,
exitPeerId: PeerId
): Result[SURB, string]
```
Inside `buildSurb`, the implementation accumulates three aligned arrays:
```nim
var
publicKeys: seq[FieldElement] = @[]
hops: seq[Hop] = @[]
delay: seq[seq[byte]] = @[]
```
Those arrays are then passed to `createSURB(publicKeys, delay, hops, id)`, which
constructs the Sphinx return-path header and reply key.
It excludes the forward-path exit and destination from the random part of the
return path:
```nim
var pubNodeInfoKeys =
mixProto.nodePool.peerIds().filterIt(it != exitPeerId and it != destPeerId)
```
Then it selects `PathLength` hops. The loop index `i` is the return-path hop
index. With the current `PathLength = 3`, `i = 0` and `i = 1` are random
intermediate return hops, while `i = 2` is the final hop. For all but the last
hop, it picks random public mix nodes from the filtered pool. This branch does
not run for the final hop:
```nim
if i < PathLength - 1:
let randomIndexPosition = cryptoRandomInt(mixProto.rng, availableIndices.len).valueOr:
return err("failed to generate random num: " & error)
let selectedIndex = availableIndices[randomIndexPosition]
let randPeerId = pubNodeInfoKeys[selectedIndex]
availableIndices.del(randomIndexPosition)
let mixPubInfo = mixProto.nodePool.get(randPeerId)
(
mixPubInfo.peerId,
mixPubInfo.multiAddr,
mixPubInfo.mixPubKey,
mixProto.delayStrategy.generateForEntry(),
)
```
There are two index layers here. `pubNodeInfoKeys` stores the candidate peer IDs.
`availableIndices` stores the indices in `pubNodeInfoKeys` that have not yet
been selected for this return path. `cryptoRandomInt` chooses a random position
inside `availableIndices`; the value at that position is then used as the index
into `pubNodeInfoKeys`.
For example:
```text
pubNodeInfoKeys = [peerA, peerB, peerC, peerD]
availableIndices = [0, 1, 2, 3]
randomIndexPosition = 2
selectedIndex = availableIndices[2] = 2
randPeerId = pubNodeInfoKeys[2] = peerC
```
After selecting `peerC`, the code removes that entry from `availableIndices`:
```nim
availableIndices.del(randomIndexPosition)
```
That prevents the same mix node from being selected twice in one SURB return
path. The tuple returned for a selected hop contains the libp2p peer ID,
dialable multiaddress, MIX public key used for Sphinx header construction, and
the encoded delay value for that hop.
The last hop is the original sender's own mix node:
```nim
else:
(
mixProto.mixNodeInfo.peerId,
mixProto.mixNodeInfo.multiAddr,
mixProto.mixNodeInfo.mixPubKey,
0.uint16,
)
```
This `else` branch is reached only when `i == PathLength - 1`. With
`PathLength = 3`, that means `i == 2`. So the last return-path hop is not random:
it is forcibly set to `mixProto.mixNodeInfo`, the local mix node that originally
created the SURB. In this example:
```text
i = 0 random mix node
i = 1 random mix node
i = 2 original sender's own mix node
```
The phrase "original sender" here means the node that created the SURB and sent
the original forward request. It is also the final recipient of the SURB reply,
because only that node has the stored `surbKey` and `surbSecret` needed to
recover the reply payload.
This maps to pull request 307 Section 8.7.2 Step 1: the initiating node selects
a return path with itself as the final hop and computes ephemeral secrets for
that path.
### Header Construction
The cryptographic construction is in `sphinx.nim`. The inputs collected during
return-path selection are:
- `publicKeys`: the MIX public keys for the selected return-path hops, in path
order. These are not libp2p identity keys. They are Curve25519/Sphinx keys
used to derive one shared secret per hop.
- `hops`: the serialized routing addresses for the same return-path hops.
- `delay`: the encoded delay value for each hop.
- `id`: the SURB identifier that the sender will later use to find the stored
reply recovery keys.
`buildSurb` fills `publicKeys`, `hops`, and `delay` together while iterating
over the selected return-path nodes. The relevant section is:
```nim
for i in 0 ..< PathLength:
let (peerId, multiAddr, mixPubKey, delayMillisec) =
if i < PathLength - 1:
let randomIndexPosition = cryptoRandomInt(mixProto.rng, availableIndices.len).valueOr:
return err("failed to generate random num: " & error)
let selectedIndex = availableIndices[randomIndexPosition]
let randPeerId = pubNodeInfoKeys[selectedIndex]
availableIndices.del(randomIndexPosition)
let mixPubInfo = mixProto.nodePool.get(randPeerId).valueOr:
return err("could not get mix pub info for peer: " & $randPeerId)
(
mixPubInfo.peerId,
mixPubInfo.multiAddr,
mixPubInfo.mixPubKey,
mixProto.delayStrategy.generateForEntry(),
)
else:
(
mixProto.mixNodeInfo.peerId,
mixProto.mixNodeInfo.multiAddr,
mixProto.mixNodeInfo.mixPubKey,
0.uint16,
)
publicKeys.add(mixPubKey)
let multiAddrBytes = multiAddrToBytes(peerId, multiAddr).valueOr:
return err("failed to convert multiaddress to bytes: " & error)
hops.add(Hop.init(multiAddrBytes))
delay.add(@(delayMillisec.uint16.toBytesBE()))
```
Each line feeds a different part of Sphinx construction:
- `publicKeys.add(mixPubKey)` stores the hop's MIX public key. Later,
`computeAlpha(publicKeys)` uses these public keys to derive one shared secret
per return-path hop.
- `multiAddrToBytes(peerId, multiAddr)` converts the hop's libp2p identity and
dialable address into the fixed 94-byte `Hop` address format. Later,
`computeBetaGamma` encrypts these hop addresses into the routing header.
- `delay.add(@(delayMillisec.uint16.toBytesBE()))` stores the hop delay as two
big-endian bytes, matching `DelaySize = 2`. Later, each return-path hop
decrypts its own delay value from Beta and applies it before forwarding.
The ordering matters. `publicKeys[i]`, `hops[i]`, and `delay[i]` must describe
the same return-path hop at index `i`; otherwise the encrypted routing header
would pair the wrong key, address, or delay with a hop.
At the end of `buildSurb`, those aligned arrays are handed to `createSURB`:
```nim
return createSURB(publicKeys, delay, hops, id)
```
`createSURB` computes the return-path `alpha_0`, per-hop secrets, `beta_0`, and
`gamma_0`, samples the reply key, and returns the final SURB object. The relevant
part is:
```nim
proc createSURB*(
publicKeys: openArray[FieldElement],
delay: openArray[seq[byte]],
hops: openArray[Hop],
id: SURBIdentifier,
rng: ref HmacDrbgContext = newRng(),
): Result[SURB, string] =
if id == default(SURBIdentifier):
return err("id should be initialized")
let (alpha_0, s) = computeAlpha(publicKeys).valueOr:
return err("Error in alpha generation: " & error)
let (beta_0, gamma_0) = computeBetaGamma(s, hops, delay, Hop(), id).valueOr:
return err("Error in beta and gamma generation: " & error)
var key = newSeqUninit[byte](k)
rng[].generate(key)
return ok(
SURB(
hop: hops[0],
header: Header.init(alpha_0, beta_0, gamma_0),
secret: Opt.some(s),
key: key,
)
)
```
This is where the distributable SURB fields are assembled:
- `hop: hops[0]` is `hop_0`, the first return-path hop the exit should send to.
- `header: Header.init(alpha_0, beta_0, gamma_0)` is the precomputed Sphinx
header for the return path.
- `key` is the reply key `k_tilde`, distributed with the SURB so the exit can
encrypt the reply payload.
- `secret: Opt.some(s)` is local recovery material. It is stored in
`connCreds` by `buildSurbs`, but is not serialized into the SURB sent to the
exit.
#### Alpha and Per-Hop Secrets
`computeAlpha(publicKeys)` creates the initial Sphinx ephemeral public value and
the shared secret for each return-path hop:
```nim
var
s: seq[seq[byte]] = newSeq[seq[byte]](publicKeys.len)
alpha_0: seq[byte]
alpha: FieldElement
secret: FieldElement
blinders: seq[FieldElement] = @[]
let x = generateRandomFieldElement()
blinders.add(x)
for i in 0 ..< publicKeys.len:
if i == 0:
alpha = multiplyBasePointWithScalars([blinders[i]])
alpha_0 = fieldElementToBytes(alpha)
else:
alpha = multiplyPointWithScalars(alpha, [blinders[i]])
secret = multiplyPointWithScalars(publicKeys[i], blinders)
let blinder = bytesToFieldElement(
sha256_hash(fieldElementToBytes(alpha) & fieldElementToBytes(secret))
)
blinders.add(blinder)
s[i] = fieldElementToBytes(secret)
```
Conceptually:
- The sender samples an ephemeral scalar `x`.
- `blinders` is the blinding chain. It starts with `x`; after each hop, the code
derives a new blinder from that hop's current `alpha` and shared secret.
- `alpha_0 = x * G` is the initial public value placed in the SURB header.
`multiplyBasePointWithScalars([x])` computes this by multiplying the
Curve25519 base point `G` by the scalar `x`. In the helper implementation,
this is done by calling `public(x)`, because a Curve25519 public key is the
base point multiplied by the private scalar.
- For every return-path hop, the sender derives a shared secret from that hop's
MIX public key and the current blinding chain.
- The next `alpha` value is produced by multiplying the previous `alpha` by the
next blinder. This lets every hop see a fresh-looking `alpha` while still
allowing the sender to precompute all per-hop secrets.
- Each hop will later derive the same secret from its private key and the `alpha`
value it receives while processing the Sphinx packet.
- The secrets `s[0] ... s[L-1]` are kept locally in `surb.secret` so the sender
can recover the reply when the return packet reaches it.
#### Beta and Gamma
The SURB section of the spec indexes the return path in the direction the reply
will travel:
```text
i = 0 first return-path hop, the node the exit sends to
i = sLen - 1 terminal return-path hop, the original sender
```
This matches pull request 307 Section 8.7.2, where `hop_0` is the first hop on
the return path and the initiating node is the final hop.
The implementation uses the same index convention in its arrays:
```text
hops[0], publicKeys[0], delay[0], s[0] -> hop_0
hops[1], publicKeys[1], delay[1], s[1] -> hop_1
hops[sLen - 1], publicKeys[sLen - 1], delay[sLen - 1] -> hop_{L-1}
```
So `sLen - 1` does not mean `hop_0`; it means `hop_{L-1}`. In the SURB return
path constructed above, `hop_{L-1}` is the original sender's own mix node,
because `buildSurb` explicitly put `mixProto.mixNodeInfo` in the final hop slot.
Equivalently, for `PathLength = 3`:
```text
s[0] -> hop_0, first return hop
s[1] -> hop_1, second return hop
s[2] -> hop_2, terminal return hop/original sender
```
The countdown loop below changes construction order only. It does not change
which hop each `s[i]` belongs to.
There is only one `computeBetaGamma` routine. This is its full signature:
```nim
proc computeBetaGamma(
s: seq[seq[byte]],
hops: openArray[Hop],
delay: openArray[seq[byte]],
destHop: Hop,
id: SURBIdentifier,
): Result[tuple[beta: seq[byte], gamma: seq[byte]], string] =
```
The forward path and the SURB return path both call this same function. The
difference is only in the argument values passed to `hops`, `destHop`, and `id`.
The implementation then iterates over the path indices from high to low:
```nim
proc computeBetaGamma(
s: seq[seq[byte]],
hops: openArray[Hop],
delay: openArray[seq[byte]],
destHop: Hop,
id: SURBIdentifier,
): Result[tuple[beta: seq[byte], gamma: seq[byte]], string] =
let sLen = s.len
var
beta: seq[byte]
gamma: seq[byte]
let filler = computeFillerStrings(s).valueOr:
return err("Error in filler generation: " & error)
for i in countdown(sLen - 1, 0):
let
beta_aes_key = deriveKeyMaterial("aes_key", s[i]).kdf()
mac_key = deriveKeyMaterial("mac_key", s[i]).kdf()
beta_iv = deriveKeyMaterial("iv", s[i]).kdf()
if i == sLen - 1:
let destBytes = destHop.serialize()
let destPadding = destBytes & delay[i] & @id & newSeq[byte](PaddingLength)
let aes = aes_ctr(beta_aes_key, beta_iv, destPadding)
beta = aes & filler
else:
let betaPrefix =
beta[0 .. (((r * (t + 1)) - t) * k) - 1]
let routingInfo = RoutingInfo.init(
hops[i + 1],
delay[i],
gamma,
betaPrefix,
)
let serializedRoutingInfo = routingInfo.serialize()
beta = aes_ctr(beta_aes_key, beta_iv, serializedRoutingInfo)
gamma = hmac(mac_key, beta).toSeq()
return ok((beta: beta, gamma: gamma))
```
The same `computeBetaGamma` routine is used for both normal forward packets and
SURB return-path headers. The difference is entirely in the arguments.
For a normal forward message, `wrapInSphinxPacket` calls:
```nim
let (beta_0, gamma_0) = computeBetaGamma(
s,
hop,
delay,
destHop,
default(SURBIdentifier),
)
```
For a SURB, `createSURB` calls:
```nim
let (beta_0, gamma_0) = computeBetaGamma(
s,
hops,
delay,
Hop(),
id,
)
```
The argument differences are:
| Argument | Forward message | SURB return path |
| --- | --- | --- |
| `hops` | Forward mix path: first mix hop, intermediate hops, final exit hop. | Return mix path: first return hop, intermediate return hops, original sender as final hop. |
| `destHop` | Real destination address, encoded as a `Hop`. The forward-path exit uses it to dial the destination protocol. | Empty `Hop()`, which serializes as zero address bytes. This marks that the terminal return hop is not forwarding to another destination. |
| `id` | `default(SURBIdentifier)`, all zero bytes. | Random nonzero SURB identifier generated by `buildSurbs`. The original sender uses it to find `connCreds`. |
For `PathLength = 3`, the final routing block produced by the same
`if i == sLen - 1` branch therefore differs like this:
```text
Forward message final block:
destHop = destination address
delay = 0
id = 0^16
meaning = normal exit; forward plaintext to destination
SURB return final block:
destHop = zero Hop()
delay = 0
id = random SURB id
meaning = reply arrived at original sender; recover using connCreds[id]
```
So there is no separate "forward Beta" and "SURB Beta" algorithm. There is one
Beta/Gamma algorithm with two terminal-block encodings.
`beta` and `gamma` are mutable byte sequences. During the countdown loop, they
always hold the header state for the hop that was just constructed. For a
3-hop path, after the first iteration they hold `beta_2/gamma_2`; after the
second iteration they hold `beta_1/gamma_1`; after the final iteration they hold
`beta_0/gamma_0`, which is what goes into the SURB header.
#### Filler
`filler` is precomputed before the Beta/Gamma countdown loop:
```nim
proc computeFillerStrings(s: seq[seq[byte]]): Result[seq[byte], string] =
var filler: seq[byte] = @[]
for i in 1 ..< s.len:
let
aes_key = deriveKeyMaterial("aes_key", s[i - 1]).kdf()
iv = deriveKeyMaterial("iv", s[i - 1]).kdf()
let
fillerLength = (t + 1) * k
zeroPadding = newSeq[byte](fillerLength)
filler = aes_ctr_start_index(
aes_key,
iv,
filler & zeroPadding,
(((t + 1) * (r - i)) + t + 2) * k,
)
return ok(filler)
```
The hard part is why this exists. Each hop decrypts and shifts the routing
header state as the packet moves forward. Without filler, the end of Beta would
gradually become distinguishable padding, and a hop could infer information
about its position in the path or the remaining path length. Filler precomputes
the bytes that must be appended at the tail so that, after each hop's Sphinx
transformation, the Beta field still has the right fixed-size shape and does not
reveal where the packet is in the path.
The call to `aes_ctr_start_index` is what makes the filler line up with the tail
of the conceptual full Beta stream:
```nim
filler = aes_ctr_start_index(
aes_key,
iv,
filler & zeroPadding,
(((t + 1) * (r - i)) + t + 2) * k,
)
```
`filler & zeroPadding` extends the previous filler by one routing block:
```text
(t + 1) * k = (6 + 1) * 16 = 112 bytes
```
`aes_ctr_start_index` encrypts that data as if it started at byte offset
`startIndex` in a larger AES-CTR stream. This matters because filler bytes live
at the tail of Beta, not at byte offset 0. AES-CTR keystream bytes depend on
position, so filler must be encrypted with the keystream positions where those
bytes will sit inside the full Beta field.
For the current constants, the filler loop runs twice for `PathLength = 3`:
```text
i = 1:
startIndex = (((7) * (5 - 1)) + 8) * 16
= 576
input length = 112 bytes
i = 2:
startIndex = (((7) * (5 - 2)) + 8) * 16
= 464
input length = 224 bytes
```
So the final filler is 224 bytes. Conceptually, it is pre-encrypted tail data
that will remain well-formed after earlier hops append zero padding and decrypt
their Beta layers.
In this implementation, filler is only appended when constructing the terminal
routing block:
```nim
if i == sLen - 1:
...
beta = aes & filler
```
That may look surprising, but remember the countdown loop builds nested header
state. The terminal block is built first and becomes the innermost state carried
forward into `beta_{L-2}`, `beta_{L-3}`, and eventually `beta_0`. Appending
filler there prepares the tail bytes that will be needed after later hops peel
their layers.
The loop in `computeFillerStrings` starts at `i = 1` because no filler is needed
before the first hop has processed anything. The expression `s[i - 1]` therefore
starts with `s[0]`, which is the secret for `hop_0`, the first return hop. It
does not start with the original sender. The filler code uses each earlier
travel-order hop's `aes_key`/`iv` to predict how zero padding at the tail will
transform as those earlier layers are processed.
That is the end of the filler-specific part. The next paragraphs return to the
general Beta/Gamma construction flow.
This countdown is construction order, not path-travel order. It is needed
because each earlier hop's routing block contains encrypted information for the
next hop. For a 3-hop return path:
```text
hop 0 -> hop 1 -> hop 2/original sender
```
the header has to be prepared in this dependency order:
```text
1. build beta_2/gamma_2 for the original sender
2. build beta_1/gamma_1, embedding gamma_2 and part of beta_2
3. build beta_0/gamma_0, embedding gamma_1 and part of beta_1
```
The exit receives only `beta_0/gamma_0` in the SURB header. Hop 0 decrypts its
routing block and learns how to forward to hop 1, including the `beta_1/gamma_1`
state that hop 1 will verify next. Hop 1 does the same for hop 2. That is the
reason the construction loop counts down from the original sender back to the
first return hop.
#### Digression: Why Build `beta_2` Before `beta_0`?
For both normal forward messages and SURB return messages, the path is indexed
in travel order:
```text
hop_0 -> hop_1 -> hop_2
```
For a normal forward message:
```text
sender -> hop_0 -> hop_1 -> hop_2/exit -> destination
```
For a SURB reply:
```text
exit -> hop_0 -> hop_1 -> hop_2/original sender
```
In both cases, the packet is processed in travel order:
```text
hop_0 processes beta_0/gamma_0
hop_1 processes beta_1/gamma_1
hop_2 processes beta_2/gamma_2
```
But the header must be constructed in the opposite order because each earlier
hop's routing block embeds the next hop's header state:
```text
beta_0 contains next-hop info for hop_1 plus beta_1/gamma_1
beta_1 contains next-hop info for hop_2 plus beta_2/gamma_2
beta_2 contains the final instruction
```
So `beta_0` cannot be built until `beta_1/gamma_1` exists, and `beta_1` cannot
be built until `beta_2/gamma_2` exists. The construction dependency is:
```text
1. build beta_2/gamma_2
2. use those to build beta_1/gamma_1
3. use those to build beta_0/gamma_0
```
The packet/SURB then carries `beta_0/gamma_0` as the starting header state. At
runtime, each hop decrypts its own layer and reveals the next header state:
```text
construction: beta_2 -> beta_1 -> beta_0
processing: beta_0 -> beta_1 -> beta_2
```
For SURBs, the final instruction in `beta_2` is special: it contains zero
address/delay plus the SURB identifier. For normal forward messages, the final
instruction contains the destination address.
Returning to the SURB header construction code, each iteration of
`computeBetaGamma` derives the keys needed to encrypt and authenticate one hop's
routing block.
For each hop secret `s[i]`, the code derives:
- `beta_aes_key`: encrypts the routing block for that hop.
- `beta_iv`: IV for that routing-block encryption.
- `mac_key`: authenticates the resulting `beta`.
For hops where `i < sLen - 1`, meaning every return-path hop before the original
sender, the routing block contains the next hop's address, this hop's delay, the
next hop's `gamma`, and the next encrypted `beta` prefix:
```nim
let betaPrefix =
beta[0 .. (((r * (t + 1)) - t) * k) - 1]
let routingInfo = RoutingInfo.init(
hops[i + 1],
delay[i],
gamma,
betaPrefix,
)
let serializedRoutingInfo = routingInfo.serialize()
beta = aes_ctr(beta_aes_key, beta_iv, serializedRoutingInfo)
```
That is the onion-routing part of the header: when hop `i` processes the packet,
it can decrypt only its own routing block and learn only the next hop, delay,
next MAC, and next encrypted header state.
The `betaPrefix` slice is the part of the already-built next-hop `beta` that
fits into a `RoutingInfo` block. The formula is:
```text
(((r * (t + 1)) - t) * k)
```
With the current constants:
```text
r = 5
t = 6
k = 16
((5 * (6 + 1)) - 6) * 16
= ((5 * 7) - 6) * 16
= (35 - 6) * 16
= 29 * 16
= 464 bytes
```
That matches the `RoutingInfo.serialize()` layout:
```text
Addr = 94 bytes
Delay = 2 bytes
Gamma = 16 bytes
Beta = 464 bytes
Total = 576 bytes
```
`RoutingInfo.serialize()` must produce exactly 576 bytes because the Sphinx
header format has a fixed-size Beta field. Each non-terminal hop encrypts one
serialized `RoutingInfo` block to produce the next `beta` value. Since address,
delay, and gamma consume 112 bytes, only 464 bytes are available for carrying
forward the next encrypted Beta state. The slice:
```nim
beta[0 .. 463]
```
keeps exactly that prefix.
There is an important distinction here:
```text
BetaSize = ((r * (t + 1)) + 1) * k = 576 bytes
betaPrefix size = ((r * (t + 1)) - t) * k = 464 bytes
```
So `(((r * (t + 1)) - t) * k)` is not the full Beta size in this
implementation. The full `beta` value is 576 bytes. The 464-byte slice is the
amount of the already-built next-hop Beta that can be embedded inside the
previous hop's `RoutingInfo`, because `RoutingInfo` must also include 112 bytes
of fresh routing data:
```text
next hop address 94 bytes
delay 2 bytes
next gamma 16 bytes
---------------------------
fresh routing 112 bytes
576-byte RoutingInfo - 112 fresh bytes = 464 bytes for betaPrefix
```
During packet processing, the receiving hop appends `(t + 1) * k = 112` zero
bytes before decrypting Beta:
```nim
let zeroPadding = newSeq[byte]((t + 1) * k)
let B = aes_ctr(beta_aes_key, beta_iv, beta & zeroPadding)
```
That is how the hop recovers a full routing block containing 112 bytes of fresh
routing data plus a restored 576-byte next-hop Beta state. The construction side
stores only a 464-byte prefix because the processing side later supplies the
extra 112 zero bytes before decryption.
For `i == sLen - 1`, meaning the terminal return-path hop and original sender,
the routing block is special:
The call passes `Hop()` as `destHop` and a non-default `id`. In
`computeBetaGamma`, the final routing block is built as:
```nim
let destBytes = destHop.serialize()
let destPadding = destBytes & delay[i] & @id & newSeq[byte](PaddingLength)
let aes = aes_ctr(beta_aes_key, beta_iv, destPadding)
beta = aes & filler
```
For a SURB, `destHop.serialize()` is all zeros and the final delay is zero.
That produces the pull request 307 Section 8.7.2 Step 3 shape:
```text
zero address/delay || SURB id || zero padding
```
This is how the original sender, when it becomes the final return-path hop, can
distinguish a SURB reply from a normal forward exit message.
After each final or non-final `beta` calculation, `gamma` is computed over that
`beta`:
```nim
gamma = hmac(mac_key, beta).toSeq()
```
The first values produced by the backwards loop, `beta_0` and `gamma_0`, are the
values placed in the distributed SURB header. Later return-path hops derive and
verify the next `gamma` values as they process the packet.
#### Hop-by-Hop Beta Example
For a concrete 3-hop SURB return path, the countdown loop behaves like this:
```text
return travel path: hop_0 -> hop_1 -> hop_2/original sender
construction loop: i = 2, then i = 1, then i = 0
```
At `i = 2`, the code builds the terminal block for the original sender:
```text
destPadding =
zero Hop() address 94 bytes
zero delay 2 bytes
SURB id 16 bytes
zero padding 240 bytes # PaddingLength
AES-CTR(key_2, iv_2, destPadding) = 352 bytes
filler = 224 bytes
beta_2 = 576 bytes
gamma_2 = HMAC(mac_key_2, beta_2)
```
Here `PaddingLength` is:
```text
(((t + 1) * (r - PathLength)) + 1) * k
= (((6 + 1) * (5 - 3)) + 1) * 16
= 240 bytes
```
At this point, the mutable `beta`/`gamma` variables hold `beta_2/gamma_2`.
At `i = 1`, the code builds routing instructions for `hop_1`. When `hop_1`
later processes the packet, it needs to learn how to forward to `hop_2` and what
header state `hop_2` should verify. So `RoutingInfo.init` is effectively:
```nim
let routingInfo = RoutingInfo.init(
hops[2], # address of hop_2/original sender
delay[1], # delay hop_1 should apply before forwarding
gamma_2, # MAC that hop_2 should verify
beta_2[0 .. 463],
)
```
`RoutingInfo.init` is just a structured container:
```nim
RoutingInfo(
Addr: hops[2],
Delay: delay[1],
Gamma: gamma_2,
Beta: beta_2[0 .. 463],
)
```
Then serialization lays those fields out in a fixed 576-byte block:
```text
hops[2] 94 bytes
delay[1] 2 bytes
gamma_2 16 bytes
beta_2 prefix 464 bytes
-------------------------
serialized 576 bytes
```
That block is encrypted to become `beta_1`, and then `gamma_1` is computed:
```text
beta_1 = AES-CTR(key_1, iv_1, serialized RoutingInfo for hop_1)
gamma_1 = HMAC(mac_key_1, beta_1)
```
At `i = 0`, the same pattern repeats for `hop_0`:
```nim
let routingInfo = RoutingInfo.init(
hops[1], # address of hop_1
delay[0], # delay hop_0 should apply before forwarding
gamma_1, # MAC that hop_1 should verify
beta_1[0 .. 463],
)
```
After encryption:
```text
beta_0 = AES-CTR(key_0, iv_0, serialized RoutingInfo for hop_0)
gamma_0 = HMAC(mac_key_0, beta_0)
```
The SURB distributed to the exit contains `hop_0` plus `alpha_0/beta_0/gamma_0`.
It does not contain `beta_1` or `beta_2` as separate fields. Those later header
states are nested inside `beta_0` through the encrypted `RoutingInfo` blocks.
#### Hop-by-Hop Processing Example
The processing side reverses the construction dependency. A return packet starts
at `hop_0` with:
```text
Header(alpha_0, beta_0, gamma_0)
Payload(delta_0)
```
At every non-terminal hop, `processSphinxPacket` verifies the current Gamma,
decrypts Beta with 112 bytes of appended zero padding, transforms Delta, and
creates the header for the next hop:
```nim
if hmac(mac_key, beta).toSeq() != gamma:
return InvalidMAC
let delta_prime = aes_ctr(delta_aes_key, delta_iv, payload)
let zeroPadding = newSeq[byte]((t + 1) * k)
let B = aes_ctr(beta_aes_key, beta_iv, beta & zeroPadding)
let routingInfo = RoutingInfo.deserialize(B)
let (address, delay, gamma_prime, beta_prime) =
routingInfo.getRoutingInfo()
let alpha_prime = multiplyPointWithScalars(alphaFE, [blinder])
let sphinxPkt = SphinxPacket.init(
Header.init(fieldElementToBytes(alpha_prime), beta_prime, gamma_prime),
delta_prime,
)
```
For the same 3-hop return path, `hop_0` receives `beta_0/gamma_0`:
```text
input at hop_0:
beta_0 576 bytes
gamma_0 16 bytes
processing:
B_0 = AES-CTR(key_0, iv_0, beta_0 || 112 zero bytes)
```
`B_0` decrypts to the routing block that was built for `hop_0`:
```text
B_0 =
hops[1] 94 bytes # next hop address
delay[0] 2 bytes # delay for hop_0
gamma_1 16 bytes # MAC for hop_1 to verify
beta_1 576 bytes # restored next-hop Beta
```
`RoutingInfo.deserialize(B_0)` returns:
```text
address = hops[1]
delay = delay[0]
gamma_prime = gamma_1
beta_prime = beta_1
```
Then `hop_0` forwards:
```text
Header(alpha_1, beta_1, gamma_1)
Payload(delta_1)
```
`hop_1` repeats the same process:
```text
B_1 = AES-CTR(key_1, iv_1, beta_1 || 112 zero bytes)
B_1 =
hops[2] 94 bytes
delay[1] 2 bytes
gamma_2 16 bytes
beta_2 576 bytes
```
Then `hop_1` forwards:
```text
Header(alpha_2, beta_2, gamma_2)
Payload(delta_2)
```
At `hop_2`, the original sender, the decrypted routing block is not parsed as a
normal `RoutingInfo`. Instead, the zero address/delay plus nonzero SURB
identifier tells the code this is a reply packet:
```text
B_2 =
zero address/delay 96 bytes
SURB id 16 bytes
zero padding ...
```
The sender extracts the SURB identifier and hands `delta_prime` to reply
recovery. That final payload is not application plaintext yet; it still needs
`processReply(surbKey, surbSecret, delta_prime)`.
### Local Credential Storage
`buildSurbs` stores local recovery state in `mixProto.connCreds`:
```nim
mixProto.connCreds[id] = ConnCreds(
igroup: igroup,
surbSecret: surb.secret.get(),
surbKey: surb.key,
incoming: incoming,
)
```
`ConnCreds` contains:
```nim
type
SURBIdentifierGroup = ref object
members: HashSet[SURBIdentifier]
ConnCreds = object
igroup: SURBIdentifierGroup
incoming: AsyncQueue[seq[byte]]
surbSecret: serialization.Secret
surbKey: serialization.Key
```
Mapping to pull request 307 Section 8.7.2 Step 4:
- `surbKey` is `k_tilde`.
- `surbSecret` is `s_0 ... s_{L-1}`.
- `connCreds[id]` is the local table indexed by SURB identifier.
- `incoming` is implementation-specific glue for waking the `Connection` reader.
The `SURBIdentifierGroup` is not described in the pull request spec. It is an
implementation policy for multiple SURBs attached to one request. All SURB IDs
created for that request share the same group object.
## Distribution to the Exit
Once SURBs are serialized into the forward payload, the whole message is encoded
as a normal `MixMessage`, padded, and wrapped in the forward Sphinx packet:
```nim
let message = buildMessage(msgWithSurbs, codec, mixProto.mixNodeInfo.peerId)
let sphinxPacket = wrapInSphinxPacket(message, publicKeys, delay, hop, destHop)
```
The exit node eventually decrypts the forward packet and extracts SURBs:
```nim
let deserialized = MixMessage.deserialize(unpaddedMsg)
let (surbs, message) = extractSURBs(deserialized.message)
await mixProto.exitLayer.onMessage(
deserialized.codec, message, processedSP.destination, surbs
)
```
`extractSURBs` reconstructs only the distributed part:
```nim
surbs[i].hop = ?Hop.deserialize(hopBytes)
surbs[i].header = ?Header.deserialize(headerBytes)
surbs[i].key = ?readBytes(offset, Opt.some(k))
```
The exit receives no `secret` field. That is correct: only the original sender
should know the per-hop return-path secrets needed for final reply recovery.
## Exit-Side Use of SURBs
The exit layer first forwards the request to the real destination protocol:
```nim
let destConn = await self.switch.dial(destPeerId, @[destAddr], codec)
await destConn.write(message)
```
If SURBs were attached, it reads a destination response using the registered
`DestReadBehavior`:
```nim
let rawResponse = await behavior.callback(destConn)
```
Then it calls:
```nim
await self.reply(surbs, response)
```
The current implementation sends the same response over every supplied SURB:
```nim
let respFuts = surbs.mapIt(self.onReplyDialer(it, msg))
await allFutures(respFuts)
```
This is an important behavioral choice. Pull request 307 Section 8.7.3 describes
how to use a SURB; it does not require using every SURB for the same response.
The Nim implementation currently treats multiple SURBs as redundant return paths
for one response.
Each individual SURB is used in `mix_protocol.reply`:
```nim
let (peerId, multiAddr) = surb.hop.get().bytesToMultiAddr()
let message = buildMessage(msg, "", peerId)
let sphinxPacket = useSURB(surb, message)
await mixProto.sendPacket(peerId, multiAddr, sphinxPacket, SendPacketLogConfig(logType: Reply))
```
`buildMessage(msg, "", peerId)` pads the reply as a normal `MessageChunk`
wrapped in a `MixMessage` with an empty codec. The empty codec is acceptable
because the reply is matched by SURB identifier rather than by application
protocol negotiation.
`useSURB` encrypts the reply payload once with the SURB reply key:
```nim
let delta_aes_key = deriveKeyMaterial("delta_aes_key", surb.key).kdf()
let delta_iv = deriveKeyMaterial("delta_iv", surb.key).kdf()
let serializedMsg = msg.serialize()
let delta = aes_ctr(delta_aes_key, delta_iv, serializedMsg)
SphinxPacket.init(surb.header, delta)
```
This maps to pull request 307 Section 8.7.3:
- prepare/pad the reply message;
- encrypt the payload with `k_tilde`;
- assemble a Sphinx packet with the SURB header;
- transmit it to `hop_0` over `/mix/1.0.0`.
## Return Path Processing
Return packets use the same `/mix/1.0.0` handler as forward packets.
Intermediate return-path nodes do not know they are forwarding a reply. They
perform ordinary Sphinx processing:
```nim
let processedSP = processSphinxPacket(...)
case processedSP.status
of Intermediate:
await mixProto.writeLp(nextPeerId, @[nextAddr], @[MixProtocolID], outgoingPacket)
```
Every intermediate hop decrypts one payload layer and forwards the transformed
packet. This is why pull request 307 Section 8.7.3 says the reply is initially encrypted
with `k_tilde`, and each return-path hop adds the corresponding Sphinx payload
transformation. The sender must later remove `L + 1` layers: one for `k_tilde`
and one for each return-path secret.
## Sender-Side Reply Detection
The original sender is the final hop of the return path. In
`processSphinxPacket`, after decrypting the final routing block `B`, the code
distinguishes normal forward exit from SURB reply:
```nim
if B.isZeros((t + 1) * k, ((t + 1) * k) + PaddingLength - 1):
let hop = Hop.deserialize(B[0 .. AddrSize - 1])
if B.isZeros(AddrSize, ((t + 1) * k) - 1):
# normal forward exit
elif B.isZeros(0, (t * k) - 1):
return ok(
ProcessedSphinxPacket(
status: Reply,
id: B.extractSurbId(),
delta_prime: delta_prime,
)
)
```
The SURB identifier is extracted at `t * k`, matching pull request 307 Section 8.7.4:
```nim
template extractSurbId(data: seq[byte]): SURBIdentifier =
const startIndex = t * k
const endIndex = startIndex + SurbIdLen - 1
var id: SURBIdentifier
copyMem(addr id[0], addr data[startIndex], SurbIdLen)
id
```
## Reply Recovery
The `Reply` branch in `handleMixMessages` performs implementation-level reply
recovery.
First it looks up the SURB identifier:
```nim
if not mixProto.connCreds.hasKey(processedSP.id):
mix_messages_error.inc(labelValues = ["Sender/Reply", "NO_CONN_FOUND"])
return
connCred = mixProto.connCreds[processedSP.id]
```
This maps to pull request 307 Section 8.7.5 Step 1: retrieve `k_tilde` and
`s_0 ... s_{L-1}` by `id`; if not found, discard.
Then it removes all payload encryption layers:
```nim
let reply = processReply(
connCred.surbKey, connCred.surbSecret, processedSP.delta_prime
)
```
`processReply` starts with `k_tilde`, then iterates over each stored return-path
secret:
```nim
var delta = delta_prime[0 ..^ 1]
var key_prime = key
for i in 0 .. s.len:
if i != 0:
key_prime = s[i - 1]
let delta_aes_key = deriveKeyMaterial("delta_aes_key", key_prime).kdf()
let delta_iv = deriveKeyMaterial("delta_iv", key_prime).kdf()
delta = aes_ctr(delta_aes_key, delta_iv, delta)
let deserializeMsg = Message.deserialize(delta)
```
This maps directly to pull request 307 Section 8.7.5 Step 2: decrypt with `k_tilde`, then
with `s_0 ... s_{L-1}`, then check/strip the leading `k` zero bytes through
`Message.deserialize`.
After that, the implementation deletes all credentials in the identifier group:
```nim
for id in connCred.igroup.members:
mixProto.connCreds.del(id)
```
This is the "first valid reply wins" policy. If a request had `numSurbs > 1`,
all those SURB IDs are in the same group. The first reply that can be recovered
causes all sibling SURB credentials to be removed.
Late replies for the same request are dropped because their identifiers no
longer exist:
```nim
if not mixProto.connCreds.hasKey(processedSP.id):
mix_messages_error.inc(labelValues = ["Sender/Reply", "NO_CONN_FOUND"])
return
```
Finally, the recovered reply is decoded and pushed to the queue used by
`MixEntryConnection.readOnce`:
```nim
let msgChunk = MessageChunk.deserialize(reply)
let unpaddedMsg = msgChunk.removePadding()
let deserialized = MixMessage.deserialize(unpaddedMsg)
await connCred.incoming.put(deserialized.message)
```
Only this first successfully recovered reply becomes visible to the application.
## `numSurbs > 1`
In the current implementation, multiple SURBs attached to one request behave as
redundant return paths:
1. The sender creates `numSurbs` distinct SURB identifiers.
2. Each SURB has its own return path, header, reply key, and stored recovery
credentials.
3. All identifiers are inserted into one `SURBIdentifierGroup`.
4. The exit sends the same destination response through all supplied SURBs.
5. The sender accepts the first valid reply to arrive.
6. The sender deletes credentials for every SURB in the group.
7. Late duplicate replies are discarded with `NO_CONN_FOUND`.
8. One response byte sequence is written to the reply queue.
This gives redundancy against return-path loss or delay, but it does not provide
multi-response semantics. If a future rewrite wants multiple distinct responses,
the grouping and `MixEntryConnection` read model need to change.
## Spec Mapping Summary
| Pull Request 307 Section | Spec Concept | Nim Implementation |
| --- | --- | --- |
| 8.7.1 | SURB is `hop_0`, header, reply key | `SURB(hop, header, key)` in `serialization.nim`; serialized as `hop || header || key` |
| 8.7.2 Step 1 | Select return path ending at sender | `MixProtocol.buildSurb`; last hop is `mixProto.mixNodeInfo` |
| 8.7.2 Step 2 | Sample `id` and `k_tilde` | `buildSurbs` samples `id`; `createSURB` samples `key` |
| 8.7.2 Step 3 | Header has zero address/delay plus `id` | `computeBetaGamma(..., Hop(), id)` |
| 8.7.2 Step 4 | Store recovery tuple by `id` | `mixProto.connCreds[id] = ConnCreds(...)` |
| 8.7.3 | Recipient uses SURB to reply | `ExitLayer.reply` -> `MixProtocol.reply` -> `useSURB` |
| 8.7.4 | Final return hop extracts `id` | `processSphinxPacket` returns `ProcessedSphinxPacket(status: Reply, id, delta_prime)` |
| 8.7.5 | Recover reply with stored keys | `processReply(surbKey, surbSecret, delta_prime)` |
## Rewrite-Relevant Observations
- The current model is single-request/single-visible-reply, even when multiple
SURBs are attached.
- Multiple SURBs are grouped and treated as redundant alternatives. First valid
reply consumes the whole group.
- The exit currently sends the same response through every SURB. That is a
policy choice in `ExitLayer.reply`, not an inherent requirement of the SURB
format.
- The reply queue is local connection glue, not part of the cryptographic spec.
- `MixEntryConnection` creates one future waiting for one incoming queue item.
It is not currently a robust stream abstraction for repeated request/reply
cycles over the same connection object.
- `connCreds` has a TODO in `MixProtocol`: credentials may need cleanup when a
response never arrives or the connection is closed.
- The spec says a SURB must be used at most once. The implementation enforces
sender-side single acceptance by deleting credentials, but the exit can still
attempt to use all SURBs it was given. The sender drops late duplicates.
- The implementation does not explicitly check SURB identifier collisions before
inserting into `connCreds`; it relies on 16 bytes of randomness.
- The implementation's application payload budget is smaller than raw
`MessageSize` because `MessageChunk` reserves two bytes for padding length and
four bytes for sequence number. Use `getMaxMessageSizeForCodec(codec,
numberOfSurbs)` when calculating usable application bytes.
## Appendix: Filler as Suffix Consistency
Another useful way to understand filler is as a suffix consistency mechanism.
This view focuses on what happens to the tail of Beta when a processing node
appends zero bytes before decrypting.
Let:
```text
q = (t + 1) * k = 112 bytes
BetaSize = 576 bytes
betaPrefix size = 464 bytes
```
When hop `i` processes a packet, it decrypts:
```text
beta_i || 0^112
```
The appended 112 zero bytes are not transmitted, but after AES-CTR they become
position-specific keystream bytes. For hop 0:
```text
tail_0 = KS_0[576..687]
```
For hop 0 to reconstruct a full `beta_1`, the missing suffix of `beta_1` must
equal that tail:
```text
beta_1[464..575] = tail_0
```
The first filler iteration computes exactly this:
```text
F1 = AES-CTR_0(start = 576, 0^112)
= KS_0[576..687]
```
For hop 1, the same problem appears one layer deeper. We need:
```text
beta_2[464..575] = KS_1[576..687]
```
But `beta_1[464..575]` is produced by encrypting `beta_2[352..463]` under hop
1's Beta key at positions `464..575`. To make hop 0's reconstructed
`beta_1[464..575]` equal `F1`, construction must choose:
```text
beta_2[352..463] XOR KS_1[464..575] = F1
```
So:
```text
beta_2[352..463] = F1 XOR KS_1[464..575]
```
The second filler iteration computes both required pieces at once:
```text
F2 = AES-CTR_1(start = 464, F1 || 0^112)
F2 =
(F1 XOR KS_1[464..575])
||
(0^112 XOR KS_1[576..687])
```
That final `F2` is appended to the terminal Beta block:
```text
beta_2 = AES-CTR_2(destPadding) || F2
```
So filler is not just "padding." It is precomputed tail material that makes the
suffix bytes created from appended zeros at one hop match the suffix bytes the
next Beta state needs.
Compactly:
```text
Construction:
precompute encrypted tail bytes as filler
Processing at each hop:
decrypt beta || 112 zero bytes
consume the first 112 bytes as routing info
carry the remaining 576 bytes forward as next beta
```