This is another document from the Mix Implementation series. In the other two notes:
you can find a high level overview I created interactively with OpenAI GPT-5.5. This note is fully hand-crafted though.
We also have a detailed illustrated example in Sphinx Header Processing Infographic with the web version available at https://link.excalidraw.com/readonly/6y7DRQlUhkkBMZScEONS.
In this note we are documenting how the Sphinx headers are created in our current MIX Protocol Implementation and how they are processed by the mix nodes in the reply path. Where handy, comments about forward processing are also included.
We are actually focusing on the
part of the header here, but I will keep use the word “header” where convenient.
The objective is to have something that illustrates the complex mechanics of maintaining the constant size of the
Notation
To create the examples that are visual and where you can “easily” see things happening, I introduced a couple of custom constructs.
[x ..< y] in Nim).
Now, to keep notation compact. Imagine we have a bit sequence of 0 of length 00000000 and a keystream
The result is a bit stream with the bit value
A sequence of XOR operations may be applied to a given bit sequence:
We denote this operation as:
In particular, if
where
Because the two
Sphinx Header
The Sphinx Header is defined as
is a (blinded) public key (group element; . is the (encrypted) routing information is a MAC (message authentication code)
Formal definition of
The intention of this document is, in the first place, to help understanding the implementation of the current MIX protocol. Thus, some things will be make bluntly concrete, e.g. sizes, and to find more details about them and about Mix protocol implementation in general, the reader should refer to libp2p MIX Architecture and API and Sphinx SURBs implementation in the libp2p MIX protocol. For brevity, in this document I will not repeat what is written there.
The MIX implementation uses the following constants:
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* = kRecall that k corresponds to the r is the maximum number of hopes supported and t*k (BetaSize - is given by formula ((r * (t + 1)) + 1) * k in the code snippet above. To decipher this formula let’s write it in a bit less compact form:
Knowing
But let’s focus back at
Constructing the filler
What may make construction of the filler hard to understand is that three things: construction of the filler, construction of the
The filler is pre-computed before the construction of successive
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)Let’s see how the filler will be created for a 4-hop Mix path.
As we see from the routine above for a 4-hop Mix path we will have 3 iterations (1 ..< s.len).
i = 1:
or to make it more explicit:
i = 2:
i = 3:
Constructing the header
Now that we have the filler constructed, let’s have an example of a step-by-step construction of the header.
Recall, here we will be moving backwards, and the corresponding routine is as follows:
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))We see that the filler is indeed pre-computed before the
Also recall, that the same routine is called for both forward and reply paths, the difference is in the routing information for the last hop.
For forward path the destHop is the real destination address, encoded as a Hop and id is set to default(SURBIdentifier), all zero bytes. The forward-path exit uses destHop to dial the destination protocol.
For the reply path the destHop is an empty Hop(), which serializes as zero address bytes. This marks that the terminal return hop is not forwarding to another destination. ‘id’ for the reply path is set to random nonzero SURB identifier generated by buildSurbs. The original sender uses it to find connCreds.
In what follows we use the reply path as an example (path length is
i = 3:
As indicated above, instead of
Now, PaddingLength) and the full filler (PaddingLength is the consequence of the fact, that the size of PaddingLength is:
or in a more generic form:
In our case PathLength = 4, thus,
Before encryption thus, we have:
and after encryption
Notice that in this first iteration only
i = 2:
From
and after encrypting
which after canceling out the outer filler encryption becomes:
i = 1:
Here again, before next level of encryption, we drop the last filler segment (
and after encrypting
which leaves us with:
i = 0: final construction step.
which after encryption
No fillers are left at this last step, thus nothing cancels out.
All those computations are happening in the entry layer of the node that wishes to use the MIX network for anonymous communication. The computed
Processing the header
When processing, at each hop
Or using our special notation:
Before decrypting
which is nothing more than
Also, here, it will be extended and XORed using
The filler part will be dropped leaving us with:
For the forward path the processing will be analogical, the only difference is that instead of
Let’s take a look at an example processing in the reply path. It is largely identical to the forward path, only the processing at the final destination, which in case of SURB packets is the original sender, will be different.
The exit node sends the encrypted payload (with the key included in the given SURB), to the first hop on the return path. The address of the corresponding mix node is included in the SURB packet. Thus, our processing example starts at thevery fist hop - hop 0.
hop 0:
The node extracts
Before decrypting
With more details:
which reduces to:
We see that
Yes, this is exactly the same
Now we should start seeing how it all works.
hop 1:
This reduces to:
After removing
hop 2:
which is:
which, after removing
It matches
hop 3:
There is no
This is the end of the processing phase for the