◐ Off-By-One · answer catalog

python-sphinx-mixnet-header-shift-blinded-key-replay-tag

1 answer(s)pythonpython3

Problem class: python-sphinx-mixnet-header-shift-blinded-key-replay-tag

📦 Source in repository (JSON)

Answer

The solution is complete and saved to ~/SOLUTION.md. Below is the full self-contained markdown solution.


Sphinx Mixnet Packet Format — Diagnosis and Verified Solution

Problem class: python-sphinx-mixnet-header-shift-blinded-key-replay-tag Language / runtime: Python 3, standard library only (no third-party crypto).

1. What the format must do

The Sphinx (Loopix / Petit-Hoffman) construction is a fixed-size mixnet header made of:

plus a per-hop replay tag, a key-blinding chain, and a header-shifting transform that appends filler so the packet length never changes. A SURB (single-use reply block) reuses the same machinery so a replier can address an originator without learning the path.

2. Root-cause analysis

Naive implementations of this format fail in a small number of characteristic ways. This problem class is defined by those failure modes:

  1. Header shift changes length. A hop consumes r bytes of routing info but forgets to append exactly r filler bytes, or shifts in the wrong direction. The header then shrinks (or grows) every hop and the receiver can link packets by size. Fix: make beta a fixed BETA_LEN = r * MAX_HOPS buffer; on each hop do beta' = beta[r:] || filler, where filler is exactly r bytes.

  2. Filler is not reproducible by the sender. If the filler is random or derived differently on each side, the sender cannot build a beta consistent with what later hops will see; routing info silently corrupts. Fix: filler = KDF(s_i, "filler", r) where s_i is the per-hop DH shared secret. Both sender and hop derive the identical bytes.

  3. Key blinding breaks the DH chain. Using the same alpha for every hop, or hashing the wrong value, means later hops compute the wrong shared secret and their MACs/routing info fail. Fix: chain the blinding scalar inside the curve's prime-order group: s_i = k_i * Y_i, b_i = H(alpha_i || s_i) mod n, k_{i+1} = k_i * b_i mod n, alpha_{i+1} = b_i * alpha_i. Then x_i * alpha_i = k_i * Y_i = s_i at every hop. (A prime-order curve — secp256k1 here — avoids the clamping/composition pitfalls of raw X25519.)

  4. MAC covers only part of the header. MACing only the first block (or skipping beta) allows header malleability and forgery. The current hop must also be able to produce the next hop's MAC. Fix: gamma_i = MAC(mu_i, alpha_i || beta_i) over the entire header, and carry the next hop's MAC key mu_{i+1} = KDF(s_{i+1},"mac") inside the current routing info so the current hop can compute gamma_{i+1}.

  5. Replay tag is non-deterministic or payload-dependent. If the tag mixes in a random nonce or the (mutable) payload, a byte-identical replay yields a different tag and evades detection. Fix: tag_i = MAC(KDF(s_i,"tag"), alpha_i || beta_i || gamma_i). It is a pure function of the received header and the per-hop secret, so a replay is bit-identical and is caught by the node's replay cache.

  6. SURB tag schedule computed from the initial header only. The originator must predict the tags the mixes will actually observe. Since the header is shifted/blinded at every hop, predicting requires replaying the same header evolution. Fix: when building the SURB, walk the path and apply the same blind/shift/MAC recurrence to derive the expected tag at each hop. The reply reuses the header verbatim, so observed tags equal the schedule.

3. The fix

The fix is a complete, dependency-free implementation with these exact properties:

Files

File Purpose
sphinx_mixnet.py Pure-Python Sphinx implementation (secp256k1, HKDF-SHA256, ChaCha20, HMAC-SHA256).
test_sphinx.py Verification suite (7 tests).

sphinx_mixnet.py

"""
Pure-Python implementation of the Sphinx mixnet packet format
(Loopix / Petit-Hoffman construction).

Components
----------
* secp256k1 group arithmetic (pure Python, Jacobian coordinates)
* HKDF-SHA256 key derivation
* ChaCha20 stream cipher (pure Python)
* HMAC-SHA256 MACs (truncated)
* Layered header encryption with a fixed-size beta buffer
* Per-hop blinded public key (alpha chaining)
* Per-hop replay tag
* Header shifting with filler so the packet length is invariant
* Single-Use Reply Blocks (SURBs)
"""

from __future__ import annotations

import hashlib
import hmac
import os
import struct
from dataclasses import dataclass, field
from typing import List, Optional, Sequence, Tuple

# ---------------------------------------------------------------------------
# secp256k1
# ---------------------------------------------------------------------------

P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
G = (Gx, Gy)

def _jac_double(p: Tuple[int, int, int]) -> Tuple[int, int, int]:
    X, Y, Z = p
    if Y == 0 or Z == 0:
        return (0, 1, 0)
    Ysq = Y * Y % P
    S = 4 * X * Ysq % P
    M = 3 * X * X % P  # a == 0
    X3 = (M * M - 2 * S) % P
    Y3 = (M * (S - X3) - 8 * Ysq * Ysq) % P
    Z3 = 2 * Y * Z % P
    return (X3, Y3, Z3)

def _jac_add(
    p: Tuple[int, int, int], q: Tuple[int, int, int]
) -> Tuple[int, int, int]:
    X1, Y1, Z1 = p
    X2, Y2, Z2 = q
    if Z1 == 0:
        return q
    if Z2 == 0:
        return p
    Z1sq = Z1 * Z1 % P
    Z2sq = Z2 * Z2 % P
    U1 = X1 * Z2sq % P
    U2 = X2 * Z1sq % P
    S1 = Y1 * Z2 * Z2sq % P
    S2 = Y2 * Z1 * Z1sq % P
    if U1 == U2:
        if S1 != S2:
            return (0, 1, 0)
        return _jac_double(p)
    H = (U2 - U1) % P
    I = (2 * H) ** 2 % P
    J = H * I % P
    r = 2 * (S2 - S1) % P
    V = U1 * I % P
    X3 = (r * r - J - 2 * V) % P
    Y3 = (r * (V - X3) - 2 * S1 * J) % P
    Z3 = ((Z1 + Z2) ** 2 - Z1sq - Z2sq) % P * H % P
    return (X3, Y3, Z3)

def _scalar_mult(k: int, point: Tuple[int, int, int]) -> Tuple[int, int, int]:
    k %= N
    if k == 0:
        return (0, 1, 0)
    result = (0, 1, 0)
    addend = point
    while k:
        if k & 1:
            result = _jac_add(result, addend)
        addend = _jac_double(addend)
        k >>= 1
    return result

def _to_affine(p: Tuple[int, int, int]) -> Optional[Tuple[int, int]]:
    X, Y, Z = p
    if Z == 0:
        return None
    Zi = pow(Z, P - 2, P)
    Zi2 = Zi * Zi % P
    return (X * Zi2 % P, Y * Zi2 * Zi % P)

def point_mul(k: int, point: Tuple[int, int]) -> Optional[Tuple[int, int]]:
    return _to_affine(_scalar_mult(k, (point[0], point[1], 1)))

def base_mul(k: int) -> Optional[Tuple[int, int]]:
    return point_mul(k, G)

def ser_point(pt: Tuple[int, int]) -> bytes:
    """SEC1 compressed encoding (33 bytes)."""
    x, y = pt
    return bytes([2 + (y & 1)]) + x.to_bytes(32, "big")

def deser_point(b: bytes) -> Tuple[int, int]:
    if len(b) != 33 or b[0] not in (2, 3):
        raise ValueError("invalid compressed point")
    x = int.from_bytes(b[1:], "big")
    ysq = (pow(x, 3, P) + 7) % P
    y = pow(ysq, (P + 1) // 4, P)
    if (y * y - ysq) % P != 0:
        raise ValueError("point not on curve")
    if (y & 1) != (b[0] & 1):
        y = P - y
    return (x, y)

def random_scalar() -> int:
    while True:
        k = int.from_bytes(os.urandom(32), "big") % N
        if k != 0:
            return k

# ---------------------------------------------------------------------------
# KDF / MAC / stream cipher
# ---------------------------------------------------------------------------

def hkdf(ikm: bytes, info: bytes, length: int = 32, salt: bytes = b"") -> bytes:
    """RFC 5869 HKDF-SHA256."""
    if not salt:
        salt = b"\x00" * hashlib.sha256().digest_size
    prk = hmac.new(salt, ikm, hashlib.sha256).digest()
    okm = b""
    t = b""
    i = 1
    while len(okm) < length:
        t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
        okm += t
        i += 1
    return okm[:length]

def kdf(secret: bytes, label: bytes, length: int = 32) -> bytes:
    return hkdf(secret, b"sphinx/" + label, length)

def mac(key: bytes, data: bytes, length: int = 16) -> bytes:
    return hmac.new(key, data, hashlib.sha256).digest()[:length]

def _rotl32(x: int, n: int) -> int:
    return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF

def _chacha_block(key: bytes, counter: int, nonce: bytes) -> bytes:
    const = b"expand 32-byte k"
    state = (
        list(struct.unpack("<4I", const))
        + list(struct.unpack("<8I", key))
        + [counter]
        + list(struct.unpack("<3I", nonce))
    )
    x = state[:]

    def qr(a, b, c, d):
        x[a] = (x[a] + x[b]) & 0xFFFFFFFF
        x[d] = _rotl32(x[d] ^ x[a], 16)
        x[c] = (x[c] + x[d]) & 0xFFFFFFFF
        x[b] = _rotl32(x[b] ^ x[c], 12)
        x[a] = (x[a] + x[b]) & 0xFFFFFFFF
        x[d] = _rotl32(x[d] ^ x[a], 8)
        x[c] = (x[c] + x[d]) & 0xFFFFFFFF
        x[b] = _rotl32(x[b] ^ x[c], 7)

    for _ in range(10):
        qr(0, 4, 8, 12)
        qr(1, 5, 9, 13)
        qr(2, 6, 10, 14)
        qr(3, 7, 11, 15)
        qr(0, 5, 10, 15)
        qr(1, 6, 11, 12)
        qr(2, 7, 8, 13)
        qr(3, 4, 9, 14)
    out = [(x[i] + state[i]) & 0xFFFFFFFF for i in range(16)]
    return struct.pack("<16I", *out)

def chacha_stream(key: bytes, length: int, nonce: bytes = b"\x00" * 12) -> bytes:
    out = bytearray()
    counter = 0
    while len(out) < length:
        out += _chacha_block(key, counter, nonce)
        counter += 1
    return bytes(out[:length])

def xor_bytes(a: bytes, b: bytes) -> bytes:
    return bytes(x ^ y for x, y in zip(a, b))

# ---------------------------------------------------------------------------
# Format parameters
# ---------------------------------------------------------------------------

FLAG_LEN = 1
ADDR_LEN = 16
MU_LEN = 32
HOP_INFO_LEN = FLAG_LEN + ADDR_LEN + MU_LEN  # 49
MAX_HOPS = 8
BETA_LEN = HOP_INFO_LEN * MAX_HOPS  # 392
KEY_LEN = 32
MAC_LEN = 16
TAG_LEN = 16
POINT_LEN = 33
HEADER_LEN = POINT_LEN + BETA_LEN + MAC_LEN  # 441
PAYLOAD_LEN = 256
DEST_ID = b"\x00" * ADDR_LEN

MODE_REGULAR = 0
MODE_SURB = 1

# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------

class SphinxError(Exception):
    pass

class InvalidMacError(SphinxError):
    pass

class ReplayError(SphinxError):
    pass

# ---------------------------------------------------------------------------
# Node / header
# ---------------------------------------------------------------------------

@dataclass
class Node:
    id: bytes
    private_key: int
    is_destination: bool = False
    replay_cache: set = field(default_factory=set)

    def __post_init__(self) -> None:
        if len(self.id) != ADDR_LEN:
            raise ValueError("node id must be %d bytes" % ADDR_LEN)
        if not (1 <= self.private_key < N):
            raise ValueError("private key out of range")
        self.public_key: Tuple[int, int] = base_mul(self.private_key)  # type: ignore

    def reset_replay_cache(self) -> None:
        self.replay_cache.clear()

@dataclass
class Header:
    alpha: bytes
    beta: bytes
    gamma: bytes

    def to_bytes(self) -> bytes:
        return self.alpha + self.beta + self.gamma

    @classmethod
    def from_bytes(cls, raw: bytes) -> "Header":
        if len(raw) != HEADER_LEN:
            raise ValueError("bad header length")
        return cls(
            alpha=raw[:POINT_LEN],
            beta=raw[POINT_LEN : POINT_LEN + BETA_LEN],
            gamma=raw[POINT_LEN + BETA_LEN :],
        )

@dataclass
class ProcessedPacket:
    node_id: bytes
    payload: bytes
    next_id: Optional[bytes] = None
    header: Optional[Header] = None

# ---------------------------------------------------------------------------
# Sphinx construction
# ---------------------------------------------------------------------------

class Sphinx:
    """Stateless helpers implementing the Sphinx sender/receiver logic."""

    # -- internal -----------------------------------------------------------

    @staticmethod
    def _build_header(path: Sequence[Node], flag: int) -> Tuple[Header, List[bytes]]:
        """Create the initial header and return (header, shared_secrets).

        The ephemeral scalar ``k`` is chained through each hop:
            alpha_i   = k_i * G
            s_i       = k_i * Y_i
            b_i       = H(alpha_i || s_i) mod N
            k_{i+1}   = k_i * b_i mod N
        so that hop i recovers the same s_i with its private key x_i:
            x_i * alpha_i = x_i * k_i * G = k_i * Y_i = s_i
        """
        k = random_scalar()
        alphas: List[bytes] = []
        secrets: List[bytes] = []
        for node in path:
            alpha = base_mul(k)
            if alpha is None:
                raise SphinxError("degenerate alpha")
            ab = ser_point(alpha)
            s_pt = point_mul(k, node.public_key)
            if s_pt is None:
                raise SphinxError("degenerate shared secret")
            s = s_pt[0].to_bytes(32, "big")
            b = int.from_bytes(
                hashlib.sha256(ab + s).digest(), "big"
            ) % N
            if b == 0:
                b = 1
            alphas.append(ab)
            secrets.append(s)
            k = (k * b) % N

        # Routing info: flag || next_id || mu_next
        ris: List[bytes] = []
        for i in range(len(path)):
            if i < len(path) - 1:
                next_id = path[i + 1].id
                mu_next = kdf(secrets[i + 1], b"mac", MU_LEN)
            else:
                next_id = DEST_ID
                mu_next = b"\x00" * MU_LEN
            ris.append(bytes([flag]) + next_id + mu_next)

        # Beta construction (backwards), with filler.
        B = os.urandom(BETA_LEN)
        for i in reversed(range(len(path))):
            if i < len(path) - 1:
                filler = kdf(secrets[i], b"filler", HOP_INFO_LEN)
                B = B[: BETA_LEN - HOP_INFO_LEN] + filler
            ks = chacha_stream(kdf(secrets[i], b"stream", KEY_LEN), BETA_LEN)
            B = xor_bytes(ks, ris[i] + B[: BETA_LEN - HOP_INFO_LEN])

        beta = B
        mu0 = kdf(secrets[0], b"mac", MU_LEN)
        gamma = mac(mu0, alphas[0] + beta, MAC_LEN)
        return Header(alpha=alphas[0], beta=beta, gamma=gamma), secrets

    # -- public API ---------------------------------------------------------

    @staticmethod
    def create_packet(path: Sequence[Node], message: bytes) -> Tuple[Header, bytes]:
        """Create a regular (payload-peeling) packet."""
        if len(path) > MAX_HOPS:
            raise SphinxError("path too long")
        header, secrets = Sphinx._build_header(path, MODE_REGULAR)
        padded = message.ljust(PAYLOAD_LEN, b"\x00")[:PAYLOAD_LEN]
        for i in reversed(range(len(path))):
            padded = xor_bytes(
                padded, chacha_stream(kdf(secrets[i], b"payload", KEY_LEN), PAYLOAD_LEN)
            )
        return header, padded

    @staticmethod
    def process_packet(
        node: Node, header: Header, payload: bytes, *, check_replay: bool = True
    ) -> Optional[ProcessedPacket]:
        """Process one hop.  Returns None if this node is a mix with no next hop.

        Raises InvalidMacError / ReplayError as appropriate.
        """
        alpha_pt = deser_point(header.alpha)
        s_pt = point_mul(node.private_key, alpha_pt)
        if s_pt is None:
            raise InvalidMacError("degenerate shared secret")
        s = s_pt[0].to_bytes(32, "big")

        # 1. MAC over the *whole* header (alpha || beta) plus the MAC key.
        mu = kdf(s, b"mac", MU_LEN)
        expected = mac(mu, header.alpha + header.beta, MAC_LEN)
        if not hmac.compare_digest(expected, header.gamma):
            raise InvalidMacError("header MAC verification failed")

        # 2. Replay tag derived deterministically from the header.
        tag = mac(kdf(s, b"tag", KEY_LEN), header.to_bytes(), TAG_LEN)
        if check_replay:
            if tag in node.replay_cache:
                raise ReplayError("replayed packet")
            node.replay_cache.add(tag)

        # 3. Decrypt the routing info (beta).
        ks = chacha_stream(kdf(s, b"stream", KEY_LEN), BETA_LEN)
        D = xor_bytes(header.beta, ks)
        ri, tail = D[:HOP_INFO_LEN], D[HOP_INFO_LEN:]
        flag = ri[0]
        next_id = ri[FLAG_LEN : FLAG_LEN + ADDR_LEN]
        mu_next = ri[FLAG_LEN + ADDR_LEN :]

        # 4. Payload transformation (regular packets peel one layer).
        if flag == MODE_REGULAR:
            payload = xor_bytes(
                payload, chacha_stream(kdf(s, b"payload", KEY_LEN), len(payload))
            )

        if node.is_destination:
            return ProcessedPacket(node_id=node.id, payload=payload, header=None)

        # 5. Blind the key and shift the header, appending filler.
        b = int.from_bytes(hashlib.sha256(header.alpha + s).digest(), "big") % N
        if b == 0:
            b = 1
        alpha_next_pt = point_mul(b, alpha_pt)
        if alpha_next_pt is None:
            raise SphinxError("degenerate blinded alpha")
        alpha_next = ser_point(alpha_next_pt)

        filler = kdf(s, b"filler", HOP_INFO_LEN)
        beta_next = tail + filler  # length invariant: BETA_LEN

        gamma_next = mac(mu_next, alpha_next + beta_next, MAC_LEN)
        return ProcessedPacket(
            node_id=node.id,
            next_id=next_id,
            payload=payload,
            header=Header(alpha=alpha_next, beta=beta_next, gamma=gamma_next),
        )

    # -- SURB ---------------------------------------------------------------

    @staticmethod
    def create_surb(path: Sequence[Node]) -> "SURB":
        """Bob creates a single-use reply block for a path ending at himself."""
        if not path or not path[-1].is_destination:
            raise SphinxError("SURB path must end at the destination")
        header, secrets = Sphinx._build_header(path, MODE_SURB)
        reply_key = kdf(secrets[-1], b"reply", KEY_LEN)

        # Pre-compute the per-hop replay-tag schedule that the mixes will
        # observe when this header is used.
        tag_schedule: List[bytes] = []
        alpha = header.alpha
        beta = header.beta
        for i, node in enumerate(path):
            alpha_pt = deser_point(alpha)
            s_pt = point_mul(node.private_key, alpha_pt)
            s = s_pt[0].to_bytes(32, "big")
            gamma = mac(kdf(s, b"mac", MU_LEN), alpha + beta, MAC_LEN)
            tag = mac(kdf(s, b"tag", KEY_LEN), alpha + beta + gamma, TAG_LEN)
            tag_schedule.append(tag)
            if i == len(path) - 1:
                break
            ks = chacha_stream(kdf(s, b"stream", KEY_LEN), BETA_LEN)
            D = xor_bytes(beta, ks)
            b = int.from_bytes(hashlib.sha256(alpha + s).digest(), "big") % N
            alpha = ser_point(point_mul(b, alpha_pt))
            beta = D[HOP_INFO_LEN:] + kdf(s, b"filler", HOP_INFO_LEN)
        return SURB(header=header, reply_key=reply_key, tag_schedule=tag_schedule)

    @staticmethod
    def use_surb(
        surb: "SURB", message: bytes, payload_len: int = PAYLOAD_LEN
    ) -> Tuple[Header, bytes]:
        """Alice seals a reply with a SURB (header is reused verbatim)."""
        padded = message.ljust(payload_len, b"\x00")[:payload_len]
        enc = xor_bytes(padded, chacha_stream(surb.reply_key, payload_len))
        return surb.header, enc

    @staticmethod
    def open_surb(surb: "SURB", payload: bytes) -> bytes:
        return xor_bytes(payload, chacha_stream(surb.reply_key, len(payload)))

@dataclass
class SURB:
    header: Header
    reply_key: bytes
    tag_schedule: List[bytes]

test_sphinx.py

"""Verification suite for the pure-Python Sphinx mixnet implementation."""

import os

from sphinx_mixnet import (
    BETA_LEN,
    HEADER_LEN,
    MAX_HOPS,
    PAYLOAD_LEN,
    Header,
    InvalidMacError,
    Node,
    ReplayError,
    Sphinx,
    chacha_stream,
    deser_point,
    kdf,
    mac,
    point_mul,
    xor_bytes,
)

RNG = os.urandom

def make_nodes(n, dest_last=True):
    nodes = []
    for i in range(n):
        nodes.append(
            Node(
                id=(i + 1).to_bytes(16, "big"),
                private_key=int.from_bytes(RNG(32), "big") % (2**256) + 1,
                is_destination=(dest_last and i == n - 1),
            )
        )
    return nodes

def observe_tag(node, header):
    s_pt = point_mul(node.private_key, deser_point(header.alpha))
    s = s_pt[0].to_bytes(32, "big")
    return mac(kdf(s, b"tag", 32), header.to_bytes(), 16)

def route(nodes, header, payload, *, reset=True):
    """Route a packet through all nodes. Returns (payload, tags, hops)."""
    tags, hops = [], []
    for i, node in enumerate(nodes):
        if reset:
            node.reset_replay_cache()
        tags.append(observe_tag(node, header))
        result = Sphinx.process_packet(node, header, payload, check_replay=not reset)
        hops.append(node.id)
        if result.header is None:
            assert i == len(nodes) - 1, "terminal reached too early"
            return result.payload, tags, hops
        assert result.next_id == nodes[i + 1].id, "routing mismatch"
        header, payload = result.header, result.payload
    return payload, tags, hops

# ---------------------------------------------------------------------------
# Test 1: end-to-end routing + exact payload recovery
# ---------------------------------------------------------------------------
def test_e2e_payload_recovery():
    nodes = make_nodes(5)
    message = b"the eagle lands at midnight"
    header, payload = Sphinx.create_packet(nodes, message)
    out, _, hops = route(nodes, header, payload)
    assert out.rstrip(b"\x00") == message
    assert hops == [n.id for n in nodes]
    print("test_e2e_payload_recovery           OK")

# ---------------------------------------------------------------------------
# Test 2: replay tags are identical across a 5-mix path on replay
# ---------------------------------------------------------------------------
def test_replay_tags_identical():
    # 5 mixes followed by the destination
    nodes = make_nodes(6)
    header, payload = Sphinx.create_packet(nodes, b"replay me")
    _, tags1, _ = route(nodes, header, payload)
    _, tags2, _ = route(nodes, header, payload)
    assert tags1 == tags2, ([t.hex() for t in tags1], [t.hex() for t in tags2])
    assert len(tags1) == 6
    assert all(len(t) == 16 for t in tags1)
    assert len(set(tags1)) == len(tags1), "tags must be unlinkable across hops"
    print("test_replay_tags_identical          OK")

# ---------------------------------------------------------------------------
# Test 3: an actual replay is rejected by the mix's replay cache
# ---------------------------------------------------------------------------
def test_replay_rejected():
    nodes = make_nodes(5)
    header, payload = Sphinx.create_packet(nodes, b"once")
    mix = nodes[0]
    mix.reset_replay_cache()
    Sphinx.process_packet(mix, header, payload)
    try:
        Sphinx.process_packet(mix, header, payload)
    except ReplayError:
        print("test_replay_rejected                OK")
        return
    raise AssertionError("replay was not detected")

# ---------------------------------------------------------------------------
# Test 4: header length is bit-identical at every hop
# ---------------------------------------------------------------------------
def test_header_length_invariant():
    nodes = make_nodes(6)
    header, payload = Sphinx.create_packet(nodes, b"length")
    sizes = [len(header.to_bytes())]
    while True:
        res = Sphinx.process_packet(nodes[len(sizes) - 1], header, payload)
        if res.header is None:
            break
        header, payload = res.header, res.payload
        sizes.append(len(header.to_bytes()))
    # initial header + one header forwarded by each of the 5 non-destination nodes
    assert sizes == [HEADER_LEN] * 6, sizes
    assert all(len(payload) == PAYLOAD_LEN for _ in [0])
    print("test_header_length_invariant        OK")

# ---------------------------------------------------------------------------
# Test 5: wrong MAC is rejected
# ---------------------------------------------------------------------------
def test_tampered_header_rejected():
    nodes = make_nodes(3)
    header, payload = Sphinx.create_packet(nodes, b"tamper")
    bad = Header(
        alpha=header.alpha,
        beta=bytes([header.beta[0] ^ 1]) + header.beta[1:],
        gamma=header.gamma,
    )
    try:
        Sphinx.process_packet(nodes[0], bad, payload)
    except InvalidMacError:
        print("test_tampered_header_rejected       OK")
        return
    raise AssertionError("tampered header accepted")

# ---------------------------------------------------------------------------
# Test 6: SURB reply headers match the originator's expected tag schedule
# ---------------------------------------------------------------------------
def test_surb_tag_schedule_and_reply():
    path = make_nodes(5)
    bob = path[-1]

    surb = Sphinx.create_surb(path)
    reply = b"message in a bottle"
    header, enc = Sphinx.use_surb(surb, reply)

    _, observed_tags, _ = route(path, header, enc)
    assert observed_tags == surb.tag_schedule, (
        [t.hex() for t in observed_tags],
        [t.hex() for t in surb.tag_schedule],
    )

    recovered = Sphinx.open_surb(surb, enc).rstrip(b"\x00")
    assert recovered == reply, recovered
    print("test_surb_tag_schedule_and_reply    OK")

# ---------------------------------------------------------------------------
# Test 7: header shift keeps beta length fixed and filler absorbs
# ---------------------------------------------------------------------------
def test_filler_absorbs_shift():
    nodes = make_nodes(MAX_HOPS)
    header, payload = Sphinx.create_packet(nodes, b"filler")
    for i in range(MAX_HOPS):
        assert len(header.beta) == BETA_LEN
        res = Sphinx.process_packet(nodes[i], header, payload)
        if res.header is None:
            assert res.payload.rstrip(b"\x00") == b"filler"
            break
        header, payload = res.header, res.payload
    print("test_filler_absorbs_shift           OK")

if __name__ == "__main__":
    tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    for t in tests:
        t()
    print("\nAll %d Sphinx verification tests passed." % len(tests))

Run

cd ~
python3 test_sphinx.py

4. Verification

4.1 Automated suite

$ python3 test_sphinx.py
test_e2e_payload_recovery           OK
test_filler_absorbs_shift           OK
test_header_length_invariant        OK
test_replay_rejected                OK
test_replay_tags_identical          OK
test_surb_tag_schedule_and_reply    OK
test_tampered_header_rejected       OK

All 7 Sphinx verification tests passed.

4.2 Concrete observed run

=== 5-mix path (5 mixes + destination) ===
initial header length: 441 (alpha=33 beta=392 gamma=16)
 hop 0 beta_len=392 tag=ec931efb2bb8a194
 hop 1 beta_len=392 tag=8fb71b9943cfc8d0
 hop 2 beta_len=392 tag=2744672080f31151
 hop 3 beta_len=392 tag=0434ec14b3b05d9d
 hop 4 beta_len=392 tag=64d48aac85e3a705
 hop 5 beta_len=392 tag=064809d2909cf77c
  -> destination plaintext: b'hello sphinx'

=== Replay: same packet through fresh caches ===
tags pass 1: ['26807c0974be', '45c362c6a3c6', '3650eb74eb83', 'ae7fcf1e2b72', 'a29be8b4dfbd', '5f45e59535d1']
tags pass 2: ['26807c0974be', '45c362c6a3c6', '3650eb74eb83', 'ae7fcf1e2b72', 'a29be8b4dfbd', '5f45e59535d1']
identical  : True

=== SURB tag schedule ===
expected: ['dde83ad4d01d', '9c9acd9ef3b7', '233ff908e770', '712668954c10', '9cccbc9ec9bf', '008f45e099bd']
observed: ['dde83ad4d01d', '9c9acd9ef3b7', '233ff908e770', '712668954c10', '9cccbc9ec9bf', '008f45e099bd']
match   : True
reply   : b'reply payload'
Requirement Evidence
Identical per-hop replay tags on replay (replay detectable) tags pass 1 == tags pass 2; second use raises ReplayError
Header length bit-identical at every hop beta_len=392 at all 6 hops; header = 441 B
SURB reply headers match originator's expected tag schedule expected == observed

5. Notes / hardening


Deliverables written to disk: - ~/sphinx_mixnet.py — implementation - ~/test_sphinx.py — tests - ~/SOLUTION.md — this document

All 7 tests pass (python3 test_sphinx.py).

Evidence & signatures

# Evidence
- Problem class: python-sphinx-mixnet-header-shift-blinded-key-replay-tag
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-02T22:19:35.621Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement the Sphinx mixnet packet format (Loopix / Petit-Hoffman construction) in pure Python: fixed-size headers of layered encryption over a per-hop blinded public key, a MAC computed over the whole remaining header plus the encrypted routing info, a per-hop replay tag derived from the header, key blinding so the next hop's key is a deterministic function of the current shared secret, and the header-shifting transform that appends filler so the packet length is invariant at every hop. Also construct a single-use reply block (SURB) with the same machinery. Verify with tests: a packet routed through a 5-mix path produces identical per-hop replay tags on a replayed copy (so a replay is detected), header length is bit-identical at every hop, and SURB reply headers match the originator's expected tag schedule.", "environment": "python3", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-sphinx-mixnet-header-shift-blinded-key-replay-tag", "provider": "openrouter", "solved_at": "2026-10-02T22:19:35.621Z", "version": "3"}
Generated from the verified corpus · MIT licensedBack to the catalog