This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.







Specifications

These technical specifications describe the specifics of Katzenpost protocols and implementations, and are aimed primarily at software developers.

Title Description Links
πŸ“– Pigeonhole replicas over Reticulum Pigeonhole storage replicas on a Reticulum mesh, and groups shared between Reticulum and the mixnet HTML
πŸ“– Pigeonhole protocols over Reticulum How a client on a Reticulum mesh reads and writes Pigeonhole boxes: replicas, requests, BACAP, tombstones and epochs, for any protocol built on boxes HTML
πŸ“– katzenqt1 chat over Reticulum How to write a group chat app for a Reticulum mesh that interoperates with katzenrns: the katzenqt1 protocol over Pigeonhole boxes HTML
πŸ“– Autoresponder extension Autoresponder agent that runs on service nodes HTMLΒ /Β PDF
πŸ“– Certificate format PKI certificate format HTML / PDF
πŸ“– Contact voucher design Contact voucher design HTML / PDF
πŸ“– Contact voucher protocol narration An implementation-oriented narration of the Contact Voucher protocol. HTML
πŸ“– Directory authority (PKI) The directory authority protocol at v0.0.107, with versioned implementation links HTML
πŸ“– Group chat design Katzenpost group chat design HTML / PDF
πŸ“– KEM Sphinx packet format The KEM Sphinx variation of Sphinx HTML / PDF
πŸ“– Mix decoy stats propagation Mix decoy stats propagation HTML / PDF
πŸ“– Mixnet design Describes the overall mixnet design HTML / PDF
πŸ“– Pigeonhole design Pigeonhole design specification HTML / PDF
πŸ“– Pigeonhole services How clients discover and connect to pigeonhole services HTML
πŸ“– Pigeonhole courier and replica decoy traffic Decoy traffic design for Pigeonhole couriers and replicas HTML
πŸ“– Public key infrastructure (deprecated) Deprecated original PKI design, kept for reference. See the Directory authority specification for the current design HTML / PDF
πŸ“– Sphinx packet format Sphinx packet format, a nested cryptographic packet format designed for mix networks HTML / PDF
πŸ“– Sphinx replay detection Sphinx packet replay detection HTML / PDF
πŸ“– Thin client Thin client library design HTML / PDF
πŸ“– Wire protocol PQ Noise transport encryption between clients, mix servers, and directory authorities HTML / PDF
πŸ“– Glossary Consolidated list of terms defined in the specifications HTML / PDF
πŸ“– References Consolidated list of references cited in the specifications HTML / PDF

1 - Contact Voucher Protocol Narration

Introduction

The voucher spec was very difficult for me to read and it overloads variable names and doesn’t make certain facts explicit enough. I wrote this spec for my own understanding of the design as an implementation guide for myself.

Streams

There is no separate reply stream. Alice replies on the very same stream Bob used to mint the voucher.

  • MessageStream: Bob writes; the group reads. This is the stream whose live messages the protocol must protect.
  • VoucherStream: the rendezvous, derived entirely from the Voucher. Box 0 holds Bob’s VoucherPayload; box 1 holds Alice’s VoucherReply.
  • the group’s other streams: each existing member has their own MessageStream, just like Bob’s.

Stream Objects

  • MessageStream: .WriteCap (Bob keeps), .ReadCap (travels in VoucherPayload, inside SignedPleaseAdd).
  • VoucherStream: read and write caps derivable by anyone holding the Voucher.
  • VoucherKeypair: VoucherSecretKey (Bob keeps), VoucherPublicKey (travels in VoucherPayload). This is the MKEM keypair under which Alice seals the reply to Bob.

Messages

  • SignedPleaseAdd is Bob’s signed self-introduction: a serialized PleaseAdd (his DisplayName and MessageStream.ReadCap) plus a signature over it produced by MessageStream.WriteCap.
// PleaseAdd is a member's request to join, carrying their display name
// and the read cap that lets others read their messages.
type PleaseAdd struct {
    // DisplayName is the party's name to be displayed in chat clients.
    DisplayName string

    // UniversalReadCap is the BACAP read cap for this member's
    // MessageStream, letting others read all messages posted by them.
    UniversalReadCap *bacap.UniversalReadCap
}

// SignedPleaseAdd binds a PleaseAdd to a signature made by the member's
// write cap, so any party can verify the name-and-cap binding.
type SignedPleaseAdd struct {
    // PleaseAdd contains the CBOR serialized PleaseAdd struct.
    PleaseAdd []byte

    // Signature contains the cryptographic signature over the PleaseAdd field.
    Signature []byte
}
  • WhoReply := the existing members’ MessageStream read caps (one or more), each unchanged and paired with a start index, so Bob can read everyone already in the group. Each index is the box after Alice’s last published acknowledgement of that stream; Alice’s own starts at her Introduction of Bob.
  • VoucherReply := WhoReply || VoucherSalt, MKEM-sealed to VoucherPublicKey and written to VoucherStream box 1. Box 1 is an ordinary BACAP box, so the payload is BACAP-encrypted as a stream box and then additionally MKEM-encrypted to Bob’s VoucherPublicKey; only Bob, holding VoucherSecretKey, can open it.
  • Introduction := Bob’s display name, his MessageStream read cap unchanged, and his salted start index, published to the existing group so the members can read Bob. The group receives the salted index directly and never the salt.
VoucherPayload := SignedPleaseAdd || VoucherPublicKey
Voucher        := Hash(VoucherPayload)

The VoucherSalt: re-seeding the MessageStream

VoucherSalt is the heart of the protocol. It is not a nonce and it does not modify BACAP. It is a value that replaces the KDF state inside a MessageStream’s MessageBoxIndex, which re-seeds the index chain and so moves the stream to a fresh, unpredictable sequence of box IDs. Only two parties ever touch it. Bob mutates his MessageStream.WriteCap by the salt and writes his real messages at the mutated position. Alice applies the same salt once to the index she pairs with Bob’s MessageStream.ReadCap, leaving the read cap itself unchanged, so writer and readers meet at the mutated position. The group never learns the salt; it only ever receives the already-salted index.

That is why the spec says derive Bob’s read capability from VoucherPayload + VoucherSalt: the read cap published in box 0 is only half of it. That derivation is Alice’s alone. The read cap in box 0 names Bob’s original stream; the salted index says where Bob’s real messages actually go. She performs the derivation once and hands the result to the group.

The salt is the one secret a voucher snoop never receives. It is born at induction, not at mint: Alice mints it and seals it inside the MKEM-encrypted VoucherReply. It is never written to box 0, so Bob’s PleaseAdd carries only his original read cap, never the salt. An interceptor of the out-of-band Voucher can derive the VoucherStream, read box 0, and check that the voucher was spent, but he holds only the read cap and its unsalted index. He cannot compute the salted box IDs, so he cannot read a single one of Bob’s actual messages. That is exactly the spec’s stated confidentiality goal.

Because the salt re-seeds rather than decorates the stream, it is a per-member fact known only to that member and to whoever inducted them. A mature group mixes voucher-joined members (each on their own salt-mutated index) with seed members (on the unmutated index), and no member needs to carry any salt: every index that circulates beside a read cap is already salted. WhoReply hands Bob the existing members’ read caps with their start indexes, and the Introduction hands the group Bob’s read cap with the index Alice salted.

Steps

  1. Bob mints. He generates his MessageStream (keeping MessageStream.WriteCap) and a VoucherKeypair (keeping VoucherSecretKey). He forms SignedPleaseAdd = {DisplayName, MessageStream.ReadCap} signed by MessageStream.WriteCap; sets VoucherPayload := SignedPleaseAdd || VoucherPublicKey; Voucher := Hash(VoucherPayload).
  2. Bob publishes. The Voucher derives the VoucherStream; Bob writes VoucherPayload to box 0.
  3. Bob β†’ Alice (OOB). He hands over only the Voucher.
  4. Alice reads and verifies. From Voucher she derives the VoucherStream, reads box 0, checks Hash(VoucherPayload) == Voucher, and verifies the SignedPleaseAdd signature against its read cap’s rootPK.
  5. Alice replies. She mints VoucherSalt, assembles WhoReply from the start positions above, forms VoucherReply := WhoReply || VoucherSalt, and MKEM-seals it to VoucherPublicKey. She publishes no acknowledgements between this step and the commit.
  6. Alice commits (all-or-nothing COPY). She first derives Bob’s start index by applying the VoucherSalt to his read cap’s index, leaving the read cap unchanged. Then, in one operation: write the sealed VoucherReply to VoucherStream box 1; publish the Introduction (Bob’s display name, his read cap, and his salted start index) to her group, which never sees the salt itself; tombstone box 0 against reuse. Because no acknowledgement separates the start positions from this commit, there is no race: every member keeps each box Alice had not acknowledged at the Introduction until Bob acknowledges it (see Joining in the group chat spec). She rewrites box 1 each replica epoch until Bob’s first message appears, for at most the Sent-box retention window; a Bob later than that needs a new voucher.
  7. Bob finishes. He polls VoucherStream box 1, MKEM-opens the VoucherReply with VoucherSecretKey, and recovers WhoReply and VoucherSalt. He mutates his MessageStream.WriteCap by the salt and begins writing real messages there. The group already holds his read cap and salted start index, so they read him without ever learning the salt. Both sides now share the live streams.

2 -

Gateway-side autoresponder extension (Kaetzchen) specification

Yawning Angel

Kali Kaneko

David Stainton

Abstract

This interface is meant to provide support for various autoresponder agents β€œKaetzchen” that run on Katzenpost gateway instances, thus bypassing the need to run a discrete client instance to provide functionality. The use-cases for such agents include, but are not limited to, user identity key lookup, a discard address, and a loop-back responder for the purpose of cover traffic.


Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119.

Terminology

The following terms are used in this specification.

BlockSphinxPlaintext
The payload structure that is encapsulated by the Sphinx body.

gateway
A mix node on the edge of the mixnet that is the first hop for messages coming from clients. The gateway authenticates client connections and queues reply messages (SURBs) for retrieval.

SURB
Single use reply block. SURBs are used to achieve recipient anonymity, that is to say, SURBs function as a cryptographic delivery token that you can give to another client entity so that they can send you a message without them knowing your identity or location on the network. See SPHINXSPEC and SPHINX.

Extension overview

Each Kaetzchen agent will register as a potential recipient on its gateway. When the gateway receives a forward packet destined for a Kaetzchen instance, it will hand off the fully unwrapped packet along with its corresponding SURB to the agent, which will then act on the packet and optionally reply utilizing the SURB.

Agent requirements

  • Each agent operation MUST be idempotent.

  • Each agent operation request and response MUST fit within one Sphinx packet.

  • Each agent SHOULD register a recipient address that consists of an RFC5322 dot-atom value, and MUST register recipient addresses that are at most 64 octets in length.

  • The first byte of the agent’s response payload MUST be 0x01 to allow clients to easily differentiate between SURB-ACKs and agent responses.

Mix message formats

Messages from clients to Kaetzchen use the following payload format in the forward Sphinx packet:

struct {
uint8_t flags;
uint8_t reserved; /* Set to 0x00. */
select (flags) {
case 0:
opaque padding[sizeof(SphinxSURB)];
case 1:
SphinxSURB surb;
}
opaque plaintext[];
} KaetzchenMessage;

The plaintext component of a KaetzchenMessage MUST be padded by appending β€œ0x00” bytes to make the final total size of a KaetzchenMessage equal to that of a BlockSphinxPlaintext.

Messages (replies) from the Kaetzchen to client use the following payload format in the SURB generated packet:

struct {
opaque plaintext[];
} KaetzchenReply;

The plaintext component of a KaetzchenReply MUST be padded by appending β€œ0x00” bytes to make the final total size of a KaetzchenReply equal to that of a BlockSphinxPlaintext

PKI extensions

Each gateway SHOULD publish the list of publicly accessible Kaetzchen agent endpoints in its MixDescriptor, along with any other information required to utilize the agent.

The gateway should make this information available in the form of a map in which the keys are the label used to identify a given service, and the value is a map with arbitrary keys.

Valid service names refer to the services defined in extensions to this specification. Every service MUST be implemented by one and only one Kaetzchen agent.

For each service, the gateway MUST advertise a field for the endpoint at which the Kaetzchen agent can be reached, as a key value pair where the key is endpoint, and the value is the gateway side endpoint identifier.

{ "kaetzchen":
{ "keyserver" : {
"endpoint": "+keyserver",
"version" : 1 } },
{ "discard" : {
"endpoint": "+discard", } },
{ "loop" : {
"endpoint": "+loopback",
"restrictedToUsers": true } },
}

Anonymity considerations

In the event that the mix keys for the entire return path are compromised, it is possible for adversaries to unwrap the SURB and determine the final recipient of the reply.

Depending on what sort of operations a given agent implements, there may be additional anonymity impact that requires separate consideration.

Clients MUST NOT have predictable retransmission otherwise this makes active confirmations attacks possible which could be used to discover the ingress gateway of the client.

Security considerations

It is possible to use this mechanism to flood a victim with unwanted traffic by constructing a request with a SURB destined for the target.

Depending on the operations implemented by each agent, the added functionality may end up being a vector for denial-of-service attacks in the form of CPU or network overload.

Unless the agent implements additional encryption, message integrity and privacy is limited to that which is provided by the base Sphinx packet format and parameterization.

Acknowledgments

The inspiration for this extension comes primarily from a design by Vincent Breitmoser.

References

KATZMIXPKI

Angel, Y., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Public Key Infrastructure Specification”, December 2017, https://katzenpost.network/docs/specs/pdf/pki.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5322

Resnick, P., Ed., β€œInternet Message Format”, RFC 5322, DOI 10.17487/RFC5322, October 2008, https://www.rfc-editor.org/info/rfc5322.

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

3 -

The main text preserves the original certificate design and key-delegation proposals. The identity signature profile defines the v0.0.107 hybrid signature and key encodings. Current directory certificate envelopes use epoch-based expiration and a map of signatures.

Certificate format

David Stainton

Abstract

This document proposes a certificate format that Katzenpost mix server, directory authority server and clients will use.


Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119.

Introduction

Mix servers and directory authority servers need to have key agility in the sense of operational abilities such as key rotation and key revocation. That is, we wish for mixes and authorities to periodically utilize a long-term signing key for generating certificates for new short-term signing keys.

Yet another use-case for these certificate is to replace the use of JOSE RFC7515 in the voting directory authority system KATZMIXPKI for the multi-signature documents exchanged for voting and consensus.

Certificate format

The CBOR RFC7049 serialization format is used to serialize certificates:

Signature is a cryptographic signature which has an associated signer ID.

type Signature struct {
// Identity is the identity of the signer.
Identity []byte
// Signature is the actual signature value.
Signature []byte
}

Certificate structure for serializing certificates.

type certificate struct {
// Version is the certificate format version.
Version uint32

// Expiration is seconds since Unix epoch.
Expiration int64

// KeyType indicates the type of key
// that is certified by this certificate.
KeyType string

// Certified is the data that is certified by
// this certificate.
Certified []byte

// Signatures are the signature of the certificate.
Signatures []Signature
}

That is, one or more signatures sign the certificate. However the Certified field is not the only information that is signed. The Certified field along with the other non-signature fields are all concatenated together and signed. Before serialization the signatures are sorted by their identity so that the output is binary deterministic.

Certificate types

The certificate type field indicates the type of certificate. So far we have only two types:

  • identity key certificate

  • directory authority certificate

Both mixes and directory authority servers have a secret, long-term identity key. This key is ideally stored encrypted and offline, it’s used to sign key certificate documents. Key certificates contain a medium-term signing key that is used to sign other documents. In the case of an β€œauthority signing key”, it is used to sign vote and consensus documents whereas the β€œmix singing key” is used to sign mix descriptors which are uploaded to the directory authority servers.

Certificate key types

It’s more practical to continue using Ed25519 ED25519 keys but it’s also possible that in the future we could upgrade to a stateless hash based post quantum cryptographic signature scheme such as SPHINCS-256 or SPHINCS+. SPHINCS256

Golang API

Our golang implementation is agnostic to the specific cryptographic signature scheme which is used. Cert can handle single and multiple signatures per document and has a variety of helper functions that ease use for multi signature use cases.

Acknowledgments

This specification was inspired by Tor Project’s certificate format specification document:

References

ED25519

https://www.rfc-editor.org/rfc/rfc8032.

KATZMIXPKI

Angel, Y., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Public Key Infrastructure Specification”, December 2017, https://katzenpost.network/docs/specs/pdf/pki.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC7049

C. Bormann, P. Hoffman, β€œConcise Binary Object Representation (CBOR)”, Internet Engineering Task Force (IETF), October 2013, https://www.rfc-editor.org/info/rfc7049.

RFC7515

Jones, M., Bradley, J., Sakimura, N., β€œJSON Web Signature (JWS)”, May 2015, https://www.rfc-editor.org/info/rfc7515.

RFC7693

Saarinen, M-J., Ed., and J-P. Aumasson, β€œThe BLAKE2 Cryptographic Hash and Message Authentication Code (MAC)”, RFC 7693, DOI 10.17487/RFC7693, November 2015, http://www.rfc-editor.org/info/rfc7693.

SPHINCS256

Bernstein, D., Hopwood, D., Hulsing, A., Lange, T., Niederhagen, R., Papachristodoulou, L., Schwabe, P., Wilcox O’Hearn, Z., β€œSPHINCS: practical stateless hash-based signatures”, http://sphincs.cr.yp.to/sphincs-20141001.pdf.

Appendix: Identity signature profile

This profile defines identity signatures and key encodings for Namenlos with Katzenpost v0.0.107. Its rules apply to the current directory certificate envelopes. The main text above preserves the original certificate design.

Here, || means byte concatenation. CBOR fields carry raw public keys as byte strings. The key bytes have no ASN.1 wrapper, length prefix, or text encoding.

Key Value Explanation
Signature scheme Ed25519 Sphincs+ Use this exact name in the baseline certificate KeyType and document PKISignatureScheme. The space is significant.
Public key 96 bytes Ed25519 public key, 32 bytes, followed by SPHINCS+ public key, 64 bytes.
Private key 192 bytes Ed25519 seed, 32 bytes, Ed25519 public key, 32 bytes, then SPHINCS+ secret key, 128 bytes.
Identity hash BLAKE2b-256(identity_public_key) Hash the 96 raw public-key bytes. The result is 32 raw bytes.

The signature scheme definition fixes the name and component order. Identity hashes use sequential BLAKE2b from RFC 7693 with a 32-byte output. Do not truncate BLAKE2b-512 to produce BLAKE2b-256.

Key files

The public PEM label is ED25519 SPHINCS+ PUBLIC KEY. The private PEM label is ED25519 SPHINCS+ PRIVATE KEY. A PEM payload is the base64 encoding of the corresponding raw key. There are no PEM headers or ASN.1 fields.

The signature private payload is Ed25519_seed[32] || Ed25519_public[32] || SPHINCS_secret[128]. This encoding permits import of reference key files. An implementation can use its own internal storage format. The PEM codec wraps the raw private bytes.

Signature algorithm

Use pure Ed25519 from RFC 8032, Section 5.1 and SPHINCS+ round 3.1, parameter set SPHINCS+-SHAKE-256f-robust. The SPHINCS+ parameters are n=32, h=68, d=17, FORS height=9, FORS trees=35, and Winternitz w=16. Use the robust tweakable hashes. The public key is PUB_SEED[32] || root[32]. The secret key is SK_SEED[32] || SK_PRF[32] || PUB_SEED[32] || root[32].

Let M be the exact byte string to sign. Directory objects use their specified signing inputs. Both component algorithms sign the same M:

signature = Ed25519.Sign(ed_sk, M) || SPHINCS+.Sign(spx_sk, M)

The first signature is 64 bytes. The second is 49856 bytes. The complete signature is 49920 bytes.

The SPHINCS+ signature contains its 32-byte R, FORS signature, and hypertree signature in the order defined by the primitive specification. The signer uses fresh 32-byte optrand for SPHINCS+. Do not add an external prehash, context, message prefix, or length field. In particular, this profile uses neither Ed25519ph nor SLH-DSA. Verification MUST reject an incorrect signature length. It MUST accept only when both component signatures verify against M.

The implementation composes the two signatures and the two public keys in this order. Its SPHINCS+ selection fixes SHAKE-256f and robust hashing, the parameters, and the key and signature encodings.

4 -

Contact voucher specification

Threebit Hacker

Leif Ryge

Abstract


In order to join or initiate a conversation securely, participants need to exchange cryptographic key material. To address this problem, we created a slightly unusual design: contact vouchers.

Voucher design

In many systems, an invitation to communicate flows from an existing member of a conversation to the user being invited. In our contact voucher protocol, this flow is reversed: Bob, who wishes to join a conversation, hands a contact voucher (out-of-band) to existing member Alice, who then inducts Bob into the group.

This design mitigates two potential problems with the conventional way of doing things:

  1. A third party who observes the contact voucher does not get to read either participant’s actual messages. However,

    • A passive adversary learns that the voucher was spent, but does not get to observe further interactions.

    • An active adversary can create a new fake group to induct the member into, but does not learn anything about the existing group.

    In the future, to prevent this one-way impersonation, we could require that Alice and Bob both bring something on paper to their meeting.

    • Bob brings the contact voucher.

    • Alice brings a fingerprint for the VoucherReplyPublicKey (thwarting the active attacker).

  2. Only one thing that needs to delivered out-of-band to achieve a two-pass protocol (instead of a three-pass protocol):

    • One of the parties needs to bring key material to a meeting in order to establish contact.

The following diagram illustrates the contact voucher authentication handshake.

FigureΒ 1.Β Contact voucher handshake

Bob and Alice exchange cryptographic information with the mixnet as proxy. They never have direct contact again after their initial meeting in real life.

Self-authenticating BACAP payload

The first message sent, the VoucherPayload, is authenticated in the following manner:

  1. The VoucherPayload is computed.

  2. A cryptographic hash of the VoucherPayload is computed. This hash is the Voucher.

  3. The Voucher is used to derive a BACAP read/write capability set.

  4. The VoucherPayload is uploaded to the BACAP sequence described by the capability (at index 0).

  5. Anyone who intercepts the Voucher can read and write the sequence.

  6. But: Since the Voucher is a hash over the VoucherPayload, writing the sequence with anything but the VoucherPayload will be detectable by the recipient.

  7. This means that the contents cannot be modified undetectably by the interceptor.

5 - Pigeonhole services

How clients discover and connect to pigeonhole services through the service directory and the voucher router

This document explains how three components work together to let a client reach a pigeonhole service it has never talked to. Two are programs that mixnet operators run; the third is a library for writing pigeonhole services.

  • The service directory (capability service_directory) holds a descriptor for each pigeonhole service, saying how to reach it. It is the service-directory program in katzenpost, under cmd/service-directory, built on the pigeonservice/directory package. Operators run it as a plugin on a service node.
  • The voucher router (capability voucher_router) relays a client’s first message to a pigeonhole service. It is the voucher-router program in katzenpost, under cmd/voucher-router, built on the pigeonservice/router package. Operators run it as a plugin on a service node.
  • The pigeonhole service library is for writing pigeonhole services, and the clients that reach them, in Go or Rust. A pigeonhole service is the responder side of an initiator–responder protocol over Pigeonhole: it accepts new connections from clients and then speaks its own protocol over each one, such as a bot that joins group chats, or a binary protocol no human reads. The Go library is the pigeonservice package in katzenpost; the Rust library is the services module of the pigeonhole_protocols crate in pigeonhole_protocols, which also has ELIZA, a pigeonhole service written with it. The two speak the same wire format.
Part Where Status
This specification website #246 in review
Service directory katzenpost cmd/service-directory #1247 in review
Voucher router katzenpost cmd/voucher-router #1247 in review
Go library katzenpost pigeonservice #1247 in review
Rust library pigeonhole_protocols src/services #11 in review
ELIZA pigeonhole_protocols src/bin/eliza.rs #11 in review
Rust thin client thin_client #100 in review

A pigeonhole service has no address and no open port; everything it sends or receives is a box on the storage replicas. The directory and the router bootstrap the first contact.

Many operators should run the directory and the router. If one operator ran the only instance of each, that operator would be a single point of failure for every pigeonhole service. With two or more there is none: a pigeonhole service registers with every router and advertises in every directory the PKI document lists, and a client can look it up in any directory and route through any of a service’s routers. A pigeonhole service notices when a new PKI document lists more routers or directories, and starts using them.

The bootstrap of a connection

Afterwards the client and the service share a connection of two Pigeonhole streams, one written by each, and each holds the other’s read cap. The router and the directory take no further part. Every party reaches the mixnet through a client daemon, kpclientd, which does all the cryptography that involves keys.

Contact vouchers

Every connection starts from a contact voucher: one side mints it, the other uses it, and both end up holding each other’s read cap. A voucher can be used once. The protocol is specified in Contact voucher and narrated in Contact voucher protocol narration. Here it matters only who mints the voucher and how it travels.

A client could instead send a service a read cap and a write cap directly. Vouchers are used because:

  • Forward secrecy. Routers keep forwarded messages for days, sealed to the service’s long-term key. Caps in them would stay valid for anyone who later gets that key; a used voucher is worthless, since its payload is tombstoned and its reply is sealed to a key only the client holds.
  • Each side makes its own stream. No one holds a write cap to the other’s stream.
  • The client knows it was accepted. The voucher reply confirms the connection; with bare caps the client cannot tell an idle service from one that never got them.
  • One token for every path. The same short, single-use token is routed, offered, or pasted into a chat app.

The cost is a few extra round trips before the first message.

Identity

A service is known by its NIKE keypair, x25519-ctidh1024, kept in its data directory so it survives restarts. Its ServiceID is the BLAKE2b-256 hash of the public key, written as 64 hex digits.

The router and the directory publish their NIKE public keys in the PKI document, as the parameters nikeScheme and publicKey of their service node entry. A client trusts a router or a directory because the PKI lists it, and trusts nothing a directory says without checking it against the PKI.

Registering with routers

A service mints a voucher and sends it to each voucher router in the PKI in a RegisterRequest, with its public key and a MAC. The router uses the voucher, and the connection that results is the service’s link with that router. The router writes every sealed voucher addressed to the service to the link as a LinkMessage, and keeps them for seven days, so a service that was offline still receives them.

The authorities publish a new PKI document once an epoch, and the client daemon tells its thin clients about each one. On each new document a service registers with any router listed in it that it has no link with. A registration that fails, and a link whose reader fails, are forgotten, so the next document registers with that router again.

Advertising and discovery

Once a service holds a link, it advertises its ServiceDescriptor in every directory in the PKI: its public key and the routers it holds links with. An AdvertiseRequest carries a MAC and a sequence number, the time in nanoseconds; the directory keeps only the descriptor with the highest. A WithdrawRequest removes it, and its sequence number stays, so a replayed older advertisement cannot bring it back.

A client finds one service with a LookupRequest, asking each directory in turn and checking that the descriptor it gets is the service’s. A ListRequest instead returns the read cap of a snapshot stream, where the directory writes all its descriptors whenever one changes; a client merges the lists of every directory, so no single directory can hide a service’s routers.

Dialing a service

A client mints a voucher, seals it to the service’s public key, and sends it to one of the service’s routers in a RouteRequest. It tries only routers the PKI lists, five minutes each. The router forwards the sealed voucher over its link without being able to open it; the service opens it, uses it, and answers in the voucher reply. The router learns which service is being contacted and when, but not by whom, since the request travels over Sphinx with a reply SURB.

A chat app that cannot route vouchers itself can mint one and hand it to pigeonhole-voucher send SERVICE_IDENTITY VOUCHER_TOKEN.

The other direction needs no router or directory: a service mints a voucher and offers it, for a client to use.

Connections

Each side writes its messages, one per box, to its own stream, and reads the peer’s stream box by box. A message is at most one box. Replicas keep a box for one to two replica epochs, so a connection rewrites everything it sent in the last seven days once a replica epoch, and saves its state after every send and receive: either side can be offline for days, or restart.

Request authentication

RegisterRequest, AdvertiseRequest and WithdrawRequest carry a MAC that proves the request comes from the holder of the service’s private key, without a challenge round:

ss  = DH(servicePriv, pluginPub) = DH(pluginPriv, servicePub)
key = HKDF-BLAKE2b-256(ss, salt = none, info = "katzenpost-pigeonservice-mac-v1" || pluginPub || servicePub)
mac = BLAKE2b-256(key = key, msg = canonical CBOR of the request with MAC null)

pluginPub is the router’s or the directory’s public key from the PKI. An all-zero shared secret is rejected, and MACs are compared in constant time.

Messages

Requests are the payloads of Sphinx queries to the plugin’s endpoint, and replies come back in the reply SURB. Every message is canonical CBOR, a map keyed by the Go field names. A decoder accepts the zero padding Sphinx adds after the item. The daemon retransmits a request until a reply arrives, so the router and the directory treat a repeated request as one.

const (
	RouterCapability    = "voucher_router"
	DirectoryCapability = "service_directory"
	ParamNIKEScheme     = "nikeScheme"
	ParamPublicKey      = "publicKey"
)

const (
	StatusOK uint8 = iota
	StatusInvalidRequest
	StatusBadMAC
	StatusUnknownService
	StatusStale
	StatusBusy
	StatusInternalError
)

type ServiceID [32]byte

type RouterRef struct {
	NodeIDHash [32]byte
	Endpoint   string
}

type ServiceDescriptor struct {
	NIKEScheme string
	PublicKey  []byte
	Routers    []RouterRef
}

// To a voucher router; exactly one field is set.
type RouterRequest struct {
	Route    *RouteRequest    `cbor:",omitempty"`
	Register *RegisterRequest `cbor:",omitempty"`
}

type RouteRequest struct {
	ServiceID  ServiceID
	Ciphertext []byte // the voucher, sealed to the service
}

type RegisterRequest struct {
	NIKEScheme string
	PublicKey  []byte
	Voucher    []byte
	MAC        []byte
}

type RouterReply struct {
	Status uint8
}

// One box on the router's stream of a link.
type LinkMessage struct {
	Forward []byte `cbor:",omitempty"` // a client's sealed voucher
}

// To a service directory; exactly one field is set.
type DirectoryRequest struct {
	Advertise *AdvertiseRequest `cbor:",omitempty"`
	Withdraw  *WithdrawRequest  `cbor:",omitempty"`
	Lookup    *LookupRequest    `cbor:",omitempty"`
	List      *ListRequest      `cbor:",omitempty"`
}

type AdvertiseRequest struct {
	Descriptor ServiceDescriptor
	Seq        uint64
	MAC        []byte
}

type WithdrawRequest struct {
	ServiceID ServiceID
	Seq       uint64
	MAC       []byte
}

type LookupRequest struct {
	ServiceID ServiceID
}

type ListRequest struct{}

type DirectoryReply struct {
	Status     uint8
	Descriptor *ServiceDescriptor `cbor:",omitempty"` // for Lookup
	ReadCap    []byte             `cbor:",omitempty"` // for List: the snapshot stream
	StartIndex []byte             `cbor:",omitempty"`
}

// A snapshot stream holds a 4-byte big-endian length, then this.
type Snapshot struct {
	Seq         uint64
	Descriptors []ServiceDescriptor
}

// The voucher reply, in the shape of katzenqt's ReplyWho with one member.
type whoReply struct {
	PleaseAdds []pleaseAdd `cbor:"please_adds"`
}

type pleaseAdd struct {
	DisplayName string `cbor:"display_name"`
	ReadCap     []byte `cbor:"read_cap"`
}
pub const ROUTER_CAPABILITY: &str = "voucher_router";
pub const DIRECTORY_CAPABILITY: &str = "service_directory";
pub const PARAM_NIKE_SCHEME: &str = "nikeScheme";
pub const PARAM_PUBLIC_KEY: &str = "publicKey";

#[repr(u8)]
pub enum Status { Ok = 0, InvalidRequest, BadMac, UnknownService, Stale, Busy, InternalError }

pub struct ServiceId(pub [u8; 32]);

pub struct RouterRef {
    pub node_id_hash: [u8; 32],
    pub endpoint: String,
}

pub struct ServiceDescriptor {
    pub nike_scheme: String,
    pub public_key: Vec<u8>,
    pub routers: Vec<RouterRef>,
}

pub enum RouterRequest {
    Route(RouteRequest),
    Register(RegisterRequest),
}

pub struct RouteRequest {
    pub service_id: ServiceId,
    pub ciphertext: Vec<u8>, // the voucher, sealed to the service
}

pub struct RegisterRequest {
    pub nike_scheme: String,
    pub public_key: Vec<u8>,
    pub voucher: Vec<u8>,
    pub mac: Option<Vec<u8>>,
}

pub enum DirectoryRequest {
    Advertise(AdvertiseRequest),
    Withdraw(WithdrawRequest),
    Lookup(ServiceId),
    List,
}

pub struct AdvertiseRequest {
    pub descriptor: ServiceDescriptor,
    pub seq: u64,
    pub mac: Option<Vec<u8>>,
}

pub struct WithdrawRequest {
    pub service_id: ServiceId,
    pub seq: u64,
    pub mac: Option<Vec<u8>>,
}

pub struct DirectoryReply {
    pub status: u8,
    pub descriptor: Option<ServiceDescriptor>, // for Lookup
    pub read_cap: Vec<u8>,                     // for List: the snapshot stream
    pub start_index: Vec<u8>,
}

pub struct Snapshot {
    pub seq: u64,
    pub descriptors: Vec<ServiceDescriptor>,
}

// The router reply, the link message and the voucher reply have no type of
// their own; these encode and decode them.
pub fn encode_router_reply(status: Status) -> Vec<u8>;
pub fn decode_status(raw: &[u8]) -> Result<u8, Error>;
pub fn encode_link_message(forward: &[u8]) -> Vec<u8>;
pub fn decode_link_message(raw: &[u8]) -> Result<Vec<u8>, Error>;
pub(crate) fn encode_who_reply(display_name: &str, read_cap: &[u8]) -> Vec<u8>;

Interfaces

The mixnet

A service reaches the mixnet through one interface, which the thin client implements and tests replace with an in-memory network.

type Pigeonhole interface {
	NewStream() (*bacap.WriteCap, *bacap.ReadCap, *bacap.MessageBoxIndex, error)
	Write(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex, plaintext []byte) (*bacap.MessageBoxIndex, error)
	Read(ctx context.Context, readCap *bacap.ReadCap, idx *bacap.MessageBoxIndex) ([]byte, *bacap.MessageBoxIndex, error)
	Tombstone(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex) error
	WritePayload(ctx context.Context, writeCap *bacap.WriteCap, idx *bacap.MessageBoxIndex, payload []byte) (*bacap.MessageBoxIndex, error)
	MaxPlaintext() int

	VoucherMint(messageWriteCap []byte, displayName string) (*MintResult, error)
	VoucherInduct(voucher, payload, whoReply []byte) (*InductResult, error)
	VoucherOpen(secretKey, sealedReply, messageWriteCap []byte) (*OpenResult, error)
	VoucherDeriveStream(voucher []byte) (writeCap, readCap []byte, err error)

	BoxSeal(nikeScheme string, publicKey, plaintext []byte) ([]byte, error)

	Request(ctx context.Context, node *[32]byte, endpoint []byte, payload []byte) ([]byte, error)
	Services(capability string) ([]ServiceNode, error)
}

func NewThinPigeonhole(c *thin.ThinClient) Pigeonhole
pub trait Backend: Send + Sync + 'static {
    fn new_keypair(&self) -> impl Future<Output = Result<Keypair>> + Send;
    fn write_box(&self, write_cap: &[u8], index: &[u8], plaintext: &[u8]) -> impl Future<Output = Result<()>> + Send;
    fn read_box(&self, read_cap: &[u8], index: &[u8]) -> impl Future<Output = Result<Read>> + Send;
    fn replica_epoch(&self) -> impl Future<Output = Result<u64>> + Send;
    fn max_plaintext(&self) -> impl Future<Output = Result<usize>> + Send;

    fn voucher_mint(&self, write_cap: &[u8], name: &str) -> impl Future<Output = Result<Minted>> + Send;
    fn voucher_derive_stream(&self, voucher: &[u8]) -> impl Future<Output = Result<(Secret, Secret)>> + Send;
    fn voucher_induct(&self, voucher: &[u8], payload: &[u8], who_reply: &[u8]) -> impl Future<Output = Result<Inducted>> + Send;
    fn voucher_open(&self, secret_key: &[u8], sealed_reply: &[u8], write_cap: &[u8]) -> impl Future<Output = Result<Opened>> + Send;

    fn request(&self, node: &[u8; 32], endpoint: &[u8], payload: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
    fn services(&self, capability: &str) -> impl Future<Output = Result<Vec<ServiceNode>>> + Send;

    fn nike_generate_key_pair(&self, scheme: &str) -> impl Future<Output = Result<(Vec<u8>, Secret)>> + Send;
    fn nike_derive_secret(&self, scheme: &str, private: &[u8], public: &[u8]) -> impl Future<Output = Result<Secret>> + Send;
    fn box_seal(&self, scheme: &str, public: &[u8], plaintext: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
    fn box_open(&self, scheme: &str, private: &[u8], ciphertext: &[u8]) -> impl Future<Output = Result<Vec<u8>>> + Send;
}

// `daemon` is `@name` for an abstract unix socket, a socket path, or `host:port`.
impl ThinClientBackend {
    pub async fn dial(daemon: &str) -> Result<ThinClientBackend, Box<dyn std::error::Error>>;
}

Writing a service

A service runs a handler for each client’s session. A session is a connection, and ends when its handler returns. Closing the service keeps its sessions on disk for the next start.

func LoadServiceKey(dataDir string) (*ServiceKey, error)
func NewService(cfg Config, ph Pigeonhole) (*Service, error)

func (s *Service) ID() ServiceID
func (s *Service) Descriptor() *ServiceDescriptor
func (s *Service) Run(ctx context.Context, h Handler) error
func (s *Service) Close() error

type Handler interface {
	OnSession(ctx context.Context, s *Session)
}

type Session struct {
	*Conn
	// ...
}

func (c *Conn) Send(ctx context.Context, msg []byte) error
func (c *Conn) Recv(ctx context.Context) ([]byte, error)
impl<B: Backend> Service<B> {
    pub fn new(cfg: Config, ph: Arc<B>) -> Result<Self, Error>;
    pub fn id(&self) -> ServiceId;
    pub fn descriptor(&self) -> ServiceDescriptor;
    pub async fn run<F, Fut>(&self, handler: F) -> Result<(), Error>
    where
        F: Fn(Session<B>) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = ()> + Send + 'static;
    pub async fn offer(&self) -> Result<Vec<u8>, Error>;
    pub async fn close(&self);
}

// A Session derefs to its Conn.
impl<B: Backend> Conn<B> {
    pub async fn send(&self, msg: &[u8]) -> Result<(), Error>;
    pub async fn recv(&self) -> Result<Vec<u8>, Error>;
}

Reaching a service

type Discoverer interface {
	Lookup(ctx context.Context, id ServiceID) (*ServiceDescriptor, error)
	List(ctx context.Context) ([]ServiceDescriptor, error)
}

type Advertiser interface {
	Advertise(ctx context.Context, d *ServiceDescriptor) error
	Withdraw(ctx context.Context, id ServiceID) error
}

// Package directory: both, over every directory in the PKI.
func NewAll(ph ps.Pigeonhole, key *ps.ServiceKey, pollInterval time.Duration) (*All, error)

// Mints a voucher, routes it to the service and waits for its reply.
func Dial(ctx context.Context, ph Pigeonhole, d *ServiceDescriptor, displayName string, pollInterval time.Duration) (*Conn, error)
// Routes a voucher someone else minted.
func Route(ctx context.Context, ph Pigeonhole, d *ServiceDescriptor, voucher []byte) error
// Uses a voucher a service offered.
func Induct(ctx context.Context, ph Pigeonhole, voucher []byte, displayName string, pollInterval time.Duration) (*Conn, error)
// One per directory in the PKI.
impl<B: Backend> Directory<B> {
    pub async fn all(ph: Arc<B>, key: Option<Keypair>, poll: Duration) -> Result<Vec<Self>, Error>;
    pub async fn lookup(&self, id: ServiceId) -> Result<ServiceDescriptor, Error>;
    pub async fn list(&self) -> Result<Vec<ServiceDescriptor>, Error>;
    pub async fn advertise(&self, d: &ServiceDescriptor) -> Result<(), Error>;
    pub async fn withdraw(&self, id: ServiceId) -> Result<(), Error>;
}

/// Mints a voucher, routes it to the service and waits for its reply.
pub async fn dial<B: Backend>(ph: Arc<B>, d: &ServiceDescriptor, display_name: &str, poll: Duration) -> Result<Conn<B>, Error>;
/// Routes a voucher someone else minted.
pub async fn route<B: Backend>(ph: &B, d: &ServiceDescriptor, voucher: &[u8]) -> Result<(), Error>;
/// Uses a voucher a service offered.
pub async fn induct<B: Backend>(ph: Arc<B>, voucher: &[u8], display_name: &str, poll: Duration) -> Result<Conn<B>, Error>;

Programs

Program Where What it does
voucher-router katzenpost cmd/voucher-router the voucher router plugin
service-directory katzenpost cmd/service-directory the service directory plugin
pigeonhole-voucher katzenpost cmd/pigeonhole-voucher list the services; send a service a voucher
pigeonhole-echo katzenpost cmd/pigeonhole-echo a service that sends back each message
eliza pigeonhole_protocols src/bin/eliza.rs ELIZA, in katzenqt’s chat framing
motd-service pigeonhole_protocols cmd/motd-service a Go service that answers each request with the message of the day, read from a file
motd pigeonhole_protocols cmd/motd fetches the message of the day from a motd-service

Talking to ELIZA

ELIZA can be dialed from any of our chat clients: katzenqt, jam, frank_chat or hushphone. Generate a voucher in the client, find ELIZA’s service identity among the services pigeonhole-voucher list shows, and send ELIZA the voucher token:

pigeonhole-voucher --thin_config thinclient.toml list
pigeonhole-voucher --thin_config thinclient.toml send SERVICE_IDENTITY VOUCHER_TOKEN

ELIZA inducts you into a new chat, opens it with a greeting, and answers each message you send.

Running the router and the directory

The voucher router and the service directory run on a service node: a special kind of mix node in a Katzenpost mix network that delivers Sphinx packet payloads to services and sends their answers back in SURB replies. The node runs each service as a plugin, beside others such as the courier. An operator adds the two plugins to the [ServiceNode] section of the node’s configuration:

[[ServiceNode.CBORPluginKaetzchen]]
  Capability = "voucher_router"
  Endpoint = "voucher_router"
  Command = "/usr/local/bin/voucher-router"
  MaxConcurrency = 1
  [ServiceNode.CBORPluginKaetzchen.Config]
    data_dir = "/var/lib/katzenpost-servicenode/voucher_router"
    log_dir = "/var/log/katzenpost/voucher_router"
    thin_config = "/etc/katzenpost-servicenode/pigeonservice-thinclient.toml"

[[ServiceNode.CBORPluginKaetzchen]]
  Capability = "service_directory"
  Endpoint = "service_directory"
  Command = "/usr/local/bin/service-directory"
  MaxConcurrency = 1
  [ServiceNode.CBORPluginKaetzchen.Config]
    data_dir = "/var/lib/katzenpost-servicenode/service_directory"
    log_dir = "/var/log/katzenpost/service_directory"
    thin_config = "/etc/katzenpost-servicenode/pigeonservice-thinclient.toml"

Capability is the name the plugin is listed under in the PKI document, and Endpoint the name a client addresses its requests to. Each plugin has its own data directory, for its key and database, and its own log directory. Because the router and the directory also read and write Pigeonhole streams, each reaches the mixnet through a client daemon, kpclientd, named by its thin client configuration. On first start a plugin generates its NIKE keypair, and the node publishes the public key in the PKI document.

6 -

Group chat specification

Threebit Hacker

David Stainton

Abstract


Prerequisites

This design specification is dependent on the BACAP and Pigeonhole protocol designs from our paper Echomix: A Strong Anonymity System with Messaging, which describes

  • the BACAP (blinded and capability) in Β§4 and

  • the Pigeonhole protocol in Β§5.

The source code and API docs for BACAP are available on pkg.go.dev.

Introduction

The Pigeonhole protocol establishes anonymous cryptographic communication channels which have a readcap (read capability) and a writecap (write capability). For example, if Alice and Bob want to communicate, they can each create their own Pigeonhole/BACAP channels and exchange readcaps on those channels. Now when Bob writes to his channel, Alice can read those messages because she has Bob’s readcap. Likewise, when Alice writes to her channel, Bob can read those messages because he has Alice’s readcap. This is the most basic construction using BACAP and Pigeonhole.

Here we extend this basic design to work as a minimal group-chat protocol, without key rotation.

BACAP primitives give us two message types:

  • SingleMessage

  • AllOrNothingMessage (used for big messages: upload n chunks to a temporary stream and then put a pointer to that in your own stream as a single message.)

The group state consists of:

  • a MembershipCap for each member, containing:

    • a BACAP readcap

    • the index of the next box to read, kept separately from the readcap

    • a nickname

  • a MembershipHash (a hash over the members’ read caps)

The group chat is completely decentralized. Each member must keep track of every other member.

Group chat message types

All messages are SingleMessage if they fit in one BACAP slot, or an AllOrNothingMessage if they are too big.

  • Text type payloads are normal chat text messages.
// TextPayload encapsulates a normal text message.
type TextPayload struct {
    // Payload contains a normal UTF-8 text message to be displayed inline.
    Payload []byte
}
  • Introduction type messages introduce new group members.
// Introduction introduces a new member to the group.
type Introduction struct {
    // DisplayName is the party's name to be displayed in chat clients.
    DisplayName string
    
    // UniversalReadCap is the BACAP UniversalReadCap
    // which lets you read all messages posted by this user.
    UniversalReadCap *bacap.UniversalReadCap
}
  • FileUpload type

A FileUpload can be used for various purposes such as uploading an image to be displayed inline by the chat client. Likewise, a sound bite could be made visible in the chat along with a play-button. Beyond that, we can support arbitrary file attachments.

// FileUpload encapsulates several file types
// which result in different client behaviors.
type FileUpload struct {
    // Payload contains the file payload.
    Payload []byte
    
    // FileType is the identifier for each file type.
    // Valid file types are:
    // "image"
    // "sound"
    // "arbitrary"
    FileType string
}
  • Who type

The Who message type is used to query who is currently in the group.

// Who is used to query the group chat to find out the member read capabilities.
type Who struct {}
  • ReplyWho type

The ReplyWho message answers the Who query with an AllOrNothingMessage BACAP stream containing readcaps for all group chat members, each with the index to start reading at.

type ReplyWho struct {
    Payload *bacap.BacapStream
}
  • GroupChatMessage type

The GroupChatMessage message encapsulates all of the above-mentioned message types and is serialized with CBOR.

// GroupChatMessage encapsulates all chat message types.
type GroupChatMessage struct {
    // Version is used to ensure we can change this message type in the future.
    Version int

    // MembershipHash is the sender's view of who is in the group.
    MembershipHash [32]byte

    TextPayload *TextPayload
    Introduction *Introduction
    FileUpload *FileUpload
    Who *Who
    ReplyWho *ReplyWho
}

Every message carries the sender’s MembershipHash: SHA-256 over the domain separator KP:membership:v1 followed by the 32-byte public key of each active member’s read cap, the sender’s own included, without repeats and in ascending byte order. An Introduction carries the hash of the group as it stood before the member it introduces, and a reader compares it with its own view less that member, whether or not it holds the member yet: the reader may be the member introduced. A reader reports a mismatch; it never drops a message for one. It is the one sign that two members disagree about who is in the group.

Protocol flow

The protocol flow for making a new group from scratch (using whatever authentication protocol) is essentially for everybody to exchange PleaseAdd messages.

// PleaseAdd is a message used by a client to try and gain access to a chat group.
type PleaseAdd struct {
    // DisplayName is the party's name to be displayed in chat clients.
    DisplayName string
    
    // UniversalReadCap is the BACAP UniversalReadCap
    // which lets you read all messages posted by this user.
    UniversalReadCap *bacap.UniversalReadCap
}

type SignedPleaseAdd struct {
    // PleaseAdd contains the CBOR serialized PleaseAdd struct.
    PleaseAdd []byte
    
    // Signature contains the cryptographic signature over the PleaseAdd field.
    Signature []byte
}

For introduction to an existing group over an existing channel between an introducer member and new member, an Invitation message is used.

type Invitation struct {
    GroupName string
}

The Invitation protocol flow works as follows.

  1. There exists a group called YoloGroup. A member of the group invites a potential new member with an Invitation message.

  2. If the invited party wants to join, then they reply with a SignedPleaseAdd message meaning “I want to join your group.” This provides the invited party’s BACAP universal readcap, their display name, and a cryptographic signature produced by their BACAP writecap.

  3. The introducer receives the SignedPleaseAdd message.

    1. If the introducer does not like the DisplayName, they reply to the invited party with a PleaseReviseDisplayName message that contains the original SignedPleaseAdd. Then they wait for a new SignedPleaseAdd.

    2. If the introducer approves of the DisplayName, then:

      • Because existing members need the new member’s readcap, the introducer publishes the SignedPleaseAdd to their own BACAP stream for the rest of the group to read.

      • Because the new member needs existing members’ readcaps, the introducer replies to the new member with ReplyWho message containing readcaps for all existing members, each with the index to start reading at.

        IMPORTANT: The content of both replies must be sent in the same AllOrNothingMessage, despite the SignedPleaseAdd being written to the introducer’s own BACAP stream for the group and the ReplyWho being written to the BACAP stream the introducer is using to communicate with the new member.

        NOTE: We could send all of this information as part of the initial Invitation, but that would allow silent members to read other members’ streams without them knowing it, which is an anti-goal.

Opportunistic acknowledgements and backfill

Pigeonhole boxes are garbage-collected after one to two weeks (see “Ephemeral” in Understanding Pigeonhole). After that, only the author has the content. Acknowledgements tell the author what to restore.

Any group message may carry acknowledgements. None is sent on its own. GroupChatMessage gains one field:

type GroupChatMessage struct {
    Version int
    MembershipHash [32]byte

    TextPayload *TextPayload
    Introduction *Introduction
    FileUpload *FileUpload
    Who *Who
    ReplyWho *ReplyWho

    Acks []byte
    Numbers []byte
    IntroducedAt []byte
}

This new Acks field is a mapping from roster indexes (see Rosters below) to BACAP Idx64 values. Its encoding is described in Acks wire format. Acks is nil when we don’t have anything to acknowledge. Numbers and IntroducedAt keep rosters in step; they are described under Rosters below.

TextPayload and FileUpload are content. Introduction, Numbers and IntroducedAt are control. A message carries either content or control, never both. The retention policy deletes content only (see Disappearing messages), so a message that changes a roster stays readable for as long as its Sent-box record lasts.

Throughout this specification we use roster indexes as the channel identities. Alice keeps a list of the channels she has numbered and refers to each by its place in that list, counting from 0: 0 is Alice herself, 1 is the next member she numbered, and so on. One byte names a member, which keeps acknowledgements small.

  • Each value in the key-value mapping is the Idx64 of a box on that member’s stream. It acknowledges that box and every earlier box from where the acknowledging member started reading the stream, so a member acknowledges a box only once it has read it and every box before it: the end of the unbroken run it has read. A box read as a tombstone counts as read, since its plaintext is gone for every reader. A box a scan stepped past without reading (see Rewrite and scan) is not read until a recheck reads it, and until then the member’s acknowledgements of that stream stop just before it.
  • The acknowledging member is whoever’s stream carried the message.
  • A stream owner checks each claimed Idx64 against its Sent-box records and ignores one matching no box it wrote.

A MessageBoxIndex is the whole BACAP derivation state for one box. Its first 8 bytes, little-endian, are the box’s Idx64. BACAP’s derivation adds one to the Idx64 at each step and never wraps: a stream ends rather than reach 264. On one stream an Idx64 therefore names exactly one box, and orders the stream’s boxes as they were written. Every member holds every member’s read cap, and so each stream’s first index, from which BACAP’s AdvanceIndexTo derives the box at any later Idx64. An acknowledgement names a box by its Idx64, 8 bytes instead of 104. An Idx64 means nothing without the stream it belongs to, so a reader MUST resolve an acknowledgement’s roster index before reading its value. IntroducedAt and the reply to a new member name a box by its whole MessageBoxIndex.

Rosters. Members are named by one-byte roster indexes, not public keys, to save bytes on constrained transports such as LoRa.

  • Roster. Each member keeps an ordered list of the members it has numbered, itself included. A member’s roster index is its position in that list. Entries are never moved or reused, so a roster holds at most 256 members ever. Every member keeps a copy of every other member’s roster. A member’s identity is its read-cap public key.

  • Starting. A lone founder’s roster holds only itself. Co-founders each start with all founders in ascending order of read-cap public key. A new member starts with a copy of its introducer’s roster as of the Introduction announcing it.

  • Numbering. A member numbers another by appending it to its roster, taking its next unused index, and announcing the assignment in the Numbers field of a control message. Members read Introductions on different streams in different orders, so only the member itself knows which index it gave whom; Numbers says so. A message numbers members at consecutive indexes and names each by the first 8 bytes of its read-cap public key: enough to tell a group’s members apart, and too many for a joiner to choose a key matching another member’s. An introducer numbers the new member in the message carrying its Introduction, which needs only the index assigned. Every other member numbers a new member after reading its Introduction, and MUST do so before acknowledging the new member’s stream.

  • Watching. A reader learns each sender’s roster from that sender’s Numbers. Until the reader knows a member whose key begins with an entry’s prefix, from an Introduction it has read or from the reply that inducted it, the member at that index is unresolved.

  • Introduced at. A new member learns where its Introduction landed by reading its introducer’s stream, and its next message after that is a control message carrying IntroducedAt: its introducer’s roster index and the MessageBoxIndex of the Introduction it joined through. A member can be introduced more than once, so readers take the new member’s starting roster from the Introduction that IntroducedAt names: the introducer’s roster up to and including the index that Introduction assigned. Indexes are assigned in order and never change, so that is the introducer’s roster as it stood then. Until readers have read that Introduction, the new member’s roster is unknown and its acknowledgements are skipped.

  • Repeats. A member already in a roster is never added again.

  • Reply to a new member. The introducer hands the new member the group privately, in its last message of the contact voucher protocol: the VoucherReply, written in the same all-or-nothing commit as the Introduction. Its WhoReply (ReplyWho here) is built before the new member’s Introduction has a MessageBoxIndex, so it names the new member only by the roster index it will take. It carries:

    • every member’s read cap, each with its start index (see Joining), in the introducer’s roster order, so a member’s place in the list is its roster index. A removed member’s slot is sent empty.
    • every member’s roster, as the introducer has followed it.
    • every Introduction the introducer has read: the roster index of the member that wrote it, its MessageBoxIndex, and the roster index of the member it introduced. An IntroducedAt names an Introduction, and the new member never reads the boxes before its start indexes, so without these it could never resolve one naming an Introduction written before it joined.
    // Rosters holds each member's roster, in introducer roster order.
    // Each byte is the introducer's roster index for that entry.
    Rosters [][]byte
    
    // Introductions holds each Introduction the introducer has read.
    Introductions []IntroductionRef
    
    type IntroductionRef struct {
        // Introducer is the roster index of the member that wrote it.
        Introducer byte
        // Index is the MessageBoxIndex of the box it was written in.
        Index [104]byte
        // Member is the roster index of the member it introduced.
        Member byte
    }
    

    Roster indexes in the reply are the introducer’s.

  • Reading. Resolve each index against the sender’s roster. Skip values at unresolved indexes. Roster indexes MUST NOT be compared across senders.

  • Skipped boxes. A reader that skips a box may miss a Numbers field. Every field states its first index, so later ones still land in place, and the missed indexes stay unresolved.

  • Removal. A member may silently stop reading a stream. It keeps the roster entry, MUST NOT acknowledge that index again, and MUST NOT reuse it. An entry still named only by a key prefix is the removed member’s only if the prefix matches no other member it knows. It no longer waits for the removed member’s acknowledgements before discarding plaintext. The removed member keeps its read caps.

Sent-box records. A stream owner records, for each box it wrote and under its Idx64, its MessageBoxIndex and its plaintext until every member has acknowledged it or its retention policy deletes the plaintext (see Disappearing messages). How far each member has acknowledged the owner’s stream is the Idx64 of the last box it acknowledged.

  • Retention. Once every other active member has acknowledged a box, its plaintext MAY be discarded, but its record is kept. A record is kept for as long as the retention policy keeps sent content, and for good when the policy keeps it for good, so an acknowledgement that arrives late still finds its box. Under a policy that deletes sent content, a record MUST be discarded after a bounded window exceeding one replica epoch.
  • Backfill. Once per replica epoch the owner rewrites every recorded box some active member has not acknowledged: with its plaintext if it still has it, otherwise as a tombstone, so the box still reads as written. A box every member has acknowledged is written once more as a tombstone when its plaintext is gone, to erase it from the replicas, and never again. When nobody is behind, nothing is written. Rewrites are idempotent since Pigeonhole writes are content-idempotent and BACAP encryption is deterministic. Acknowledgements never trigger a rewrite.
  • Rate limit. Implementations SHOULD NOT rewrite a box more than once per replica epoch.
  • Timing. A client takes the replica epoch from the PKI document, not its clock: the epoch most storage replicas publish envelope keys around. It takes up each epoch at a point in the epoch’s first half that only it can work out, so that members do not all rewrite at the same moment, and at once if it has missed a whole epoch.

Joining. A new member receives its introducer’s view of the group as of the Introduction, privately in the introducer’s VoucherReply: a read cap and a start index for every member’s stream in the introducer’s roster, the rosters the introducer knew about, and the Introductions it had read (see Reply to a new member). Read caps are sent unchanged; the index travels beside them and is advanced separately.

  • Catching up. Before an invitation begins, the introducer reads every stream as far as it can.
  • Start indexes. Each start index is the index of the next box the introducer had to read on that stream when the invitation began, and the introducer’s own is the next box it writes, no later than the Introduction.
  • Holding back. From the start of an invitation until the all-or-nothing commit that writes the Introduction, the introducer sends no acknowledgements and numbers no one. Its published acknowledgements then stop short of every start index, and its roster stays as the reply describes it.
  • Waiting. Before building the reply, the introducer waits until every message it has composed has been written. One composed earlier may number a member, taking the index the reply would otherwise give the new member.
  • Retention. A member that reads an Introduction keeps every box of its own stream that the introducer had not acknowledged at that point, until the new member acknowledges it or the member’s retention policy deletes it.
  • No race. Members read the introducer’s stream in order, so each sees the Introduction before any acknowledgement that would let it discard a box the new member was given.
  • History. Older history is sent from the introducer’s own copy, never by starting a stream earlier.

Acks wire format

We encode the Acks mapping by hand as a byte slice rather than as a CBOR map. The motivation is to let this protocol run over constrained pipes, such as LoRa radio links, where every byte counts.

Let n be the number of entries. Every value is an 8-byte Idx64, so the values cost 8n bytes in any encoding. Only the cost of naming the keys varies.

  • Simplest. A list of (roster index, value) pairs. The keys cost one byte each, n bytes in total.
  • Bitmap. A bitmap with one bit per roster index, followed by the values in ascending roster index order. Let b be the bytes a bitmap needs to reach the highest acknowledged index; b is at most 32. The keys cost b bytes. This beats the list when many members are acknowledged at once.
  • Most compact. Use whichever of the two key encodings is shorter: min(n, b) bytes, the list when they tie. Keys can be longer than one value, so the field length alone no longer says how many values follow: the field begins with one byte holding n - 1. This is the encoding Acks uses.

For example, if the highest acknowledged index is 19, the bitmap is 3 bytes. Acknowledging 3 members then costs 3 key bytes either way; acknowledging 10 costs 10 as a list but only 3 as a bitmap, and the whole field 1 + 3 + 80 bytes.

The most compact encoding is defined as follows.

  • Layout. Acks is one byte holding n - 1, then the keys, then the values in ascending roster index order, each little-endian. If n <= b, the keys are a list of n index bytes in ascending order. Otherwise they are a b-byte bitmap where index i is bit i mod 8, counting from the most significant bit, of byte i div 8.
  • Parsing. The first byte plus one is n. The key length is the field length less one, less 8n. A key length equal to n means a list; smaller means a bitmap. A parser MUST ignore the whole field if the key length is negative or exceeds n, a list is not strictly ascending, a bitmap’s last byte is zero, a bitmap does not have exactly n bits set, or a bitmap exceeds 32 bytes.

Numbers and IntroducedAt wire format

Numbers and IntroducedAt are encoded by hand for the same constrained pipes as Acks.

  • Numbers. In a message carrying an Introduction, a single byte: the roster index assigned to the member that Introduction introduces. Otherwise the first roster index assigned (1 byte), then the first 8 bytes of each member’s read-cap public key, one per consecutive index from the first: 1 + 8n bytes. Numbering alice’s next two members, erin and dave, takes 17 bytes. A parser MUST ignore the whole field if its length is neither of these, if it would assign an index above 255, or if a prefix appears twice.
  • IntroducedAt. One entry without its first byte: 105 bytes.

Rewrite and scan

A reader cannot tell a quiet stream from one stuck behind a garbage-collected box by reading it: both return BoxIDNotFound. Nor can it tell when a missing box will read again: that happens only once its owner, who may be offline, rewrites it. So a client never scans on its own.

  1. Periodic rewrite. Well within each replica epoch, the stream owner rewrites due boxes (see Backfill), keeping every recorded box populated.

  2. Kept indexes. A reader keeps the MessageBoxIndex of each box a scan stepped past without reading it, until a recheck reads it. BACAP indexes cannot be derived backward, so an index is kept as it is passed. A reader MAY drop kept indexes to bound what it keeps. A box whose index it drops is lost to it, and its acknowledgements of that stream never pass that box.

  3. Scan. The reader rechecks its kept indexes and ingests any box now readable, data or tombstone. If members have acknowledged boxes the reader has not yet reached, it advances its stream from the box it expects next to the furthest acknowledged Idx64, keeping the indexes it passes. The advance MUST be bounded: an acknowledged Idx64 beyond the bound is ignored, and not looked for again. The reader then probes forward without waiting between boxes: data is ingested, a tombstone is skipped, and the first BoxIDNotFound becomes the box it expects next, ending the scan.

  4. When to scan. A scan runs only when the user asks for one, one stream at a time. The client MAY show how long a stream has been quiet, whether other members have acknowledged boxes on it the reader has not reached, and how many boxes a scan stepped past are still unread.

A reader’s acknowledgements of a stream stop just before the first box it has not read, so the owner keeps that box’s plaintext and goes on rewriting it until its Sent-box record is discarded. A scan the user asks for before then delivers it. Two members stuck behind gaps in each other’s streams resynchronise once both scan within the owners’ retention windows. A box gone for good is lost to the reader: after a scan it reads on past it, and its acknowledgements of that stream stay before it.

Disappearing messages

Disappearing messages is the feature that causes a user’s client to remove messages from the client’s state database based on a user specified message retention policy. For example, a client’s message retention policy could keep decrypted plaintext messages from other members for X number of days. Likewise policy could retain sent message plaintext for Y number of days.

The retention policy applies to content only. Control messages are kept for as long as their Sent-box records last, so a message that disappears never takes a roster change with it.

Once a client makes a message “disappear” as per this feature, then after that point in time the client can never retransmit that box. Backfill rewrites it as a tombstone instead. The tombstone in place of the box is still very useful for resyncing. A member that reaches a tombstone in place of a box it has not yet read can also tell from it that it didn’t read your message in time before your client’s retention policy purged the plaintext data.

Addenda

GOOD QUESTION: If we are adding a lot of people at once,do we really need to upload all of the members n times?

OPEN PROBLEM: Our BACAP API defines a ReadCap type as containing the master public key AND the start index. Therefore whenever we send a readcap over the wire we are needlessly sending a start index even when we don’t need it anymore. We should probably fix this, especially given that our goal is to be as bandwidth efficient as possible.

FUTURE WORK: Forward secrecy. We can add two extensions that allow transmitting public keys + stuff encrypted under those public keys. We can also refer to the Reunion protocol which is a n-way PAKE with strong anonymity properties. Reunion is described in Communication in a world of pervasive surveillance: Sources and methods: Counter-strategies against pervasive surveillance architecture. Currently, Python and Go implementations of Reunion are available.

7 -

For distribution of epoch keys and agreement on the packet geometry, see the directory authority specification. That page distinguishes configured KEM Sphinx networks from the NIKE Sphinx values used in its namenlos example.

KEMSphinx specification

David Stainton

Abstract

Here I present a modification of the Sphinx cryptographic packet format that uses a KEM instead of a NIKE whilst preserving the properties of bitwise unlinkability, constant packet size and route length hiding.


Introduction

We’ll express our KEM Sphinx header in pseudo code. The Sphinx body will be exactly the same as the section called β€œReferences” Our basic KEM API has three functions:

  • PRIV_KEY, PUB_KEY = GEN_KEYPAIR(RNG)

  • ct, ss = ENCAP(PUB_KEY) - Encapsulate generates a shared secret, ss, for the public key and encapsulates it into a ciphertext.

  • ss = DECAP(PRIV_KEY, ct) - Decapsulate computes the shared key, ss, encapsulated in the ciphertext, ct, for the private key.

Additional notation includes:

  • || = concatenate two binary blobs together

  • PRF = pseudo random function, a cryptographic hash function, e.g.Β Blake2b.

Therefore we must embed these KEM ciphertexts in the KEMSphinx header, one KEM ciphertext per mix hop.

Post-quantum hybrid KEM

Special care must be taken in order correctly compose a hybrid post-quantum KEM that is IND-CCA2 robust.

The hybrid post quantum KEMs found in Cloudflare’s circl library are suitable to be used with Noise or TLS but not with KEM Sphinx because they are not IND-CCA2 robust. Noise and TLS achieve IND-CCA2 security by mixing in the public keys and ciphertexts into the hash object and therefore do not require an IND-CCA2 KEM.

Firstly, our post quantum KEM is IND-CCA2 however we must specifically take care to make our NIKE to KEM adapter have semantic security. Secondly, we must make a security preserving KEM combiner.

NIKE to KEM adapter

We easily achieve our IND-CCA2 security by means of hashing together the DH shared secret along with both of the public keys:

func ENCAPSULATE(their_pubkey publickey) ([]byte, []byte) {
my_privkey, my_pubkey = GEN_KEYPAIR(RNG)
ss = DH(my_privkey, their_pubkey)
ss2 = PRF(ss || their_pubkey || my_pubkey)
return my_pubkey, ss2
}

func DECAPSULATE(my_privkey, their_pubkey) []byte {
s = DH(my_privkey, their_pubkey)
shared_key = PRF(ss || my_pubkey || their_pubkey)
return shared_key
}

KEM combiner

The KEM Combiners paper ??? makes the observation that if a KEM combiner is not security preserving then the resulting hybrid KEM will not have IND-CCA2 security if one of the composing KEMs does not have IND-CCA2 security. Likewise the paper points out that when using a security preserving KEM combiner, if only one of the composing KEMs has IND-CCA2 security then the resulting hybrid KEM will have IND-CCA2 security.

Our KEM combiner uses the split PRF design from the paper when combining two KEM shared secrets together we use a hash function to also mix in the values of both KEM ciphertexts. In this pseudo code example we are hashing together the two shared secrets from the two underlying KEMs, ss1 and ss2. Additionally the two ciphertexts from the underlying KEMs, cct1 and cct2, are also hashed together:

func SplitPRF(ss1, ss2, cct1, cct2 []byte) []byte {
cct := cct1 || cct2
return PRF(ss1 || cct) XOR PRF(ss2 || cct)
}

Which simplifies to:

SplitPRF := PRF(ss1 || cct2) XOR PRF(ss2 || cct1)

The Split PRF can be used to combine an arbitrary number of KEMs. Here’s what it looks like with three KEMs:

func SplitPRF(ss1, ss2, ss3, cct1, cct2, cct3 []byte) []byte {
cct := cct1 || cct2 || cct3
return PRF(ss1 || cct) XOR PRF(ss2 || cct) XOR PRF(ss3 || cct)
}

KEMSphinx header design

NIKE Sphinx header elements:

  1. Version number (MACed but not encrypted)

  2. Group element

  3. Encrypted per routing commands

  4. MAC for this hop (authenticates header fields 1 thru 4)

KEM Sphinx header elements:

  1. Version number (MACed but not encrypted)

  2. One KEM ciphertext for use with the next hop

  3. Encrypted per routing commands AND KEM ciphertexts, one for each additional hop

  4. MAC for this hop (authenticates header fields 1 through 4)

We can say that KEMSphinx differs from NIKE Sphinx by replacing the header’s group element (e.g.Β an X25519 public key) field with the KEM ciphertext. Subsequent KEM ciphertexts for each hop are stored inside the β€œrouting information” section of the Sphinx packet header.

First we must have a data type to express a mix hop, and we can use lists of these hops to express a route:

type PathHop struct {
public_key kem.PublicKey
routing_commands Commands
}

Here’s how we construct a KEMSphinx packet header where path is a list of PathHop, and indicates the route through the network:

  1. Derive the KEM ciphertexts for each hop.
route_keys = []
route_kems = []
for i := 0; i < num_hops; i++ {
kem_ct, ss := ENCAP(path[i].public_key)
route_kems += kem_ct
route_keys += ss
}
  1. Derive the routing_information keystream and encrypted padding for each hop.

Same as in the section called β€œReferences” except for the fact that each routing info slot is now increased by the size of the KEM ciphertext.

  1. Create the routing_information block.

Here we modify the Sphinx implementation to pack the next KEM ciphertext into each routing information block. Each of these blocks is decrypted for each mix mix hop which will decrypt the KEM ciphertext for the next hop in the route.

  1. Assemble the completed Sphinx Packet Header and Sphinx Packet Payload SPRP key vector. Same as in SPHINXSPEC except the kem_element field is set to the first KEM ciphertext, route_kems[0]:
var sphinx_header SphinxHeader
sphinx_header.additional_data = version
sphinx_header.kem_element = route_kems[0]
sphinx_header.routing_info = routing_info
sphinx_header.mac = mac

KEMSphinx unwrap operation

Most of the design here will be exactly the same as in SPHINXSPEC. However there are a few notable differences:

  1. The shared secret is derived from the KEM ciphertext instead of a DH.

  2. Next hop’s KEM ciphertext stored in the encrypted routing information.

Acknowledgments

I would like to thank Peter Schwabe for the original idea of simply replacing the Sphinx NIKE with a KEM and for answering all my questions. I’d also like to thank Bas Westerbaan for answering questions.

References

KEMCOMB

Federico Giacon, Felix Heuer, Bertram Poettering, β€œKEM Combiners”, 2018, https://link.springer.com/chapter/10.1007/978-3-319-76578-5_7

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

8 -

This page describes the packet-loss statistics design. See directory descriptor uploads for the current signed upload structure and its LoopStats field. At v0.0.107, carrying these statistics does not make them inputs to the directory tally.

Propagation of mix decoy loop statistics

David Stainton

Eva Infeld

Leif Ryge

Abstract

In the context of continuous time mixing strategies such as the memoryless mix used by Katzenpost, n-1 attacks may use strategic packet loss. Nodes can also fail for benign reasons. Determining whether or not it’s an n-1 attack is outside the scope of this work. This document describes how we will communicate statistics from mix nodes to mix network directory authorities which tells them about the packet loss they are observing.


Terminology

The following terms are used in this specification.

epoch
A fixed-time interval with a current default value of 20 minutes. A new PKI document containing public key material is published for each epoch and is valid only for that epoch. For more information, see Sphinx mix and provider key rotation.

wire protocol
Refers to our PQ Noise-based protocol that currently uses TCP but in the near future will optionally use QUIC. This protocol has messages known as wire protocol commands, which are used for various mixnet functions such as sending or retrieving a message and dirauth voting. For more information, see our wire protocol specification.

Design overview

Nodes (mixes, gateways, and providers) need to upload packet-loss statistics to the directory authorities, so that authorities can label malfunctioning nodes as such in the consensus in the next epoch.

Nodes currently sign and upload a Descriptor in each epoch.

In the future, they would instead upload an UploadDescStats containing:

  • Descriptor

  • Stats

  • Signature

Contains a map from pairs-of-mixes to the ratio of count-of-loops-sent vs. count-of-loops-received.

Tracking packet loss and detecting faulty mixes

Katzenpost lets different elements in the network track whether other elements are functioning correctly. A node A will do this by sending packets in randomly generated loops through the network, and tracking whether the loop comes back or not. When it comes back, it will mark that as evidence, that the nodes on the path of that loop are functioning correctly.

Experimental setup, node A:

  • Data: each network node A collects a record of emitted test loops in a certain epoch, their paths and whether they returned or not. Importantly, each loop is the same length and includes l steps.

  • A segment is defined as a possible connection from a device in the network to another, for example from a node in the layer k to a node in the layer k+1. Each loop is a sequence of such segments.

  • Each node A will create 3 hashmaps, sent_loops_A, completed_loops_A and ratios_A. Each of these will use a pair of concatenated mix node ID’s as the key. The ordering of the ID’s will be from lesser topology layer to greater, e.g. the two-tuple (n, n+1) which is represented here as a 64 byte array:

    var sent_loops_A map[[64]byte]int
    var completed_loops_A map[[64]byte]int
    var ratios_A map[[64]byte]float64
    
  • Every time the node A sends out a test loop, for each segment in the loop path, it will increment the value in sent_loops_A.

  • When a test loop returns, for each step in the loop path, it will increment the value in completed_loops_A.

  • Generate a new map entry in ratios_A for each mix-node-pair p, if sent_loops_A[p]==0 set ratios_A[p]=1. Else ratios_A[p] = completed_loops_A[p]/sent_loops_A[p]

  • Plot the resulting distribution, and calculate the standard deviation to detect anomalies. Have the node report significant anomalies after a sufficient time period as to not leak information on the route of individual loops.

  • Anomalies may have to be discarded if the corresponding sent_loops_A[p] is small.

You would expect the distribution of values in completed_loops to approximate a binomial distribution. In an absence of faulty nodes, ratios should be 1, and when there are some faulty nodes values at faulty nodes should approach 0 (if the node doesn’t work at all), and be binomially distributed at nodes that can share a loop with faulty nodes.

Therefore each mix node generates a statistics report to upload to the dirauth nodes, of the struct type:

type LoopStats struct {
Epoch           uint64
MixIdentityHash *[32]byte
Ratios          map[[64]byte]float64
}

The report is subsequently uploaded to the directory authorities, which combine the reports of individual nodes into a health status of the network and arrive at a consensus decision about the topology of the network.

Uploading stats to dirauths

Stats reports are uploaded along with the mix descriptor every Epoch. A cryptographic signature covers both of these fields:

type UploadDescStats struct {
Descriptor []byte
StatsReport []byte
Signature []byte
}    

Statistics reports collected during the XXX period of time, that is, the time between descriptor N+1 upload and descriptor N+2 upload, are what will affect the topology choices in epoch N+2 if the dirauths collectively decide to act on the very latest statistics reports in order to determine for example if a mix node should be removed from the network:

| ---------------- epoch N ---------------- | ---------------- epoch N+1 ---------------- | ---------------- epoch N+2 ---------------- |
| ----------- UD_N+1 ---------------------- | ------------ UD N+2 ----------------------- | ----------- UD N+3 ------------------------ |             | ------------------ XXX ---------------- |  

9 -

For node descriptors, directory voting and the signed network parameters used by clients, see the directory authority specification. Its client overview connects applications, kpclientd and gateways.

Mix network specification

Yawning Angel

George Danezis

Claudia Diaz

Ania Piotrowska

David Stainton

Abstract

This document describes the high level architecture and detailed protocols and behavior required of mix nodes participating in the Katzenpost mix network.


Terminology

The following terms are used in this specification.

classes of traffic
We distinguish the following classes of traffic:

  • SURB replies (sometimes referred to as ACKs)

  • Forwarded messages

client
Software run by a user on their local device to participate in the mixnet. A client is not considered a node in the network for purposes of analysis of the core mnixnet protocols.

entry mix, entry node
A mix node that has some additional features:

  • An entry mix is always the first hop in routes where the message originates from a client.

  • An entry mix authenticates client’s direct connections via the mixnet’s wire protocol.

  • An entry mix queues reply messages and allows clients to retrieve them later.

KiB
Defined as 1024 8 bit octets.

message
A variable-length sequence of octets sent anonymously through the network. Short messages are sent in a single packet; long messages are fragmented across multiple packets.

mix node
A cryptographic router that is used to compose a mixnet. Mix nodes use a cryptographic operation on messages being routed which provides bitwise unlinkability with respect to input versus output messages. Katzenpost is a decryption mixnet that uses the Sphinx cryptographic packet format.

mixnet
A mixnet, or mix network, is a network of mix servers that can be used to build various privacy-preserving protocols.

MSL
Maximum segment lifetime, currently set to 120 seconds.

node
Clients are NOT considered nodes in the mix network. However, network protocols are often layered. In our design documents, we describe “mixnet hidden services” that can be operated by mixnet clients. Therefore if you are using the term “node” in some adherence to mathematical terminology, one could conceivably designate a client as a node. However, in discussion of our core mixnet protocol, it is inappropriate to refer to clients as nodes.

packet
A Sphinx packet, of fixed length for each class of traffic, carrying a message payload and metadata for routing. Packets are routed anonymously through the mixnet and cryptographically transformed at each hop.

payload
The fixed-length portion of a packet containing an encrypted message or part of a message, to be delivered anonymously.

service node
A service node is a mix node with additional features:

  • A service node is always the last hop in routes where the message originates from a client.

  • A service node runs mixnet services which use a Sphinx SURB-based protocol.

user
An agent using the Katzenpost system.

Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119

Introduction

This specification provides the design of a mix network meant provide an anonymous messaging protocol between clients and public mixnet services.

Various system components such as client software, end to end messaging protocols, Sphinx cryptographic packet format and wire protocol are described in their own specification documents.

System overview

The presented system design is based on LOOPIX Below, we present the system overview.

The entry mixes are responsible for authenticating clients, accepting packets from the client, and forwarding them to the mix network, which then relays packets to the destination service mix. Our network design uses a strict topology where forward message traverse the network from entry mix to service mix. Service mixes can optionally reply if the forward message contained a Single Use Reply Block (see SPHINXSPEC.

The PKI system that handles the distribution of various network wide parameters, and information required for each participant to participate in the network such as IP address/port combinations that each node can be reached at, and cryptographic public keys. The specification for the PKI is beyond the scope of this document and is instead covered in KATZMIXPKI.

The mix network provides neither reliable nor in-order delivery semantics. The described mix network is neither a user facing messaging system nor is it an application. It is intended to be a low level protocol which can be composed to form more elaborate mixnet protocols with stronger more useful privacy notions.

Threat model

Here we cannot present the threat model to the higher level mixnet protocols. However this low level core mixnet protocol does have it’s own threat model which we attempt to elucidate here.

We assume that the clients only talk to mixnet services. These services make use of a client provided delivery token known as a SURB (Single Use Reply Block) to send their replies to the client without knowing the client’s entry mix. This system guarantees third-party anonymity, meaning that no parties other than client and the service are able to learn that the client and service are communicating. Note that this is in contrast with other designs, such as Mixminion, which provide sender anonymity towards recipients as well as anonymous replies.

Mixnet clients will randomly select an entry node to use and may reconnect if disconnected for under a duration threshold. The entry mix can determine the approximate message volume originating from and destined to a given client. We consider the entry mix follows the protocol and might be an honest-but-curious adversary.

External local network observers can not determine the number of Packets traversing their region of the network because of the use of decoy traffic sent by the clients. Global observers will not be able to de-anonymize packet paths if there are enough packets traversing the mix network. Longer term statistical disclosure attacks are likely possible in order to link senders and receivers.

A malicious mix only has the ability to remember which input packets correspond to the output packets. To discover the entire path all of the mixes in the path would have to be malicious. Moreover, the malicious mixes can drop, inject, modify or delay the packets for more or less time than specified.

Network topology

The Katzenpost mix network uses a layered topology consisting of a fixed number of layers, each containing a set of mixes. At any given time each Mix MUST only be assigned to one specific layer. Each Mix in a given layer N is connected to every other Mix in the previous and next layer, and or every participating Provider in the case of the mixes in layer 0 or layer N (first and last layer). :

Layer 0          Layer 1        Layer 2        Layer 3           Layer 4
+-----------+      +-------+      +-------+      +-------+      +-------------+
+-> | entry mix | -+-> |  Mix  | -+-> |  Mix  | -+-> |  Mix  | -+-> | service mix |
|   +-----------+  |   +-------+  |   +-------+  |   +-------+  |   +-------------+
|                  |              |              |              |
|   +-----------+  |   +-------+  |   +-------+  |   +-------+  |   +-------------+
+-> | entry mix | -+-> |  Mix  | -+-> |  Mix  | -+-> |  Mix  | -+-> | service mix |
|   +-----------+  |   +-------+  |   +-------+  |   +-------+  |   +-------------+
|                  |              |              |              |
|                  |   +-------+  |   +-------+  |   +-------+  |   +-------------+
|                  +-> |  Mix  | -+-> |  Mix  | -+-> |  Mix  | -+-> | service mix |
|                      +-------+      +-------+      +-------+  |   +-------------+
|                                                               |
+---------------------------------------------------------------+

Note: Multiple distinct connections are collapsed in the figure for sake of brevity/clarity.

The network topology MUST also maximize the number of security domains traversed by the packets. This can be achieved by not allowing mixes from the same security domain to be in different layers.

Requirements for the topology:

  • Should allow for non-uniform throughput of each mix (Get bandwidth weights from the PKI).

  • Should maximize distribution among security domains, in this case the mix descriptor specified family field would indicate the security domain or entity operating the mix.

  • Other legal jurisdictional region awareness for increasing the cost of compulsion attacks.

Packet format overview

For the packet format of the transported messages we use the Sphinx cryptographic packet format. The detailed description of the packet format, construction, processing and security / anonymity considerations see SPHINXSPEC, β€œThe Sphinx mix network cryptographic packet format specification”.

As the Sphinx packet format is generic, the Katzenpost mix network must provide a concrete instantiation of the format, as well as additional Sphinx per-hop routing information commands.

Sphinx cryptographic primitives

For the current version of the Katzenpost mix network, let the following cryptographic primitives be used as described in the Sphinx specification.

  • H(M) - As the output of this primitive is only used locally to a Mix, any suitable primitive may be used.

  • MAC(K, M) - HMAC-SHA256 RFC6234, M_KEY_LENGTH of 32 bytes (256 bits), and MAC_LENGTH of 32 bytes (256 bits).

  • KDliteralF(SALT, IKM) - HKDF-SHA256, HKDF-Expand only, with SALT used as the info parameter.

  • S(K, IV) - CTR-AES256 [SP80038A], S_KEY_LENGTH of 32 bytes (256 bits), and S_IV_LENGTH of 12 bytes (96 bits), using a 32 bit counter.

  • SPRP_Encrypt(K, M)/SPRP_Decrypt(K, M) - AEZv5 AEZV5, SPRP_KEY_LENGTH of 48 bytes (384 bits). As there is a disconnect between AEZv5 as specified and the Sphinx usage, let the following be the AEZv5 parameters:

    • nonce - 16 bytes, reusing the per-hop Sphinx header IV.

    • additional_data - Unused.

    • tau - 0 bytes.

  • EXP(X, Y) - X25519 RFC7748 scalar multiply, GROUP_ELEMENT_LENGTH of 32 bytes (256 bits), G is the X25519 base point.

Sphinx packet parameters

The following parameters are used as for the Katzenpost mix network instantiation of the Sphinx Packet Format:

  • AD_SIZE - 2 bytes.

  • SECURITY_PARAMETER - 32 bytes. (except for our SPRP which we plan to upgrade)

  • PER_HOP_RI_SIZE - (XXX/ya: Addition is hard, let’s go shopping.)

  • NODE_ID_SIZE - 32 bytes, the size of the Ed25519 public key, used as Node identifiers.

  • RECIPIENT_ID_SIZE - 64 bytes, the maximum size of local-part component in an e-mail address.

  • SURB_ID_SIZE - Single Use Reply Block ID size, 16 bytes.

  • MAX_HOPS - 5, the ingress provider, a set of three mixes, and the egress provider.

  • PAYLOAD_SIZE - (XXX/ya: Subtraction is hard, let’s go shopping.)

  • KDF_INFO - The byte string Katzenpost-kdf-v0-hkdf-sha256.

The Sphinx Packet Header additional_data field is specified as follows:

struct {
uint8_t version;  /* 0x00 */
uint8_t reserved; /* 0x00 */
} KatzenpostAdditionalData;

Double check to ensure that this causes the rest of the packet header to be 4 byte aligned, when wrapped in the wire protocol command and framing. This might need to have 3 bytes reserved instead.

All nodes MUST reject Sphinx Packets that have additional_data that is not as specified in the header.

Design decision.

  • We can eliminate a trial decryption step per packet around the epoch transitions by having a command that rewrites the AD on a per-hop basis and including an epoch identifier.

I am uncertain as to if the additional complexity is worth it for a situation that can happen for a few minutes out of every epoch.

Sphinx per-hop routing information extensions

The following extensions are added to the Sphinx Per-Hop Routing Information commands.

Let the following additional routing commands be defined in the extension RoutingCommandType range (0x80 - 0xff):

enum {
mix_delay(0x80),
} KatzenpostCommandType;

The mix_delay command structure is as follows:

struct {
uint32_t delay_ms;
} NodeDelayCommand;

Mix node operation

All Mixes behave in the following manner:

All Nodes are identified by their link protocol signing key, for the purpose of the Sphinx packet source routing hop identifier.

All Nodes participating in the mix network MUST share a common view of time, via NTP or similar time synchronization mechanism.

All communication to and from participants in the Katzenpost mix network is done via the Katzenpost mix network wire protocol KATZMIXWIRE.

Nodes are responsible for establishing the connection to the next hop, for example, a mix in layer 0 will accept inbound connections from all Providers listed in the PKI, and will proactively establish connections to each mix in layer 1.

Nodes MAY accept inbound connections from unknown Nodes, but MUST not relay any traffic until they became known via listing in the PKI document, and MUST terminate the connection immediately if authentication fails for any other reason.

Nodes MUST impose an exponential backoff when reconnecting if a link layer connection gets terminated, and the minimum retry interval MUST be no shorter than 5 seconds.

Nodes MAY rate limit inbound connections as required to keep load and or resource use at a manageable level, but MUST be prepared to handle at least one persistent long lived connection per potentially eligible peer at all times.

Sphinx mix- and service-node key rotation

Each Node MUST rotate the key pair used for Sphinx packet processing periodically for forward secrecy reasons and to keep the list of seen packet tags short. The Katzenpost mix network uses a fixed interval (epoch), so that key rotations happen simultaneously throughout the network, at predictable times.

Let each epoch be exactly 10800 seconds (3 hours) in duration, and the 0th Epoch begin at 2017-06-01 00:00 UTC. For more details see our β€œKatzenpost mix network public key infrastructure specification” document. KATZMIXPKI

Sphinx packet processing

The detailed processing of the Sphinx packet is described in the Sphinx specification: β€œThe Sphinx mix network cryptographic packet format specification”. Below, we present an overview of the steps which the node is performing upon receiving the packet:

  1. Records the time of reception.

  2. Perform a Sphinx_Unwrap operation to authenticate and decrypt a packet, discarding it immediately if the operation fails.

  3. Apply replay detection to the packet, discarding replayed packets immediately.

  4. Act on the routing commands.

All packets processed by Mixes MUST contain the following commands.

  • NextNodeHopCommand, specifying the next Mix or Provider that the packet will be forwarded to.

  • NodeDelayCommand, specifying the delay in milliseconds to be applied to the packet, prior to forwarding it to the Node specified by the NextNodeHopCommand, as measured from the time of reception.

Mixes MUST discard packets that have any commands other than a NextNodeHopCommand or a NodeDelayCommand. Note that this does not apply to Providers or Clients, which have additional commands related to recipient and SURB (Single Use Reply Block) processing.

Nodes MUST continue to accept the previous epoch’s key for up to 1MSL past the epoch transition, to tolerate latency and clock skew, and MUST start accepting the next epoch’s key 1MSL prior to the epoch transition where it becomes the current active key.

Upon the final expiration of a key (1MSL past the epoch transition), Nodes MUST securely destroy the private component of the expired Sphinx packet processing key along with the backing store used to maintain replay information associated with the expired key.

Nodes MAY discard packets at any time, for example to keep congestion and or load at a manageable level, however assuming the Sphinx_Unwrap operation was successful, the packet MUST be fed into the replay detection mechanism.

Nodes MUST ensure that the time a packet is forwarded to the next Node is around the time of reception plus the delay specified in NodeDelayCommand. Since exact millisecond processing is unpractical, implementations MAY tolerate a small window around that time for packets to be forwarded. That tolerance window SHOULD be kept minimal.

Nodes MUST discard packets that have been delayed for significantly more time than specified by the NodeDelayCommand.

Anonymity considerations

Topology

Layered topology is used because it offers the best level of anonymity and ease of analysis, while being flexible enough to scale up traffic. Whereas most mixnet papers discuss their security properties in the context of a cascade topology, which does not scale well, or a free-route network, which quickly becomes intractable to analyze when the network grows, while providing slightly worse anonymity than a layered topology. MIXTOPO10

Important considerations when assigning mixes to layers, in order of decreasing importance, are:

  1. Security: do not allow mixes from one security domain to be in different layers to maximise the number of security domains traversed by a packet

  2. Performance: arrange mixes in layers to maximise the capacity of the layer with the lowest capacity (the bottleneck layer)

  3. Security: arrange mixes in layers to maximise the number of jurisdictions traversed by a packet (this is harder to do really well than it seems, requires understanding of legal agreements such as MLATs).

Mixing strategy

As a mixing technique the Poisson mix strategy LOOPIX and KESDOGAN98 is used, which REQUIRES that a packet at each hop in the route is delayed by some amount of time, randomly selected by the sender from an exponential distribution. This strategy allows to prevent the timing correlation of the incoming and outgoing traffic from each node. Additionally, the parameters of the distribution used for generating the delay can be tuned up and down depending on the amount of traffic in the network and the application for which the system is deployed.

Security considerations

The source of all authority in the mixnet system comes from the directory authority system which is also known as the mixnet PKI. This system gives the mixes and clients a consistent view of the network while allowing human intervention when needed. All public mix key material and network connection information is distributed by this directory authority system.

References

AEZV5

Hoang, V., Krovetz, T., Rogaway, P., β€œAEZ v5: Authenticated Encryption by Enciphering”, March 2017, http://web.cs.ucdavis.edu/~rogaway/aez/aez.pdf.

KATZMIXPKI

Angel, Y., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Public Key Infrastructure Specification”, December 2017, https://katzenpost.network/docs/specs/pdf/pki.pdf.

KATZMIXWIRE

Angel, Y., β€œKatzenpost Mix Network Wire Protocol Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/wire.pdf.

KESDOGAN98

Kesdogan, D., Egner, J., and BΓΌschkes, R., β€œStop-and-Go-MIXes Providing Probabilistic Anonymity in an Open System”, Information Hiding, 1998, https://www.freehaven.net/anonbib/cache/stop-and-go.pdf.

LOOPIX

Piotrowska, A., Hayes, J., Elahi, T., Meiser, S., Danezis, G., β€œThe Loopix Anonymity System”USENIX, August 2017, https://arxiv.org/pdf/1703.00536.pdf.

MIXTOPO10

Diaz, C., Murdoch, S., Troncoso, C., β€œImpact of Network Topology on Anonymity and Overhead in Low-Latency Anonymity Networks”, PETS, July 2010, https://www.esat.kuleuven.be/cosic/publications/article-1230.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5246

Dierks, T. and E. Rescorla, β€œThe Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246, DOI 10.17487/RFC5246, August 2008, http://www.rfc-editor.org/info/rfc5246.

RFC6234

Eastlake 3rd, D. and T. Hansen, β€œUS Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF)”, RFC 6234, DOI 10.17487/RFC6234, May 2011, https://www.rfc-editor.org/info/rfc6234.

RFC7748

Langley, A., Hamburg, M., and S. Turner, β€œElliptic Curves for Security”, RFC 7748, January 2016, https://www.rfc-editor.org/info/rfc7748.

SP80038A

Dworkin, M., β€œRecommendation for Block Cipher Modes of Operation”, SP800-38A, 10.6028/NIST.SP.800, December 2001, https://doi.org/10.6028/NIST.SP.800-38A.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

10 -

The directory authority specification describes how service nodes advertise courier endpoints and how replicas publish descriptors. Its clock and key-retention rules distinguish mixnet epochs from storage-replica epochs.

Pigeonhole protocol specification

Threebit Hacker

David Stainton

Abstract

In this specification we describe the components and protocols that compose Pigeonhole scattered storage. We define the behavior of communication clients that send and retrieve individual messages, BACAP streams, and AllOrNothing streams. Client actions are mediated through courier services that interact with storage replicas.


Introduction

Pigeonhole scattered storage enables persistent anonymous communication in which participants experience a coherent sequence of messages or a continuous data stream, but where user relationships and relations between data blocks are unlinkable not only from the perspective of third-party observers, but also from that of the mixnet components. This latter attribute provides resilience against deanonymization by compromised mixnet nodes.

The data blocks that Pigeonhole stores are supplied by the BACAP (Blinding-and-Capability) scheme. The Pigeonhole protocol scatters messages around many storage servers and among a space of BACAP box IDs. From a passive network observer’s perspective, all of this is seemingly random. All communication among users consists of user-generated read or write queries to Pigeonhole storage, never directly to other users.

Many derivative protocols can be composed using Pigeonhole communication channels, including group communications. For details about the protocol specified here, see “Β§5.6. End-to-end reliable group channels” in our paper Echomix: a Strong Anonymity System with Messaging. For more information about BACAP, see Β§4 of the paper. For an understanding of how the core BACAP primitives are implemented, see bacap.go.

Message-layout snippets in this specification are given in the trunnel binary-format description language (matching pigeonhole/pigeonhole_messages.trunnel); code snippets showing in-memory courier or replica state are given in Go.

Terminology

The following terms are used in this specification.

box
BACAP’s unit of data storage. Each box has a box ID (which also serves as its public key), a signature, and a ciphertext-signed payload.

courier
Service that runs on a service node and interacts with storage replicas. Proxies requests from clients and routes replies back to clients (via SURBs).

envelope hash
BLAKE2b-256(CourierEnvelope.sender_pubkey || CourierEnvelope.ciphertext). Used by the courier for deduplication of retransmissions and for demultiplexing replica replies.

MKEM
Multi-recipient KEM addressed to the pair of intermediate replicas. One MKEM ciphertext carries, for each recipient, a separate DEK encapsulation (Dek1, Dek2); either recipient may decapsulate and recover the padded ReplicaInnerMessage.

replica epoch
A one-week period, distinct from the 20-minute mixnet PKI epoch, during which a given replica-side MKEM envelope keypair is valid. For more information, see Epochs.

storage replicas
“Intermediate replicas are chosen independently of the two final replicas for that box ID which are derived using the sharding scheme. The reason Alice designates intermediate replicas, as opposed to addressing the final replicas directly, is to avoid revealing to the courier which shard the box falls into.” (From “Β§5.4.1 “Writing messages” in Echomix: a Strong Anonymity System with Messaging. A designated replica (a.k.a. final replica or shard replica) is one of the two replicas selected deterministically for a given box ID by the Shard2 consistent-hash algorithm. The intermediate replicas write through to the designated replicas.

SURB
Single use reply block. SURBs are used to achieve recipient anonymity, that is to say, SURBs function as a cryptographic delivery token that you can give to another client entity so that they can send you a message without them knowing your identity or location on the network. See SPHINXSPEC and SPHINX.

Storage deployment requirements

A conforming deployment MUST run at least four storage replicas (n β‰₯ 4). The enables the intermediate-replica unlinkability property described above and in Β§5.4.1 of the Echomix: a Strong Anonymity System with Messaging: for each CourierEnvelope, the client picks two intermediate replicas which MUST be disjoint from the two final (sharded) replicas that hold the box. Disjointness is what prevents the courier from learning which shard the box falls into.

A conformant deployment and its undesirable alternatives can be compared as follows.

  • n β‰₯ 4: At least two non-shard replicas exist, so the intermediate set can be drawn uniformly at random from the non-shard subset, and disjointness is preserved.

  • n = 3: Only one replica sits outside any given two-shard set, so the client cannot pick two independent non-shard intermediates. Implementations in this case fall back to an intermediate set that includes at least one shard replica, which tells the courier that replica is one of the two shards for the box.

    The resulting information exposure is bounded:

    • The courier does NOT learn the box ID. The box ID lives inside the MKEM-encrypted inner message addressed to the intermediate replicas; only the replicas can decrypt it.

    • The courier does NOT learn the other shard member. Knowing one element of the two-element shard set does not constrain the identity of the other among the remaining replicas.

    • The courier does NOT gain a way to link boxes in the same sequence. BACAP’s unlinkability of consecutive box IDs (paper Β§4) is a property of the capability-derivation scheme and is independent of sharding.

  • n = 2: There is only one possible shard set, and the intermediate set is necessarily identical to it.

Consequently, the main unlinkability properties of Pigeonhole are not lost at n = 3, but the defense-in-depth margin provided by disjoint intermediate replicas is. Operators SHOULD treat n β‰₯ 4 as the minimum supported configuration.

Epochs

Katzenpost has two distinct notions of “epoch” that operate on very different timescales, and Pigeonhole touches both:

  • Mixnet epoch (a.k.a. normal epoch, PKI epoch): the short cadence on which the directory authorities publish a new PKI document. The default is 20 minutes. Mix nodes, gateways, service nodes, and clients all synchronise on this epoch. Sphinx replay keys on mix nodes rotate once per mixnet epoch.

  • Pigeonhole storage-replica epoch: the long cadence on which each storage replica rotates its MKEM envelope keypair. The default is one week. A replica publishes, in every mixnet-epoch PKI descriptor, two envelope public keys: the current replica-epoch key and the next replica-epoch key.

Epoch tolerance for CourierEnvelope

A CourierEnvelope carries an epoch field that identifies the replica epoch whose envelope key the client used to encrypt the MKEM ciphertext. Conforming couriers and storage replicas MUST accept epoch ∈ {current βˆ’ 1, current, current + 1} where current is the courier’s / replica’s own view of the current replica epoch.

The current βˆ’ 1 tolerance handles the grace window immediately after a replica-epoch boundary, when a client with a slightly stale PKI view still encrypts to the previous envelope public key. Combined with current, this gives a two replica-epoch data TTL β€” roughly two weeks β€” because the future replica-epoch key is by definition a key that hasn’t started being used yet, so current and current βˆ’ 1 are the epochs where actual data flows.

The current + 1 tolerance handles the same boundary seen from the other side: a client whose clock or PKI view is slightly ahead of the courier / replica.

Envelopes outside this three-epoch window MUST be rejected, because by definition no replica still holds the matching decapsulation key:

  • Older than current βˆ’ 1: the envelope public key has been pruned from replicas (see the replica’s envelope-key GC worker). The replica cannot decrypt and the courier can fail fast rather than forward a doomed request.

  • Newer than current + 1: no replica has generated that envelope key yet (replicas generate current and current + 1 only).

Couriers SHOULD reject with a dedicated envelope-level error code (EnvelopeErrorInvalidEpoch) so clients can distinguish “stale encryption” from other courier-side rejections.

Pigeonhole message format and constants

The Pigeonhole message types are defined in trunnel at pigeonhole/pigeonhole_messages.trunnel; the Go bindings live in pigeonhole/trunnel_messages.go. All integer fields are big-endian and all variable-length fields carry an explicit length prefix. This trunnel encoding replaces the earlier CBOR encoding with a fixed-overhead binary format whose serialized size can be computed deterministically from the Sphinx geometry.

Carriage of these messages differs by hop:

  • Client β†’ Courier: a CourierQuery is carried inside a Sphinx packet payload. (See the section called β€œCourierQuery” for details.) The reverse direction uses a SURB supplied by the client.

  • Courier β†’ Replica and Replica β†’ Replica: the courier and replicas do not use Sphinx packets between themselves. They communicate over the Katzenpost wire protocol defined in core/wire and core/wire/commands; the relevant commands are ReplicaMessage, ReplicaMessageReply, ReplicaWrite, and ReplicaWriteReply. Some of these commands embed trunnel-serialized pigeonhole blobs as their payload.

Fundamental size constants

**TableΒ 1.Β **

Key Value Explanation
BACAP_BOX_ID_SIZE 32 bytes Ed25519 public key
BACAP_SIGNATURE_SIZE 64 bytes Ed25519 signature
HASH_SIZE 32 bytes BLAKE2b-256
MKEM_DEK_SIZE 60 bytes mkem.DEKSize
CTIDH-1024 public key 128 bytes hpqc/nike/ctidh/ctidh1024
X25519 public key 32 bytes hpqc/nike/x25519
Hybrid CTIDH-1024 Γ— X25519 NIKE public key 160 bytes X25519[32], then CTIDH-1024[128]. Tagged encoding.
BACAP payload encryption overhead 16 bytes ChaCha20-Poly1305 AEAD
MKEM encapsulation overhead 28 bytes ChaCha20-Poly1305 nonce (12) + tag (16)

Maximum BACAP payload

The maximum plaintext BACAP payload a single box can carry is derived backwards from the Sphinx UserForwardPayloadLength by subtracting every layer of framing and cryptographic overhead that sits between the Sphinx payload and the BACAP plaintext. The authoritative calculation is performed by NewGeometryFromSphinx in pigeonhole/geo/geometry_impl.go.

Informally:

MaxPlaintextPayloadLength
  = UserForwardPayloadLength
    βˆ’ CourierQuery framing (1 byte: query_type discriminator)
    βˆ’ CourierEnvelope header (intermediate_replicas[2] + Dek1 + Dek2
                              + reply_index + epoch + sender_pubkey_len
                              + sender_pubkey + ciphertext_len)
    βˆ’ MKEM AEAD overhead (28 bytes)
    βˆ’ ReplicaInnerMessage framing (1 byte: message_type discriminator)
    βˆ’ ReplicaWrite header (box_id + signature + payload_len = 100 bytes)
    βˆ’ BACAP AEAD overhead (16 bytes)
    - BACAP length prefix (4 bytes)
    - ReplicaInnerMessage length prefix (4 bytes)

With the 160-byte CTIDH1024-X25519 public key, this write-path overhead is 451 bytes. The tagged geometry calculation includes both four-byte length prefixes.

The CourierEnvelope as seen by the courier

The courier, upon unwrapping the Sphinx payload of a client’s packet, sees a CourierQuery whose content is a CourierEnvelope with the following layout:

struct courier_envelope {
    u8  intermediate_replicas[2];          // replica indices in the PKI
    u8  dek1[MKEM_DEK_SIZE];               // DEK encapsulation for replica 0
    u8  dek2[MKEM_DEK_SIZE];               // DEK encapsulation for replica 1
    u8  reply_index;                       // which replica's reply to prefer
    u64 epoch;                             // replica-epoch under which the
                                           //   MKEM ciphertext was produced
    u16 sender_pubkey_len;
    u8  sender_pubkey[sender_pubkey_len];  // client's ephemeral hybrid NIKE pk
    u32 ciphertext_len;
    u8  ciphertext[ciphertext_len];        // MKEM-encrypted ReplicaInnerMessage
} 

Notable points:

  • The ciphertext is opaque to the courier. It is an MKEM envelope addressed to the pair of intermediate replicas; either replica can decapsulate using its own DEK (dek1 or dek2 respectively).

  • The epoch field names the replica-epoch whose envelope keys were used to produce the MKEM ciphertext. See the section called β€œEpochs” for the tolerance window.

  • Prior to encryption, the inner ReplicaInnerMessage is zero-padded to ReplicaInnerMessageWriteSize() so that reads, writes and tombstones produce MKEM ciphertexts of identical length.

The ReplicaInnerMessage as seen by a replica

Once an intermediate replica decrypts the MKEM envelope, it obtains a ReplicaInnerMessage β€” a discriminated union over message_type:

struct replica_inner_message {
    u8 message_type IN [0, 1];    // 0 = read, 1 = write
    union content[message_type] {
        0: struct replica_read  read_msg;   // { box_id[32] }
        1: struct replica_write write_msg;  // { box_id[32], signature[64],
                                            //   payload_len[u32], payload[] }
    };
} 

A ReplicaWrite with payload_len == 0 is a tombstone.

Message types and interactions

Overview

  • A client sends a CourierQuery inside a Sphinx packet payload. The courier’s reply travels back to the client by means of a SURB the client also supplied.

  • A client always designates two intermediate replicas per CourierEnvelope. The courier dispatches the corresponding pair of ReplicaMessage wire commands, one to each intermediate, and collects up to two ReplicaMessageReply results.

  • The reply_index field is a preference indicating which of the two replica replies the client would like forwarded first. It is not a strict selector: should the preferred slot still be empty when the courier is ready to respond, the courier falls back to whichever reply it does hold, and indicates in the CourierEnvelopeReply the actual index that was served (see courier/server/plugin.go).

  • Clients MUST resend identical CourierEnvelope bodies β€” same sender_pubkey and ciphertext β€” until they receive a reply. The courier deduplicates resends by EnvelopeHash. Only the Sphinx-layer SURB is rotated between retransmissions.

CourierQuery

A CourierQuery is the top-level discriminated union that a client places into a Sphinx packet payload for the courier:

struct courier_query {
    u8 query_type IN [0, 1];
    union content[query_type] {
        0: struct courier_envelope envelope;      // read or write a single box
        1: struct copy_command     copy_command;  // AllOrNothing copy
    };
}  

The symmetric reply is a CourierQueryReply:

struct courier_query_reply {
    u8 reply_type IN [0, 1];
    union content[reply_type] {
        0: struct courier_envelope_reply envelope_reply;
        1: struct copy_command_reply     copy_command_reply;
    };
}  

CourierEnvelope

The CourierEnvelope layout was given in the section called β€œThe CourierEnvelope as seen by the courier”. The client constructs one CourierEnvelope per read or write. The courier performs the following actions.

  1. Computes EnvelopeHash = BLAKE2b-256(sender_pubkey || ciphertext).

  2. Verifies epoch ∈ {current βˆ’ 1, current, current + 1} per the replica-epoch tolerance window, otherwise rejecting with EnvelopeErrorInvalidEpoch.

  3. Looks EnvelopeHash up in its dedup cache. If present, the courier returns the cached reply (or an ACK if no reply has yet arrived) without re-dispatching to replicas.

  4. On a cache miss, the courier constructs two ReplicaMessage wire commands β€” one bound for intermediate_replicas[0] carrying dek1, the other for intermediate_replicas[1] carrying dek2 β€” and forwards them over the wire protocol.

  5. The courier immediately sends an ACK reply to the client so it may stop retransmitting.

ReplicaMessage (wire command)

ReplicaMessage is not a trunnel pigeonhole type; it is the core/wire/commands command sent from a courier to a replica. Its payload fields are copied verbatim from the matching CourierEnvelope:

// core/wire/commands
type ReplicaMessage struct {
    SenderEPubKey []byte     // copied from CourierEnvelope.sender_pubkey
    DEK           *[MKEM_DEK_SIZE]byte  // dek1 or dek2, depending on destination
    Ciphertext    []byte     // copied from CourierEnvelope.ciphertext
}

The recipient replica decapsulates the MKEM envelope using the replica-epoch key that corresponds to CourierEnvelope.epoch (trying each of the three keys in its tolerance window), yielding a padded ReplicaInnerMessage. The replica then dispatches on message_type to ReplicaRead or ReplicaWrite handling.

ReplicaMessageReply (wire command)

In response to a ReplicaMessage, the courier expects an asynchronous ReplicaMessageReply wire command from the replica:

// core/wire/commands
type ReplicaMessageReply struct {
    ErrorCode     uint8                 // see the replica error-code table below
    EnvelopeHash  *[HASH_SIZE]byte      // lets the courier demultiplex the reply
    EnvelopeReply []byte                // MKEM-encrypted ReplicaMessageReplyInnerMessage
}

The EnvelopeReply byte blob is produced by the replica via the MKEM scheme’s EnvelopeReply() method (see replica/handlers.go). It carries a ReplicaMessageReplyInnerMessage β€” a discriminated union over either a ReplicaReadReply or a ReplicaWriteReply β€” padded to ReplicaReplyInnerMessageReadSize() so that read replies and write replies are indistinguishable in size, and encrypted to the client’s ephemeral NIKE public key under the replica’s envelope keypair.

CourierBookKeeping

For each outstanding EnvelopeHash, the courier maintains an in-memory dedup entry. Its actual structure is:

// courier/server/plugin.go
type CourierBookKeeping struct {
    Epoch                uint64        // replica-epoch at cache insertion
    CreatedAt            time.Time     // for TTL eviction (~5 minutes)
    QueryType            uint8         // the query_type that produced this entry
    IntermediateReplicas [2]uint8
    EnvelopeReplies      [2]*commands.ReplicaMessageReply
}  

Note that the courier does not cache SURBs or SURB timestamps. A client’s SURB is consumed by the Sphinx-layer plugin infrastructure at the moment the courier emits a reply and is not retained by the courier’s Pigeonhole state. Should the client not receive that reply, it is expected to retransmit a fresh Sphinx packet carrying a fresh SURB but the identical CourierEnvelope body; the courier, recognising the EnvelopeHash, replies using the new SURB.

The dedup cache has a TTL of 5 minutes (DedupCacheTTL in courier/server/plugin.go).

CourierEnvelopeReply

The courier’s reply to a CourierEnvelope has the following trunnel layout:

struct courier_envelope_reply {
    u8  envelope_hash[HASH_SIZE];       // identifies the originating envelope
    u8  reply_index;                    // which intermediate replica's reply
                                        //   is being returned (may differ
                                        //   from the client's requested index)
    u8  reply_type IN [0, 1];           // 0 = ACK, 1 = PAYLOAD
    u32 payload_len;
    u8  payload[payload_len];           // the MKEM-encrypted EnvelopeReply,
                                        //   present iff reply_type == PAYLOAD
    u8  error_code;                     // see envelope error codes below
}

A reply_type of ACK (0) indicates the courier has received the envelope and dispatched it to the replicas but has not yet received a reply for the requested index. A reply_type of PAYLOAD (1) indicates the payload field carries the MKEM-encrypted EnvelopeReply produced by a replica.

Embedded pigeonhole types

These trunnel structs are not carried on the wire in isolation; they are embedded inside MKEM envelopes and their replies.

ReplicaRead

Embedded inside the MKEM-encrypted ReplicaInnerMessage a client sends to a replica (via the courier) for a read operation.

struct replica_read {
    u8 box_id[BACAP_BOX_ID_SIZE];
}  

ReplicaReadReply

Embedded inside the MKEM-encrypted ReplicaMessageReplyInnerMessage a replica returns for a successful read. Padding is applied at the outer ReplicaMessageReplyInnerMessage level; this struct carries no padding of its own.

struct replica_read_reply {
    u8  error_code;
    u8  box_id[BACAP_BOX_ID_SIZE];
    u8  signature[BACAP_SIGNATURE_SIZE];
    u32 payload_len;
    u8  payload[payload_len];
}

ReplicaWrite

Used both (a) embedded inside the MKEM-encrypted ReplicaInnerMessage for a client write, and (b) carried directly as a core/wire/commands command between replicas during replication.

struct replica_write {
    u8  box_id[BACAP_BOX_ID_SIZE];
    u8  signature[BACAP_SIGNATURE_SIZE];
    u32 payload_len;
    u8  payload[payload_len];
}

A ReplicaWrite with payload_len == 0 is a tombstone. Replicas treat tombstone writes as overwrites: an existing box at the same box_id is replaced by the tombstone, and subsequent reads return ReplicaErrorTombstone.

ReplicaWriteReply

Embedded inside a ReplicaMessageReplyInnerMessage on the client reply path, and also used as the core/wire/commands reply to inter-replica replication.

struct replica_write_reply {
    u8 error_code;
}

Tombstones

A ReplicaWrite whose payload_len == 0 is a tombstone. It marks a box as deleted without revealing that fact to the courier.

  • Replicas treat a tombstone write as an ordinary overwrite. An existing Box at the same box_id is replaced by the tombstone, and subsequent reads of that box_id return ReplicaErrorTombstone. (See the section called β€œReplica error codes”.)

  • ReplicaErrorTombstone is an expected outcome rather than a failure; it positively confirms that the box was deleted, as distinct from ReplicaErrorBoxNotFound.

  • Because the inner ReplicaInnerMessage is zero-padded to ReplicaInnerMessageWriteSize() before MKEM encryption (as described in the section called β€œThe CourierEnvelope as seen by the courier”), a tombstone write produces an MKEM ciphertext of exactly the same length as a non-empty write or a read. A passive observer therefore cannot distinguish deletion from any other operation.

Tombstones are also used by the AllOrNothing copy protocol. After processing a CopyCommand, the courier overwrites every box of the temporary stream with tombstones.

EnvelopeHash

The EnvelopeHash uniquely identifies a CourierEnvelope for the purposes of deduplication and reply demultiplexing. It is computed as:

EnvelopeHash = BLAKE2b-256(sender_pubkey || ciphertext)

where sender_pubkey and ciphertext are the corresponding fields of the CourierEnvelope. The implementation is CourierEnvelope.EnvelopeHash() in pigeonhole/helpers.go.

A retransmitted CourierEnvelope carries the same sender_pubkey and ciphertext as the original, and therefore hashes to the same value; only the surrounding Sphinx packet (and its SURB) changes between attempts.

Error codes

All error codes are defined in pigeonhole/errors.go.

Replica error codes

Returned by a replica in ReplicaMessageReply.ErrorCode, ReplicaReadReply.error_code, and ReplicaWriteReply.error_code.

**TableΒ 2.Β **

Code Name Meaning
0 ReplicaSuccess Operation completed successfully
1 ReplicaErrorBoxIDNotFound Read miss (expected outcome)
2 ReplicaErrorInvalidBoxID Malformed box ID
3 ReplicaErrorInvalidSignature BACAP signature verification failed
4 ReplicaErrorDatabaseFailure Transient RocksDB error
5 ReplicaErrorInvalidPayload Malformed payload
6 ReplicaErrorStorageFull Storage capacity exceeded
7 ReplicaErrorInternalError Internal server error
8 ReplicaErrorInvalidEpoch Replica-epoch envelope key unavailable
9 ReplicaErrorReplicationFailed Replication to shard peer failed
10 ReplicaErrorBoxAlreadyExists Idempotent-write outcome (expected)
11 ReplicaErrorTombstone Read returned a tombstone (expected)

Codes 1, 10 and 11 are “expected outcomes” β€” they correspond to normal protocol states rather than faults. The thin-client helper thin.IsExpectedOutcome(err) treats these three codes as non-errors.

Courier envelope error codes

Returned by the courier in CourierEnvelopeReply.error_code.

**TableΒ 3.Β **

Code Name Meaning
0 EnvelopeErrorSuccess Operation completed.
1 EnvelopeErrorInvalidEnvelope Malformed envelope (e.g. reply_index > 1)
2 EnvelopeErrorCacheCorruption Internal cache inconsistency
3 EnvelopeErrorPropagationError Failed to dispatch to replicas.
4 EnvelopeErrorInvalidEpoch CourierEnvelope.epoch outside tolerance window.

Copy command status codes

Returned by the courier in CopyCommandReply.status (see below).

Returned by the courier in the status field of a CopyCommandReply. (See the section called β€œCopyCommandReply” for more information.)

**TableΒ 4.Β **

Code Name Meaning
0 CopyStatusSucceeded All destination writes completed.
1 CopyStatusInProgress Courier has accepted the command; processing continues.
2 CopyStatusFailed Aborted; see error_code + failed_envelope_index.

Sharding and replica selection

For each box, two designated (final) replicas are derived deterministically from the box ID using the Shard2 consistent-hash algorithm, as shown in the following pseudo-code.

for each online replica r with identity key k_r:
    h_r = BLAKE2b-256(k_r || box_id)
return the two replicas whose h_r values are smallest

For more information about sharding, see replica/common/shard.go.

The two intermediate replicas chosen by the client for a given CourierEnvelope are drawn independently of the designated replicas, per pigeonhole.GetRandomIntermediateReplicas:

  • n β‰₯ 4 replicas: two intermediates are drawn uniformly at random from the replicas that are not in the designated (shard) set β€” preserving the disjointness invariant described in the section called β€œStorage deployment requirements”.

  • n = 3: fallback β€” at least one intermediate must coincide with a designated replica; this weakens the intermediate/final disjointness guarantee but does not compromise box unlinkability.

  • n < 3: rejected.

Intermediate replicas, upon accepting a ReplicaWrite, compute the designated replicas themselves and forward the ReplicaWrite to them via the core/wire/commands``ReplicaWrite command. For more information, see replica/connector.go.

Protocol sequence visualizations

For simplicity, the following diagrams omit replication while illustrating the Pigeonhole write and read operations.

Pigeonhole write operation

FigureΒ 1.Β Annotated writes

Protocol annotation image of writes.

Pigeonhole read operation

FigureΒ 2.Β Annotated reads

Protocol annotation image of reads.

Pigeonhole AllOrNothing protocol

The AllOrNothing delivery mechanism ensures that a set of associated BACAP writes either succeeds or fails atomically from the point of view of a replica or second-party client reader. This behavior prevents an adversary from detecting a correlation between (A) the sending client’s failure to transmit multiple messages at once with (B) a network interruption on the sending client’s side of the network. Regardless of the number of messages in the set, the adversary gets to observe “at most once” that the sending client interacted with the network.

The protocol works as follows.

Step 1

The client uploads a temporary Pigeonhole stream.

The stream is sequence of CourierEnvelopes. Each CourierEnvelope is serialized and prefixed with a single 4-byte (u32) length field giving the size of that one envelope; the resulting length-prefixed blobs are concatenated back-to-back into one continuous byte stream.

That byte stream is then split across the BACAP Boxes of the temporary stream. A serialized CourierEnvelope is strictly larger than the maximum BACAP box payload (it wraps a full box payload plus its own metadata), so a single envelope does not fit in one box and the concatenated stream necessarily spans several boxes. Envelope boundaries therefore do not align with box boundaries. The precise framing is given in the section called β€œTemporary stream data format”.

Step 2

The client sends a random courier the copy command which encapsulates the write capability to the temporary Pigeonhole stream written in Step 1 above. When the courier receives this copy command it extracts the ReadCap from the given WriteCap and uses it to read the stream of data. The courier then reads a box at a time and tries to extract 0 or 1 envelopes from each accumulation of stream segments.

After processing the command, the courier then overwrites the temporary stream with tombstones.

Temporary stream data format

Each box in the temporary stream is a serialized CopyStreamElement. Defined in trunnel as:

// CopyStreamElement - wraps a CourierEnvelope chunk with stream position flags.
// Overhead: 1 byte (flags) + 4 bytes (envelope_len) = 5 bytes
struct copy_stream_element {
    // Flags: bit 0 = isStart, bit 1 = isFinal
    u8 flags;
  
    // The CourierEnvelope serialized bytes
    u32 envelope_len;
    u8 envelope_data[envelope_len];
}

Here it is in golang:

type CopyStreamElement struct {
    Flags        uint8
    EnvelopeLen  uint32
    EnvelopeData []uint8
}

The purpose of this specific format is to use the isStart and isFinal flags to tell the courier the first box and last box of the stream to process. The payload encapsulated within the EnvelopeData fields of many of these CopyStreamElements is itself a stream of data which contains CourierEnvelopes with four-byte prefixes.

A key property of this encoding is that envelope boundaries do not align with box boundaries. Each BACAP box payload has a maximum size of N bytes, but a serialized CourierEnvelope (which contains a full box payload plus metadata) exceeds N bytes. Therefore envelopes are serialized into a continuous byte stream and split across multiple boxes in the temporary copy stream:

TEMPORARY COPY STREAM BOXES (each holds N bytes):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    Box 0    β”‚ β”‚    Box 1    β”‚ β”‚    Box 2    β”‚ β”‚    Box 3    β”‚ β”‚    Box 4    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
SERIALIZED ENVELOPE DATA (envelope boundaries don't align with box boundaries):
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚       Envelope 1        β”‚β”‚     Envelope 2     β”‚β”‚       Envelope 3         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
|         |         |         |         |         |
     Box 0     Box 1     Box 2     Box 3     Box 4

The courier reads the stream box by box, accumulating data until it can extract complete envelopes. The isStart and isFinal flags on the CopyStreamElement wrappers tell the courier where the stream begins and ends.

Each embedded CourierEnvelope is processed as a normal write and results in a ReplicaMessage being dispatched to the intermediate replicas over the wire protocol.

The courier does NOT need to keep track of the EnvelopeHash for each contained CourierEnvelope for the purpose of replying to the client (the “client” of these envelopes being, in this case, the courier itself), but it does need to keep resending them until the intermediate replicas have ACKed them.

The courier MUST keep track of the hash of the CopyCommand (computed as BLAKE2b-256(write_cap)) and MUST NOT process a given command more than once. This dedup cache has a TTL of 30 minutes (CopyDedupCacheTTL in courier/server/plugin.go).

CopyCommand

CopyCommand is sent by a client to its chosen courier after the client has successfully uploaded every box of the temporary stream. The trunnel layout is:

struct copy_command {
    u32 write_cap_len;
    u8  write_cap[write_cap_len];   // serialized BACAP BoxOwnerCap
}

On receipt, the courier:

  1. Computes copyKey = BLAKE2b-256(write_cap).

  2. Consults its copy dedup cache. If an in-progress entry is found, the courier responds immediately with CopyStatusInProgress. If a completed entry within its TTL is found, the courier returns the cached terminal reply.

  3. Otherwise, the courier reconstructs the BACAP WriteCap from the bytes, derives the corresponding ReadCap, and reads the temporary stream box by box, feeding the decrypted BACAP payloads into a CopyStreamEnvelopeDecoder.

  4. Each complete CourierEnvelope emitted by the decoder is dispatched to its two intermediate replicas; the courier waits for acknowledgements with bounded retries and exponential backoff.

  5. Upon processing the box bearing the isFinal flag (or upon an unrecoverable failure), the courier attempts β€” on a best-effort basis β€” to overwrite every box of the temporary stream with a tombstone.

Replica error handling during a CopyCommand is classified as temporary or permanent by courier/server/copy_errors.go: transient errors (storage full, database failure, internal error, replication failed, box-ID not found) trigger bounded retries against the same shard before failover; permanent errors (invalid box ID, invalid signature, invalid payload, invalid epoch, box already exists, tombstone) cause immediate failover or abort.

CopyCommandReply

The courier’s reply to a CopyCommand has the following trunnel layout:

struct copy_command_reply {
    u8  status;                   // CopyStatus{Succeeded, InProgress, Failed}
    u8  error_code;               // replica error code (meaningful iff status == Failed)
    u64 failed_envelope_index;    // 1-based sequential position in the
                                  //   CourierEnvelope stream at which processing
                                  //   stopped (meaningful iff status == Failed)
}  

The status codes are enumerated in “Copy command status codes” above. failed_envelope_index counts envelopes within the stream, not boxes: the first envelope in the first box of the temporary stream is index 1.

A client receiving CopyStatusInProgress should continue polling β€” i.e. resend the same CopyCommand via a fresh SURB after a short interval (CopyPollInterval, currently 5 seconds) β€” until it receives either CopyStatusSucceeded or CopyStatusFailed. Because the courier’s copy dedup cache keys on BLAKE2b-256(write_cap), these repeated polls will not cause the CopyCommand to be processed more than once.

Potential use cases of AllOrNothing

In no particular order:

  • Atomically writing to two or more boxes.

    • The boxes can reside on distinct streams (or not); the courier doesn’t know anything about streams of CourierEnvelope.
  • Sending long messages that span more than one BACAP payload, like a file / document / picture.

  • Group chat join uses AllOrNothing when adding a new member to the group:

    • The person introducing a new member writes to their group chat stream.

    • The person introducing a new member also writes to the existing conversation stream that the new member can already read.

  • Group chat uses AllOrNothing in all cases where it needs to send long messages – files, pictures, audio, long cryptographic keys such as the group membership list, and so on.

Protocol narration example

Alice wants to send a message to Bob, who is already connected to Alice. That is, Alice will write to a box with ID 12345 that Bob is trying to read from.

  1. Alice sends:

    1. SPHINX packet containing:

      • SPHINX header

        • Routing commands to make it arrive at a courier (on a service provider)
      • SPHINX payload

        • Reply SURB

        • CourierEnvelope encrypted for a courier chosen at random. The envelope tells the courier about two randomly chosen replicas (“intermediate replicas”).

    2. Alice doesn’t know if the packet makes it through the network. Until she receives a reply, she keeps resending the CourierEnvelope to the same courier.

  2. The courier receives the CourierEnvelope from Alice.

    1. The courier records the EnvelopeHash of the CourierEnvelope in its CourierBookKeeping datastructure.

    2. If the courier has already seen that hash, GOTO step 4.

  3. The courier sends an ACK to Alice using the SURB on file for Alice, telling her to stop resending the CourierEnvelope. If there’s no SURB, the Courier waits for the next re-sent CourierEnvelope from Alice matching the EnvelopeHash.

    1. The courier constructs two ReplicaMessage objects and puts them in its outgoing queue for the two intermediate replicas. (Note that each courier maintains constant-rate traffic with all replicas).

    2. The courier keeps doing this for each intermediate replica until it receives an ACK from that replica.

Alice’s actions are now complete.

  1. The two intermediate replicas receive the two ReplicaMessage objects.

    1. They decrypt them and compute the “designated replicas” based on the BACAP box ID.

    2. The replicas put ACKs in their outgoing queues for the courier saying “we have received these messages” (see step 3.2), referencing the EnvelopeHash. This tells the courier to stop resending to the intermediate replicas.

    3. They put the decrypted contents (a ReplicaWrite BACAP tuple: box ID 12345; signature; encrypted payload) in their outgoing queues for the “designated replicas”.

    4. Each replica waits for a reply from its designated replica.

    5. When an intermediate replica receives its ACK from the designated replica, the intermediate replica has no more tasks.

Bob now wants to check if Alice has written a message at box 12345.

  1. Similar to step 1, Bob sends a SPHINX packet to a courier chosen at random.

    1. SPHINX packet containing:

      • SPHINX header

        • Routing commands to make it arrive at a courier (on a service provider).
      • SPHINX payload

        • The envelope tells the courier about two randomly chosen replicas (β€œintermediate replicas”).

        • CourierEnvelope encrypted for a courier chosen at random. Its encrypted payload is a ReplicaRead command (reference box ID 12345).

    2. Bob doesn’t know if the packet makes it through the network. Until he receives a reply, he keeps resending the CourierEnvelope to the same courier.

  2. The courier receives Bob’s CourierEnvelope. (Similar to step 2).

  3. The courier sends an ACK to Bob. (Similar to step 3.)

  4. The two intermediate replicas receive the two ReplicaMessage objects containing Bob’s ReplicaRead.

    1. Similar to 4.A.

    2. Similar to 4.B.

    3. Similar to 4.C.

    4. Similar to 4.D.

    5. When the intermediate replica receives its ACK from the designated replica, it will include the (perhaps confusingly named) ReplicaWrite (BACAP tuple).

    6. The intermediate replica wraps it in a ReplicaMessageReply encrypted for Bob’s ReplicaMessage.EPubKey and puts it in its outgoing queue for the Courier.

  5. The Courier receives two ReplicaMessageReply wire commands from the two intermediate replicas.

    1. It matches ReplicaMessageReply.EnvelopeHash to its recorded bookkeeping state (step 2.1).

    2. It wraps the replica’s EnvelopeReply blob (the MKEM-encrypted ReplicaMessageReplyInnerMessage) into a CourierEnvelopeReply with reply_type = PAYLOAD, and β€” if Bob’s latest retransmission arrived with a fresh Sphinx SURB β€” forwards the resulting CourierQueryReply back through the mixnet to Bob.

    3. Either way the courier retains the ReplicaMessageReply in its dedup cache for the cache TTL (5 minutes), so that a subsequently-arriving retransmission of the same envelope can be served from cache without re-dispatching to replicas.

  6. Bob keeps resending his CourierEnvelope from step 5 until he receives a CourierEnvelopeReply with reply_type = PAYLOAD.

    1. Bob decapsulates the MKEM EnvelopeReply with the private key corresponding to the sender_pubkey he put in his CourierEnvelope, obtaining the padded ReplicaMessageReplyInnerMessage.

    2. Once unpadded, the inner message is either a ReplicaReadReply carrying the BACAP tuple (box ID, signature, ciphertext) or a non-success error_code. If the code is ReplicaErrorBoxIDNotFound, Bob waits and polls again (i.e. returns to step 5). If it is ReplicaErrorTombstone, Alice has deleted the message. Otherwise Bob BACAP-decrypts and verifies the payload.

Bob can now read Alice’s message.

11 -

Abstract

In this document we describe the traffic shaping and decoy traffic design for the direct connections among Pigeonhole couriers and storage replicas. These links exist outside of the mix network proper: couriers maintain persistent connections to every storage replica, and replicas maintain persistent connections to one another. Because these connections are direct, they receive none of the protections of the Sphinx packet format or the mixing strategy, and their traffic patterns must instead be protected by rate discipline and decoy traffic. This document specifies the send pacing, the decoy generation rules, the reply scheduling, the link liveness rules, and the capacity semantics that follow from them. It complements our mix decoy loop specification and the Pigeonhole protocol specification, and corresponds to Β§5 of our paper Echomix: a Strong Anonymity System with Messaging, in particular the fixed-throughput connections described there and the reply delays of Β§5.4.

1. Introduction

Every client interaction with Pigeonhole storage traverses two very different kinds of network path. The path from the client through the mix network to a courier is protected by Sphinx packets, per-hop mixing delays, and the client’s own Poisson decoy processes. The onward path from the courier to the storage replicas, and among the replicas themselves, is a set of plain, long-lived, mutually authenticated PQ Noise connections. An adversary observing one of these direct links sees neither box IDs nor payload contents, but without further measures it would see the one thing we have promised to hide: when, and how much, real work the network is doing.

Our answer is the classic one, applied uniformly: each such link is a fixed-throughput channel. Its send rate is a public constant drawn from the PKI document, its slots are filled with decoys whenever no real work is queued, and its observable behavior is therefore independent of client activity. The rest of this document makes that principle precise, and records the queueing-theoretic reasoning behind the two mechanisms that implement it: a Poisson send clock on the originator side of each connection, and independently jittered replies on the responder side.

A guiding rule throughout: dimensionless ratios may live in code; timescales come from the PKI document. Every delay, deadline, and rate in this design is derived from the consensus parameters, chiefly LambdaR, so that operators tune the network in one place and every component follows.

2. Roles: originator and responder

Each direct connection has exactly one originator and one responder, fixed for the life of the connection:

  • A courier is the originator on each of its connections to the R storage replicas.
  • A replica is the originator on each of its connections to its Rβˆ’1 replica peers, used for write replication and proxying.
  • The responder is whichever party accepted the connection. Replicas respond to couriers and to peer replicas.

The two roles have deliberately asymmetric traffic rules, described next. The asymmetry is not incidental: the originator’s job is rate discipline, the responder’s job is unlinkability of replies, and as we show in Β§4, using the originator’s mechanism on the responder side is not merely unnecessary but mathematically unsound.

3. Originator: the Poisson send clock

The originator paces every transmission on the link, real or decoy, with a single pseudorandom clock: inter-send intervals are drawn from the exponential distribution with rate LambdaR (events per millisecond), taken from the PKI document’s Parameters section. Sampled intervals are clamped at the (1 βˆ’ 10⁻¹²) quantile of that same distribution, our standard sampling safety cap, so the realized process is indistinguishable from the unclamped exponential in any honest measurement window.

In front of the clock sits a FIFO queue of real commands. On each tick the originator sends the oldest queued real command; if the queue is empty it sends a decoy command instead. A real message therefore replaces a decoy in its slot, and the wire carries a constant-rate Poisson stream at LambdaR regardless of load. The responder can distinguish decoys from real commands (it must, to answer them), but a passive observer of the link cannot, and the link’s rate reveals nothing about how busy the courier or replica is.

Two consequences deserve emphasis:

  • Sends are never gated on replies. An earlier revision of the courier made each send wait for the previous command’s reply, as a form of flow control. This coupled the link’s observable timing to replica processing latency, silenced decoy slots exactly when a replica was slow, and head-of-line blocked the link behind a single lost reply. It also provided no capacity that the Poisson clock does not already provide: LambdaR is a hard, deterministic ceiling on the send rate, which is a stronger guarantee against overrunning a replica than any reply-coupled scheme. The clock is the sole pacing authority; replies are matched to commands by envelope hash and may arrive in any order, at any time.

  • Load shedding is provisioning, not backpressure. Nothing any client does can push a replica beyond (C + R βˆ’ 1) Γ— LambdaR inbound messages per second, where C is the number of couriers and R the number of replicas. Keeping up is therefore decided once, when LambdaR is provisioned, not negotiated at runtime. Β§6 gives the arithmetic.

4. Responder: one-for-one echo with uniform jitter

The responder never originates traffic. It answers each received command with exactly one reply: a real reply to a real command, a decoy reply to a decoy command. Its outbound rate is thus pinned to the originator’s LambdaR by one-to-one correspondence, and the link is symmetric and constant-rate in both directions.

Each reply is held for an independently sampled delay drawn from the uniform distribution on [0, J], where J = 1/LambdaR, one mean link slot, computed from the PKI document. The delay is drawn from a cryptographic randomness source, since a predictable jitter defends nothing. Replies wait in a timer queue keyed by their ready-at time; by Little’s Law the expected queue depth is LambdaR Γ— J / 2, which with J = 1/LambdaR is one half of one reply at any rate.

This generalizes the reply delays of Β§5.4 of the paper, whose purpose is to prevent a courier from linking replies that pertain to the same box, and to hide the processing-time variance that would otherwise distinguish, say, a cheap decoy echo from a write that waited on a storage compaction.

Two questions about this design have principled answers worth recording:

Why is the responder not paced by its own Poisson clock, like the originator? Because its production rate is pinned at LambdaR by the echo rule, a paced drain at the same LambdaR would place the reply queue exactly at the M/M/1 boundary, utilization ρ = 1. A queue at ρ = 1 is null-recurrent: it possesses no stationary distribution and its depth wanders upward without bound. We observed precisely this in practice, as sustained per-connection backlogs of fifty to ninety replies, before replacing the responder’s paced sender with the delayed-reply emitter. A paced drain strictly faster than LambdaR would be sound but pointless, since the output would simply track the input. There is no rate for a responder-side clock that is both useful and correct.

Why uniform jitter rather than exponential? By the displacement theorem for Poisson processes, displacing each event of a Poisson stream by an independent, identically distributed delay yields another Poisson stream of the same rate, whatever the delay distribution. The reply stream therefore inherits its Poisson character from the command stream for free, and the jitter distribution influences only two things: the worst-case added latency, and how thoroughly nearby replies are decorrelated within that latency budget. Replies sit on an interactive path, so the budget is a hard cap, and the uniform distribution is the maximum-entropy distribution under a hard cap. An exponential jitter would buy an unbounded latency tail and, by the displacement theorem, nothing else.

A fixed-throughput link doubles as its own failure detector. When decoy traffic is enabled, inter-arrival gaps on a healthy link are exponential with rate LambdaR, so we set the wire session’s read deadline to the same (1 βˆ’ 10⁻¹²) quantile used as the sampling safety cap, roughly 27.6 / LambdaR milliseconds. A peer silent for that long is dead with overwhelming probability, and the connection is torn down and redialed. There is no separate keepalive machinery: the decoy stream is the keepalive.

When decoy traffic is disabled, as in our continuous integration and other testing postures, an idle link is legitimate and protocol silence implies nothing. No idle read deadline is imposed at all. Dead peers are instead detected by TCP keepalive at the kernel, which errors out a blocked read on a vanished peer, and writes remain bounded by the wire session’s write deadline. Disabling decoys trades away the metadata protections; it does not have to trade away link stability.

6. Capacity semantics

LambdaR is not merely a decoy rate; it is the provisioned capacity of every link, made literal by the design: with decoys on, each link carries exactly LambdaR messages per second, and real traffic consumes slots that decoys would otherwise fill.

The stability condition is the elementary single-server queueing bound: the real offered load on each link must stay below LambdaR, with headroom, because queueing delay grows as 1/(LambdaR βˆ’ load) as the load approaches capacity. In terms of network parameters, with X clients each producing pigeonhole operations at LambdaP, C couriers, and R replicas:

  • Each operation is one courier envelope, dispatched as two replica messages, one to each client-chosen intermediate replica. Per courier-to-replica link the offered load is X Γ— LambdaP Γ— 2 / (C Γ— R), and stability requires this to remain below LambdaR.
  • Each write additionally causes the intermediate replicas to replicate or proxy toward the box’s two final shard replicas, costing between one and four messages on the replica-to-replica mesh depending on shard coincidences, spread over R Γ— (R βˆ’ 1) directed links, each likewise paced at LambdaR.

The worst-case inbound rate at any single replica is (C + R βˆ’ 1) Γ— LambdaR, a quantity fixed entirely by public consensus values. Replicas are sized for it once.

Because a link’s rate never varies with demand, capacity planning is deliberately an over-provisioning exercise: LambdaR is chosen for a target user population comfortably above the expected one. We must never derive LambdaR from measured load; a demand-adaptive rate would broadcast the network’s activity level in the consensus itself, defeating the purpose of the fixed-throughput design. A future refinement is to have the directory authorities compute LambdaR at voting time from a declared target capacity together with the topology, in the same manner that LambdaG is computed from the Coupon Collector’s Bound, so that the provisioning intent is stated once and the rate follows the topology automatically.

7. Summary of parameters and derivations

Quantity Value Source
Originator send pacing Exp(LambdaR), safety-capped PKI document
Decoy generation on tick, when FIFO empty originator only
Reply rate 1:1 echo of command rate responder rule
Reply jitter bound J 1/LambdaR (uniform on [0, J]) PKI document
Read deadline, decoys on SafetyCap(LambdaR) β‰ˆ 27.6/LambdaR PKI document
Read deadline, decoys off none (TCP keepalive detects dead peers) posture
Worst-case replica ingress (C + R βˆ’ 1) Γ— LambdaR topology + PKI document

The only constants living in code are dimensionless: the choice of one mean slot for the jitter bound, and the 10⁻¹² tail probability shared with every other exponential sampler in Katzenpost.

Appendix A: the theory, briefly

Poisson process. Events occurring independently at a constant average rate Ξ». The gaps between events follow the exponential distribution with mean 1/Ξ», and the process is memoryless: having waited a while tells you nothing about how much longer you will wait. This is what makes it the natural shape for traffic that must reveal nothing.

M/M/1. The simplest queue: Poisson arrivals, exponential service times, one server. Its behavior is governed by the utilization ρ, the arrival rate divided by the service rate. For ρ < 1 the queue is stable and keeps returning to empty; the average wait grows as 1/(service βˆ’ arrival), so it explodes as ρ approaches 1.

The M/M/1 boundary (ρ = 1). The server keeps up exactly on average, and intuition says the queue should hold steady. It does not. At ρ = 1 the queue length is a fair coin-flip walk with no restoring force; the floor at zero clips its downward excursions but not its upward ones, so it drifts up roughly like the square root of elapsed time and never settles. Formally the queue is null recurrent: it always eventually empties, but the expected time to do so is infinite. This is why the responder must not pace replies at the same rate they are produced, and why offered load must sit well below LambdaR.

Little’s Law. In any stable system, average occupancy equals arrival rate times average time spent inside: L = Ξ»W. With reply jitter uniform on [0, J] the average wait is J/2, so the emitter’s timer queue holds LambdaR Γ— J/2 items, which for J = 1/LambdaR is one half of one reply at any rate.

Displacement theorem. Delay each event of a Poisson stream by an independent random amount, drawn from any distribution, and the result is again a Poisson stream at the same rate. Hence reply jitter cannot and need not shape the aggregate traffic; its only job is to decorrelate individual replies, so we choose the jitter distribution purely for that.

Maximum entropy. Among all distributions confined to [0, J], the uniform is the least predictable; among all with a fixed mean on unbounded support, the exponential is. Replies live under a hard latency budget, so uniform is the correct choice; an exponential jitter would buy an unbounded tail and, by the displacement theorem, nothing else.

SafetyCap. Sampling from an exponential distribution occasionally yields absurdly long delays, and a real system must bound them, yet any visible truncation would distort the distribution an adversary observes. SafetyCap(Ξ») resolves the tension by capping at the (1 βˆ’ 10⁻¹²) quantile: solving e^(βˆ’Ξ»t) = 10⁻¹² gives t = ln(10ΒΉΒ²)/Ξ» β‰ˆ 27.63/Ξ». A draw is clamped once per trillion samples, so the realized traffic is statistically indistinguishable from the pure exponential, while the system still holds a hard bound. The same number serves as the dead-peer test on a decoy-bearing link: under the healthy hypothesis, silence longer than the cap has probability 10⁻¹², so observing it is overwhelming evidence the peer is gone. One dimensionless constant, two duties, zero hardcoded timescales.

12 -

Deprecated. This page describes the original PKI design and it no longer matches the current network.

For the current design, see the Directory authority specification.

Public key infrastructure specification

Yawning Angel

Claudia Diaz

Ania Piotrowska

David Stainton

Masala

Abstract

to do


Terminology

The following terms are used in this specification.

directory authority system
Refers to PKI schemes used by Mixminion and Tor.

family
Identifier of security domains or entities operating one or more mix nodes in the network. This is used to inform the path selection algorithm.

gateway
A mix node on the edge of the mixnet that is the first hop for messages coming from clients. The gateway authenticates client connections and queues reply messages (SURBs) for retrieval.

layer
The layer value indicates which network topology layer a particular mix node resides in.

mix descriptor
A database record that describes a mixnet server component.

MSL
Maximum segment lifetime, currently set to 120 seconds.

nickname
A component nickname string that must be unique in the consensus document.

PKI
Public key infrastructure.

Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119.

The β€œC” style Presentation Language as described in RFC5246 Section 4 is used to represent data structures for additional cryptographic wire protocol commands. KATZMIXWIRE

Introduction

Mixnets are designed with the assumption that a public key infrastructure (PKI) exists and it gives each client the same view of the network. This specification is inspired by the Tor and Mixminion directory authority systems MIXMINIONDIRAUTHTORDIRAUTH whose main features are precisely what we need for our PKI. These are decentralized systems meant to be collectively operated by multiple entities.

The mix network directory authority system (PKI) is essentially a cooperative decentralized database and voting system that is used to produce network consensus documents which mix clients periodically retrieve and use for their path selection algorithm when creating Sphinx packets. These network consensus documents are derived from a voting process between the directory authority servers.

This design prevents mix clients from using only a partial view of the network for their path selection so as to avoid fingerprinting and bridging attacks FINGERPRINTING, BRIDGING, and LOCALVIEW.

The PKI is also used by authority operators to specify network-wide parameters, for example in the Katzenpost decryption mix network KATZMIXNET the Poisson mix strategy is used and, therefore, all clients must use the same lambda parameter for their exponential distribution function when choosing hop delays in the path selection. The mix network directory authority system, aka PKI, SHALL be used to distribute such network-wide parameters in the network consensus document that have an impact on security and performance.

Security properties overview

This directory authority system has the following feature goals and security properties:

  • All directory authority servers must agree with each other on the set of directory authorities.

  • All directory authority servers must agree with each other on the set of mixes.

  • This system is intentionally designed to provide identical network consensus documents to each mix client. This mitigates epistemic attacks against the client path selection algorithm such as fingerprinting and bridge attacks FINGERPRINTINGBRIDGING.

  • This system is NOT byzantine-fault-tolerant, it instead allows for manual intervention upon consensus fault by the directory authority operators. Further, these operators are responsible for expelling bad acting operators from the system.

  • This system enforces the network policies such as mix join policy wherein intentionally closed mixnets will prevent arbitrary hosts from joining the network by authenticating all descriptor signatures with a list of allowed public keys.

  • The directory authority system for a given mix network is essentially the root of all authority.

Differences from Tor and Mixminion directory authority systems

In this document we specify a directory authority system which is different from that of Tor’s and Mixminion’s in a number of ways:

  • The list of valid mixes is expressed in an allow-list. For the time being there is no specified β€œbandwidth authority” system which verifies the health of mixes (Further research required in this area).

  • There’s no non-directory channel to inform clients that a node is down, so it will end up being a lot of packet loss, since clients will continue to include the missing node in their path selection until keys published by the node expire and it falls out of the consensus.

  • The schema of the mix descriptors is different from that used in Mixminion and Tor, including a change which allows our mix descriptor to express n Sphinx mix routing public keys in a single mix descriptor whereas in the Tor and Mixminion directory authority systems, n descriptors are used.

  • The serialization format of mix descriptors is different from that used in Mixminion and Tor.

  • The shared random number computation is performed every voting round, and is required for a vote to be accepted by each authority. The shared random number is used to deterministically generate the network topology.

Overview of mix PKI interaction

Each Mix MUST rotate the key pair used for Sphinx packet processing periodically for forward secrecy reasons and to keep the list of seen packet tags short. SPHINX09SPHINXSPEC The Katzenpost mix network uses a fixed interval (epoch), so that key rotations happen simultaneously throughout the network, at predictable times.

Each directory authority server MUST use some time synchronization protocol in order to correctly use this protocol. This directory authority system requires time synchronization to within a few minutes.

Let each epoch be exactly 1200 seconds (20 minutes) in duration, and the 0th Epoch begin at 2017-06-01 00:00 UTC.

To facilitate smooth operation of the network and to allow for delays that span across epoch boundaries, Mixes MUST publish keys to the PKI for at least 3 epochs in advance, unless the mix will be otherwise unavailable in the near future due to planned downtime.

At an epoch boundary, messages encrypted to keys from the previous epoch are accepted for a grace period of 2 minutes.

Thus, at any time, keys for all Mixes for the Nth through N + 2nd epoch will be available, allowing for a maximum round trip (forward message + SURB) delay + transit time of 40 minutes. SURB lifetime is limited to a single epoch because of the key rotation epoch, however this shouldn’t present any usability problems since SURBs are only used for sending ACK messages from the destination Provider to the sender.

PKI protocol schedule

There are two main constraints to authority schedule:

  1. There MUST be enough key material extending into the future so that clients are able to construct Sphinx packets with a forward and reply paths.

  2. All participants should have enough time to participate in the protocol; upload descriptors, vote, generate documents, download documents, establish connections for user traffic.

The epoch duration of 20 minutes is more than adequate for these two constraints.

NOTE: perhaps we should make it shorter? but first let’s do some scaling and bandwidth calculations to see how bad it gets…

Directory authority server schedule

directory authority server interactions are conducted according to the following schedule, where T is the beginning of the current epoch, and P is the length of the epoch period.

  • T - Epoch begins

  • T + P/2 - Vote exchange

  • T + (5/8)*P - Reveal exchange

  • T + (6/8)*P - Tabulation and signature exchange

  • T + (7/8)*P - Publish consensus

Mix schedule

Mix PKI interactions are conducted according to the following schedule, where T is the beginning of the current epoch.

T + P/8 - Deadline for publication of all mixes documents for the next epoch.

T + (7/8)*P - This marks the beginning of the period where mixes perform staggered fetches of the PKI consensus document.

T + (8/9)*P - Start establishing connections to the new set of relevant mixes in advance of the next epoch.

T + P - 1MSL - Start accepting new Sphinx packets encrypted to the next epoch’s keys.

T + P + 1MSL - Stop accepting new Sphinx packets encrypted to the previous epoch’s keys, close connections to peers no longer listed in the PKI documents and erase the list of seen packet tags.

Mix layer changes are controlled by the directory authorities and therefore a mix can be reassigned to a different layer in our stratified topology at any new epoch. Mixes will maintain incoming and outgoing connections to the various nodes until all mix keys have expired, iff the node is still listed anywhere in the current document.

Voting for consensus protocol

In our directory authority protocol, all the actors conduct their behavior according to a common schedule as outlined in section “2.1 PKI Protocol Schedule”. The directory authority servers exchange messages to reach consensus about the network. Other tasks they perform include collecting mix descriptor uploads from each mix for each key rotation epoch, voting, shared random number generation, signature exchange and publishing of the network consensus documents.

Protocol messages

There are only two document types in this protocol:

  • mix_descriptor: A mix descriptor describes a mix.

  • directory: A directory contains a list of descriptors and other information that describe the mix network.

Mix descriptor and directory documents MUST be properly signed.

Mix descriptor and directory signing

Mixes MUST compose mix descriptors which are signed using their private identity key, an ed25519 key. Directories are signed by one or more directory authority servers using their authority key, also an ed25519 key. In all cases, signing is done using JWS RFC7515.

Vote exchange

As described in section β€œ2.1 PKI Protocol Schedule”, the directory authority servers begin the voting process 1/8 of an epoch period after the start of a new epoch. Each authority exchanges vote directory messages with each other.

Authorities archive votes from other authorities and make them available for retrieval. Upon receiving a new vote, the authority examines it for new descriptors and includes any valid descriptors in its view of the network.

Each authority includes in its vote a hashed value committing to a choice of a random number for the vote. See section 4.3 for more details.

3.2.1 Voting wire protocol commands

The Katzenpost wire protocol as described in KATZMIXWIRE is used by authorities to exchange votes. We define additional wire protocol commands for sending votes:

enum {

:   vote(22), vote_status(23),

} Command;

The structures of these commands are defined as follows:

struct {
:   uint64_t epoch_number; opaque public_key[ED25519_KEY_LENGTH];
opaque payload[];

} VoteCommand;

struct {
:   uint8 error_code;

} VoteStatusCommand;

3.2.2 The vote Command

The vote command is used to send a PKI document to a peer authority during the voting period of the PKI schedule.

The payload field contains the signed and serialized PKI document representing the sending authority’s vote. The public_key field contains the public identity key of the sending authority which the receiving authority can use to verify the signature of the payload. The epoch_number field is used by the receiving party to quickly check the epoch for the vote before deserializing the payload.

Each authority MUST include its commit value for the shared random computation in this phase along with its signed vote. This computation is derived from the Tor Shared Random Subsystem, TORSRV.

3.2.3 The vote_status command

The vote_status command is used to reply to a vote command. The error_code field indicates if there was a failure in the receiving of the PKI document.

enum {

:   vote_ok(0), /\* None error condition. */ vote_too_early(1), /*
The Authority should try again later. */ vote_too_late(2), /*
This round of voting was missed. \*/
}

The epoch_number field of the vote struct is compared with the epoch that is currently being voted on. vote_too_early and vote_too_late are replied back to the voter to report that their vote was not accepted.

Reveal exchange

As described in section β€œ2.1 PKI Protocol Schedule”, the directory authority servers exchange the reveal values after they have exchanged votes which contain a commit value. Each authority exchanges reveal messages with each other.

3.3.1 Reveal wire protocol commands

The Katzenpost wire protocol as described in KATZMIXWIRE is used by authorities to exchange reveal values previously committed to in their votes. We define additional wire protocol commands for exchanging reveals:

enum {
:   reveal(25), reveal_status(26),
} Command;

The structures of these commands are defined as follows:

struct {
:   uint64_t epoch_number; opaque public_key[ED25519_KEY_LENGTH];
opaque payload[];

} RevealCommand;

struct {
:   uint8 error_code;

} RevealStatusCommand;

3.3.2 The reveal Command

The reveal command is used to send a reveal value to a peer authority during the reveal period of the PKI schedule.

The payload field contains the signed and serialized reveal value. The public_key field contains the public identity key of the sending authority which the receiving authority can use to verify the signature of the payload. The epoch_number field is used by the receiving party to quickly check the epoch for the reveal before deserializing the payload.

3.3.3 The reveal_status Command

The reveal_status command is used to reply to a reveal command. The error_code field indicates if there was a failure in the receiving of the shared random reveal value.

enum {

:   reveal_ok(8), /* None error condition. */ reveal_too_early(9), 
/* The Authority should try again later. */
reveal_not_authorized(10), /* The Authority was rejected. */
reveal_already_received(11), /* The Authority has already revealed
this round. */ reveal_too_late(12) /* This round of revealing was
missed. */

} Errorcodes;

The epoch_number field of the reveal struct is compared with the epoch that is currently being voted on. The reveal_too_early and reveal_too_late codes are replied back to the authority to report their reveal was not accepted. The status code reveal_not_authorized is used if the authority is rejected. The reveal_already_received is used to report that a valid reveal command was already received for this round.

Cert exchange

The Cert command is the same as a Vote but contains the set of Reveal values as seen by the voting peer. In order to ensure that a misconfigured or malicious authority operator cannot amplify their ability to influence the threshold voting process, after Reveal messages have been exchanged, authorities vote again, including the Reveals seen by them. Authorities may not introduce new MixDescriptors at this phase in the protocol.

Otherwise, a consensus partition can be obtained by withholding Reveal values from a threshold number of peers. In the case of an even-number of authorities, a denial of service by a single authority was observed.

Vote tabulation for consensus computation

The main design constraint of the vote tabulation algorithm is that it MUST be a deterministic process that produces the same result for each directory authority server. This result is known as a network consensus file.

A network consensus file is a well formed directory struct where the status field is set to consensus and contains 0 or more descriptors, the mix directory is signed by 0 or more directory authority servers. If signed by the full voting group then this is called a fully signed consensus.

  1. Validate each vote directory:
  • that the liveness fields correspond to the following epoch

  • status is vote

  • version number matches ours

  1. Compute a consensus directory:

Here we include a modified section from the Mixminion PKI spec MIXMINIONDIRAUTH:

For each distinct mix identity in any vote directory:

  • If there are multiple nicknames for a given identity, do not include any descriptors for that identity.

  • If half or fewer of the votes include the identity, do not include any descriptors for the identity. This also guarantees that there will be only one identity per nickname.

  • If we are including the identity, then for each distinct descriptor that appears in any vote directory:

    • Do not include the descriptor if it will have expired on the date the directory will be published.

    • Do not include the descriptor if it is superseded by other descriptors for this identity.

    • Do not include the descriptor if it not valid in the next epoch.

    • Otherwise, include the descriptor.

  • Sort the list of descriptors by the signature field so that creation of the consensus is reproducible.

  • Set directory status field to consensus.

  1. Compute a shared random number from the values revealed in the β€œReveal” step. Authorities whose reveal value does not verify their commit value MUST be excluded from the consensus round. Authorities ensure that their peers MUST participate in commit-and-reveal, and MUST use correct Reveal values obtained from other peers as part of the β€œCert” exchange.

  2. Generate or update the network topology using the shared random number as a seed to a deterministic random number generator that determines the order that new mixes are placed into the topology.

Signature collection

Each authority signs their view of consensus, and exchanges detached Signatures with each other. Upon receiving each Signature it is added to the signatures on the Consensus if it validates the Consensus. The authority SHOULD warn the administrator if network partition is detected.

If there is disagreement about the consensus directory, each authority collects signatures from only the servers which it agrees with about the final consensus.

// TODO: consider exchanging peers votes amongst authorities (or hashes thereof) to // ensure that an authority has distributed one and only unique vote amongst its peers.

Publication

If the consensus is signed by a majority of members of the voting group then it’s a valid consensus and it is published.

PKI protocol data structures

Mix descriptor format

Note that there is no signature field. This is because mix descriptors are serialized and signed using JWS. The IdentityKey field is a public ed25519 key. The MixKeys field is a map from epoch to public X25519 keys which is what the Sphinx packet format uses.

{
"Version": 0,
"Name": "",
"Family": "",
"Email": "",
"AltContactInfo":"",
"IdentityKey": "",
"LinkKey":"",
"MixKeys": {
"Epoch": "EpochPubKey",
},
"Addresses": ["IP:Port"],
"Layer": 0,
"LoadWeight":0,
"AuthenticationType":""
}

Scheduling mix downtime

Mix operators can publish a half empty mix descriptor for future epochs to schedule downtime. The mix descriptor fields that MUST be populated are:

  • Version

  • Name

  • Family

  • Email

  • Layer

  • IdentityKey

  • MixKeys

The map in the field called “MixKeys” should reflect the scheduled downtime for one or more epochs by not have those epochs as keys in the map.

Directory format

{
"Signatures": [],
"Version": 0,
"Status": "vote",
"Lambda" : 0.274,
"MaxDelay" : 30,
"Topology" : [],
"Providers" : [],
}

Shared random value structure

Katzenpost’s shared random value computation is inspired by Tor’s shared random subsystem TORSRV.

Each voting round a commit value is included in the votes sent to other authorities. These are produced as follows:

H = blake2b-256

COMMIT = Uint64(epoch) | H(REVEAL) REVEAL = Uint64(epoch) | H(RN)

After the votes are collected from the voting round, and before signature exchange, the Shared Random Value field of the consensus document is the output of H over the input string calculated as follows:

  1. Validated Reveal commands received including the authorities own reveal are sorted by reveal value in ascending order and appended to the input in format IdentityPublicKeyBytes_n | RevealValue_n

However instead of the identity public key bytes we instead encode the reveal with the blake2b 256 bit hash of the public key bytes.

  1. If a SharedRandomValue for the previous epoch exists, it is appended to the input string, otherwise 32 NUL (x00) bytes are used.
REVEALS = ID_a \| R_a \| ID_b \| R_b \| \... SharedRandomValue =
H("shared-random" | Uint64(epoch) | REVEALS | PREVIOUS_SRV)

PKI wire protocol

The Katzenpost wire protocol as described in KATZMIXWIRE is used by both clients and by directory authority peers. In the following section we describe additional wire protocol commands for publishing mix descriptors, voting and consensus retrieval.

Mix descriptor publication

The following commands are used for publishing mix descriptors and setting mix descriptor status:

enum {
/* Extending the wire protocol Commands. */
post_descriptor(20),
post_descriptor_status(21),
}

The structures of these command are defined as follows:

struct {
uint64_t epoch_number;
opaque payload[];
} PostDescriptor;

struct {
uint8 error_code;
} PostDescriptorStatus;

The post_descriptor command

The post_descriptor command allows mixes to publish their descriptors.

The post_descriptor_status command

The post_descriptor_status command is sent in response to a post_descriptor command, and uses the following error codes:

enum {
descriptor_ok(0),
descriptor_invalid(1),
descriptor_conflict(2),
descriptor_forbidden(3),
} ErrorCodes;

Voting

The following commands are used by authorities to exchange votes:

enum {
vote(22),
vote_status(23),
get_vote(24),
} Command;

The structures of these commands are defined as follows:

struct {
uint64_t epoch_number;
opaque public_key[ED25519_KEY_LENGTH];
opaque payload[];
} VoteCommand;

struct {
uint8 error_code;
} VoteStatusCommand;

The vote command

The vote command is used to send a PKI document to a peer authority during the voting period of the PKI schedule.

The payload field contains the signed and serialized PKI document representing the sending authority’s vote. The public_key field contains the public identity key of the sending authority which the receiving authority can use to verify the signature of the payload. The epoch_number field is used by the receiving party to quickly check the epoch for the vote before deserializing the payload.

The vote_status command

The vote_status command is used to reply to a vote command. The error_code field indicates if there was a failure in the receiving of the PKI document.

enum {
vote_ok(0),               /* None error condition. */
vote_too_early(1),        /* The Authority should try again later. */
vote_too_late(2),         /* This round of voting was missed. */
vote_not_authorized(3),   /* The voter's key is not authorized. */
vote_not_signed(4),       /* The vote signature verification failed */
vote_malformed(5),        /* The vote payload was invalid */
vote_already_received(6), /* The vote was already received */
vote_not_found(7),        /* The vote was not found */
}

The epoch_number field of the vote struct is compared with the epoch that is currently being voted on. vote_too_early and vote_too_late are replied back to the voter to report that their vote was not accepted.

The get_vote command

The get_vote command is used to request a PKI document (vote) from a peer authority. The epoch field contains the epoch from which to request the vote, and the public_key field contains the public identity key of the authority of the requested vote. A successful query is responded to with a vote command, and queries that fail are responded to with a vote_status command with error_code value of vote_not_found.

Retrieval of consensus

Providers in the Katzenpost mix network system KATZMIXNET may cache validated network consensus files and serve them to clients over the mix network’s link layer wire protocol KATZMIXWIRE. We define additional wire protocol commands for requesting and sending PKI consensus documents:

enum {
/* Extending the wire protocol Commands. */
get_consensus(18),
consensus(19),
} Command;

The structures of these commands are defined as follows:
struct {
uint64_t epoch_number;
} GetConsensusCommand;

struct {
uint8 error_code;
opaque payload[];
} ConsensusCommand;

The get_consensus command

The get_consensus command is a command that is used to retrieve a recent consensus document. If a given get_consensus command contains an Epoch value that is either too big or too small then a reply consensus command is sent with an empty payload. Otherwise if the consensus request is valid then a consensus command containing a recent consensus document is sent in reply.

Initiators MUST terminate the session immediately upon reception of a get_consensus command.

The consensus command

The consensus command is a command that is used to send a recent consensus document. The error_code field indicates if there was a failure in retrieval of the PKI consensus document.

enum {
consensus_ok(0),        /* None error condition and SHOULD be accompanied with
a valid consensus payload. */
consensus_not_found(1), /* The client should try again later. */
consensus_gone(2),      /* The consensus will not be available in the future. */
} ErrorCodes;

The Cert command

The cert command is used to send a PKI document to a peer authority during the voting period of the PKI schedule. It is the same as the vote command, but must contain the set of SharedRandomCommit and SharedRandomReveal values as seen by the authority during the voting process.

The CertStatus command

The cert_status command is the response to a cert command, and is the same as a vote_status response, other than the command identifier. Responses are CertOK, CertTooEarly, CertNotAuthorized, CertNotSigned, CertAlreadyReceived, CertTooLate.

8. Signature exchange

Signatures exchange is the final round of the consensus protocol and consists of the Sig and SigStatus commands.

The sig command

The sig command contains a detached signature from PublicKey of Consensus for Epoch.

The sig_status command

The sig_status command is the response to a sig command. Responses are SigOK, SigNotAuthorized, SigNotSigned, SigTooEarly, SigTooLate, SigAlreadyReceived, and SigInvalid.

Scalability considerations

TODO: notes on scaling, bandwidth usage etc.

Future work

  • Byzantine fault tolerance

  • PQ crypto signatures for all PKI documents: mix descriptors and directories. SPHINCS256 could be used, we already have a golang implementation: https://github.com/Yawning/sphincs256/

  • Make a bandwidth authority system to measure health of the network. Also perform load balancing as described in PEERFLOW?

  • Implement byzantine attack defenses as described in MIRANDA and MIXRELIABLE where mix link performance proofs are recorded and used in a reputation system.

  • Choose a different serialization/schema language?

  • Use a append only Merkle tree instead of this voting protocol.

Anonymity considerations

  • This system is intentionally designed to provide identical network consensus documents to each mix client. This mitigates epistemic attacks against the client path selection algorithm such as fingerprinting and bridge attacks FINGERPRINTING, BRIDGING.

  • If consensus has failed and thus there is more than one consensus file, clients MUST NOT use this compromised consensus and refuse to run.

  • We try to avoid randomizing the topology because doing so splits the anonymity sets on each mix into two. That is, packets belonging to the previous topology versus the current topology are trivially distinguishable. On the other hand if enough mixes fall out of consensus eventually the mixnet will need to be rebalanced to avoid an attacker compromised path selection. One example of this would be the case where the adversary controls the only mix is one layer of the network topology.

Security considerations

  • The directory authority / PKI system for a given mix network is essentially the root of all authority in the system. The PKI controls the contents of the network consensus documents that mix clients download and use to inform their path selection. Therefore if the PKI as a whole becomes compromised then so will the rest of the system in terms of providing the main security properties described as traffic analysis resistance. Therefore a decentralized voting protocol is used so that the system is more resilient when attacked, in accordance with the principle of least authority. SECNOTSEP

  • Short epoch durations make it is more practical to make corrections to network state using the PKI voting rounds.

  • Fewer epoch keys published in advance is a more conservative security policy because it implies reduced exposure to key compromise attacks.

  • A bad-acting directory authority who lies on each vote and votes inconsistently can trivially cause a denial of service for each voting round.

Acknowledgements

We would like to thank Nick Mathewson for answering design questions and thorough design review.

References

BRIDGING

Danezis, G., Syverson, P., β€œBridging and Fingerprinting: Epistemic Attacks on Route Selection”, Proceedings of PETS 2008, Leuven, Belgium, July 2008, https://www.freehaven.net/anonbib/cache/danezis-pet2008.pdf.

FINGERPRINTING

Danezis, G., Clayton, R., β€œRoute Finger printing in Anonymous Communications”, https://www.cl.cam.ac.uk/~rnc1/anonroute.pdf.

KATZMIXNET

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/mixnet.pdf.

KATZMIXWIRE

Angel, Y., β€œKatzenpost Mix Network Wire Protocol Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/wire.pdf.

LOCALVIEW

Gogolewski, M., Klonowski, M., Kutylowsky, M., β€œLocal View Attack on Anonymous Communication”, https://cs.pwr.edu.pl/kutylowski/articles/LocalView-WWW.pdf.

MIRANDA

Leibowitz, H., Piotrowska, A., Danezis, G., Herzberg, A., β€œNo right to remain silent: Isolating Malicious Mixes”, 2017, https://eprint.iacr.org/2017/1000.pdf.

MIXMINIONDIRAUTH

Danezis, G., Dingledine, R., Mathewson, N., β€œType III (Mixminion) Mix Directory Specification”, December 2005, https://www.mixminion.net/dir-spec.txt.

MIXRELIABLE

Dingledine, R., Freedman, M., Hopwood, D., Molnar, D., β€œA Reputation System to Increase MIX-Net Reliability”, 2001, Information Hiding, 4th International Workshop, https://www.freehaven.net/anonbib/cache/mix-acc.pdf.

PEERFLOW

Johnson, A., Jansen, R., Segal, A., Syverson, P., β€œPeerFlow: Secure Load Balancing in Tor”, July 2017, Proceedings on Privacy Enhancing Technologies, https://petsymposium.org/2017/papers/issue2/paper12-2017-2-source.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5246

Dierks, T. and E. Rescorla, β€œThe Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246, DOI 10.17487/RFC5246, August 2008, http://www.rfc-editor.org/info/rfc5246.

RFC7515

Jones, M., Bradley, J., Sakimura, N., β€œJSON Web Signature (JWS)”, May 2015, https://www.rfc-editor.org/info/rfc7515.

SECNOTSEP

Miller, M., Tulloh, B., Shapiro, J., β€œThe Structure of Authority: Why Security Is not a Separable Concer”, http://www.erights.org/talks/no-sep/secnotsep.pdf.

SPHINCS256

Bernstein, D., Hopwood, D., Hulsing, A., Lange, T., Niederhagen, R., Papachristodoulou, L., Schwabe, P., Wilcox O’Hearn, Z., β€œSPHINCS: practical stateless hash-based signatures”, http://sphincs.cr.yp.to/sphincs-20141001.pdf.

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

TORDIRAUTH

β€œTor directory protocol, version 3”, https://spec.torproject.org/dir-spec/index.html.

TORSRV

β€œTor Shared Random Subsystem Specification”, https://spec.torproject.org/srv-spec/index.html.

13 -

The Katzenpost directory authority protocol.

Directory authority specification

Introduction

Katzenpost is a project for building mixnets tuned to different uses1. Each network starts with a fixed group of computers called directory authorities (DAs). We usually call them dirauths. Different parties operate these dirauths, whether or not they trust one another. Their shared configuration defines the network and which nodes may join it.

Namenlos, the first public post-quantum hybrid mixnet, is one Katzenpost deployment2. Operators create other networks by choosing parameters and allowed nodes, then distributing configurations to authorities, other servers, and end users.

The dirauths publish a signed network consensus document, or consensus, for each epoch, a fixed-length time period. Publication requires a simple majority to sign the same document and the network constraints to be met. The consensus gives users the network’s agreed view for that epoch. It supplies node keys, topology, and traffic rates for Katzenpost Sphinx packets. It is the network’s public-key infrastructure (PKI) document.

Nodes submit signed descriptors that announce their identities, addresses, keys, and roles. Authorities accept announcements only from allowed identities and include those that receive enough votes. Applications on one host SHOULD share one local kpclientd daemon. The daemon verifies the consensus before it uses the network. Nodes renew their announcements, and authorities repeat the process for the next epoch.

Namenlos network map

Configured Namenlos nodes and packet paths
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: network
    accTitle: Configured Namenlos nodes and packet paths
    accDescr: Six configured directory authorities publish a consensus with at least four signatures. Users verify it through kpclientd. Packets use one of four gateways and one mix per layer. The mix layers contain two, two, and three mixes. Four service nodes host mixnet services. Courier services use four storage replicas. Each node role has its own icon and each node has its configured name.
    subgraph CONTROL["Directory authorities"]
        direction TB
        CONFIG["Shared authority configuration<br/>and allowed node identities"] --> AUTH["6 directory authorities<span class='node-roster authorities'><span class='node-badge role-authority'>annares</span><span class='node-badge role-authority'>gnunet</span><span class='node-badge role-authority'>luscus</span><span class='node-badge role-authority'>respectmy</span><span class='node-badge role-authority'>wauland</span><span class='node-badge role-authority'>windfall</span></span>"]
        AUTH -->|"4 or more matching signatures"| CONS["<img src='/docs/specs/dirauth/icons/directory.svg' alt='' width='32' height='32'/><br/>Consensus for one epoch<br/>Node keys, topology, and rates"]
    end
    subgraph PATH["Mixnet packet paths"]
        direction TB
        CLIENT["<img src='/docs/specs/dirauth/icons/client.svg' alt='' width='32' height='32'/><br/>User applications<br/>through kpclientd"] -->|"choose one gateway"| GW["4 gateways<span class='node-roster'><span class='node-badge role-gateway'>allmixedup</span><span class='node-badge role-gateway'>gnunet</span><span class='node-badge role-gateway'>haywood</span><span class='node-badge role-gateway'>waulandgateway</span></span>"]
        GW --> L1["Mix layer 1<span class='node-roster'><span class='node-badge role-mix'>windfall</span><span class='node-badge role-mix'>cryptonymity</span></span>"]
        L1 --> L2["Mix layer 2<span class='node-roster'><span class='node-badge role-mix'>omelas</span><span class='node-badge role-mix'>mixy</span></span>"]
        L2 --> L3["Mix layer 3<span class='node-roster'><span class='node-badge role-mix'>oneeyedman</span><span class='node-badge role-mix'>reed</span><span class='node-badge role-mix'>wauland</span></span>"]
        L3 -->|"choose one service node"| SN["4 service nodes<span class='node-roster'><span class='node-badge role-service'>annares</span><span class='node-badge role-service'>lilly</span><span class='node-badge role-service'>waulandservice</span><span class='node-badge role-service'>windfallservice</span></span>"]
        SN -. "courier storage requests" .-> REP["4 storage replicas<span class='node-roster'><span class='node-badge role-replica'>storagereplica0</span><span class='node-badge role-replica'>storagereplica1</span><span class='node-badge role-replica'>storagereplica2</span><span class='node-badge role-replica'>storagereplica3</span></span>"]
    end
    CONS -. "Consensus to client<br/>via gateway" .-> CLIENT
    class CONFIG,CLIENT local
    class AUTH,CONS directory
    class GW,L1,L2,L3,SN,REP link
    linkStyle 1,8 stroke:#f3bd66,stroke-width:2px
    linkStyle 2,3,4,5,6 stroke:#c3abff,stroke-width:2px
    linkStyle 7 stroke:#7acee9,stroke-width:2px
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

This network uses the Namenlos configuration. Six dirauths require four matching signatures. Packet routes use one gateway, one mix per layer, and one service node. Only allowed nodes with enough votes enter the consensus. Prerequisites distinguish configured counts from publication minimums.

History

This protocol continues the original Katzenpost PKI design, described in KATZMIXPKI. That design already used configured authorities, node allow-lists, and a majority-signed consensus. The post-quantum hybrid protocol adds new key schemes, encodings, node roles, and voting phases.

Conventions

The glossary defines shared terms. MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY have the meanings in RFC 2119. Reference tables and requirements appear in linked appendices.

The post-quantum hybrid protocol

The network configuration fixes membership and keys. In each voting round, dirauths agree on a consensus of signed directory data. Participants exchange that data over authenticated connections under the security requirements.

Network membership and node roles

Configured authority keys define which directory authorities a participant trusts. The authorities’ shared allow-lists define which other nodes can enter the consensus.

Node roles

The node roles include gateways, which connect clients to the mixnet. Mixes relay packets through layers, and service nodes host application endpoints. Storage replicas support Pigeonhole. The topology groups mixes into ordered layers. Node keys follow the cryptographic schemes.

Prerequisites

Clients need a usable consensus to reach mixnet services under the network prerequisites. Authorities can publish that consensus only when the network requirements are met.

All parties in a network MUST use the same epoch duration, cryptographic schemes, and packet geometry.

Layers is the configured number of mix layers. MinNodesPerLayer is the minimum configured mix count per layer.

At startup and reload, an authority MUST require at least Layers * MinNodesPerLayer configured mixes, one gateway, and one service node. A fixed topology MUST also meet MinNodesPerLayer in each layer. At signing, every mix layer MUST be non-empty. At least one gateway and one service node MUST remain (R-16-6).

One authority cannot complete voting, which needs at least two contributors. Let A be the number of configured authorities. Publication requires floor(A/2)+1 matching signatures (threshold). Replica requirements depend on the storage service (Pigeonhole).

Namenlos configures six authorities, seven mixes across three layers, four gateways, four service nodes, and four storage replicas. Its fixed layers contain two, two, and three mixes.

Client software and configuration

Applications on one host SHOULD share one local kpclientd daemon through the thin_client interface.

kpclientd fetches the consensus through a configured gateway. It MUST verify the consensus before use. The client configuration supplies gateway pins and the authority verification keys. The Namenlos configurations give example values.

Mixnet services

A mixnet service provides an application endpoint on a service node. Clients use an accepted consensus to find the mixnet service and select a route to it.

A service node lists its mixnet services in its descriptor’s Kaetzchen field. Each entry names a capability and a client endpoint. Clients reach the service through the mixnet without revealing their network addresses to it. Authorities copy Kaetzchen into the consensus under R-5-9. Mixnet services have no separate descriptor.

The courier service forwards requests to Pigeonhole storage replicas. Couriers and replicas can fetch the consensus directly from authorities.

Epochs and timing

Epoch boundaries set deadlines for uploads, votes, and publication. Each consensus names the epoch it describes.

Constants and notation

Each consensus covers one fixed-length time period, called an epoch. The current epoch has a number, denoted N. Each epoch has the same duration, denoted P. T is the instant that epoch N starts. Elapsed time is t = now - T. An authority prepares a document for a voting epoch, denoted V. In a normal round, the voting epoch is the next epoch: V = N+1.

The notation reference gives definitions and Namenlos example values.

Time

The network divides time into epochs. Every participant MUST use the same epoch length and starting point.

Epoch 0 starts at Z = 2017-06-01 00:00:00 UTC. Epoch N starts at Z + N*P. The next epoch starts at Z + (N+1)*P. P ranges from 2 minutes to 7 days, with a default of 1200 seconds. R-9-3 defines how a party selects P. Namenlos leaves EpochDuration unset, which selects the default unless the environment overrides it.

Phase boundaries scale with P. operation defaults indexes operation defaults and fixed time values.

Repeating epoch cycle
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: clock
    accTitle: Repeating epoch cycle
    accDescr: Epoch 0 starts at Z. Each turn lasts P, which is 1200 seconds or 20 minutes in the Namenlos configuration. At each boundary the epoch number increases, the start time advances by P, and elapsed time resets to zero.
    ZERO["Epoch 0 starts at Z<br/>N = 0, T = Z"] --> START(("Start epoch N<br/>at T<br/>t = 0"))
    START --> ELAPSED(("During epoch N<br/>0 <= t < P<br/>P = 20 min<br/>in Namenlos"))
    ELAPSED --> BOUNDARY(("After P seconds<br/>N = N + 1<br/>T = T + P"))
    BOUNDARY -->|"Next epoch<br/>t = 0"| START
    class ZERO,START,ELAPSED,BOUNDARY local
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

An epoch includes its start and excludes its end. The exact end boundary belongs to the next epoch. The clock also gives till, the time until the next epoch. At an absolute instant now at or after Z, the epoch clock is:

Epoch clock
N       = floor((now - Z) / P)
T       = Z + N * P
t       = now - T
till    = P - t

Requirements R-9-1 through R-9-6.

Directory objects and signatures

Nodes announce their keys in descriptors. Authorities exchange signed proposals and publish a signed consensus. Producers MUST use canonical Concise Binary Object Representation (CBOR). Receivers MUST apply the decoding rules.

Descriptors

A descriptor announces one node’s identity, addresses, and keys. The node signs its upload so authorities can verify who sent it.

Mixes, gateways, and service nodes post a MixDescriptor. Storage replicas post a ReplicaDescriptor.

Descriptor fields

A node announces its identity, addresses, keys, and role in the descriptor fields. Its role determines the required fields.

An authority keeps SoftwareVersion and ContactInfo only when [Server].AcceptDescriptorMetadata is true, which defaults to false. Otherwise, it removes both fields after signature verification, before validation, storage, voting, and comparison.

Upload encoding and signatures

Mix servers and storage replicas use different upload structures. Each structure binds the descriptor to the node’s signature.

The upload types are SignedUpload and SignedReplicaUpload. LoopStats contains MixIdentityHash (32 bytes or null), Epoch (uint64), and SegmentRatios (64-byte segment ID to float64). To verify a signature, the authority decodes the upload, sets Signature to null, and re-encodes it canonically. The consensus preserves these descriptor encodings.

Validation stages

Authorities verify an upload before they admit its descriptor. Consumers also parse keys when they use them.

The upload handler parses the identity key and verifies the signature and session binding. Descriptor validation verifies names, addresses, roles, and the current mix key. An upload receiver accepts empty future mix-key values, but their indexes MUST remain within NumMixKeys. Replica uploads require a non-empty envelope-key map with non-empty values. Upload validation imposes no replica-epoch window. Consumers parse other keys when they use them. Authorities verify retained metadata at upload.

Length and parsing alone do not establish that a public key is non-degenerate. The X25519 parser and X448 parser require the scheme’s public-key length. Sphinx rejects an all-zero derived shared secret during packet construction and unwrapping (construction, unwrapping). CTIDH parsing also rejects the base curve E0 and verifies supersingularity.

Requirements R-5-1 through R-5-11.

Votes and voting certificates

A vote is an authority’s signed proposal for the next consensus. It contains node announcements, traffic rates, notices, and a commitment to a random contribution.

A commitment fixes the contribution before its reveal. A reveal opens that commitment so peers can verify the contribution.

A voting certificate carries a tallied document and the matched signed commitment/reveal pairs the sender holds. Peers use these certificates to reconcile contributions before computing the shared random value.

The consensus document

The consensus describes the network for one epoch. Nodes and clients use it to select peers and apply shared settings.

A consensus lists allowed nodes whose descriptors receive enough votes (R-16-2). It records the full configured set only for replicas. A consumer MUST NOT infer other configured node sets from it. Missing nodes can have failed to upload.

Traffic rates

Traffic rates control packet frequency and mix delays. Authorities vote on the rates that the consensus publishes.

A traffic rate measures events per millisecond. Its inverse is the mean waiting time in an exponential distribution. Rates use float64 values. R-16-3 tallies five rates. R-16-8 derives LambdaG. The mixing strategy defines how these rates control traffic. Authority configuration replaces zero with 0.00025 for each voted rate and rejects negative values. It does not test finiteness (defaults and validation).

Signed document fields

The consensus stores the network description in one signed body. Votes and voting certificates use the same document structure.

The Document structure defines the signed fields. Document validation verifies them in votes, voting certificates, and the consensus.

Full document validation rejects duplicate identities across Topology, GatewayNodes, and ServiceNodes, but not StorageReplicas. An empty PriorSharedRandom is valid only when GenesisEpoch equals Epoch. Consumer validation differs by fetch path (gateway consensus transfer).

The decoder treats null and absent keys as empty. An encoder MUST preserve the distinctions in the field reference.

Votes supply five rates, descriptors, and notices. Reconciled commit/reveal pairs supply the shared random value (SRV), which seeds automatic topology assignment. Epoch is V. R-18-7 to R-18-9 define genesis and history. LambdaG, geometry hash, scheme, and configured replicas come from local configuration and MUST agree across authorities. Signatures remain outside the body in its certificate envelope. Descriptor nesting follows upload encoding. Commit/reveal map values contain serialized certificate envelopes.

R-6-5 limits mixing delays. SafetyCap is 27632 milliseconds at Mu = 0.001 and 110525 at Mu = 0.00025. Debug.DisableServicePreDelay disables the service-node hold. The authority’s [Notice] table defaults to empty.

Deterministic CBOR

Producers MUST encode equal data as identical bytes. Deterministic CBOR defines the encoding that every participant uses.

Producers MUST encode descriptors, documents, and certificate envelopes with deterministic CBOR.

Requirements R-6-1 through R-6-7.

Keys and schemes

Identity signatures authenticate directory data. Link and Sphinx keys protect different parts of mixnet communication.

Nodes and authorities have identity signing keys and link key encapsulation mechanism (KEM) keys. Mix servers also have Sphinx keys per epoch. Replicas have envelope non-interactive key exchange (NIKE) keys per replica epoch. Sphinx uses NIKE or KEM keys. Every network uses the same replica envelope scheme. Parties MUST configure matching schemes. Signature scheme details give component sizes and platform defaults. The Namenlos profile defines the deployed byte formats.

Certificate envelopes and signing

A certificate envelope holds data and the signatures that authenticate it. Several authorities can sign the same data.

Envelope fields

The envelope separates the payload from its signatures. Authorities can add signatures without changing the payload.

A certificate envelope holds a payload and its signatures. The implementation defines five fields:

  • Version, a uint32 that is 0
  • Expiration, an epoch
  • KeyType, the scheme name
  • Certified, a byte string containing the payload bytes
  • Signatures, a map from signer hash to PublicKeySum256 and Payload

Producers MUST use canonical CBOR and the configured signature scheme name. Receiver decoding and envelope verification define accepted encodings and signature verification. The decoded envelope Version MUST be zero. KeyType MUST be nonempty and forms part of the signed message. The document schema has a separate Version field, “v1”.

Certificate signature input
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: signer
    accTitle: Certificate signature input
    accDescr: Version, expiration, scheme name, and certified payload form the signed message. The envelope stores signatures outside that message.
    META["Version<br/>Expiration<br/>KeyType<br/>Certified: payload bytes"] --> MSG["signed message bytes"]
    MSG --> SIGN["sign with the identity key"]
    SIGN --> SIG["store signature in Signatures"]
    class META,MSG,SIG local
    class SIGN directory
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

Certified contains document CBOR, a raw 40-byte commit/reveal, or one CBOR cert.Signature for sig commands. Descriptor uploads use separate structures.

Signature input

Every signature covers the same four envelope fields. The signature map itself remains outside that input.

The signed message concatenates four fields with no separator and no length prefix:

signed_message = LE_uint32(Version)
              || LE_uint64(Expiration)
              || KeyType, the UTF-8 bytes of the scheme name
              || Certified

Requirements R-7-1 through R-7-7.

Byte identity

Authorities can combine signatures only when they build identical consensus bytes. Shared encoding, ordering, and configuration make this possible.

Four threshold gates and consensus byte identity
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: authority
    accTitle: Four threshold gates and consensus byte identity
    accDescr: With six authorities, require four matching parameter votes, four voting certificates, four surviving commit/reveal pairs, and four valid signatures. Local configuration and history also enter the signed bytes.
    QV{"4 matching parameter<br/>votes"} --> QC{"4 voting certificates"}
    QC --> QR{"4 surviving commit/reveal<br/>pairs"}
    INPUTS["Inputs<br/>Voted and reconciled values<br/>Carried history<br/>Local configuration"]
    QR --> INPUTS
    INPUTS --> BODY["Build the signed body"]
    BODY --> CBOR["Canonical CBOR bytes"]
    CBOR --> MSG["Certificate signing bytes"]
    MSG --> SIG["Signatures over identical bytes"]
    SIG --> TH{"4 valid signatures over<br/>identical bytes"}
    TH --> PUB["<img src='/docs/specs/dirauth/icons/directory.svg' alt='' width='32' height='32'/><br/>Published consensus"]
    class QV,QC,QR,BODY,SIG,TH directory
    class INPUTS,CBOR,MSG,PUB local
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

Namenlos has A = 6. The general threshold is floor(A/2)+1. Document inputs, CBOR, signature input, SRV ordering and wire encoding MUST produce identical bytes. Different configuration or history can prevent signatures from combining.

Requirements R-26-1 through R-26-8.

A successful voting round

Nodes announce their keys for the next epoch. In a successful round, dirauths agree on one signed consensus. Clients fetch it through gateways and verify it before use.

Directory round
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
sequenceDiagram
    %% panel: network
    %% actors: N=other-servers,D=authority,G=gateway,C=client
    accTitle: Directory round
    accDescr: Mixes, service nodes, replicas, and gateways upload signed descriptors. Dirauths compare votes, reveal randomness, and collect majority signatures. Servers fetch the signed consensus. Clients fetch it through gateways and verify it before use. Each role follows its configured fetch schedule.
    autonumber
    box rgb(26,40,48)
        participant N as Mixes<br/>Service nodes<br/>Replicas
    end
    box rgb(49,38,23)
        participant D as Dirauths
    end
    box rgb(26,40,48)
        participant G as Gateway
    end
    box rgb(45,48,54)
        participant C as Client<br/>kpclientd
    end
    loop One successful round
        N->>D: Upload signed<br/>descriptors
        G->>D: Upload signed<br/>descriptor
        D->>D: Compare votes
        D->>D: Reveal randomness
        D->>D: Sign the same<br/>consensus
        D->>D: Publish at majority
        N->>D: Fetch consensus
        D-->>N: Signed consensus
        G->>D: Fetch consensus
        D-->>G: Signed consensus
        C->>G: Fetch consensus
        G-->>C: Signed consensus
        C->>C: Verify consensus
    end

Phases

Each voting round follows a fixed schedule. Different phases collect node announcements, exchange votes, and publish the next consensus.

The authority state machine starts each phase’s sends and computations at its boundary. The diagram uses P = 1200 seconds. Its times are offsets after T. Phase fractions measure elapsed time from T as a share of P.

Authority phases, node uploads and fetches.

Fetch retries follow R-10-9 to R-10-11. The descriptor upload rules are in R-10-3.

Epoch phase schedule
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
sequenceDiagram
    %% panel: clock
    %% actors: N=other-servers,D=authority,G=gateway,C=client
    accTitle: Epoch phase schedule
    accDescr: Node uploads end at 125 seconds during Phase_DESC. Dirauth phases advance at 150-second boundaries. Mixes, gateways, and service nodes fetch at 750 seconds. Couriers, replicas, and clients fetch at 825 seconds. Clients fetch through gateways. Mixes, gateways, and service nodes admit next-epoch peers strictly after 1050 seconds. The next epoch starts at 1200 seconds.
    box rgb(26,40,48)
        participant N as Mixes<br/>Service nodes<br/>Replicas
    end
    box rgb(49,38,23)
        participant D as Dirauths
    end
    box rgb(26,40,48)
        participant G as Gateway
    end
    box rgb(45,48,54)
        participant C as Client<br/>kpclientd
    end
    Note over N,C: 0-150 s: Phase_DESC
    N->>D: Until 125 s:<br/>signed descriptors
    G->>D: Until 125 s:<br/>signed descriptor
    Note over D: 150-300 s:<br/>Phase_VOTE
    D->>D: Send votes
    Note over D: 300-450 s:<br/>Phase_REVEAL
    D->>D: Send reveals
    Note over D: 450-600 s:<br/>Phase_CERT
    D->>D: Tally and send<br/>certificates
    Note over D: 600-750 s:<br/>Phase_SIG
    D->>D: Build and sign<br/>the consensus<br/>Exchange<br/>signatures
    Note over N,C: 750-1050 s: Phase_PUB
    D->>D: Publish N+1<br/>at threshold
    N->>D: Mixes and<br/>service nodes:<br/>fetch N+1<br/>from 750 s
    D-->>N: Consensus N+1
    G->>D: Fetch N+1<br/>from 750 s
    D-->>G: Consensus N+1
    N->>D: Couriers and<br/>replicas:<br/>fetch N+1<br/>from 825 s
    D-->>N: Consensus N+1
    C->>G: Fetch N+1<br/>from 825 s
    G-->>C: Consensus N+1
    Note over N,C: 1050-1200 s: Phase_TAIL
    N->>N: Mixes and<br/>service nodes:<br/>admit N+1 peers<br/>strictly after<br/>1050 s
    G->>G: Admit N+1 peers<br/>strictly after<br/>1050 s
    D->>D: Gone if N+1<br/>is missing
    Note over N,C: 1200 s: Epoch N+1 starts

Each send window starts at its phase boundary and ends according to R-10-1 and R-10-8.

Requirements R-10-1 through R-10-14.

Phase_DESC. Descriptor upload

Nodes MUST attempt descriptor uploads to every configured authority. Retries MAY omit authorities that already accepted that descriptor. Nodes MAY fetch a consensus from any configured authority.

Nodes send signed announcements with post_descriptor or post_replica_descriptor. After upload validation, the authority applies the descriptor storage rules. It compares an existing descriptor before it tests whether it voted or published.

Descriptor storage and replay
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
stateDiagram-v2
    %% panel: authority
    direction TB
    accTitle: Descriptor storage and replay
    accDescr: A dirauth stores one live descriptor per command epoch and node identity after upload validation. The authority persists an accepted upload before retaining its descriptor. Identical canonical descriptors succeed even after voting or publication. A finality race can persist an upload without retaining its descriptor.
    state "No live descriptor" as Empty
    state "Descriptor retained" as Stored
    [*] --> Empty
    Empty --> Stored: No own vote<br/>or consensus:<br/>persist and retain,<br/>DescriptorOk 0
    Empty --> Empty: Own vote or consensus<br/>before or after writing:<br/>DescriptorConflict 2
    Stored --> Stored: Same: DescriptorOk 0<br/>Changed:<br/>DescriptorConflict 2

An authority MUST serialize uploads per node identity across epochs. Different identities MAY proceed concurrently. DescriptorInternalError triggers an uploader retry. A failure to persist the upload is fatal to the authority. DescriptorInvalid, DescriptorForbidden, and DescriptorConflict are final for that authority, as is a strict QUIC pin mismatch (R-24-11). R-14-10 defines durable storage before acknowledgment.

Requirements R-14-1 through R-14-10.

Phase_VOTE, Phase_REVEAL. Commit and reveal

Authorities exchange random contributions in two steps. Each authority commits to a value before it reveals that value.

With RN a 32-byte random value and H = BLAKE2b-256, each commit or reveal is 40 bytes:

Commit and reveal values
RN     = 32 bytes from the random source
REVEAL = BE_uint64(epoch) || H(RN)
COMMIT = BE_uint64(epoch) || H(REVEAL)
Commit and reveal exchange
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
sequenceDiagram
    %% panel: authority
    %% actors: A=authority,B=authority
    accTitle: Commit and reveal exchange
    accDescr: Dirauth 1 sends its vote and commitment to Dirauth 2. During the reveal phase it sends the signed reveal. Dirauth 2 checks the reveal against the stored commitment and returns the relevant status.
    box rgb(49,38,23)
        participant A as Dirauth 1
    end
    box rgb(49,38,23)
        participant B as Dirauth 2
    end
    Note over A,B: Phase_VOTE, from P/8
    A->>B: vote 22, Epoch V, PublicKey<br/>Document with one commit
    B-->>A: vote_status 23, VoteOk 0
    Note over A,B: Phase_REVEAL, from 2/8
    A->>B: reveal 25, PublicKey<br/>Signed 40-byte reveal
    B->>B: Verify reveal<br/>against stored<br/>commit
    B-->>A: reveal_status 26, RevealOk 8
    Note over B: No stored commit:<br/>RevealTooEarly 9<br/>From 3/8:<br/>RevealTooLate 13

R-15-8 prevents an authority from contributing a late commit during the reveal phase. Without this restriction, it could observe other reveals before deciding whether to contribute its own committed value.

Requirements R-15-1 through R-15-8.

Phase_CERT, Phase_SIG. Tabulation

Each authority counts votes for node announcements and traffic settings. Only results with enough matching votes enter the consensus.

The tally compares descriptor and parameter bytes. R-16-3 keeps signed zeros separate and groups NaNs. History comes from the prior consensus, not a vote tally. R-16-6 separates startup provisioning from per-epoch topology usability.

Requirements R-16-1 through R-16-8.

Phase_CERT. Voting certificates

Authorities exchange certificates that summarize the contributions they received. Other authorities verify this evidence before they compute the shared random value.

A voting certificate contains the tallied document and commit/reveal pairs in a signed envelope. Authorities send it between the reveal and signature phases. The receiver verifies voting certificates before it computes the SRV.

Voting certificate exchange
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
sequenceDiagram
    %% panel: authority
    %% actors: A=authority,B=authority
    accTitle: Voting certificate exchange
    accDescr: Dirauth 1 tallies its votes and sends its voting certificate. Dirauth 2 verifies the cited signatures and pairs under the document validation rules, then returns a certificate status.
    box rgb(49,38,23)
        participant A as Dirauth 1
    end
    box rgb(49,38,23)
        participant B as Dirauth 2
    end
    Note over A,B: Phase_CERT, from 3/8
    A->>A: Tally votes<br/>Build full<br/>document
    A->>B: cert 29, Epoch V, PublicKey<br/>Document with every held pair
    B->>B: Verify cited<br/>signatures from<br/>authorized<br/>authorities
    B->>B: Validate reveals<br/>against commits
    B-->>A: cert_status 30, CertOk 14
    Note over B: Validation failure:<br/>CertNotSigned 17<br/>Sender's vote<br/>missing:<br/>CertTooEarly 19<br/>From 4/8:<br/>CertTooLate 15

Document validation treats exactly one commit as a vote and rejects a reveal alongside it. A certificate built from matched commitments and reveals needs at least two pairs to pass validation. The zero-commit receiver case does not inspect reveals. A one-authority network cannot complete this phase. An authority sends no voting certificate if its tally lacks a usable topology or it has exactly one pair. It still advances to the next phase.

Reconciliation compares complete serialized commit and reveal certificate envelopes, after signature verification. Equal 40-byte payloads do not establish envelope equality. Differences in signatures or other envelope bytes can produce a conflict. R-6-1 permits an additional signature during individual-envelope verification. It does not change the equality comparison during reconciliation. The SRV calculation uses the extracted 40-byte reveal, not its envelope.

Requirements R-17-1 through R-17-9.

Phase_SIG. Shared random value and topology

Authorities combine verified random contributions into one shared value. This value controls automatic mix placement and links the round to the previous round.

The authority needs at least a threshold of voting certificates before reconciliation. It needs the same threshold of surviving commit/reveal pairs before it computes the SRV. The epoch in this calculation is the voting epoch V. H is BLAKE2b-256 and PREV is the previous epoch’s SRV or 32 zero bytes. The authority computes the shared random value as follows:

SRV = H( "shared-random"
      || BE_uint64(epoch)
      || identity_hash(1) || reveal(1)
      || ...
      || identity_hash(k) || reveal(k)
      || PREV )

Each reveal is the 40-byte certified payload of that authority’s signed reveal. R-18-2 gives the order of the k pairs.

The Namenlos authority config pins three fixed layers. With gateways configured, document construction requires a fixed Topology. Omitting it reaches a nil topology during LambdaG calculation. The automatic assignment subroutine defines automatic layer placement. An empty resulting layer prevents signing.

Shared random value and automatic topology
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: authority
    accTitle: Shared random value and automatic topology
    accDescr: Hash the domain label, voting epoch, ordered identity and reveal pairs, and previous shared random value. The output seeds the automatic topology draw, which also uses prior placement when available.
    INPUT["Inputs in byte order:<br/>label shared-random<br/>BE_uint64(V)<br/>identity / reveal pairs in R-18-2 order<br/>previous SRV or 32 zero bytes"] --> HASH["BLAKE2b-256"]
    HASH --> SRV["SharedRandomValue:<br/>32 bytes"]
    SRV --> STREAM["ChaCha20:<br/>8-byte zero nonce"]
    STREAM --> DRAW["Automatic topology draw<br/>with prior placement, if held"]
    DRAW --> TOPOLOGY["Topology:<br/>mix layers"]
    class INPUT,HASH,SRV,STREAM,DRAW,TOPOLOGY directory
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

Requirements R-18-1 through R-18-10.

The topology draw and layer assignment define the complete automatic placement algorithms.

Phase_SIG, Phase_PUB. Signatures and publication

Each authority signs the consensus it built. Matching signatures let the authorities publish that document for the network.

Signature receipt and publication
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
sequenceDiagram
    %% panel: network
    %% actors: A=authority,B=authority,N=publisher
    accTitle: Signature receipt and publication
    accDescr: Each dirauth signs its own consensus and sends the signature to every peer. A receiver verifies the envelope before storing the inner signature. At publication it attaches only signatures that authenticate its own consensus, then publishes at threshold.
    box rgb(49,38,23)
        participant A as Dirauth 1
    end
    box rgb(49,38,23)
        participant B as Dirauth 2
    end
    box rgb(26,40,48)
        participant N as Fetching node
    end
    Note over A,B: Phase_SIG, from 4/8
    A->>A: Tally votes<br/>Compute SRV<br/>Build and sign<br/>doc V
    A->>B: sig 27, Epoch V<br/>Signed cert.Signature envelope
    B->>B: Verify envelope<br/>Parse and store<br/>inner signature
    B-->>A: sig_status 28, SigOk 20
    Note over A,B: Every dirauth sends to every peer
    Note over A,B: Phase_PUB, at 5/8
    A->>A: Verify stored<br/>inner signatures<br/>against own<br/>consensus
    A->>A: Attach valid<br/>signatures<br/>At threshold:<br/>publish and<br/>persist doc V
    N->>A: get_consensus 18, Epoch N+1
    A-->>N: consensus 19, ConsensusOk 0

Requirements R-19-1 through R-19-4.

Receiver states, failures, and retention

Receiver state controls message acceptance. Epoch windows limit how long documents and keys remain usable.

The authority state machine

After startup or restart, an authority enters bootstrap to select its voting epoch. Each authority runs the same sequence of voting steps. Timers move the authority from one step to the next. If processing misses a deadline, the next timer fires immediately. Remaining phases run in sequence. If an action panics, the authority recovers and retries that state after one second.

Authority state machine
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
stateDiagram-v2
    %% panel: authority
    accTitle: Authority state machine
    accDescr: Bootstrap selects a voting epoch. Descriptor, vote, reveal, certificate, and signature states advance on timers. Publication succeeds at threshold or returns to bootstrap.
    state "Signatures" as accept_signature
    [*] --> bootstrap: start or<br/>restart
    bootstrap --> accept_desc: t <= P/8, V =<br/>N+1
    bootstrap --> bootstrap: t > P/8, wait<br/>for next epoch
    accept_desc --> accept_vote: 1/8, send vote
    accept_vote --> accept_reveal: 2/8, send<br/>reveal
    accept_reveal --> accept_cert: 3/8, tally and<br/>send cert
    accept_cert --> accept_signature: 4/8, build and<br/>sign consensus
    accept_signature --> accept_desc: 5/8, threshold<br/>reached,<br/>publish
    accept_signature --> bootstrap: 5/8, no<br/>threshold

Requirements R-12-1 through R-12-5.

Acceptance windows and status codes

An authority accepts messages only for the round it is processing. Response codes tell a sender whether to retry or stop.

An authority can publish consensus N during the preceding epoch, N-1. Its scheduled publication time is T - 3*P/8, before epoch N starts. After successful publication, it advances V to N+1 and can accept votes and commits for that voting epoch.

Vote, reveal, cert, and sig handlers use V and authority state to decide acceptance. They do not compare elapsed time. Handlers evaluate phase and duplicate status only for messages at epoch V. Vote, voting certificate, and signature handlers verify the command’s Epoch field. The reveal handler verifies the epoch in the signed payload and ignores the command’s Epoch field.

After building its voting certificate, an authority can still accept a valid vote for the consensus tally until it enters the signature state. Its existing voting certificate does not change. NotFound permits a retry. Gone is final for N+1 and past epochs. A courier also retries Gone for its current epoch.

Requirements R-13-1 through R-13-3.

Clocks and retention

Authorities manage clock and retention settings outside this protocol. Operators SHOULD keep clocks within a few seconds of UTC. The timing margins depend on delivery time, retries, and successful preceding phases. They are not clock synchronization targets.

A reveal, detached-signature envelope, or commit for N+1 passes expiration verification only through the receiver’s epoch N. A document, vote, or voting certificate for N remains unexpired through epoch N+4. Other validation rules still apply.

The replica clock gives RE = floor((now - Z) / 604800 seconds), independently of P. A client MAY adjust its clock from the gateway handshake.

Independent epoch and key lifetimes
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: clock
    accTitle: Independent epoch and key lifetimes
    accDescr: Mixnet epochs have length P. Mix servers retain keys N-1 through N+3 and publish N+1 through N+3. Replica epochs always last seven days. Shared-random history rotates every 504 mixnet epochs, except initialization takes precedence.
    subgraph M["<img src='/docs/specs/dirauth/icons/mix.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/gateway.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/service.svg' alt='' width='32' height='32'/><br/>Mixes, gateways, and service nodes<br/>Mix keys: epoch length P"]
        direction TB
        MK["Retain mix keys N-1 through N+3"]
        MK --> USE["Unwrap with N-1, N, and N+1"]
        USE --> ADV["Advertise N+1 through N+3"]
    end
    subgraph R["<img src='/docs/specs/dirauth/icons/replica.svg' alt='' width='32' height='32'/><br/>Replica clock: 7 days"]
        RK["Publish RE and RE+1,<br/>plus RE-1 while retained"]
        RK --> DEL["Older key deletion:<br/>3-hour grace"]
    end
    subgraph H["<img src='/docs/specs/dirauth/icons/authority.svg' alt='' width='32' height='32'/><br/>Dirauth shared-random history"]
        INIT["Initialize at genesis or empty history"]
        INIT --> ROT["Otherwise rotate every 504 epochs"]
    end
    M ~~~ R ~~~ H
    class MK,USE,ADV packet
    class RK,DEL local
    class INIT,ROT directory
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

504 epochs equal seven days only at P = 1200 seconds. Replica epochs do not scale with P.

Requirements R-11-1 through R-11-6.

Connections and messages

Directory commands travel over encrypted links. Each connection also applies peer authorization and resource limits.

Transport layers

Directory commands carry signed CBOR objects directly over PQ Noise, outside Sphinx packets. The direct consensus reply gives the directory encoding and link records. PQ Noise protects each connection over TCP or QUIC.

Descriptor map keys and URL schemes select transports and address protocols. The mix server’s address conversion places tcp:// IPv4 addresses under tcp4 and tcp:// IPv6 addresses under tcp6.

Mix-server dialing selects map keys tcp4, tcp6, and quic (transport selectors). A tcp entry alone cannot provide a mix-server destination. Replicas instead preserve the lowercased URL scheme as the descriptor map key (replica conversion). Their outgoing connections use ClientTransports, which includes tcp (replica dialing). Upload validation accepts more URL schemes than mix servers publish (R-5-6).

Authorities use configured address lists, not descriptors. Their listeners accept tcp:// and quic:// URLs only (listeners). Mixes, gateways, and service nodes MUST advertise a reachable tcp4 or tcp6 entry. Replicas MUST advertise a reachable tcp entry. Authorities MUST advertise a reachable tcp:// endpoint. A dialer uses QUIC only with the peer’s pinned QUIC key hash (QUIC transport).

PQ Noise carries commands over each transport. An authority PKI session carries one command and one reply unless configuration enables persistent peer connections (R-22-15). kpclientd uses a persistent gateway session for packets, replies, and consensus retrieval (connection loop).

An authority MUST accept only commands allowed for the sender’s role. It MUST drop others without reply (R-23-4).

Wire protocol

A command carries one request or response. PQ Noise encrypts each command and its length before it sends them.

Commands travel over the PQ Noise link: pattern PqXX, ChaChaPoly, BLAKE2b-512, the configured KEM and a one-byte prologue 0x03. The handshake profile defines its four flights. The responder MUST send NoOp as its first transport command. Each command uses two encrypted Noise transport messages in the data-transfer phase. The first encrypts the four-byte big-endian command-ciphertext length. Its tag makes this header 20 bytes. The second encrypts the command with a separate 16-byte tag. The length includes the second tag. Each endpoint MUST rekey the affected direction after both encrypted messages (sender, receiver).

Before encryption, a command has a 6-byte header, command_id (1) || reserved (1, zero) || BE_uint32(body_length), and a body. PublicKey is the sender’s full marshaled identity key. The command reference defines each Payload type.

Six-byte plaintext command header
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: data
    accTitle: Six-byte plaintext command header
    accDescr: Byte zero is command_id. Byte one is zero. Bytes two through five are the unsigned big-endian body length. The declared body follows, then any zero padding.
    CID["Byte 0<br/>command_id: 1 byte"] --> ZERO["Byte 1<br/>reserved: zero"]
    ZERO --> LENGTH["Bytes 2-5<br/>body length: uint32, big-endian"]
    LENGTH --> BODY["Body: the declared number of bytes"]
    BODY --> PADDING["Optional zero padding"]
    class CID,ZERO,LENGTH,BODY local
    class PADDING local
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

The wire session selects the Noise primitives. Noise uses BLAKE2b-512. The directory’s H is BLAKE2b-256. The command encoders define the bodies and command constants define their IDs.

Direct authority fetchers send empty additional data to identify an unauthenticated client. kpclientd’s gateway handshake carries a generated queue identifier and keeps the session open.

Gateway consensus transfer

Clients fetch the consensus through their gateway connection. The gateway compresses the document and sends it in chunks.

kpclientd MUST fetch the consensus through a configured gateway with get_consensus2. It uses its existing gateway connection. On success, the gateway gzip-compresses the signed consensus envelope and sends consensus2 chunks numbered 0 through ChunkTotal-1. The chunk capacity comes from the connection’s configured packet geometry. An error uses one empty chunk, ChunkNum 0 and ChunkTotal 1, with the consensus status code below (gateway handler).

The client MUST verify chunk order, totals, and sizes before it decompresses the envelope. It MUST verify the authority signature threshold, requested epoch, and local geometry before using the consensus. Chunks carry one document. Gateway commands have separate padding and size bounds from direct authority sessions.

The direct authority fetcher also calls IsDocumentWellFormed before accepting a consensus (fetch validation). kpclientd’s gateway path uses Deserialize to verify the signature threshold and decode the document (Deserialize). It also verifies epoch and geometry, but does not call IsDocumentWellFormed (client verification).

A client reports Gone only when replies from queried authorities make a signing threshold impossible. With fewer Gone replies, it can retry.

Receive size ceiling

A size ceiling bounds the largest directory command a receiver accepts. The default ceiling depends on network size and cryptographic sizes.

R-22-17 uses EstimateConsensusSize. The ceiling bounds the command ciphertext, including its 16-byte tag. The encrypted 20-byte length header is separate. Let sign and sig be the PKI scheme’s public-key and signature sizes. Let link, sphinx and envelope be the public-key sizes of the wire KEM, Sphinx scheme, and replica NIKE. Let nodes, replicas, and authorities be their counts.

mix      = sign + link + 3 * sphinx + 4096
replica  = sign + link + 3 * envelope + 4096
unsigned = 8192 + nodes * mix + replicas * replica
signed   = unsigned + authorities * (sig + 96)
                   + 2 * authorities * (sig + 96 + 40)
ceiling  = min(500000000, max(262144, 2 * signed))

The factor 3 counts per-epoch keys. The 4096 and 8192 allowances cover names, addresses, service data and framing. The 96 covers the signature key hash and framing. The 40 counts a shared-random value. The second authority term includes commit and reveal envelopes in a voting certificate. The final factor 2 provides headroom.

An authority uses its configured counts of mixes, gateways, service nodes, replicas, and authorities. Before its first consensus, a client uses 64 nodes and 16 replicas. A configured MaxConsensusSize above zero replaces the calculated ceiling for that party.

Requirements R-22-1 through R-22-18.

Authentication and authorization

Authorities distinguish configured nodes from unauthenticated clients. Each role has its own permitted commands.

The authority listener authorizes peers by role. Gateway clients use a separate path. Allow-lists map identity hashes to names, adding ReplicaID for replicas and a pinned link key for authorities. Lookup order is mix/gateway/service, authority, then replica. Unauthenticated fetchers, including couriers and replicas, send empty additional data and a fresh random link key. R-23-7 permits unpinned nodes to upload. Their identity signature and session-key binding remain mandatory.

Requirements R-23-1 through R-23-7.

Authority message delivery

Authorities send each voting message to every peer. A retry loop handles temporary failures while the voting schedule continues.

All four message families use one retry loop. Default backoff starts at 10 ms. A 3-second cap applies before 0.2 jitter, so delays can approach 3.6 seconds (calculation). Phase sends use the deadline, with an additional retry cap when PeerRetryMaxAttempts is positive. Bootstrap defaults to 20 retries plus the initial attempt. After a bounded wait, the loop can start an attempt without testing the deadline again. The state machine advances independently of delivery. A peer needs the sender’s vote and commit before it can accept the reveal.

Requirements R-21-1 through R-21-4.

QUIC transport

A node or authority MAY listen on QUIC as well as TCP. PQ Noise authenticates the peer over one QUIC stream. QUIC also requires a pinned TLS key. The dialer MUST compare SHA-256 of the certificate’s SubjectPublicKeyInfo with QUICKeyHash. For nodes, the hash comes from the descriptor. Authorities use configured hashes. Before its first consensus, a client uses its configured gateway hash.

Mixes, service nodes, and replicas rotate TLS keys each epoch. Each key comes from a random Ed25519 seed in DataDir/quic. Gateways with QUIC listeners and authorities keep one long-term key in DataDir/quic-tls.key. The authority’s -g option writes this key and quic-tls.hash. TCP-only authorities neither read nor create the key file.

The QUIC configuration supplies defaults for ALPN, CurvePreferences, AddressValidation, KeepAlivePeriod, KeepAliveJitter, PinMismatch, and CloseLinger.

Requirements R-24-1 through R-24-15.

Connection bounds and operation

One listener carries descriptor uploads, voting, and consensus retrieval. The listener applies connection limits and deadlines before and after the handshake.

An authority MUST refuse connections above a nonzero pool cap before the handshake. Each epoch, it updates the peer pool from consensus and configured authority addresses. A failed refresh keeps the previous set. A panic in one connection handler MUST NOT stop the authority. Persistent authority connections default to off.

Stored state carries a schema version. An authority MUST refuse to start if that version differs from the expected version. It MUST also refuse to start unless its configured authority list contains its own identity and link key.

An operator MAY reload the authorized node set. Every authority MUST reload the same set in the same epoch, or their votes disagree. An authority MUST reject a reload that would fail startup validation (R-16-6) and keep the old set.

Mixnet packet paths

Sphinx packets protect routing information and payloads through successive hops. For a client forward path, Namenlos uses a gateway, three mixes, and a service node. Each relay removes one Sphinx layer and forwards the packet over a separately authenticated PQ Noise link.

The forward path selects these five hops from the consensus. Sphinx protection spans the route. PQ Noise protects only one neighbor connection. Sphinx packets retain their configured length as each hop unwraps them. The Sphinx packet format defines the byte layout.

Security considerations

Clients trust the configured authority keys. A threshold of authorities can sign a network view that clients accept.

  • Any two signing quorums overlap in at least 2*threshold - A authorities. With a simple majority, this minimum is two for even A and one for odd A. Quorum intersection prevents conflicting signed consensuses only if the overlap exceeds the number of dishonest authorities and honest authorities sign once per epoch. At A = 3 or A = 5, one dishonest signer can belong to both quorums. A simple majority cannot guarantee agreement with two dishonest authorities, regardless of A. Crash and omission faults do not create conflicting signed consensuses if honest authorities sign once per epoch.
  • Verified conflicting commits or reveals exclude their signer from the SRV calculation only (R-17-4). A dishonest authority can still send different descriptor sets to different peers and prevent matching consensus bytes.
  • Authorities with different PriorSharedRandom rules can sign different bytes at multiples of 504. Only a group with threshold matching signatures can publish its document.
  • Nodes can authenticate peers with descriptors from N, N-1, and N-2. A superseded link key can remain valid for up to three epochs while the current consensus still lists that node. Removing a node from all authority allow-lists prevents its inclusion in later consensuses. Nodes reject its older descriptors when they apply a current consensus that omits it (authentication). A key listed only in N+1 becomes eligible strictly after 7/8 of N (R-11-2). A peer key that cannot be marshaled MUST fail authentication without stopping the node.
  • A consensus records accepted descriptors, not live node availability. Clients can select a failed node from a cached document and lose packets sent to it. An absent descriptor can mean a missing upload, insufficient votes, or removal from the allow-lists.
  • A mix creates a Sphinx key pair for each epoch and removes expired keys. The key manager maintains this window. Forward secrecy depends on erasing expired private keys, including any copies. Epoch changes also limit the lifetime of the replay cache.
  • A node retains peer connections while the peer remains authorized by the applicable documents (R-11-2). Authentication and permission to send can change at an epoch boundary.
  • Five authorities require three signatures. Six require four. Both configurations can publish with two authorities unavailable, but not three. Quorum overlap determines agreement when authorities sign conflicting documents.
  • Without node link-key pinning (authentication and authorization) anyone could present a node’s identity hash, hold its per-identity slots and keep it from uploading.
  • With fresh authority connections and no retries, each send phase can require A*(A-1) peer handshakes. Retries can increase this count. HandshakeTimeoutSec defaults to 3 seconds. Persistent authority connections can reduce the count (connection bounds and operation).

Appendix A. Formats and validation

Producers MUST encode CBOR objects using CDDL. Command and transport-record encodings MUST follow ABNF. Encoding and validation rules also govern canonical bytes, signatures, authorization, and epoch acceptance.

Key Value Explanation
Fields Descriptors, documents, envelopes, CDDL Field names, types, null, and omission
Byte identity R-6-3, signature input, R-26-2 through R-26-8, conformance vectors Canonical byte identity
Key validation R-5-1 through R-5-4, R-14-2 through R-14-8, R-23-6 Key sizes, parsing, and identity/session binding
Descriptor fields R-5-5 through R-5-11 Names, roles, addresses, services, and metadata
Network configuration prerequisites, R-16-6, R-26-1 Authority configuration and topology minima
Time time through acceptance windows and status codes, R-18-8, R-18-9 Clock, phase, expiry, and retention windows
Wire protocol wire protocol, ABNF Receive ceiling, command fields, and status values
Retries authority message delivery, R-22-18 Retries and operation budgets
QUIC QUIC transport, option limits below QUIC pinning, streams, rotation, and options
Connections connection bounds and operation Connection pools, handlers, identity slots, and first-command deadline

Configuration defaults and limits come from authority configuration and QUIC options.

Key Value Explanation
Mu, LambdaP, LambdaL, LambdaM, LambdaR 0.00025 Zero selects the default. Negative values fail configuration validation. Finiteness is not tested.
Layers / MinNodesPerLayer 3 / 2 Values at most zero select the defaults. Layers above 3 fail. R-16-6 defines provisioning minima.
AcceptDescriptorMetadata / Notice false / empty
AllowHostnameAddresses false Authority URL addresses require IP literals, except onion URLs. Metrics addresses require IP literals too (policy).
PeerRetryBaseDelay / PeerRetryMaxDelay / PeerRetryJitter 10 ms / 3 s / 0.2 Zero selects the defaults. Jitter follows the delay cap.
QUIC ALPN h3 Empty selects the default. Maximum 255 bytes.
QUIC KeepAlivePeriod / KeepAliveJitter 45 s, or 20 s for clients / 0.25 The period MUST be non-negative. Jitter MUST be at least 0 and less than 1.
QUIC CloseLinger / PinMismatch / AddressValidation 30 s / fallback / always 0 / empty / empty select the defaults. Negative linger fails. Other modes follow R-24-11 and R-24-14.
QUIC CurvePreferences TLS defaults Empty selects the defaults. No duplicate or unknown names.

TLS curve names are X25519, X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, CurveP256, CurveP384, and CurveP521. These local settings do not replace network-wide scheme agreement.

Let M be the positive decompressed limit. Set C = M + 18 + 5 * (floor(M/16384) + 2). The dechunker requires:

  • Sequential chunk numbers starting at zero, with one constant total between 1 and C.
  • A non-empty first payload of size S and total at most ceil(C/S).
  • Every non-final payload exactly S bytes, and the final payload between 1 and S bytes.
  • At most C compressed bytes and M decompressed bytes.

Error responses use a single empty chunk instead. Signature, epoch, and geometry verification follow reassembly.

Appendix B. Existing TLA+ models

Katzenpost includes TLA+ models for authority voting, node state, key retention, and client retransmission.

Key Value Explanation
VotingAuthority VotingAuthority.tla, guide Voting, quorum agreement, equivocation, and chained epochs.
MixNode MixNode.tla, guide Packet processing and scheduler state.
MixKeys MixKeys.tla, guide Key retention, replay state, and restart.
ClientARQ ClientARQ.tla, guide Retransmission and connection changes.

Appendix C. Field and encoding reference

Descriptors and consensus documents carry node identities, network parameters, and cryptographic material. Their encodings determine the signed bytes.

Directory formation overview

Nodes submit descriptors, and authorities reconcile contributions before signing a consensus.

Directory publication
%%{init: {"flowchart":{"diagramPadding":6,"useMaxWidth":false},"gantt":{"useMaxWidth":false},"look":"classic","packet":{"bitWidth":7,"bitsPerRow":48,"rowHeight":48,"useMaxWidth":false},"sequence":{"actorFontSize":14,"actorMargin":24,"diagramMarginX":24,"height":96,"messageFontSize":14,"messageMargin":22,"noteMargin":10,"useMaxWidth":false,"width":128,"wrap":false},"state":{"useMaxWidth":false},"theme":"base","themeCSS":".edgeLabel p { font-family: sans-serif; font-size: 13px; line-height: 1.4; color: #e6edf3; background-color: #34383d; border: 1px solid #c3cbd5; border-radius: 4px; padding: 3px 7px; margin: 0; } .edgeLabel .labelBkg { fill: #34383d; }","themeVariables":{"activationBkgColor":"#48351f","activationBorderColor":"#f3bd66","activeTaskBkgColor":"#48351f","activeTaskBorderColor":"#f3bd66","actorBkg":"#1c3c4c","actorBorder":"#7acee9","actorLineColor":"#7acee9","actorTextColor":"#e6edf3","altSectionBkgColor":"#24282d","background":"#24282d","clusterBkg":"#24282d","clusterBorder":"#c3cbd5","darkMode":true,"edgeLabelBackground":"#24282d","fontFamily":"sans-serif","fontSize":"14px","gridColor":"#59616b","labelBoxBkgColor":"#34383d","labelBoxBorderColor":"#c3cbd5","labelTextColor":"#e6edf3","lineColor":"#c3cbd5","noteBkgColor":"#34383d","noteBorderColor":"#c3cbd5","noteTextColor":"#e6edf3","packet":{"blockFillColor":"#48351f","blockStrokeColor":"#f3bd66","endByteColor":"#c3cbd5","labelColor":"#e6edf3","startByteColor":"#c3cbd5","titleColor":"#e6edf3"},"primaryBorderColor":"#c3cbd5","primaryColor":"#34383d","primaryTextColor":"#e6edf3","secondaryColor":"#34383d","secondaryTextColor":"#e6edf3","sectionBkgColor":"#34383d","sectionBkgColor2":"#1c3c4c","signalColor":"#7acee9","signalTextColor":"#e6edf3","taskBkgColor":"#48351f","taskBorderColor":"#f3bd66","taskTextColor":"#e6edf3","taskTextOutsideColor":"#e6edf3","tertiaryColor":"#24282d","tertiaryTextColor":"#e6edf3","textColor":"#e6edf3","todayLineColor":"#f3bd66"}} }%%
flowchart TB
    %% panel: network
    accTitle: Directory publication
    accDescr: Nodes upload descriptors. Authorities reconcile votes and random contributions, exchange signatures, and serve a threshold-signed consensus.
    N(["<img src='/docs/specs/dirauth/icons/mix.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/gateway.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/service.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/replica.svg' alt='' width='32' height='32'/><br/>Mixes, gateways,<br/>service nodes, and replicas"])
    subgraph D["<img src='/docs/specs/dirauth/icons/authority.svg' alt='' width='32' height='32'/><br/>Dirauths"]
        direction TB
        V["Exchange votes and reveals"] --> C["Reconcile commit and reveal pairs"]
        C --> B["Build the local consensus bytes"]
        B --> S["Exchange signatures"]
        S --> P["Publish at the signing threshold"]
    end
    N -- "Signed descriptors" --> D
    D --> CONS["<img src='/docs/specs/dirauth/icons/directory.svg' alt='' width='32' height='32'/><br/>Signed consensus"]
    CONS -- "get_consensus responses" --> R["<img src='/docs/specs/dirauth/icons/mix.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/gateway.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/service.svg' alt='' width='32' height='32'/><img src='/docs/specs/dirauth/icons/replica.svg' alt='' width='32' height='32'/><br/>Nodes and couriers"]
    class N,R link
    class V,C,B,S,P directory
    class CONS local
classDef directory fill:#48351f,stroke:#f3bd66,color:#ffffff
classDef packet fill:#342b4c,stroke:#c3abff,color:#ffffff
classDef link fill:#1c3c4c,stroke:#7acee9,color:#ffffff
classDef local fill:#34383d,stroke:#c3cbd5,color:#ffffff

The network map includes all configured node roles.

Descriptor fields

Mixes, gateways, and service nodes use MixDescriptor. Storage replicas use ReplicaDescriptor.

Required fields describe producer output. Receiver decoding, descriptor validation, and upload validation define acceptance of missing fields and alternate encodings.

Key Value Explanation
Name text Required in both types.
Epoch uint64 Required in both types.
IdentityKey byte string Required in both types.
LinkKey byte string Required in both types.
MixKeys map from uint64 to byte string Required in MixDescriptor. Maps epochs to keys.
EnvelopeKeys map from uint64 to byte string Required in ReplicaDescriptor. Maps replica epochs to keys.
Addresses map from text to a list of text Required in both types. Maps transports to addresses. Replicas cannot advertise onion addresses.
Kaetzchen map from text to a map from text to any MixDescriptor, null when unset. Service nodes only (R-5-9).
KaetzchenAdvertizedData map from text to a map from text to any MixDescriptor, null when unset.
IsGatewayNode boolean Required in MixDescriptor.
IsServiceNode boolean Required in MixDescriptor.
LoadWeight uint8 Required in MixDescriptor. Reserved.
AuthenticationType text Required in MixDescriptor. Gateways use “tofu”, other nodes use empty text.
Version text MixDescriptor only. The standard producer emits empty text. Receivers also accept other text, including “v0”. ReplicaDescriptor omits this field.
ReplicaID uint8 Required in ReplicaDescriptor.
SoftwareVersion text Both types, omitted when empty. Retained only under AcceptDescriptorMetadata (R-5-11).
ContactInfo text Both types, omitted when empty. Retained only under AcceptDescriptorMetadata (R-5-11).
QUICKeyHash byte string Both types, omitted when empty. Zero to three 32-byte hashes (QUIC transport).

Traffic rates

Consensus traffic rates control packet delays and transmission frequency.

Key Value Explanation
Mu 0.001 Per-hop delay rate.
LambdaP 0.001 Client send rate, with drop decoys when its queue is empty.
LambdaL 0.0005 Client loop decoy rate.
LambdaM 0.0005 Mix loop decoy rate.
LambdaR 0.02 Courier and replica decoy rate.
LambdaG derived (R-16-8) Gateway loop decoy rate.

Signed document fields

The document body contains the topology, traffic rates, and signed directory contributions.

Key Value Explanation
Epoch unsigned integer Required.
GenesisEpoch unsigned integer Required, nonzero.
Mu, LambdaP, LambdaL, LambdaM, LambdaR float Required.
LambdaG float Required.
Topology array of arrays of byte strings Required. One entry per layer, mixes only. An empty layer is null, valid only in a vote.
GatewayNodes, ServiceNodes array of byte strings Required, [] when empty.
StorageReplicas array of descriptor maps Required, [] when empty.
ConfiguredReplicaIDs byte string Omitted when empty.
ConfiguredReplicaIdentityKeys array of byte strings Omitted when empty.
SharedRandomCommit map from a 32-byte string to a byte string Consensus: null. Vote: one commit. Voting certificate: each available commit/reveal pair.
SharedRandomReveal map from a 32-byte string to a byte string Consensus and vote: null. Voting certificate: each available commit/reveal pair.
SharedRandomValue byte string Required, 32 bytes. All zero in votes and voting certificates (shared random value and topology).
PriorSharedRandom array of byte strings Required. Two history values, rotated every 504 epochs. [] in a vote without history (shared random value and topology).
SphinxGeometryHash byte string Required. BLAKE2b-256 of canonical Sphinx geometry.
Version text Required, “v1”.
PKISignatureScheme text Required.
MinClientVersion, ClientNotice text Omitted when empty.

Transport addresses

Descriptor address maps select link transports and endpoint URLs.

Key Value Explanation
tcp4 tcp://, Namenlos: every mix, gateway, and service node TCP over IPv4 for mixes, gateways, and service nodes
tcp6 tcp://, Namenlos: some mixes, gateways, and service nodes TCP over IPv6 for mixes, gateways, and service nodes
tcp tcp://, Namenlos: every storage replica TCP for storage replicas. Also a client transport selector
quic quic://, Namenlos: none QUIC over UDP, with a pinned TLS key (QUIC transport)
onion onion://, Namenlos: one gateway gateway endpoint for clients through Tor
ws ws://, Namenlos: none additional client transport selector

Directory commands

Directory peers exchange descriptors, votes, reveals, certificates, and signatures through numbered wire commands.

Key Value Explanation
get_consensus ID 18. BE_uint64(Epoch) Sent by client, mix, gateway, service node, authority. A replica and a courier send it as clients (authentication and authorization)
consensus ID 19. ErrorCode (1) || Payload: consensus certificate envelope on success Sent by authority, gateway
post_descriptor ID 20. BE_uint64(Epoch) || Payload: SignedUpload CBOR Sent by mix, gateway, service node
post_descriptor_status ID 21. ErrorCode (1) Sent by authority
post_replica_descriptor ID 11. BE_uint64(Epoch) || Payload: SignedReplicaUpload CBOR Sent by replica
post_replica_descriptor_status ID 10. ErrorCode (1) Sent by authority
vote, reveal, cert, sig ID 22, 25, 29, 27. BE_uint64(Epoch) || PublicKey || Payload: certificate envelope Sent by authority
vote_status, reveal_status, cert_status, sig_status ID 23, 26, 30, 28. ErrorCode (1) Sent by authority
get_consensus2 ID 32. BE_uint64(Epoch), upstream padded Sent by kpclientd to gateway
consensus2 ID 31. ErrorCode (1) || BE_uint32(ChunkNum) || BE_uint32(ChunkTotal) || Payload, downstream padded Sent by gateway to kpclientd
get_vote ID 24. reserved

Connection limits

Limits bound message sizes, timeouts, connection pools, and concurrent handlers.

Key Value Explanation
PKI message ceiling when not derived 2 MiB Fallback ceiling.
receive chunk 64 KiB Maximum chunk size.
handshake, receive, and send deadlines 3 seconds, 30 seconds, 30 seconds Operation deadlines.
persistent-connection idle 2 minutes Idle timeout.
accept pools by source address: client, peer, loopback 1024 each 0 means unlimited.
per-address cap in the client and peer pools, IPv6 by /64 64 Client and peer pool limit, with IPv6 grouped by /64.
concurrent handlers 64 A client runs only below half of them, and never blocks.
connections per proven identity 8 Per-identity connection limit.
first-command deadline 5 seconds Time to receive the first command.
hostname resolution per lookup 2 seconds A party never resolves an onion address.

Signature schemes

Ed25519 Sphincs+ concatenates an Ed25519 signature and a SPHINCS+ signature. Ed25519 has a 32-byte public key and a 64-byte signature. SPHINCS+ runs at the sphincs-shake-256f parameter set, with a 64-byte public key and a 49856-byte signature (parameter selection, sizes). The hybrid implementation adds the component sizes. A verifier MUST verify both components (verification). On Windows the authority default is Ed25519 alone. A network that mixes platforms MUST configure the same signature scheme explicitly.

Deterministic CBOR

R-6-3 applies these encoding requirements to descriptors, documents, and certificate envelopes:

  • Map keys MUST use the descriptor fields, document fields, and envelope fields. They MUST be sorted by encoded key length first, then bytewise. This is the canonical order of RFC 7049 section 3.9, retained in RFC 8949 section 4.2.6.
  • Integers MUST use their shortest form. All lengths MUST be definite.
  • Floats MUST use the shortest of float16, float32, and float64 that represents them exactly. NaN MUST be 0xf97e00. Infinities MUST use float16.
  • Each field MUST be present, omitted, or null as the field tables specify. A nil map or list encodes as null. An empty map encodes as {}, and an empty list as []. These are distinct byte sequences.

RFC 8949 section 4.2.3 sorts keys bytewise without comparing length first. A library that supports both orders MUST use the length-first order here.

Reply status codes

A response status tells the sender whether its command succeeded or why it failed.

Key Value Explanation
consensus ConsensusOk 0, ConsensusNotFound 1, ConsensusGone 2 Response conditions.
descriptor DescriptorOk 0, DescriptorInvalid 1, DescriptorConflict 2, DescriptorForbidden 3, DescriptorInternalError 4 Response conditions.
vote VoteOk 0, VoteTooEarly 1, VoteTooLate 2, VoteNotAuthorized 3, VoteNotSigned 4, VoteMalformed 5, VoteAlreadyReceived 6, VoteNotFound 7 Response conditions.
reveal RevealOk 8, RevealTooEarly 9, RevealNotAuthorized 10, RevealNotSigned 11, RevealAlreadyReceived 12, RevealTooLate 13 Response conditions.
cert CertOk 14, CertTooLate 15, CertNotAuthorized 16, CertNotSigned 17, CertAlreadyReceived 18, CertTooEarly 19 Response conditions.
sig SigOk 20, SigNotAuthorized 21, SigNotSigned 22, SigTooLate 23, SigTooEarly 24, SigAlreadyReceived 25, SigInvalid 26 Response conditions.

Protocol components

The protocol combines network configuration, voting, signed objects, and authenticated transport.

Key Value Explanation
Configuration constants and notation, prerequisites constants and prerequisites
Roles services, roles, keys, and schemes services, keys, and schemes
Formats descriptors to certificate envelopes and signing descriptors, documents, and envelopes
Clients client software and configuration applications and kpclientd
Time time to clocks and retention epochs, phases, and clocks
Phases the authority state machine to signatures and publication the authority state machine
Transport transport layers to connection bounds and operation delivery, wire, authentication, and QUIC
Properties byte identity, security considerations byte identity and security

Constants and notation

Epoch and quorum parameters set the voting schedule and signature threshold.

Key Value Explanation
A 6 Number of authorities.
N runtime Current epoch number.
T Z + N * P Start time of epoch N.
threshold 4 floor(A/2)+1. Matching votes or valid signatures required.
dissenters 2 floor(A/2)-1. Logging only, without effect on the result.
P 1200 seconds, default Epoch duration. EpochDuration is unset.
epoch 0 2017-06-01 00:00:00 UTC Start of the first epoch.
V N+1 in a healthy round Authority voting epoch.
t 0 to less than P Elapsed time in N. A fraction such as 5/8 means t = 5P/8.
W 150 seconds P/8. Phase width.
buf 5 seconds Authority retry-loop deadline margin, when time permits.
U 125 seconds 5P/48. Descriptor upload cutoff.
F 825 seconds 11P/16. Client, courier, and storage replica fetch time.
H BLAKE2b-256 Hash function.
identity hash 32 bytes H of the marshaled identity public key.
Layers 3 Number of mix layers.
topology mode fixed: 2, 2, 3 Mixes in each layer.
MinNodesPerLayer 1 Startup minimum per layer.
NumMixKeys 3 Sphinx key epochs per descriptor.
Mu 0.001 Per-hop mixing delay rate.
LambdaP 0.001 Client send rate.
LambdaL 0.0005 Client loop rate.
LambdaM 0.0005 Mix loop rate.
LambdaR 0.02 Courier and replica decoy rate.
PKISignatureScheme Ed25519 Sphincs+ Descriptor and directory signatures.
WireKEMScheme KYBER768-X25519 Link key KEM.
SphinxGeometry.NIKEName x25519 Sphinx mix key scheme.
EnvelopeScheme CTIDH1024-X25519 Replica envelope NIKE, fixed for every network.
NrHops 5 Hops per Sphinx path.
UserForwardPayloadLength 2000 bytes Maximum user payload.

Prerequisites

A network’s configuration supplies trusted authorities, allowed nodes, cryptographic schemes, and transport addresses.

Key Value Explanation
Authorities 6: annares, gnunet, luscus, respectmy, wauland, windfall Fixed and known to every party before the first round.
threshold 4 of 6 Each round needs 4 reachable authorities in agreement, or no consensus.
clocks not specified in these configs Operators synchronize clocks under the clock and skew rules.
EpochDuration unset, default 1200 seconds Select P under R-9-3. Runtime overrides can change it.
allow-lists 7 mixes, 4 gateways, 4 service nodes, 4 replicas Authorities MUST hold identical lists and reload them in the same epoch (connection bounds and operation).
PKISignatureScheme Ed25519 Sphincs+ Descriptor and directory signatures (roles, keys, and schemes).
WireKEMScheme KYBER768-X25519 PQ Noise link key KEM.
SphinxGeometry.NIKEName / KEMName x25519 / empty Selects NIKE Sphinx.
SphinxGeometry 5 hops, 3082-byte packets, 2000-byte user payload All geometry fields MUST agree (byte identity).
link protocol PqXX, ChaCha20-Poly1305, BLAKE2b-512, prologue 0x03 Every party MUST use this PQ Noise protocol and the configured wire KEM (wire protocol).
Addresses IPv4 TCP: all nodes and authorities. IPv6 TCP: some. Nodes and authorities MUST publish a reachable TCP address. IPv6-only peers need IPv6 (transport layers).
authority key pins 6 identity and link key pairs per authority Each authority MUST hold every authority’s identity key and pin its link key, including its own (connection bounds and operation).
DataDir annares: /var/lib/katzenpost/authority/annares-pq-authority Authorities MUST store descriptors and documents durably and stop on storage failure (R-14-10).

Mixnet services

Service nodes advertise mixnet services through the Kaetzchen descriptor field.

Key Value Explanation
echo capability echo, endpoint +echo, 4 service nodes Returns the request payload. The ping tool uses it to test a route and measure delay.
courier capability courier, endpoint courier, 3 service nodes Forwards client read and write requests to Pigeonhole storage replicas and returns their replies.

Node roles

Descriptors identify node roles and their published keys.

Key Value Explanation
mix MixDescriptor Listed in Topology layers.
gateway node MixDescriptor with IsGatewayNode Listed in GatewayNodes.
service node MixDescriptor with IsServiceNode Listed in ServiceNodes.
storage replica ReplicaDescriptor Listed in StorageReplicas.
authority none Listed in every configuration.

Key schemes

Parties use identity signatures, link KEMs, Sphinx keys, and replica envelope keys for separate tasks.

Key Value Explanation
PKI signature Ed25519 Sphincs+. Sizes: public key 96 bytes, private key 192 bytes, signature 49920 bytes. signs descriptors, votes, reveals, voting certificates, and the consensus
wire KEM configured, no default. Sizes: by scheme. the KEM of the PQ Noise link key
Sphinx NIKE or KEM configured, no default. Sizes: by scheme. the scheme of a mix key
replica envelope NIKE CTIDH1024-X25519, public key 160 bytes Fixed for every network.

Client software and configuration

The client daemon loads its listeners, gateway pins, and authority keys from client.toml.

Key Value Explanation
thinclient.toml Dial.Unix.Address = @katzenpost Selects the daemon’s socket through [Dial.Unix] or [Dial.Tcp].
client.toml Listen.Unix.Address = @katzenpost Sets kpclientd’s listener, pinned gateways, authority keys, cryptographic schemes, and Sphinx geometry.

Authority phases

Authorities collect descriptors and exchange voting material on the epoch schedule. The times below are fractions of the epoch duration P. Each phase includes its start and excludes its end.

Key Value Explanation
Phase_DESC 0 to before 1/8 Accept descriptors for N+1.
Phase_VOTE 1/8 to before 2/8 Build and send the vote for V.
Phase_REVEAL 2/8 to before 3/8 Send the reveal.
Phase_CERT 3/8 to before 4/8 Consume reveals, tally, and send the voting certificate.
Phase_SIG 4/8 to before 5/8 Verify voting certificates, compute the SRV, tally again, sign, and send the signature.
Phase_PUB 5/8 to before 7/8 Combine signatures, publish N+1, and set V = N+2.
Phase_TAIL 7/8 to before 1 Answer Gone for N+1 if no document is available.

Phases: Mix, gateway, service node

Mixes, gateways, and service nodes upload next-epoch descriptors before they fetch the next consensus.

Key Value Explanation
Mix, gateway, service node 0 Rotate keys and upload for N+1 until U.
Storage replica Until U Upload the replica descriptor.
Mix, gateway, service node 5/8 Fetch N+1.
Replica, courier, client F Fetch N+1.
Mix, gateway, service node After 7/8 Admit N+1 peers.

Clocks and retention

Epoch windows determine key retention, document expiry, and retry timing.

Key Value Explanation
authority behind a peer, for reveal, cert, sig 145 seconds W - buf - delivery
authority behind a peer, for a vote 295 seconds, 445 seconds 2W - buf - delivery to reach the voting certificate, 3W - buf - delivery to reach the consensus
authority ahead of a peer 600 seconds 4W for a vote. Other messages can receive TooEarly. All four families retry under authority message delivery
node behind an authority 25 seconds P/48, from U to P/8
node ahead of an authority 112.5 seconds U minus P/96
mix ahead of every authority, fetching at 5/8 300 seconds 2W with retries
client ahead of its gateway 75 seconds P/16 minus the gateway’s fetch latency

Clocks and retention: authority

Authorities retain voting state and limit retries using separate timing settings.

Key Value Explanation
authority N-3 to N N+1 from 5/8
mix, gateway, service node N-2 to N N+1 from 5/8
replica, courier N-2 to N N+1 from F
client N-1, N N+1 from F

Acceptance windows and status codes

Receivers use the request epoch and local state to select a response status.

Key Value Explanation
descriptor Phase_DESC of N receives DescriptorOk Outside {N-1, N, N+1}: DescriptorInvalid. After vote construction: DescriptorConflict. Byte-identical re-upload: DescriptorOk.
vote 5/8 of N-1 to 4/8 of N receives VoteOk or VoteAlreadyReceived Epoch errors. Accepted duplicate: VoteAlreadyReceived. Closed phase: VoteTooLate.
reveal from the sender’s vote to 3/8 receives RevealOk Epoch errors. Accepted duplicate: RevealAlreadyReceived. Closed phase: RevealTooLate. Missing sender commit: RevealTooEarly.
cert from the sender’s vote to 4/8 receives CertOk Epoch errors. Accepted duplicate: CertAlreadyReceived. Closed phase: CertTooLate. Missing sender vote: CertTooEarly.
sig 5/8 of N-1 to 5/8 of N: SigOk Epoch errors.
GetConsensus(e), e at most N held: ConsensusOk Missing document: ConsensusGone.
GetConsensus(N+1) from 5/8: ConsensusOk Before 5/8: ConsensusNotFound. Missing after 7/8: ConsensusGone.
GetConsensus(e), e at least N+2 ConsensusNotFound No acceptance window.

Commands by role

The authenticated peer role determines which directory commands the receiver accepts.

Key Value Explanation
kpclientd gateway node Commands: get_consensus2
gateway node kpclientd Commands: consensus2
unauthenticated authority client authority Commands: get_consensus
mix, gateway node, service node authority Commands: get_consensus, post_descriptor
storage replica authority Commands: post_replica_descriptor, and get_consensus as a client
courier authority Commands: get_consensus as a client
authority authority Commands: get_consensus, vote, reveal, cert, sig

Appendix D. Requirements

R-s-n identifies requirement n in group R-s. For example, R-6-3 is the third requirement in R-6. The consensus document.

R-5. Descriptors

Nodes publish signed descriptors with identity keys, addresses, and role fields.

Key Value Explanation
R-5-1 node / on build A producer MUST include a non-empty IdentityKey, LinkKey, each MixKeys value, and each EnvelopeKeys value required by its descriptor type. A mix upload MUST include a MixKeys value for the command epoch.
R-5-2 node / on build Each key field MUST be exactly the public key size of its scheme.
R-5-3 node / on build Each key field MUST parse under its scheme.
R-5-4 Sphinx sender or receiver / on key use NIKE Sphinx MUST reject an all-zero derived shared secret. X25519 and X448 parsing alone does not reject small-order points. CTIDH parsing rejects the base curve E0, the all-zero encoding, and non-supersingular curves. Upload verification follows the validation stages.
R-5-5 node / on build, on upload The name MUST contain 1 to 32 bytes. This is a byte limit, not a character count.
R-5-6 node / on build, authority / on upload A descriptor MUST list at least one transport, with no empty address list. Producers MUST use the map keys and URL schemes in transport layers. Upload validation MUST apply AddressShape. It accepts tcp, tcp4, tcp6, ws, and quic URLs if the host component permits separation into host and port. It accepts onion URLs in gateway descriptors only. This syntax rule does not require a numeric port or equality between the map key and URL scheme.
R-5-7 mix / on build, authority / on upload A producer MUST include a mix key for its descriptor epoch and none outside the NumMixKeys window that starts there. An upload receiver MUST select that window from the command epoch, require a nonempty key for that epoch, and reject keys outside that window. It does not compare the descriptor’s Epoch with the command epoch or require nonempty future key values (descriptor validation).
R-5-8 node / on build, on upload A descriptor MUST NOT set both IsGatewayNode and IsServiceNode.
R-5-9 node / on build, on upload Only service nodes MAY carry non-null Kaetzchen. Others MUST NOT supply even an empty map. Each capability MUST have a non-empty name and a parameter map containing endpoint text of 1 to 32 bytes, RecipientIDLength. Capabilities MAY share an endpoint.
R-5-10 storage replica / on build, on upload A replica MUST publish envelope keys for RE and RE+1, plus RE-1 when retained. These are replica epochs, independent of mixnet Epoch (clocks and retention). Upload verification requires a non-empty map with non-empty values, but imposes no replica-epoch window (producer).
R-5-11 authority / on upload, on restore, on encode With AcceptDescriptorMetadata false, an authority MUST remove SoftwareVersion and ContactInfo after signature verification, before validation, in-memory storage, voting, and comparison. The persisted signed upload retains its original bytes. Otherwise, SoftwareVersion MUST contain at most 128 printable ASCII bytes and ContactInfo at most 256, in 0x20..0x7e. Producers MUST omit empty metadata fields when encoding. Invalid retained metadata MUST receive DescriptorInvalid when the ordered upload procedure reach metadata validation.

R-6. The consensus document

Nodes and clients use the consensus to select peers and apply network settings.

Key Value Explanation
R-6-1 authority / on build, on validation Producers MUST use BLAKE2b-256 of the signer’s identity public key as each SharedRandomCommit and SharedRandomReveal map key. Each producer envelope MUST carry a valid signature by that identity. Receivers MUST apply the document-validation algorithm, including its zero-commit and one-commit cases. An additional signature by another identity does not invalidate the required signature (envelope verification).
R-6-2 authority / Phase_PUB A published consensus MUST carry no commits and no reveals.
R-6-3 producer / on encode Descriptors, documents, and certificate envelopes MUST use the deterministic CBOR encoding. Receiver decoding defines acceptance and re-encoding.
R-6-4 authority / on build Detached signatures MUST NOT be part of the signed body.
R-6-5 mix, gateway, service node / on packet handling Before subtracting queue dwell time, forwarding nodes MUST drop packets whose advertised delay exceeds P * NumMixKeys. Mixes and gateways MUST drop packets whose remaining per-hop delay exceeds the limit. With service pre-delay enabled, service nodes MUST apply that limit to the advertised last-hop delay (delay queue). The selected cap MUST be SafetyCap(consensus Mu) when nonzero. Otherwise, it MUST be a positive configured fallback, or the built-in SafetyCap(0.001) of 27632 ms if none is positive. The final limit MUST be min(selected cap, P * NumMixKeys) (selection). Both paths SHOULD warn if fallback or consensus caps differ from the built-in value by more than a factor of 4 (scheduler, service).
R-6-6 consumer / on fetch A consumer MUST treat a SphinxGeometryHash mismatch in a threshold-signed consensus as a failed fetch, not a fatal error.
R-6-7 authority, client / at startup, Phase_CERT, Phase_SIG MinClientVersion MUST be at most 32 bytes and ClientNotice at most 512, both printable ASCII 0x20..0x7e. An authority MUST propose its configured pair and reject invalid startup values. It MUST tally the pair separately from rates. It MUST publish a pair present in at least threshold well-formed votes. Otherwise it MUST omit the notice without preventing consensus. It MUST log malformed vote pairs and exclude them from this tally only. Clients MUST NOT act on either field.

R-7. Certificate envelopes and signing

A certificate envelope binds a payload to one or more identity signatures.

Key Value Explanation
R-7-1 any party / on build A certificate envelope MUST carry Version, Expiration, KeyType, Certified, and Signatures.
R-7-2 any party / on sign, on verify The signed message MUST be the signature-input concatenation, with little-endian integers and no length prefixes.
R-7-3 any party / on verify A verifier MUST accept a signature only while its current epoch is less than Expiration.
R-7-4 authority / on build A document’s Expiration MUST be Epoch + 5.
R-7-5 any party / on build A party MUST verify each signature over the same message before adding it by signer hash to a multi-signed document.
R-7-6 any party / on parse A parser MUST reject a certificate envelope whose decoded Version is not 0. An absent or null Version decodes to 0 under the receiver decoding rules. Producers MUST include Version under R-7-1.
R-7-7 producer, document validator / on build, on validation A producer MUST set document Version to “v1”. Full document validation MUST reject any other value. ParseDocument alone does not enforce this requirement, and kpclientd’s gateway path does not repeat it (gateway consensus transfer).

R-9. Time

Participants use the same epoch clock to select current and next-epoch data.

Key Value Explanation
R-9-1 any party / at startup P MUST be the EpochDuration selected by R-9-3. Epoch 0 starts at 2017-06-01 00:00:00 UTC.
R-9-2 any party / at startup P MUST be a whole number of seconds from 2 minutes to 7 days. A party MUST refuse to start on any other value.
R-9-3 any party / at startup A party MUST set P once per process, before any epoch arithmetic. It MUST use the configured value. Without one, it MUST use the environment value and log a deprecation warning. Without either, it MUST use the default. A party MUST refuse to start if configuration and environment values differ, or if it receives a second, different value.
R-9-4 any party / always All parties in a network MUST use the same P. No document or message carries it.
R-9-5 any party / always The epoch number N, elapsed time t, and remaining time till MUST follow the clock formula. The start instant belongs to the epoch. A time before epoch 0 is an error.
R-9-6 authority / always The PriorSharedRandom rotation period MUST be 504 epochs at every P (one week only at 1200 seconds).

R-10. Phases

The voting phases schedule uploads, authority exchanges, and consensus publication.

Key Value Explanation
R-10-1 authority / every send phase An authority MUST calculate its retry deadline from the time until the phase boundary, treating negative time as zero. It MUST subtract buf only when more than buf remains. This deadline does not cancel in-progress attempts (authority message delivery).
R-10-2 any party / always Phase boundaries MUST use the phase fractions and the acting party’s clock. Each phase includes its start. Bootstrap skips the round only after P/8. Gone and N+1 peer authentication apply strictly after 7/8. The sender upload cutoff U is exclusive. Authorities may accept uploads after U until vote construction (R-10-5). Each timer MUST target its phase boundary.
R-10-3 node / Phase_DESC, before U A node MUST upload its descriptor for N+1 at or after the start of N and before U. A replica MUST retry every second. After an unsuccessful post, a node MUST post again every P/96 while the window is open. A DescriptorConflict from an authority that accepted an earlier attempt counts as acceptance.
R-10-4 authority / Phase_VOTE, at P/8 An authority MUST build its vote for N+1 at P/8 from exactly the descriptors it holds at that instant.
R-10-5 authority / on upload After the live validation pass, an authority MUST apply the ordered upload-storage procedure. It MUST answer DescriptorConflict to a new descriptor for an epoch whose document or own vote it already holds. It MUST first compare an existing descriptor, returning DescriptorOk for the same canonical descriptor bytes and DescriptorConflict for a change.
R-10-6 authority / on receive An authority MUST apply the state and error precedence in message acceptance. New reveals close in accept_cert and accept_signature. New votes and voting certificates close in accept_signature. The sig handler has no state gate. The stored voting epoch controls its epoch comparisons. State-machine transitions normally place these cuts at 3/8, 4/8, and advancement of V at 5/8.
R-10-7 authority / at 3/8, 4/8, 5/8 An authority MUST tally votes at 3/8 for its voting certificate and again at 4/8 for its consensus. It MUST process collected reveals at 3/8, voting certificates at 4/8, and signatures at 5/8. Each action MUST run at the corresponding state-machine wake.
R-10-8 authority / every send phase Each authority MUST start delivery at the corresponding phase wake. Retry-deadline targets are 2/8 for votes, 3/8 for reveals, 4/8 for voting certificates, and 5/8 for signatures. R-10-1 applies the buffer. These targets do not guarantee completion before the boundary.
R-10-9 mix, gateway node, service node / Phase_PUB, from 5/8 A mix, a gateway, or a service node MUST first request doc N+1 at 5/8. It MUST retry every P/16 until it holds the document or it receives Gone. While doc N is missing it MUST fetch every P/96 until it posts its descriptor, and every P/16 after that.
R-10-10 courier, storage replica / Phase_PUB, from F A courier or a replica MUST first request doc N+1 at F, and MUST retry every P/32 until it holds the document or receives Gone. While doc N is missing it MUST fetch every P/32.
R-10-11 client / Phase_PUB, from F A client MUST first request doc N+1 from its gateway at F. It MUST retry every P/16 until it receives the document or Gone. While doc N is missing it MUST request it every P/16, and MUST use doc N-1 if available.
R-10-12 mix, gateway, service node, courier, replica, client / every fetch pass Mixes, gateways, service nodes, couriers, and replicas MUST use min(3 minutes, P/8) for each fetch pass. kpclientd MUST use a separate 30-second timeout per document. Authority bootstrap uses R-12-2’s retry budget.
R-10-13 mix, gateway, service node / epoch boundary At the start of epoch N these nodes MUST retain mix keys N-1 through N+3. They MUST publish N+1 through N+3 in their descriptors. They MUST unwrap with keys N-1, N, and N+1.
R-10-14 client / epoch boundary A client MUST wake at the epoch boundary and adopt doc N+1 there, without refetching a cached document it already verified.

R-11. Clocks and retention

Authorities, nodes, and clients apply separate clock and retention limits.

Key Value Explanation
R-11-1 authority / every wake An authority MUST drop documents older than N-3 at every state-machine wake.
R-11-2 node / on authentication, after 7/8 A node MUST authenticate a peer from documents N, N-1, and N-2, and, strictly after 7/8, from N+1. An incoming peer that only N+1 lists MAY send from that instant. A node MUST evaluate an outgoing link’s send permission again at the epoch boundary.
R-11-3 courier, storage replica / on authentication A courier or replica MUST authenticate peers from documents N-1, N, and N+1.
R-11-4 client / on clock adjustment A client that adjusts its clock MUST record skew = local - responder. It MUST then use local - skew. It MUST NOT apply a skew larger than P/8.
R-11-5 authority / on build A signature, a commit, and a reveal MUST carry Expiration V. A vote, a voting certificate, and a document MUST carry Expiration Epoch + 5.
R-11-6 node, client / always A node MUST keep documents for epochs at or above N-2 and a client at or above N-1.

R-12. The authority state machine

Each authority advances through voting states on the epoch schedule.

Key Value Explanation
R-12-1 authority / always The authority MUST implement the bootstrap, descriptor, vote, reveal, certificate, and signature acceptance states. The state labels identify the reference transitions.
R-12-2 authority / bootstrap For each missing document N-1 and N, an authority MUST start a background fetch with a 2-minute retry budget. The budget does not cancel an in-progress attempt. It MUST decide V by R-12-4 without waiting for those fetches.
R-12-3 authority / Phase_PUB, at 5/8 At 5/8 an authority that holds a threshold-signed document MUST store doc V, set V = V + 1, and wake at P/8 of N+1. An authority that holds none MUST enter bootstrap, set V = N+2, and wake at the boundary.
R-12-4 authority / bootstrap In bootstrap, at t of at most P/8, an authority MUST set V = N+1 and wake at P/8. At t greater than P/8 it MUST stay in bootstrap, set V = N+2, and wake at the boundary.
R-12-5 authority / on restart A restarted authority MUST start in bootstrap and attempt restoration for N-1, N, and N+1. For each persisted document, it MUST run threshold verification, parse the document, compare its epoch with its storage key, and skip failures. It MUST apply descriptor restoration to persisted signed uploads and use the current AcceptDescriptorMetadata setting. Restoration does not repeat live session binding, QUICKeyHash validation, or the live late-upload cut. A late conflict can reject an upload after persistence, so storage does not prove prior acceptance.

R-13. Acceptance windows and status codes

Message acceptance windows determine the status returned to a sender.

Key Value Explanation
R-13-1 authority / on get_consensus An authority MUST answer GetConsensus under the availability table: Ok when held, NotFound for N+1 through 7/8 and for N+2 on, Gone otherwise.
R-13-2 gateway node / on consensus retrieval A gateway MUST answer ConsensusGone for an epoch below N-1 or an epoch marked as a permanent fetch failure. Permanent failures are an authority’s Gone response, a Sphinx geometry mismatch, or failure to build the document cache. For other epochs, it MUST answer ConsensusOk if it has the document, and ConsensusNotFound otherwise.
R-13-3 authority / on every rejection An authority MUST log every epoch-gated rejection of a peer message with the local epoch, the elapsed time, and the phase. It MUST log a rejected descriptor upload with that upload’s epoch.

R-14. Phase_DESC. Descriptor upload

Authorities accept descriptor uploads from authorized nodes.

Key Value Explanation
R-14-1 authority / Phase_DESC, on upload An authority MUST accept descriptor uploads only for epochs N-1, N, and N+1, and answer DescriptorInvalid otherwise.
R-14-2 authority / Phase_DESC, on upload The descriptor’s identity hash MUST be pinned for the claimed role and the announced name MUST match the pinned name.
R-14-3 authority / Phase_DESC, on upload A replica’s ReplicaID MUST match the pinned identifier.
R-14-4 authority / on upload SignedUpload and SignedReplicaUpload signature verification MUST use the canonical encoding of the decoded upload with Signature set to null (upload validation). This verification MUST succeed. It does not verify the upload signature’s PublicKeySum256.
R-14-5 authority / Phase_DESC, on upload The identity hash presented on the link MUST equal the descriptor’s identity hash.
R-14-6 authority / Phase_DESC, on upload An authority MUST verify the identity key length against the scheme before it parses the key.
R-14-7 authority / on upload After live validation succeeds, an authority MUST compare re-uploads for the same command epoch and identity using canonical descriptor bytes after the metadata policy. It MUST return DescriptorConflict for a changed descriptor and DescriptorOk for an unchanged descriptor. The comparison excludes the upload signature and LoopStats (storage order).
R-14-8 authority / on upload An authority MUST apply the ordered live upload procedure and storage procedure. Core descriptor validation and its caller-selected epoch follow descriptor validation. Key parsing and degeneracy tests at key use follow R-5-4.
R-14-9 authority / always Every authority MUST use the same AcceptDescriptorMetadata setting in an epoch. With mixed settings, a descriptor containing metadata needs threshold authorities to retain it or threshold authorities to remove it. Otherwise, neither form reaches the identical-bytes threshold of R-16-2.
R-14-10 authority / on upload For a new upload, an authority MUST persist the original signed upload before it returns DescriptorOk. Persistence MUST also precede insertion into the in-memory upload set. The authority MUST recheck the vote and document cut before that insertion. A cut reached during persistence produces DescriptorConflict. An unchanged existing descriptor returns DescriptorOk without a new write. Storage failure MUST produce DescriptorInternalError and trigger fatal shutdown (storage algorithm).

R-15. Phase_VOTE, Phase_REVEAL. Commit and reveal

Authorities commit to random values before revealing them.

Key Value Explanation
R-15-1 authority / on build, on receive Producers MUST use the commit and reveal formulas, each with exactly 40 bytes: an 8-byte epoch and a 32-byte digest or value. A reveal receiver MUST reject fewer than 8 certified bytes before reading the epoch. It MUST apply the later 40-byte and commitment-opening requirements in the order specified by message acceptance.
R-15-2 authority / Phase_VOTE, on receive A vote MUST carry the sender’s commit, or be answered VoteMalformed.
R-15-3 authority / on receive When the ordered reveal procedure reach commitment verification, the reveal MUST open the sender’s stored commitment for the same epoch. Failure at that condition MUST produce RevealNotSigned.
R-15-4 authority / Phase_VOTE, Phase_REVEAL, on build Commit and reveal MUST each be signed with the sender’s identity key.
R-15-5 authority / Phase_VOTE, Phase_REVEAL, on send An authority MUST send identical signed commit envelopes and identical signed reveal envelopes to every peer. Conflicting verified envelope bytes exclude the signer from the SRV calculation under R-17-4.
R-15-6 authority / on receive A receiver MUST store a reveal only if it holds the sender’s commitment. If earlier conditions hold and that commitment is missing, it MUST return RevealTooEarly under the ordered reveal procedure.
R-15-7 authority / on receive A vote MUST pass VoteDocument, which omits the usable-topology requirement. It MUST also contain exactly one commitment under the sender’s identity hash and no reveals. Failure at these conditions MUST produce VoteMalformed, after the preceding conditions in message acceptance.
R-15-8 authority / on receive A receiver MUST store a received vote’s commitment only in accept_desc or accept_vote. If it accepts a vote in bootstrap, accept_reveal, or accept_cert, it MUST store the vote without its commitment and log a warning. A later reveal follows message acceptance, including state closure before the commitment-presence test.

R-16. Phase_CERT, Phase_SIG. Tabulation

Authorities tally votes for descriptors and traffic rates.

Key Value Explanation
R-16-1 authority / Phase_CERT, Phase_SIG Tabulation MUST require at least threshold votes for the epoch.
R-16-2 authority / Phase_CERT, Phase_SIG An authority MUST include a descriptor only if its allow-list authorizes it and at least threshold votes contain identical serialized bytes.
R-16-3 authority / Phase_CERT, Phase_SIG An authority MUST adopt a parameter set only with at least threshold votes. It groups the votes by the canonical CBOR map {Mu, LambdaP, LambdaL, LambdaM, LambdaR}, with keys in canonical order and floats as in R-6-3. Two sets group together if and only if each rate is bit-identical to its counterpart, or both of that pair are NaN.
R-16-4 authority / Phase_CERT, Phase_SIG dissenters MUST be floor(A/2)-1, and it MUST NOT decide any output. An authority logs a parameter set that has fewer than threshold votes and at least dissenters votes.
R-16-5 authority / Phase_CERT, Phase_SIG An authority MUST sort included nodes and replicas by identity-key hash.
R-16-6 authority / at startup, on reload, on sign Layers defaults to 3 and MinNodesPerLayer defaults to 2. An authority MUST reject Layers above 3. At startup and reload, the allow-lists MUST contain at least Layers * MinNodesPerLayer mixes, one gateway, and one service node. A fixed topology MUST contain at least MinNodesPerLayer mixes per layer. At signing, a topology MUST have non-empty layers, a gateway, and a service node. It does not require MinNodesPerLayer.
R-16-7 authority / Phase_SIG, on sign An authority MUST refuse to sign a document that is not well formed.
R-16-8 authority / on document construction Each authority MUST derive LambdaG from its configured topology, gateway count, and rates using gateway rate arithmetic. This value is outside the voted parameter key. With gateways present, the tagged document builder requires a fixed topology.

R-17. Phase_CERT. Voting certificates

Authorities verify voting certificates before reconciling random contributions.

Key Value Explanation
R-17-1 authority / Phase_CERT An authority MUST send its voting certificate between the reveal and signature phases. It MUST include only pairs for which it has both the commit and reveal. R-17-6 defines when it sends none.
R-17-2 authority / Phase_SIG, on verify Each commit and reveal cited in a voting certificate MUST be signed by an authorized authority.
R-17-3 authority / Phase_SIG, on verify Each cited reveal MUST open its commit for the matching epoch.
R-17-4 authority / Phase_SIG During ordered reconciliation, two encountered verified current-epoch commit or reveal envelopes with different serialized bytes MUST exclude their signer from the SRV calculation (R-17-8). The comparison includes envelope metadata and signatures, not only Certified. The SRV MUST use the pairs that survive ordered reconciliation.
R-17-5 authority / on receive A voting-certificate upload MUST pass the ordered certificate procedure. These bind its declared key to the connected peer. They compare its document epoch with V and require a nonempty reveal map. They require a stored vote from the sender before full document validation.
R-17-6 authority / Phase_CERT, on send A voting certificate MUST contain the tallied document. An authority MUST send it only if that document passes validation, including the usable-topology requirement. Otherwise, it MUST send none.
R-17-7 authority / Phase_SIG, on verify An authority MUST follow ordered reconciliation, including its empty accumulators, sorted traversal, exclusions, skips, early exits, and final deletion.
R-17-8 authority / Phase_SIG, on verify An encountered entry that fails verification MUST exclude the voting certificate’s author and stop that certificate’s entry loop. An authority MUST exclude a cited authority only for two verified current-epoch commits or reveals that differ under that authority’s own key.
R-17-9 authority / on receive A receiver MUST run FullDocument at the point specified by certificate acceptance. Failure there MUST reject the whole certificate with CertNotSigned and log the sender, epoch, failed rule, and cited authorities. With two or more commitments, this verifies a matching reveal for every commitment. With zero commitments, the validator does not inspect the reveal map.

R-18. Phase_SIG. Shared random value and topology

Authorities derive the shared random value and assign mix layers.

Key Value Explanation
R-18-1 authority / Phase_SIG, at 4/8 The SRV MUST use the shared-random digest.
R-18-2 authority / Phase_SIG, at 4/8 An authority MUST sort reveals by descending certified bytes, breaking ties by ascending identity hash. Each pair contributes the identity hash followed by the reveal.
R-18-3 authority / Phase_SIG, at 4/8 PREV MUST be the previous epoch’s SRV if held, else 32 zero bytes.
R-18-4 authority / Phase_SIG, at 4/8 An authority MUST hold voting certificates from at least threshold authorities before reconciliation. It MUST compute the SRV only if at least threshold commit/reveal pairs remain after reconciliation.
R-18-5 authority / Phase_SIG, at 4/8 Automatic assignment MUST seed its generator with the SRV and follow layer assignment. Fixed assignment MUST preserve configured order and use no random draws.
R-18-6 authority / Phase_SIG, on build PriorSharedRandom MUST be restored from the previous consensus and carried unchanged, except for initialization under R-18-8 and rotation under R-18-9.
R-18-7 authority / Phase_VOTE, at P/8 At vote construction, an authority MUST copy GenesisEpoch from document V-1 if available, or use V otherwise. An older retained document MUST NOT supply this value. It restores shared-random history from V-1 when available. GenesisEpoch remains fixed for the round. Later document construction reads the stored history and prior topology.
R-18-8 authority / Phase_SIG, on build With an empty history or at the genesis epoch, PriorSharedRandom MUST be [srv, srv].
R-18-9 authority / Phase_SIG, on build Initialization under R-18-8 MUST take precedence. Otherwise, at V mod 504 = 0, an authority MUST set PriorSharedRandom to [srv, previous first element]. It MUST rotate once, using the real SRV.
R-18-10 authority / Phase_SIG, on build Topology generation MUST read only those layers of the prior consensus that exist in the configured layer count.

R-19. Phase_SIG, Phase_PUB. Signatures and publication

Authorities exchange consensus signatures and publish when the threshold is met.

Key Value Explanation
R-19-1 authority / Phase_SIG, at 4/8 Each authority MUST sign its own consensus and send the detached signature to every peer.
R-19-2 authority / on receipt, on publication On receipt, an authority MUST follow ReceiveSig. It MUST run verification on the enclosing certificate, then verify for an existing message. For a new message, it MUST decode and store the inner Signature. SigOk acknowledges receipt, not agreement on consensus bytes. At publication, it MUST attach only inner signatures whose verification over its own consensus succeeds (attachment).
R-19-3 authority / Phase_PUB, at 5/8 An authority MUST publish and persist a consensus with at least threshold valid signatures.
R-19-4 client / on fetch A client MUST apply the same threshold verification when it fetches a consensus.

R-21. Authority message delivery

Authorities deliver voting messages through peer connections and retry within phase deadlines.

Key Value Explanation
R-21-1 authority / every send Each candidate address MUST use the configured DialTimeoutSec, 30 seconds by default. The retry deadline limits waits between attempts, but does not cancel an in-progress attempt.
R-21-2 authority / on retry Votes, reveals, voting certificates, and signatures MUST retry transient errors and their matching TooEarly responses under the shared retry policy. A non-TooEarly response MUST end that send.
R-21-3 authority / every send Phase sends MUST use R-10-1’s retry deadline. Bootstrap MUST use R-12-2’s 2-minute retry budget. Both MUST use PeerRetryMaxAttempts when positive, permitting an initial attempt plus that many retries. Without this setting, bootstrap defaults to 20 retries per peer.
R-21-4 authority / on retry Each retry MUST preserve the command’s serialized payload bytes. Commit and reveal envelopes MUST also be byte-identical across peers, including their metadata and signatures. The sender MUST reuse these bytes rather than sign them again.

R-22. Wire protocol

Directory wire commands carry signed protocol objects over PQ Noise.

Key Value Explanation
R-22-1 any party / always Status codes MUST use the reply status reference for their reply-command family. Different families reuse some numeric values.
R-22-2 authority / on receive Epoch comparison MUST follow the ordered message procedure. At that step, an epoch below V MUST produce the command’s TooLate code. An epoch above V MUST produce its TooEarly code. A reveal uses the epoch in its verified signed body. The command Epoch field has no effect on that comparison.
R-22-3 authority / on receive Vote, reveal, cert, and sig handlers MUST require the stored voting epoch V, with the command-specific state conditions in message acceptance. These handlers do not compare elapsed time with phase deadlines. The sig handler has no state gate.
R-22-4 authority / on receive An authority MUST answer NotAuthorized unless a vote, reveal, cert, or sig declares an authorized authority key matching the link-authenticated peer.
R-22-5 authority / on receive The first accepted vote, reveal, certificate, or signature from an identity for V MUST retain its slot. A duplicate MUST produce AlreadyReceived only when the ordered message procedure reach that command’s duplicate test. Earlier authorization, epoch, state, parsing, or verification can produce another status.
R-22-6 authority / on build, on receive At a signature-verification failure, the ordered message procedure MUST return the command’s NotSigned status. For a new sig message, failure to decode the verified body as Signature MUST return SigInvalid. A producer MUST encode that body as canonical CBOR containing PublicKeySum256, a 32-byte value, and Payload, the signature bytes. Canonical order places Payload first. Receiver acceptance follows CBOR decoding.
R-22-7 authority / Phase_DESC, on upload Descriptor uploads MUST be answered with DescriptorInvalid, DescriptorConflict, and DescriptorForbidden as descriptor uploads and authentication and authorization specify.
R-22-8 any party / on build, on parse Every command MUST use the 6-byte header, big-endian integers, and zero padding.
R-22-9 any party / on build, on parse Command bodies MUST follow the command reference, with the full identity public key in vote, reveal, cert, and sig.
R-22-10 any party / on send A sender MUST NOT pad a command on a direct authority PKI link. Gateway get_consensus2 and consensus2 commands MUST use the upstream and downstream padding of their connection, respectively.
R-22-11 authority client / after get_consensus The initiator MUST end a direct authority consensus-fetch session after get_consensus. This does not close kpclientd’s persistent gateway session after get_consensus2.
R-22-12 authority, client / on handshake The responder MUST send NoOp as the first encrypted command. The client MUST reject any other command there.
R-22-13 client / on handshake A client MUST authenticate an authority with AD[:32] = H(identity key) and the pinned static link key. It MUST reject additional data shorter than 32 bytes before it reads that data.
R-22-14 any party / every command Each command MUST use the two encrypted Noise messages: a 20-byte encrypted length header, then the command ciphertext. The header’s four-byte big-endian value MUST include the command’s 16-byte tag. The sender MUST rekey its sending direction after both messages. The receiver MUST rekey its receiving direction after authenticating both messages.
R-22-15 any party / every command An authority PKI session MUST carry one command and one reply on a fresh connection. Configuration may enable persistent authority peer connections. Client-gateway sessions remain persistent and carry multiple commands.
R-22-16 client / on fetch A direct authority fetcher MUST use get_consensus and require a threshold-signed, well-formed document matching the requested epoch. kpclientd MUST use get_consensus2 through its gateway and verify the signature threshold, requested epoch, and local geometry. Its gateway path does not call IsDocumentWellFormed.
R-22-17 any party / on receive An authority PKI session MUST bound received commands by the receive size ceiling. It MUST read the body in chunks of at most 64 KiB.
R-22-18 authority / every send An authority MUST use configured HandshakeTimeoutSec and ResponseTimeoutSec, which default to 3 and 30 seconds. These operation budgets do not inherit the phase retry deadline.

R-23. Authentication and authorization

Session authentication and authorization bind commands to configured identities and roles.

Key Value Explanation
R-23-1 authority / always Allow-lists MUST be keyed by BLAKE2b-256 of the marshaled identity key (roles, keys, and schemes).
R-23-2 authority, node / on handshake An authority MUST treat a peer with zero-length additional data as an unauthenticated client. A node MUST present a 32-byte hash that an allow-list holds. An authority MUST also present its pinned link key.
R-23-3 authority / Phase_DESC, on upload A descriptor upload MUST come from an authenticated node of the descriptor’s kind.
R-23-4 authority / on receive Allowed commands MUST follow commands by role. An authority MUST drop unauthorized commands without reply and close unless this is a persistent authority connection. Authorization follows body parsing, so R-22-17’s ceiling applies before dispatch.
R-23-5 authority / on handshake The responder MUST read one command from every connection that completes the handshake. It MUST read that command before it applies per-command authorization. It MUST drop a connection that sends no command within the first-command deadline.
R-23-6 authority / Phase_DESC, on upload An authority MUST answer DescriptorForbidden unless the descriptor’s LinkKey matches the session’s link key in a constant-time comparison.
R-23-7 authority / on handshake An authority MUST NOT pin a node session unless its link key matches an accepted descriptor for that identity in N-1, N, or N+1. An authority MUST allocate per-identity slots to authorities and pinned nodes only. Unpinned nodes MUST use global and per-address bounds only, permitting first uploads and key rotation. A claimed identity hash alone does not establish pinning.

R-24. QUIC transport

Peers authenticate QUIC endpoints with pinned keys.

Key Value Explanation
R-24-1 node, authority / Phase_DESC, on upload A descriptor MAY carry QUICKeyHash, a concatenation of zero to three 32-byte hashes. A descriptor omits it when empty, so a TCP-only descriptor does not change. An authority MUST answer DescriptorInvalid to any other length. It verifies the length at upload only, after the link-key verification of R-23-6 and before authorization. R-5-11 does not remove the field.
R-24-2 node, authority / at startup A QUIC listener’s TLS key MUST be an Ed25519 key from a random seed kept on disk, and QUICKeyHash MUST be SHA-256 of its SubjectPublicKeyInfo.
R-24-3 any party / on dial A dialer MUST NOT dial a QUIC address of a peer for which it holds no QUIC key hash. It MUST abort a QUIC connection whose certificate SubjectPublicKeyInfo hash matches none of the hashes it holds. It MUST compare them in constant time.
R-24-4 authority / at startup, on dial An authority entry MAY carry one 32-byte QUICKeyHash, written in hex. A dialer MUST pin it, and MUST NOT dial that authority over QUIC without it. An authority that listens on QUIC and whose own entry carries one MUST refuse to start unless its QUIC key file exists and matches it.
R-24-5 gateway node, authority / on build, at startup For common TCP reachability, a gateway MUST advertise a tcp4 or tcp6 map entry with a tcp:// URL. An authority MUST configure tcp:// in its [Server] Addresses and own [[Authorities]] entry. Authority listeners support tcp:// and quic:// only. Gateways MAY advertise onion endpoints for clients. An authority warns about a QUIC gateway descriptor without a TCP or onion alternative. The authority PKI client randomizes addresses, while inter-authority sends use filtered configuration order (client, peer sends).
R-24-6 node, authority / at startup A QUIC listener’s stateless reset key MUST derive from its identity private key through HKDF-SHA256. The derivation MUST use no salt and the label “katzenpost quic stateless reset”. This preserves reset-key continuity across restarts.
R-24-7 any party / every connection A QUIC connection MUST carry one stream: a listener MUST allow one incoming stream and a dialer none, and a second stream MUST be refused.
R-24-8 any party / every connection A QUIC link MUST select a keepalive period per connection. The default is 45 seconds for nodes and authorities, or 20 seconds for clients, multiplied by 1 +/- 0.25. quic-go limits the period to half the idle timeout. QUIC MaxIdleTimeout defaults to 2 minutes. Wire read deadlines can be shorter. The client configures 15 seconds. The handshake MUST use the connect deadline.
R-24-9 mix, service node, storage replica / on build A mix, a service node, or a replica with a QUIC listener MUST present one QUIC key for each epoch. In the descriptor for epoch e it MUST publish the hashes of the keys it holds for e-1, e, and e+1.
R-24-10 gateway node, node / at startup, on build A gateway with a QUIC listener MUST present one long-term QUIC key. It MUST publish that key’s single hash in every descriptor. It MUST refuse to start without that key file. A node without a QUIC listener MUST NOT set QUICKeyHash.
R-24-11 any party / on dial After a pin mismatch a dialer MUST choose one behaviour for the rest of that epoch. Under fallback, the default, it dials that peer at non-QUIC addresses only. Under strict, it dials that peer over pinned QUIC only. A node under strict MUST count the mismatch as final for its upload to that authority in that epoch.
R-24-12 node / on build A node MUST omit QUICKeyHash from its descriptor unless every authority entry in its configuration carries a QUICKeyHash. No authority that drops the field then receives it (R-26-8).
R-24-13 any party / on close A QUIC close MUST close the stream’s send side and allow data to drain before connection closure. This wait MUST end when the peer closes or CloseLinger expires. CloseLinger defaults to 30 seconds, and 0 selects that default. Close does not guarantee delivery.
R-24-14 any party / at startup, every connection All parties in a network MUST offer the same ALPN, which defaults to “h3”. A listener MUST answer a new connection with a Retry unless its configuration says otherwise. AddressValidation takes three values. The default is always. Under-load starts at 128 handshakes in flight. The third value is off.
R-24-15 client, any party / on dial A party whose links go through a proxy MUST NOT dial QUIC. A client MUST dial QUIC only when its PreferedTransports lists quic. It MUST then pin the QUICKeyHash of the gateway’s descriptor, or of its PinnedGateways entry before it holds a consensus.

R-26. Byte identity

Authorities require identical consensus bytes before combining signatures.

Key Value Explanation
R-26-1 authority, client / always An authority’s threshold MUST be floor(A/2)+1 over the authority identity keys deduplicated by hash, its own key included. A client MUST use floor(A/2)+1 over its configured authority list without deduplication, so a list that names one authority twice raises its threshold.
R-26-2 any party / on build, on parse SphinxGeometryHash MUST be BLAKE2b-256 of canonical Geometry CBOR (hash, structure). Its 12 integer fields and text fields NIKEName and KEMName appear in the conformance vectors and the CDDL schema. Field names are map keys. R-6-3’s length-first order applies.
R-26-3 node / on build A SignedUpload MUST be the canonical CBOR of Signature, MixDescriptor, and LoopStats. A SignedReplicaUpload MUST be the canonical CBOR of Signature and ReplicaDescriptor, with no LoopStats. Both MUST be signed with Signature nil. The MixDescriptor value MUST be a byte string that holds the descriptor’s own canonical CBOR, and the ReplicaDescriptor value MUST be an inline map.
R-26-4 authority / Phase_SIG, on build The automatic topology generator MUST be ChaCha20 keyed by the SRV with an 8-byte zero nonce and initial counter zero. The generated topology is in the signed body (byte identity), so a party MUST use the draw algorithm.
R-26-5 any party / on handshake The link handshake MUST be PqXX with the one-byte prologue 0x03.
R-26-6 any party / every frame A session MUST serialize nonce use and message operations separately in each direction. Concurrent operations on one direction can reuse a nonce.
R-26-7 authority / on build ConfiguredReplicaIDs and ConfiguredReplicaIdentityKeys MUST carry the full configured replica set sorted by identity key. ConfiguredReplicaIDs is one byte string of the identifiers in that order, not an array of integers, because its Go type is a byte slice. the consensus document gives the encoding.
R-26-8 authority, operator / always A new descriptor or document field MUST be omitted when empty. It MUST remain empty unless an authority configuration option enables it. That option MUST be false or empty by default. Operators MUST wait until every authority can decode the field before enabling it. Otherwise, an older authority can discard the unknown field and sign different bytes. This applies to retained descriptor metadata and notices. AcceptDescriptorMetadata defaults to false, and the [Notice] table defaults to empty.

Appendix E. Cryptographic configuration

Namenlos uses hybrid identity signatures and post-quantum link keys.

Key Value Explanation
PKISignatureScheme Ed25519 Sphincs+ The identity signature profile defines signatures, raw keys, PEM files, and identity hashes.
WireKEMScheme KYBER768-X25519 The wire profile defines the KEM and keys. The handshake profile defines the four flights.
SphinxGeometry.NIKEName x25519 Sphinx uses 32-byte X25519 public keys.
Replica envelope NIKE CTIDH1024-X25519 This fixed scheme uses the 160-byte hybrid NIKE encoding.

Appendix F. Receiver and construction algorithms

Receivers parse signed inputs, authorize senders, verify epochs, and construct the consensus. A receiver can accept an input that violates a producer requirement.

Receivers MUST evaluate the applicable algorithm’s conditions in order and return its first failure. A later condition does not change an earlier status. Decoded maps have no iteration order unless an operation sorts them.

Go reference implementation

The Katzenpost reference implementation uses the Go programming language. Internal data types and library calls can differ between languages. Compatible implementations MUST produce the same accepted values, canonical bytes, and consensus fields.

The Go CBOR library determines conversion and normalization behavior for received objects. The conversion of binary64 values to int64 can depend on the Go runtime and target architecture. The receiver decoding rules retain those distinctions.

Go’s math.Log computes the natural logarithm for the gateway rate. Other mathematics libraries can round the result differently. A different LambdaG encoding changes the signed consensus bytes. Compatible results require the binary64 evaluation order and logarithm constants.

CBOR decoding

The reference uses github.com/fxamacker/cbor/v2 v2.9.1 with DecOptions{} for these objects (pinned dependency). Encoding with CanonicalEncOptions does not configure the decoder. Each Unmarshal call first verifies the structure and limits of one complete CBOR item. It rejects trailing bytes, then decodes that item into the destination type. If either step returns an error, the caller rejects the result, including any fields set before the error (decoder).

Key Value Explanation
Nesting limit 32 The structural checker starts at depth zero. Each array or map adds one. In a consecutive run of tags, each tag after the first adds one. The first tag does not add a level. The checker examines the content at that depth.
Array limit 131072 elements This applies to each definite-length or indefinite-length array, including arrays in skipped fields.
Map limit 131072 pairs This applies to each definite-length or indefinite-length map, before the decoder collapses duplicate keys.
Byte and text string limits No separate decoder option The available input and the wire message ceiling limit their encoded size. The decoder permits indefinite-length strings.
Canonical form Not required by the decoder The decoder accepts non-minimal integer and length encodings, non-canonical map order, and supported wider floating-point encodings. This does not permit malformed CBOR.
Indefinite lengths Allowed Arrays, maps, byte strings, and text strings can use the indefinite-length form.
Tags Allowed When decoded, tag 0 requires text, tag 1 requires an integer or float, and tags 2 and 3 require bytes. The decoder removes self-described CBOR tags. Other tags around ordinary typed fields decode through to their content after pointer allocation. Untyped values retain unrecognized tags. Only structural validation applies to tags in skipped values.
Unknown struct fields Ignored The decoder skips unmatched text keys, unmatched integer keys that fit int64, and their values. It rejects byte strings, arrays, maps, and other unsupported types as struct field names.
Struct field matching Exact name, then case-insensitive fallback Matching uses the field name or its CBOR tag. For example, the document history field uses PriorSharedRandom.
Duplicate struct fields Retain the first matched field The decoder skips later keys that match the same destination field, including differently capitalized keys. A malformed value in the first occurrence still causes a decoding error.
Duplicate map entries Retain the last decoded entry This applies after conversion of the key to its destination type. The decoder does not return a duplicate-key error. A type error in an earlier entry still causes a decoding error.
Absent fields Unchanged destination value These receivers decode into new objects, so absent fields have their zero value: zero, false, an empty string, or a nil pointer, map, or slice. The later algorithms enforce field presence.
null and undefined Nil, or no change When untagged, they set pointers, maps, slices, and interface values to nil. For scalar values, fixed arrays, and ordinary structs, they leave the destination unchanged without a type error. A new scalar or struct therefore remains zero-valued.
Tagged pointers Allocate before removing tags The decoder allocates and dereferences pointers before it removes tags. An otherwise accepted tag around null or undefined leaves a new ordinary struct pointer allocated, with zero-valued fields.
Scalar map temporaries Retain the preceding decoded value Each map uses separate key and value temporaries. Booleans, integers, floats, and strings reuse their temporary across entries. Each temporary starts at zero. A null or undefined scalar therefore retains the preceding decoded key or value. Other destination types reset between entries.
Empty containers Distinct from nil containers An empty map or array decodes as an allocated empty map or slice. This distinction affects conditions such as Kaetzchen == nil and subsequent canonical encoding.
Text UTF-8 when decoded Decoded text strings, including field names, MUST contain valid UTF-8. The decoder verifies the CBOR structure of values in unknown or duplicate struct fields, but skips their text decoding. The default decoder does not convert byte strings to string fields.
Unsigned integer fields A supported numeric value that fits the destination Unsigned integers, unsigned bignums that fit uint64, and unassigned simple values 0 through 19 or 32 through 255 convert to the destination integer. Negative integers and floats fail. A uint8 field therefore receives only 0 through 255, apart from the null behavior above.
Floating-point fields CBOR integers, floats, and supported numeric conversions Unsigned integers through uint64, negative integers through int64, corresponding bignums within those ranges, and the simple values above convert to the destination float. The decoder permits NaN and positive or negative infinity.
Byte slices Byte strings or arrays of byte values Arrays decode element by element using the uint8 conversion above. Tags 2 and 3 around bytes can also decode their contained bytes into a byte slice or fixed byte array.
Fixed byte arrays Copy, truncate, or zero-fill A byte string or array can decode into [32]byte. The decoder discards extra elements and zero-fills a short value. It does not require an encoded length of 32 for a hash field or hash map key.
Arbitrary parameter scalars Default interface decoding Booleans remain booleans. Positive integers become uint64. Negative integers become int64, or big.Int if necessary. Floats become float64. Bytes become byte slices.
Arbitrary parameter containers Default interface decoding Arrays become interface slices. Maps become maps with interface keys and values. Byte-string map keys use the comparable cbor.ByteString type. Unhashable map keys fail.
Arbitrary parameter tags and simple values Default interface decoding Tags 0 and 1 decode as time values. Tags 2 and 3 decode as bignums. Unrecognized tags decode as cbor.Tag. Unassigned simple values decode as cbor.SimpleValue.

These settings follow the decoder options, struct field rules, map decoding, byte conversion, and structural conditions. The limits apply again when the receiver decodes an embedded byte string as another CBOR object. They do not impose a combined nesting limit across separate decodes of an envelope, its certified payload, and an embedded descriptor.

The producer encodes a MixDescriptor as a byte string containing its CBOR map. The receiver invokes MixDescriptor.UnmarshalBinary on that byte string. The default decoder also accepts a direct CBOR map for this destination type. A ReplicaDescriptor has no UnmarshalBinary method and uses a direct map. Its Unmarshal method does not give it the byte-string conversion used for a mix descriptor. Descriptor methods, replica methods.

Canonical encoding after decoding

Upload verification and descriptor tallying encode decoded values again. Arbitrary mixnet service parameters therefore use these conversions before canonical encoding.

Key Value Explanation
Time tags 0 and 1 Untagged whole Unix seconds Tag 0 parses RFC 3339 text. Tag 1 converts numeric seconds. Both produce a time value. Encoding discards its fractional seconds.
Fractional numeric time Truncate, then normalize Split the binary64 value into whole seconds truncated toward zero and a signed fractional remainder. Multiply the fraction by 1000000000. Truncate that product toward zero for nanoseconds. Normalize negative nanoseconds into the preceding second. Encode the resulting whole seconds.
Time bounds Signed 64-bit integer seconds Integer input outside this range fails. For finite floats, compare whole seconds against binary64 -2^63 and +2^63. Values outside those bounds fail. Conversion at exactly +2^63 depends on the Go implementation. Go 1.26.10 on Linux/amd64 converts it to -2^63.
Zero and nonfinite times CBOR null The zero time is 0001-01-01T00:00:00Z. Tag 1 NaN and either infinity decode to that zero time.
Bignums Shortest integer or bignum Tags 2 and 3 decode to integers. Encode them without a tag when major type 0 or 1 can represent them. Otherwise, encode the shortest bignum magnitude.
Unrecognized tags Tag number and encoded content Preserve the tag number and recursively encode the decoded content.
Simple values Original simple value, or null Preserve unassigned simple values. Undefined decodes to nil and encodes as null.

The time decoder, time encoder, and encoding defaults define these conversions. The map decoder defines scalar reuse. These byte examples use hexadecimal CBOR. The receiver tests verify them against the pinned decoder.

Key Value Explanation
c1f93e00 01 Time 1.5 becomes integer 1.
c1f9b800 20 Time -0.5 becomes integer -1.
c1fbbddb7cdfd9d7bdbb 00 Time -0.0000000001 truncates to zero nanoseconds.
c074303030312d30312d30315430303a30303a30305a f6 The zero time becomes null.
c1f97e00, c1f97c00, c1f9fc00 f6 NaN and either infinity become null.
c24101 01 Bignum 1 becomes integer 1.
d903e8f7 d903e8f6 An unknown tag retains its number. Its undefined content becomes null.
LoopStats: d903e8f6, d9d9f7f6, d903e8f7, d9d9f7f7 a36545706f6368006d5365676d656e74526174696f73f66f4d69784964656e7469747948617368f6 Tagged null or undefined allocates a zero-valued LoopStats. Upload verification uses this map instead of null. Bare f6 or f7 leaves the pointer nil and encodes as f6.
a2054161f64162 or a2054161f74162 a1054162 A map with unsigned integer keys reuses key 5 for null or undefined. The last entry replaces its bytes.
a26161f93c006162f6 a26161f93c006162f93c00 A map with text keys and floating-point values reuses value 1 for null.

Certificate parsing and signature verification

Certificate operations verify the signed payload and its expiration. H(key) is the hash of the serialized identity public key. N is the current local epoch number at verification.

Envelope(raw):
    Decode raw into a new Certificate, using receiver-decoding.
    Require Version == 0.
    Require N < Expiration.
    Require KeyType to be nonempty.
    Require Certified to be nonempty.
    Replace a nil Signatures map with an empty map.
    Return the decoded certificate.

VerifyEnvelope(key, raw):
    c = Envelope(raw).
    For each signature value s in c.Signatures, in unspecified map order:
        If s.PublicKeySum256 == H(key):
            Require len(s.Payload) == key.scheme.SignatureSize.
            Require key.scheme.Verify(key,
                LE32(c.Version) || LE64(c.Expiration) ||
                UTF8(c.KeyType) || c.Certified, s.Payload).
            Return c.Certified.
    Fail because no signature value identifies key.

ParseDocument(raw):
    c = Envelope(raw).
    Require at least one entry in c.Signatures.
    Create a new Document, with an empty detached-signature map.
    For each signature value s in c.Signatures:
        Set document.Signatures[s.PublicKeySum256] = s.
    Decode c.Certified into the document body, using receiver-decoding.
    Return the document.

ParseDocument does not run outer signature verification or document validation. In the reference, it calls GetCertified and GetSignatures separately. Each call decodes the envelope and repeats its validity tests before body decoding (envelope validation). The body excludes the detached Signatures field (document parsing).

Envelope verification does not compare the envelope map key with the inner signature’s PublicKeySum256. It searches signature values. It also does not compare KeyType with the verifier’s configured scheme name. It requires a nonempty KeyType and includes that string in the signed message (signature search).

If different map entries contain signatures with the same inner identity hash, the first matching value visited controls verification. Conflicting entries can therefore give an order-dependent result. Producers use matching outer and inner identity hashes and one signature per identity.

Threshold verification calls VerifyEnvelope for each configured verifier and counts distinct hashes of successful verifier keys. Unknown or invalid signatures do not invalidate an otherwise sufficient threshold. A threshold greater than the number of supplied verifier entries fails before verification. Duplicate configured keys cannot provide more than one successful identity. Threshold verification.

Descriptor validation

A dirauth applies MixShape(d, e) to mix descriptors and ReplicaShape(d, e) to replica descriptors. The caller supplies e. MixShape uses it for the key window. Neither algorithm uses the descriptor’s Epoch.

MixShape(d, e):
    Require d != nil.
    Require 1 <= byte_length(d.Name) <= 32.
    Require d.LinkKey and d.IdentityKey to be nonempty.
    Require d.MixKeys[e] to be nonempty.
    For each key epoch k in d.MixKeys:
        Require e <= k < e + 3.
    Require d.Addresses to be nonempty.
    For each address list in d.Addresses:
        Require the list to be nonempty.
        Check each URL with AddressShape(URL, d.IsGatewayNode, true).
    Require that IsGatewayNode and IsServiceNode are not both true.
    If IsServiceNode is false:
        Require Kaetzchen == nil.
    Otherwise, for each capability and parameter map in Kaetzchen:
        Require a nonempty capability name and a non-nil parameter map.
        Require parameter "endpoint" to exist and to be a string.
        Require 1 <= byte_length(endpoint) <= 32.

ReplicaShape(d, e):
    Require d != nil.
    Require 1 <= byte_length(d.Name) <= 32.
    Require d.LinkKey and d.IdentityKey to be nonempty.
    Require d.EnvelopeKeys to be nonempty.
    Require each EnvelopeKeys value to be nonempty.
    Require d.Addresses to be nonempty.
    For each address list in d.Addresses:
        Require the list to be nonempty.
        Check each URL with AddressShape(URL, false, false).

AddressShape(URL, isGateway, allowOnion):
    Parse the URL with the reference URL syntax.
    If its scheme is tcp, tcp4, tcp6, ws, or quic:
        Require its host component to split into host and port.
    Else if its scheme is onion:
        Require allowOnion and isGateway.
    Else:
        Fail.

The URL syntax is that of Go net/url.Parse, and host-port splitting is that of net.SplitHostPort. This is a syntax rule. It does not require a nonempty host, a numeric nonempty port, a particular address family, or equality between the address-map key and the URL scheme. The onion branch omits host-port validation. Additional URL components are not rejected by this validator. Address validation.

MixShape permits missing or empty future key values when their epoch lies in the permitted window. It does not require a consecutive set of three keys. It does not verify d.Epoch, d.Version, key lengths, or key decoding. A service can have nil or empty Kaetzchen, and multiple capabilities can use the same endpoint. Other capability parameters and KaetzchenAdvertizedData have no additional shape validation here. ReplicaShape does not use its epoch argument, does not verify envelope-key epochs, and does not verify ReplicaID against configuration. A replica descriptor has no Version field. Mix validation, replica validation.

Live descriptor uploads

Upload validation starts after the wire session and command parser accept the command. peerID is the session’s authenticated identity hash, peerLinkKey is its authenticated link public key, and e is the command epoch. The handler uses the same sequence for mix and replica uploads.

Key Value Explanation
1. Command epoch DescriptorInvalid on failure Require e to be the current local epoch minus one, the current epoch, or the current epoch plus one.
2. Decode upload DescriptorInvalid on failure Decode SignedUpload or SignedReplicaUpload, and require its descriptor pointer to be non-nil.
3. Session identity DescriptorForbidden on failure Require H(descriptor.IdentityKey) == peerID.
4. Identity-key length DescriptorInvalid on failure Require the public-key size of the configured PKI signature scheme. This precedes key decoding.
5. Decode identity key DescriptorForbidden on failure Decode the identity key with the configured PKI signature scheme.
6. Upload signature DescriptorForbidden on failure Run signature verification on the decoded upload as specified below.
7. Session link key DescriptorForbidden on failure Require byte equality between the descriptor’s link key and peerLinkKey.
8. QUIC hash length DescriptorInvalid on failure Require the byte length of QUICKeyHash to be 0, 32, 64, or 96.
9. Current authorization DescriptorForbidden on failure Verify identity, role, and pinned name. For a replica, also verify the pinned ReplicaID.
10. Metadata policy and shape DescriptorInvalid on failure Apply the metadata policy, then run MixShape(d, e) or ReplicaShape(d, e), and the metadata conditions below.
11. Store Status from storage Return DescriptorOk, DescriptorConflict, or DescriptorInternalError according to the storage algorithm below.

For step 6, require a non-nil signature with the configured scheme’s exact signature size. Make a new upload object with Signature = nil. Retain the decoded descriptor and, for a mix upload, the decoded LoopStats. Encode this new object canonically. Run signature verification on Signature.Payload over those bytes with the decoded descriptor identity key (upload signature operations).

This operation does not verify the upload signature’s PublicKeySum256 or include it in the preimage. The decoder already discarded unknown fields. The receiver therefore verifies the canonical encoding of the decoded object, rather than the received upload bytes.

For step 9, a mix descriptor with only IsGatewayNode set uses the gateway authorization map. A descriptor with only IsServiceNode set uses the service map. A descriptor with neither flag set uses the mix map. Both flags set cause rejection. The selected map MUST contain the identity hash and the exact descriptor name. A replica MUST have an identity hash, name, and ReplicaID that match one current storage-replica entry. Authorization.

For step 10, if AcceptDescriptorMetadata is false, clear ContactInfo and SoftwareVersion before validation and storage. Otherwise, retain them. Require SoftwareVersion to contain at most 128 bytes and ContactInfo to contain at most 256 bytes. Each retained byte MUST be in the inclusive range 0x20 through 0x7e. Empty strings are valid (printable-string validation).

The implementation evaluates the core shape validation, the software-version validation, and the contact-information validation, and joins their errors. Any error gives the same wire status. The signature verification always occurs before metadata removal (metadata policy).

The handler does not compare descriptor.Epoch with e. For a mix, e selects the required mix key and its permitted window. For a replica, shape validation leaves both descriptor and envelope-key epochs unchecked. Storage uses e in both cases.

The command handlers do not require QUICKeyHash to match a QUIC session key (replica upload order). They also do not require a gateway to advertise a TCP or onion address. A QUIC-only gateway can produce a warning after acceptance (mix upload order).

Upload storage and replay

Storage serializes upload decisions for an identity hash. After metadata policy, the receiver compares the incoming descriptor with any descriptor stored for (command epoch, identity hash). Comparison uses canonical descriptor bytes, and excludes the upload signature and LoopStats.

StoreUpload(e, identity, rawUpload, decodedDescriptor):
    If a descriptor already exists at (e, identity):
        Canonically encode both descriptors.
        If either encoding fails: return DescriptorInternalError.
        If the encodings differ: return DescriptorConflict.
        Return DescriptorOk.
    If a document for e exists, or this authority's own vote for e exists:
        Return DescriptorConflict.

    Persist the original rawUpload at (e, identity).
    If persistence fails:
        Report a fatal persistence error, and return DescriptorInternalError.

    Recheck state while committing the in-memory descriptor:
        If a descriptor now exists at (e, identity): return DescriptorOk.
        If a document or this authority's own vote now exists for e:
            Return DescriptorConflict.
        Store decodedDescriptor, and notify the state worker.
    Return DescriptorOk.

An unchanged upload can therefore succeed after the authority votes or publishes, because comparison precedes the late-upload condition. The nominal upload deadline causes a warning. It is not itself a rejection condition. Persistence can succeed before a later finality comparison produces DescriptorConflict, so a conflict response does not prove the absence of a write. The live descriptor has the applied metadata policy, while the stored signed upload preserves its original bytes (storage and vote cut).

Document validation

The shared base validator operates on a parsed body and a set of verifier keys. It does not authenticate the document envelope. VerifyEnvelope includes the current-epoch expiration condition for each embedded commitment or reveal.

BaseDocument(d, verifiers):
    Require d.Version == "v1".
    Require d.GenesisEpoch != 0.
    If PriorSharedRandom is empty:
        Require d.GenesisEpoch == d.Epoch.
    Build verifierByHash from verifiers.

    For each (identity, signedCommit) in SharedRandomCommit:
        Require verifierByHash[identity] to exist.
        commit = VerifyEnvelope(verifierByHash[identity], signedCommit).
        Require len(commit) == 40.
        Require BE64(commit[0:8]) == d.Epoch.
        If SharedRandomCommit has exactly one entry:
            Require SharedRandomReveal to have zero entries.
        Otherwise:
            Require reveal count == commitment count.
            Require SharedRandomReveal[identity] to exist.
            reveal = VerifyEnvelope(verifierByHash[identity], that entry).
            Require len(reveal) == 40.
            Require BE64(reveal[0:8]) == BE64(commit[0:8]).
            Require H(reveal) == commit[8:40].

    seen = empty identity-hash set.
    For each descriptor in Topology, in layer and list order:
        Run MixShape(descriptor, d.Epoch).
        Require H(descriptor.IdentityKey) not in seen, then add it.
    For each descriptor in GatewayNodes:
        Run MixShape(descriptor, d.Epoch).
        Require descriptor.IsGatewayNode.
        Require H(descriptor.IdentityKey) not in seen, then add it.
    For each descriptor in ServiceNodes:
        Run MixShape(descriptor, d.Epoch).
        Require descriptor.IsServiceNode.
        Require H(descriptor.IdentityKey) not in seen, then add it.
    For each descriptor in StorageReplicas:
        Run ReplicaShape(descriptor, d.Epoch).

VoteDocument(d, verifiers):
    Run BaseDocument(d, verifiers).

FullDocument(d, verifiers):
    Run BaseDocument(d, verifiers).
    Require Topology to contain at least one layer.
    Require every layer to contain at least one descriptor.
    Require GatewayNodes to contain at least one descriptor.
    Require ServiceNodes to contain at least one descriptor.

If SharedRandomCommit is empty, the commitment loop does not run. The base validator then neither rejects nonempty SharedRandomReveal nor runs verification on it. The one-commit case rejects every nonempty reveal map. The two-or-more-commit case requires the same identity set and a valid matching reveal for each commitment (commitment opening). The vote handler adds its own exact count and sender conditions below (document validators).

The base validator does not require role flags to be clear for entries in Topology. It does require the appropriate flag for entries in the gateway and service lists, and the core descriptor validation rejects contradictory flags. It rejects repeated identity hashes across those three collections. It does not verify replica identity uniqueness, replica-ID uniqueness, or identity overlap with replicas. It does not consult the receiver’s node authorization tables.

This document validator omits descriptor metadata and QUIC hash lengths. It does not parse descriptor keys, compare descriptor epochs, or require a configured topology size. It also omits delay and rate ranges, floating-point finiteness, SharedRandomValue length, and geometry-hash length or equality. It does not constrain the body PKI scheme name, notice text, or the length and contents of nonempty PriorSharedRandom. Such producer rules or conditions in a later consumer are distinct from this validator (complete base-validation body).

Authority message acceptance

Authority message validation starts after wire command decoding and authority-role dispatch. Command decoding verifies the minimum command length. It takes the declared key from the fixed-size public-key field, then decodes it with the configured signature scheme. A decoding failure does not reach these handlers (command decoding).

The session role controls command dispatch (role dispatch):

  • A client session can request consensus.
  • A mix, gateway, or service session can request consensus or post a mix descriptor.
  • A replica session can only post a replica descriptor.
  • An authority session can request consensus or send Vote, Reveal, Cert, or Sig.

An unsupported command for the session role receives no command status.

V is the receiver’s current votingEpoch, and S is its current state. Message authorization first compares H(command.PublicKey) with the session identity hash, then verifies membership in the configured authority authorization map. Either failure returns that command’s NotAuthorized status. Message validation and storage are atomic with respect to the state machine.

ReceiveVote(command):
    Check authorization; otherwise return VoteNotAuthorized.
    If command.Epoch < V: return VoteTooLate.
    If command.Epoch > V: return VoteTooEarly.
    Ensure the vote and commitment maps for V exist.
    If this sender already has a vote for V: return VoteAlreadyReceived.
    If S == accept_signature: return VoteTooLate.
    VerifyEnvelope(command.PublicKey, command.Payload);
        on failure, return VoteNotSigned.
    d = ParseDocument(command.Payload);
        on failure, return VoteNotSigned.
    If d.Epoch != V: return VoteMalformed.
    Run VoteDocument(d, verifiers);
        on failure, return VoteMalformed.
    Require exactly one commitment and zero reveals;
        otherwise return VoteMalformed.
    Require the commitment map to contain this sender's identity;
        otherwise return VoteMalformed.
    Store d as this sender's vote for V.
    If S == accept_desc or S == accept_vote:
        Store the sender's commitment for V.
    Return VoteOk.

ReceiveReveal(command):
    Check authorization; otherwise return RevealNotAuthorized.
    reveal = VerifyEnvelope(command.PublicKey, command.Payload);
        on failure, return RevealNotSigned.
    If len(reveal) < 8: return RevealNotSigned.
    e = BE64(reveal[0:8]).
    If e < V: return RevealTooLate.
    If e > V: return RevealTooEarly.
    If S == accept_cert or S == accept_signature:
        If this sender already has a reveal for V:
            Return RevealAlreadyReceived.
        Return RevealTooLate.
    If no commitment exists for this sender at V: return RevealTooEarly.
    commit = VerifyEnvelope(command.PublicKey, the stored commitment);
        on failure, return RevealNotSigned.
    Require len(commit) == 40 and len(reveal) == 40,
        equal eight-byte epoch prefixes, and H(reveal) == commit[8:40];
        otherwise return RevealNotSigned.
    If this sender already has a reveal for V: return RevealAlreadyReceived.
    Store command.Payload as this sender's signed reveal for V.
    Return RevealOk.

ReceiveCert(command):
    Check authorization; otherwise return CertNotAuthorized.
    If command.Epoch < V: return CertTooLate.
    If command.Epoch > V: return CertTooEarly.
    If S == accept_signature:
        If this sender already has a certificate for V:
            Return CertAlreadyReceived.
        Return CertTooLate.
    VerifyEnvelope(command.PublicKey, command.Payload);
        on failure, return CertNotSigned.
    d = ParseDocument(command.Payload);
        on failure, return CertNotSigned.
    If d.Epoch != V: return CertNotSigned.
    If d.SharedRandomReveal is empty: return CertNotSigned.
    If this sender has no stored vote for V: return CertTooEarly.
    Run FullDocument(d, verifiers);
        on failure, return CertNotSigned.
    If this sender already has a certificate for V: return CertAlreadyReceived.
    Store d as this sender's certificate for V.
    Return CertOk.

ReceiveSig(command):
    Check authorization; otherwise return SigNotAuthorized.
    If command.Epoch < V: return SigTooLate.
    If command.Epoch > V: return SigTooEarly.
    body = VerifyEnvelope(command.PublicKey, command.Payload);
        on failure, return SigNotSigned.
    If this sender already has a signature for V: return SigAlreadyReceived.
    Decode body into a new Signature, using receiver-decoding;
        on failure, return SigInvalid.
    Store that Signature under this sender's identity for V.
    Return SigOk.

The reveal handler does not use command.Epoch for its epoch decision. It uses the signed body’s epoch prefix. It verifies the full 40-byte length only after the epoch, state, and stored-commitment conditions (vote and reveal handlers).

The signature handler has no state gate. It verifies the outer envelope signature before the duplicate test, but it does not decode a duplicate’s inner signature. For a new signature, it omits inner-signature verification, sender-hash comparison, and payload-length conditions at receipt. A valid outer envelope whose certified body is CBOR null can therefore decode to a zero-valued inner Signature and receive SigOk. Publication verifies whether collected signatures authenticate the locally constructed consensus (signature handler).

There is no general rule that limits a command to the state named after that command. For a matching epoch, the receiver can store a new valid vote in bootstrap, accept_desc, accept_vote, accept_reveal, or accept_cert. It stores the commitment only in accept_desc and accept_vote. It can store a valid certificate before accept_cert if its sender’s vote already exists (certificate handler). It can store a reveal before accept_reveal if its commitment exists.

The explicit closed-state conditions still apply. Bootstrap does not reject all messages.

When t <= P/8, the bootstrap transition sets V to N+1. When t > P/8, it sets V to N+2 and remains in bootstrap until the next epoch. Successful publication increments V. Failed publication sets it to N+2 and returns to bootstrap (publication transition). Epoch comparisons use this stored V, rather than a fresh calculation from the nominal phase deadline.

Consensus reception and restoration

Network retrieval, client deserialization, and restoration apply different validation sequences.

Key Value Explanation
Network fetch Threshold, parse, full validation, epoch The standalone client and authority background fetch first require a successful Consensus response. They run threshold verification, parse the document, run FullDocument, and then require the requested epoch, in that order.
Client Deserialize Threshold, then parse This operation does not run FullDocument or compare against a requested epoch.
Persisted consensus Threshold, parse, storage epoch Restoration verifies the threshold, parses the document, and compares its epoch with the database epoch key. It does not run FullDocument.

The network fetch operations do not compare the geometry hash in this sequence. An application that verifies its local geometry does so separately. Client fetch and deserialize, authority fetch, consensus restoration.

Descriptor restoration and node reload

At startup, the reference restores descriptor and document records for the current local epoch minus one, the current epoch, and the current epoch plus one. It creates fresh in-memory vote, commitment, reveal, certificate, and signature maps. It does not restore these round messages. It then starts in bootstrap (state initialization).

For each descriptor record, the database bucket supplies the upload epoch e, and the record key supplies the expected identity hash. The restoration sequence is:

RestoreUpload(rawUpload, e, storedIdentity):
    Decode the corresponding signed upload.
    Require a non-nil descriptor.
    Require the configured identity public-key length, and decode the key.
    Verify the signed upload over its canonical unsigned form.
    Apply the current AcceptDescriptorMetadata policy.
    Run MixShape(descriptor, e) or ReplicaShape(descriptor, e),
        and validate retained SoftwareVersion and ContactInfo.
    Require H(descriptor.IdentityKey) == storedIdentity.
    Require current identity, role, name, and, for replicas, ReplicaID authorization.
    Insert the descriptor under (e, storedIdentity).

A failed descriptor validation discards that record and allows restoration to continue. A database record key whose identity-hash length is not 32 bytes causes a panic before these descriptor validations (restoration loop). Restoration does not recheck QUICKeyHash, bind the descriptor to a live session link key, or apply the live late-upload cut. The receiver runs signature verification before metadata removal. Thus, a different AcceptDescriptorMetadata setting can change the restored in-memory metadata without a change to the stored upload (restored-upload validation).

A node reload verifies the new node counts using the running layer and minimum-node settings (node-count validation). It loads all proposed node identity keys. It then replaces the mix, gateway, service, replica, and reverse-key tables as one operation. Any failure retains the current tables (node reload).

Reload does not change the startup-only Server, Authorities, Logging, Parameters, Debug, Topology, or SphinxGeometry settings. It also does not remove accepted descriptors or existing round messages. New uploads use the new authorization tables. Descriptor selection from the vote tally verifies current authorization again. Thus an accepted descriptor can remain in memory after a reload but fail later selection (tally authorization).

Document construction

Votes, voting certificates, and consensuses share the document body format. Their inputs differ as follows. V is the stored voting epoch.

Key Value Explanation
Vote Local descriptors, configured rates and notice, zero[32] SRV Copy GenesisEpoch and prior-random history from document V-1 if present. Otherwise set GenesisEpoch to V and retain stored history. Include this authority’s signed commitment only. Leave reveals null.
Voting certificate Tallied descriptors, rates and notice, zero[32] SRV Include only locally held commitments with a matching held reveal. Both maps are present, including when empty. Sign, then run FullDocument before storing or sending it.
Consensus Tallied descriptors, rates and notice, computed SRV First require the certificate threshold, reconcile pairs, and require the surviving-pair threshold. Compute SRV and update history. Build, run FullDocument and the configured topology test, then sign. Leave commit and reveal maps null.

The builder sets Version to “v1”, Epoch to V, and GenesisEpoch to the stored genesis epoch. It copies the supplied five voted rates. LambdaG comes from local configuration under gateway rate arithmetic. It copies the configured PKISignatureScheme and geometry hash, the supplied notice, and stored prior-random history. Configured replica IDs and keys include the full authorized set in R-26-7 order. Empty optional fields follow the field reference.

Split mix descriptors by flags: gateway first, then service node, then ordinary mix. Place ordinary mixes by the configured fixed topology order. Gateway and service lists retain the supplied descriptor order. Replica descriptors retain their supplied order. The vote uses locally collected descriptors. Tallying sorts the included descriptors and replicas for certificate and consensus construction. Voting documents therefore need not share one descriptor order across different authors.

Before building the consensus, initialize history to [SRV, SRV] when history is empty or V equals GenesisEpoch. Otherwise, at V modulo 504 equal to zero, set history to [SRV, old_history[0]]. Other rounds preserve history. This update precedes tallying and final document validation. It changes stored state even if a later construction condition fails.

The tagged constructors and common builder implement these steps.

Ordered reconciliation

Reconciliation processes accepted voting certificates. It compares signed envelope bytes, not only their certified payloads. Certificate authors and cited identities use ascending 32-byte identity-hash order.

Start with empty commit and reveal maps. Do not seed them from separately received votes or reveals. For each entry, verify these conditions in order:

  1. The cited identity is an authorized authority.
  2. Its commit envelope passes certificate verification.
  3. A reveal envelope exists under the same identity.
  4. That reveal envelope passes certificate verification under that identity’s key.
  5. Both certified payloads have 40 bytes and start with BE_uint64(V).
  6. The commit’s last 32 bytes equal H of the entire reveal payload.

A failed condition excludes the certificate author and stops its entry loop. A verified pair enters reconciliation.

bad = empty set
commits = empty map
reveals = empty map

for author in sorted(certificate authors):
    if author in bad:
        continue
    certificate = certificates[author]
    for signer in sorted(certificate.commit identities):
        if signer in bad:
            continue
        if the ordered pair checks fail:
            bad.add(author)
            break
        commit = certificate.commits[signer]
        reveal = certificate.reveals[signer]
        if signer not in commits:
            commits[signer] = commit
        else if commits[signer] != commit:
            bad.add(signer)
        if signer not in reveals:
            reveals[signer] = reveal
        else if reveals[signer] != reveal:
            bad.add(signer)

for signer in bad:
    delete commits[signer]
    delete reveals[signer]

The reveal comparison still runs when the commit comparison excludes its signer. An exclusion does not remove earlier entries attributed to other identities. A certificate author excluded during its own entry loop does not automatically stop that loop. Only a failed pair condition stops it. At each certificate’s turn, skip its author if already excluded.

The resulting maps supply the surviving-pair threshold and SRV. The tagged reconciliation implements this order.

The topology draw

The shared random value seeds one deterministic generator. Every authority MUST consume that generator in the same order.

The generator uses original ChaCha20 with a 256-bit SRV key, an eight-byte zero nonce, and initial 64-bit block counter zero. It reads consecutive keystream bytes without reset. It MUST stop before the counter wraps. Every draw from the topology generator MUST use this integer algorithm.

  • One draw, called Int63, reads 8 bytes of keystream and clears the top bit of the last byte. It then reads those 8 bytes as a little-endian unsigned 64-bit integer.
  • Int31 is Int63 shifted right by 32 and read as a signed 32-bit integer.
  • Intn(n), for n of at least 1 and at most 2^31-1, is as follows. When n is a power of two, it is Int31 masked with n-1. In every other case the party draws Int31 again while the value is above 2^31-1-(2^31 mod n), and then takes the value modulo n. A larger n is outside this definition. n is the size of the input list, which may include all mixes.
  • The permutation of n items MUST iterate i from 0 to n-1 inclusive. At each step it draws j = Intn(i+1), then sets m[i] to m[j], then sets m[j] to i.

The iteration at i = 0 MUST NOT be skipped. It assigns m[0] = 0 and consumes one draw. Skipping it changes all later draws.

Layer assignment

The assignment algorithm places accepted mixes into layers. A fixed topology takes precedence over automatic placement.

R-18-5 uses the following assignment algorithms. Sort input mix descriptors by identity-key hash (R-16-5). The prior consensus is the document for V-1. An older retained document does not select this algorithm. Layer indexes start at zero. Each append preserves the layer’s order.

With no prior consensus and no configured fixed topology, an authority MUST:

  1. Start a fresh generator keyed by the SRV and draw Perm(number of input mixes).
  2. Read mixes in that permutation order. Append each to successive layers, wrapping from the last layer back to layer zero.

With a prior consensus and no configured fixed topology, an authority MUST:

  1. Start one fresh generator keyed by the SRV. Create Layers empty output lists and a pending set containing every input mix, keyed by identity hash.
  2. Set target = floor(number of input mixes / Layers). Set priorLayers to the smaller of the prior layer count and Layers.
  3. Visit prior layers 0 through priorLayers-1 in order. For each layer, draw Perm of its full prior list, including absent nodes. Visit entries in that order. If the output layer contains target nodes, stop visiting entries in that layer. Otherwise, append a prior entry’s current descriptor if its identity remains pending. Remove each appended identity from the pending set.
  4. Sort the remaining descriptors by identity hash. Draw Perm of this pending list with the same generator. Keep one cursor into that permutation for the next steps.
  5. Visit output layers 0 through priorLayers-1. Fill each to target by appending the next pending descriptor and advancing the cursor.
  6. Starting at layer zero, append each remaining pending descriptor to successive layers, wrapping to layer zero after the last layer. Preserve descriptors already appended in steps 3 and 5.

An empty permutation consumes no draws. Draw each prior-layer permutation in full, even when target is zero or the algorithm uses few entries. Then draw the pending permutation from the same generator.

With a configured fixed topology, an authority MUST visit configured layers and their identity lists in configuration order. It appends the current descriptor for each included identity and skips identities with no included descriptor. It uses no random draws for this assignment. This case takes precedence over prior-consensus placement.

Gateway rate arithmetic

LambdaG uses the configured largest layer size n and configured gateway count g. It does not use the number of surviving descriptors. If g is zero or n is at most one, add configured LambdaP and LambdaL. Otherwise, evaluate (((n * n) * Log(n)) * Mu) / g from left to right. Convert n and g to binary64 first. Mu, LambdaP, and LambdaL are binary64 configuration values.

Round every arithmetic operation to binary64 with nearest-even rounding. Do not fuse multiply-add operations. The recorded bit patterns come from separate multiply and add operations. Go can emit fused multiply-add on arm64. On such a platform, evaluate each product and sum separately. Parentheses fix evaluation order. The logarithm constants are binary64 bit patterns.

Key Value Explanation
SQRT_HALF 3fe6a09e667f3bcd Reduction boundary, sqrt(2)/2.
LN2_HI 3fe62e42fee00000 High part of ln(2).
LN2_LO 3dea39ef35793c76 Low part of ln(2).
C1 3fe5555555555593 Polynomial coefficient.
C2 3fd999999997fa04 Polynomial coefficient.
C3 3fd2492494229359 Polynomial coefficient.
C4 3fcc71c51d8e78af Polynomial coefficient.
C5 3fc7466496cb03de Polynomial coefficient.
C6 3fc39a09d078c69f Polynomial coefficient.
C7 3fc2f112df3e5244 Polynomial coefficient.

For positive integer n of at least two, define Log as follows. Decompose its binary64 value exactly as fraction * 2^exponent, with 0.5 <= fraction < 1.

if fraction < SQRT_HALF:
    fraction = fraction * 2
    exponent = exponent - 1
f = fraction - 1
k = binary64(exponent)
s = f / (2 + f)
z = s * s
w = z * z
a = z * (C1 + w * (C3 + w * (C5 + w * C7)))
b = w * (C2 + w * (C4 + w * C6))
r = a + b
q = (0.5 * f) * f
Log(n) = (k * LN2_HI) - ((q - ((s * (q + r)) + (k * LN2_LO))) - f)

The Go logarithm uses this reduction and polynomial. For n = 3, Log(n) has bits 3ff193ea7aad030a. The tagged rate calculation uses these configured inputs.

Appendix G. References

Network operation depends on the packet, transport, client, storage, and certificate protocols.

Key Value Explanation
KATZMIXNET Mix network design Routing, mixing, and cover traffic.
KATZKEMSPHINX KEM Sphinx Packet headers using a KEM.
KATZTHINCLIENT Thin client The local library and daemon interface.
KATZDECOY Mix decoy statistics The design behind LoopStats.
KATZREPLAY Replay detection Epoch keys and replay caches.
KATZGLOSSARY Glossary Terms shared across the specifications.
KATZPKI Original PKI specification, KATZMIXPKI citation The original design, which this protocol continues.
KATZWIRE Katzenpost Wire Protocol Specification. /docs/specs/wireprotocol The PQ Noise link this protocol runs over.
KATZSPHINX Sphinx Packet Format Specification. /docs/specs/sphinx The packet format the Sphinx keys and the geometry of byte identity belong to.
KATZPIGEON Pigeonhole Specification. /docs/specs/pigeonhole The storage system the courier service and the storage replicas serve.
KATZCERT Certificate Format Specification. /docs/specs/certificate The certificate envelope.
RFC2119 Bradner, S. Key words for use in RFCs to Indicate Requirement Levels. RFC 2119, March 1997.
RFC7049 Bormann, C., Hoffman, P. Concise Binary Object Representation (CBOR). RFC 7049, October 2013. Section 3.9 gives the canonical map key order this protocol uses.
RFC8949 Bormann, C., Hoffman, P. Concise Binary Object Representation (CBOR). RFC 8949, December 2020.
RFC9000 Iyengar, J., Thomson, M. QUIC, a UDP-Based Multiplexed and Secure Transport. RFC 9000, May 2021.
RFC5869 Krawczyk, H., Eronen, P. HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869, May 2010.
PQNOISE Angel, Y., Dowling, B., Hulsing, A., Schwabe, P., Weber, F. Post Quantum Noise. The PqXX pattern.
SPHINCSPLUS Bernstein, P., Hulsing, A., Kolbl, S., Niederhagen, R., Rijneveld, J., Schwabe, P. SPHINCS+. The signature half of Ed25519 Sphincs+.
TORSRV Tor Shared Random Subsystem Specification. The commit-and-reveal design of shared random value and topology follows it.
BLAKE2 Aumasson, J., Neves, S., Wilcox-O’Hearn, Z., Winnerlein, C. BLAKE2. RFC 7693 H in constants and notation.

Appendix H. Conformance and vectors

Conformance testing MUST cover:

  1. A named test passes for every protocol requirement.
  2. A mixed network with the reference implementation produces matching, threshold-signed consensuses under successful-round conditions. This test includes an epoch divisible by 504.
epoch   245448 (245448 mod 504 == 0; 2026-10-01 00:00 UTC)
RN      0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20
REVEAL  000000000003bec8441edc56cebc8e285d02267aa650819f15add7b06ef9b41b2690128dce655924
COMMIT  000000000003bec8e95138c6f44dfe875fa3daa92364a1ece9b315f25a7c7777f6f2064745a254c0

KEY_A   aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
REV_A   000000000003bec8f15f1ea681af55bcdc2aff6ae09ca8ca122c84c4770d9571293f73e5d1e032f3
KEY_B   bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
REV_B   000000000003bec8afa49d096346b9834fdc42890e35cae35b3ff4546094e747aa5468fe7a961334
PREV    (absent: 32 zero bytes)
order   A then B (descending by reveal bytes)
SRV     702b1bb93f15155761dc058d56edd07d2a5127478566f520366fb4b490a5e8ae

BODY contains the Certified bytes of a document’s envelope, encoded under R-6-3. This encoding fixture has one empty layer, no nodes, no commits, no reveals, and short byte strings. It is not a well-formed document.

Epoch 245448, GenesisEpoch 245439, Mu 0.001, LambdaP 0.5, LambdaL 0.0005,
LambdaM 0.0005, LambdaG 0.25, LambdaR 0.02, Topology [nil], GatewayNodes [],
ServiceNodes [], StorageReplicas [], SharedRandomValue 00000000,
PriorSharedRandom [01, 02], SphinxGeometryHash aa, Version "v1",
PKISignatureScheme "Ed25519", every other field empty or nil

BODY
b3624d75fb3f50624dd2f1a9fc6545706f63681a0003bec8674c616d62646147
f93400674c616d6264614cfb3f40624dd2f1a9fc674c616d6264614dfb3f4062
4dd2f1a9fc674c616d62646150f93800674c616d62646152fb3f947ae147ae14
7b6756657273696f6e62763168546f706f6c6f677981f66c476174657761794e
6f646573806c47656e6573697345706f63681a0003bebf6c536572766963654e
6f646573806f53746f726167655265706c6963617380715072696f7253686172
656452616e646f6d82410141027153686172656452616e646f6d56616c756544
0000000072504b495369676e6174757265536368656d65674564323535313972
53686172656452616e646f6d436f6d6d6974f67253686172656452616e646f6d
52657665616cf672537068696e7847656f6d657472794861736841aa

PARAMKEY groups the example network’s five voted rates under R-16-3.

Mu 0.001, LambdaP 0.001, LambdaL 0.0005, LambdaM 0.0005, LambdaR 0.02

PARAMKEY (81 bytes)
a5624d75fb3f50624dd2f1a9fc674c616d6264614cfb3f40624dd2f1a9fc674c
616d6264614dfb3f40624dd2f1a9fc674c616d62646150fb3f50624dd2f1a9fc
674c616d62646152fb3f947ae147ae147b

GEOMETRY_CBOR and SPHINXGEOMETRYHASH encode the example network’s Sphinx geometry (R-26-2). The list gives the 14 fields in struct order. CBOR uses the length-first key order of R-6-3.

NrHops 5, PacketLength 3082, HeaderLength 476, RoutingInfoLength 410,
PerHopRoutingInfoLength 82, SURBLength 572, SphinxPlaintextHeaderLength 2,
PayloadTagLength 32, ForwardPayloadLength 2574, UserForwardPayloadLength 2000,
NextNodeHopLength 65, SPRPKeyMaterialLength 64, NIKEName "x25519", KEMName ""

GEOMETRY_CBOR (273 bytes)
ae664e72486f707305674b454d4e616d6560684e494b454e616d656678323535
31396a535552424c656e67746819023c6c4865616465724c656e6774681901dc
6c5061636b65744c656e677468190c0a705061796c6f61645461674c656e6774
681820714e6578744e6f6465486f704c656e677468184171526f7574696e6749
6e666f4c656e67746819019a74466f72776172645061796c6f61644c656e6774
68190a0e75535052504b65794d6174657269616c4c656e677468184077506572
486f70526f7574696e67496e666f4c656e6774681852781855736572466f7277
6172645061796c6f61644c656e6774681907d0781b537068696e78506c61696e
746578744865616465724c656e67746802

SPHINXGEOMETRYHASH (32 bytes)
ed57ca3bc2165a0483e0d16d97511e15f318ed5cedfc3e7388c85ee8a2c0fb47

The message ceiling is 264512 bytes for these synthetic inputs. The synthetic key sizes differ from the Namenlos keys.

sign 32, sig 64, link 1216, sphinx 32, envelope 1024
nodes 16, replicas 4, authorities 6

mix      = 32 + 1216 + 3 * 32 + 4096            = 5440
replica  = 32 + 1216 + 3 * 1024 + 4096          = 8416
unsigned = 8192 + 16 * 5440 + 4 * 8416          = 128896
signed   = 128896 + 6 * (64 + 96)
                  + 2 * 6 * (64 + 96 + 40)      = 132256
ceiling  = 2 * 132256 = 264512, above the 262144 floor

CERT_CBOR holds REVEAL in an envelope with an artificial expiration and a short signature. It tests encoding only. Signature verification fails.

Version 0, Expiration 245453, KeyType "Ed25519", Certified = REVEAL above,
one signature keyed by 32 bytes of 0xaa, with Payload 01020304

CERT_CBOR (204 bytes)
a5674b65795479706567456432353531396756657273696f6e00694365727469
666965645828000000000003bec8441edc56cebc8e285d02267aa650819f15ad
d7b06ef9b41b2690128dce6559246a45787069726174696f6e1a0003becd6a53
69676e617475726573a15820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaa2675061796c6f616444010203046f5075626c69
634b657953756d3235365820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaa

SIGNEDUPLOAD_CBOR uses the upload encoding. Its MixDescriptor byte string starts with 0x58cb, which encodes a length of 203 bytes. The contents are the descriptor’s own canonical CBOR. Signature and LoopStats are inline maps.

Signature {PublicKeySum256 32 bytes of 0xbb, Payload 0506}
MixDescriptor {Name "mix1", Epoch 245448, IdentityKey bb, LinkKey cc,
  MixKeys {245448: dd}, Addresses {"tcp": ["tcp://127.0.0.1:1"]},
  every other field empty, nil, or false}
LoopStats {MixIdentityHash 32 bytes of 0xbb, Epoch 245448,
  SegmentRatios {01 then 63 zero bytes: 0.5}}

SIGNEDUPLOAD_CBOR (448 bytes)
a3694c6f6f705374617473a36545706f63681a0003bec86d5365676d656e7452
6174696f73a15840010000000000000000000000000000000000000000000000
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000f938006f4d69784964656e74697479486173685820bbbbbb
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb695369
676e6174757265a2675061796c6f61644205066f5075626c69634b657953756d
3235365820bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
bbbbbbbbbb6d4d697844657363726970746f7258cbad644e616d65646d697831
6545706f63681a0003bec8674c696e6b4b657941cc674d69784b657973a11a00
03bec841dd6756657273696f6e6069416464726573736573a163746370817174
63703a2f2f3132372e302e302e313a31694b6165747a6368656ef66a4c6f6164
576569676874006b4964656e746974794b657941bb6d4973476174657761794e
6f6465f46d4973536572766963654e6f6465f47241757468656e746963617469
6f6e5479706560774b6165747a6368656e416476657274697a656444617461f6

Automatic topology assignment starts with this SRV, no prior consensus, six mixes, and Layers 3. The authority permutes all six mixes and assigns them to successive layers, under the layer assignment algorithm. The mix numbers 0 through 5 represent ascending identity-key hash order (R-16-5).

SRV     702b1bb93f15155761dc058d56edd07d2a5127478566f520366fb4b490a5e8ae
Perm(6) = [1 5 2 4 3 0]

layer 0 = mix 1, mix 4
layer 1 = mix 5, mix 3
layer 2 = mix 2, mix 0

Direct consensus reply

A successful direct fetch from the Namenlos dirauth annares returns a consensus command. Let B be the byte length of its certificate envelope.

The Namenlos authority configuration sets the address and cryptographic schemes. The consensus command encoder adds the status and command header. The session encoder adds the two encrypted records after the PQ Noise handshake. TCP segments carry slices of the encrypted stream inside IPv4 datagrams. Their boundaries need not align with directory commands. IPv4 datagrams can fragment in transit. Directory commands travel directly between peers, outside Sphinx packets. The gateway consensus commands use different framing.


  1. Reference implementation: Katzenpost v0.0.107. ↩︎

  2. Namenlos started in 2022, as recorded in the 37C3 talk description. ↩︎

14 -

For publication of epoch keys and their retention across epoch boundaries, see directory clocks and retention. The directory security considerations also describe key rotation and node removal.

Sphinx packet replay detection

David Stainton

Abstract

This document defines the replay detection for any protocol that uses the Sphinx cryptographic packet format. This document is meant to serve as an implementation guide and to document the existing replay protection for deployed mix networks.


Terminology

The following terms are used in this specification.

epoch
A fixed-time interval with a current default value of 20 minutes. A new PKI document containing public key material is published for each epoch and is valid only for that epoch. For more information, see Sphinx mix and provider key rotation.

group
A finite set of elements and a binary operation that satisfy the properties of closure, associativity, invertability, and the presence of an identity element.

group element
An individual element of a group.

group generator
A group element capable of generating any other element of a group, via repeated applications of the generator and the group operation.

header
The packet header consisting of several components which convey the information necessary to verify packet integrity and to correctly process the packet.

packet
A Sphinx packet, of fixed length for each class of traffic, carrying a message payload and metadata for routing. Packets are routed anonymously through the mixnet and cryptographically transformed at each hop.

payload
The fixed-length portion of a packet containing an encrypted message or part of a message, to be delivered anonymously.

SEDA
Staged Event Driven Architecture. 1. A highly parallelizable computation model. 2. A computational pipeline composed of multiple stages connected by queues utilizing active queue-management algorithms that can evict items from a queue based on dwell time or other criteria where each stage is a thread pool. 3. The only correct way to efficiently implement a software based router on general purpose computing hardware.

Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119.

Introduction

The Sphinx cryptographic packet format is a compact and provably secure design introduced by George Danezis and Ian Goldberg in SPHINX09. Although it supports replay detection, the exact mechanism of replay detection is neither described in SPHINX09 nor is it described in our SPHINXSPEC. Therefore we detail here how to efficiently detect Sphinx packet replay attacks.

Sphinx cryptographic primitives

This specification borrows the following cryptographic primitive constants from our SPHINXSPEC:

  • H(M) - A cryptographic hash function which takes a byte array M to produce a digest consisting of a HASH_LENGTH byte array. H(M) MUST be pre-image and collision resistant.

  • EXP(X, Y) - An exponentiation function which takes the GROUP_ELEMENT_LENGTH byte array group elements X and Y, and returns X ^^ Y as a GROUP_ELEMENT_LENGTH byte array.

Let G denote the generator of the group, and EXP_KEYGEN() return a GROUP_ELEMENT_LENGTH byte array group element usable as a private key.

The group defined by G and EXP(X, Y) MUST satisfy the decisional Diffie-Hellman assumption.

Sphinx parameter constants

  • HASH_LENGTH - 32 bytes. Katzenpost currently uses SHA-512/256. RFC6234

  • GROUP_ELEMENT_LENGTH - 32 bytes. Katzenpost currently uses X25519. RFC7748

System overview

Mixnets as currently deployed have two modes of operation:

  • Sphinx routing keys and replay caches are persisted to disk.

  • Sphinx routing keys and replay caches are persisted to memory.

These two modes of operation fundamentally represent a tradeoff between mix server availability and notional compulsion attack resistance. Choosing a mode is the mix operator’s decision to make since the security and availability of the mix servers are affected. Since mix networks are vulnerable to various types of compulsion attack (see Compulsion Threat Considerations in the Sphinx cryptographic packet format specification) there is some advantage to not persisting the Sphinx routing keys to disk. The mix server operator can simply power-off the servers before seizure rather than physically destroying disks in order to prevent capture of the Sphinx routing keys. An argument can be made for using full disk encryption, but this may not be practical for servers hosted in remote locations.

On the other hand, persisting Sphinx routing keys and replay caches to disk is useful because it allows mix operators to shut down their server for maintenance purposes without loosing the Sphinx routing keys and replay caches. This means that as soon as the maintenance operation is completed, the server is able to rejoin the network. Our current PKI system, described in KATZMIXPKI, does not provide a mechanism to notify directory authorities of outages or maintenance periods. Consequently if the Sphinx routing keys are lost, a mix server outage occurs until the next epoch.

Both modes of operation completely prevent replay attacks after a system restart. In the case of disk persistence, replay attacks are prevented because all packets traversing the server have their replay tags persisted to disk cache. This cache is therefore once again used to prevent replays after a system restart. In the case of memory persistence, replays are prevented upon restart because the Sphinx routing keys are destroyed and therefore the mix will not participate in the network at all until at least the next epoch rotation. However, availability of the mix server may require two epoch rotations because (in accordance with KATZMIXPKI) the servers publish future epoch keys so that Sphinx packets flowing through the network can seamlessly straddle epoch boundaries.

Sphinx packet replay cache

Sphinx replay tag composition

The following excerpt from our SPHINXSPEC shows how the replay tag is calculated.

hdr = sphinx_packet.header
shared_secret = EXP( hdr.group_element, private_routing_key )
replay_tag = H( shared_secret )

However, this tag is not utilized in replay detection until the rest of the Sphinx packet is fully processed and its header is MAC-verified as described in SPHINXSPEC.

Sphinx replay tag caching

Although it would be sufficient to check a key-value store or hashmap to detect duplicate replay tags, we additionally employ a bloom filter to increase performance. Sphinx keys must periodically be rotated and destroyed to mitigate compulsion attacks, and our replay caches must likewise be rotated. This kind of key erasure scheme limits the time window during which an adversary can perform a compulsion attack. See our PKI specification KATZMIXPKI for more details regarding epoch key rotation and the grace period before and after the epoch boundary.

We tune our bloom filter for line-speed; that is to say, the bloom filter for a given replay cache is tuned for the maximum number of Sphinx packets that can be sent on the wire during the epoch duration of the Sphinx routing key. The tuning must take into account the size of the Sphinx packets as well as the maximum line speed of the network interface. This is a conservative tuning heuristic given that there must be more than this maximum number of Sphinx packets in order for there to be duplicate packets.

Our bloom-filter-with-hashmap replay detection cache looks like this:

FigureΒ 1.Β Replay cache

Replay cache

This diagram does not express the full complexity of the replay caching system. In particular, it does not describe how entries are entered into the bloom filter and hashmap. Upon either bloom filter mismatch or hashmap mismatch, both data structures must be locked and the replay tag inserted into each.

For the disk persistence mode of operation, the hashmap can simply be replaced with an efficient key-value store. Persistent stores may use a write-back cache and other techniques for efficiency.

Epoch boundaries

Since mix servers publish future epoch keys, our replay detection forms a special kind of double-bloom-filter system. During the epoch grace period, servers perform a trial decryption of Sphinx packets. The replay cache used is the one associated with the Sphinx routing key that was successfully used to decrypt (unwrap transform) the Sphinx packet. This is not a double-bloom filter in the normal sense of the term since each bloom filter used is distinct and associated with its own cache. Furthermore, replay tags are only inserted into one cache and one bloom filter.

Cost of checking replays

The cost of checking a replay tag from a single replay cache is the sum of the following operations:

  1. Sphinx packet unwrap operation

  2. Bloom filter lookup

  3. Hashmap or cache lookup

These operations are roughly O(1) in complexity. However, Sphinx packets processed near epoch boundaries will not be constant time due to trial decryption with two Sphinx routing keys as mentioned above in the section called β€œEpoch boundaries”.

Concurrent processing of Sphinx packet replay tags

The best way to implement a software-based router is with a SEDA computational pipeline. We therefore need a mechanism to allow multiple threads to reference our rotating Sphinx keys and the associated replay caches. Here we shall describe how the mix server uses a shadow memory system such that the individual worker threads always have a reference to the current set of candidate mix keys and associated replay caches.

PKI updates

The mix server periodically updates its knowledge of the network by downloading a new consensus document as described in KATZMIXPKI. The individual threads in the cryptoworker thread pool that process Sphinx packets make use of a MixKey data structure consisting of the following:

  • A Sphinx routing key material (public and private X25519 keys)

  • A replay cache

  • A reference counter

Each of these cryptoworker threads has its own hashmap associating epochs with a reference to the MixKey. The mix server PKI thread maintains a single hashmap which associates the epochs with the corresponding MixKey. We refer to this hashmap as MixKeys. After a new MixKey is added to MixKeys, a reshadow operation is performed for each cryptoworker thread. The reshadow operation performs two tasks:

  • Removes entries from each cryptoworker thread’s hashmap that are no longer present in MixKeys and decrements the MixKey reference counter.

  • Adds entries present in MixKeys but not present in the thread’s hashmap and increments the MixKey reference counter.

Once a given MixKey reference counter is decremented to zero, the MixKey and its associated on-disk data are purged.

Although we do not discuss synchronization primitives, it should be obvious that updating the replay cache should likely make use of a mutex or similar primitive to avoid data races between cryptoworker threads.

References

COMPULS05

Danezis, G., Clulow, J., β€œCompulsion Resistant Anonymous Communications”, Proceedings of Information Hiding Workshop, June 2005, https://www.freehaven.net/anonbib/cache/ih05-danezisclulow.pdf.

KATZMIXNET

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/mixnet.pdf.

KATZMIXPKI

Angel, Y., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Public Key Infrastructure Specification”, December 2017, https://katzenpost.network/docs/specs/pdf/pki.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC6234

Eastlake 3rd, D. and T. Hansen, β€œUS Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF)”, RFC 6234, DOI 10.17487/RFC6234, May 2011, https://www.rfc-editor.org/info/rfc6234.

RFC7748

Langley, A., Hamburg, M., and S. Turner, β€œElliptic Curves for Security”, RFC 7748, January 2016, https://www.rfc-editor.org/info/rfc7748.

SEDA

Welsh, M., Culler, D., Brewer, E., β€œSEDA: An Architecture for Well-Conditioned, Scalable Internet Services”, 2001, ACM Symposium on Operating Systems Principles, http://www.sosp.org/2001/papers/welsh.pdf.

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

15 -

Directory authorities publish Sphinx public keys and a hash of the packet geometry. See descriptors and geometry hashing in the directory authority specification for the v0.0.107 encoding and validation rules.

Sphinx cryptographic packet format specification

Yawning Angel

George Danezis

Claudia Diaz

Ania Piotrowska

David Stainton

Abstract

This document defines the Sphinx cryptographic packet format for decryption mix networks, and provides a parameterization based around generic cryptographic primitives types. This document does not introduce any new crypto, but is meant to serve as an implementation guide.


Terminology

The following terms are used in this specification.

group element
An individual element of a group.

group generator
A group element capable of generating any other element of a group, via repeated applications of the generator and the group operation.

header
The packet header consisting of several components which convey the information necessary to verify packet integrity and to correctly process the packet.

message
A variable-length sequence of octets sent anonymously through the network. Short messages are sent in a single packet; long messages are fragmented across multiple packets.

payload
The fixed-length portion of a packet containing an encrypted message or part of a message, to be delivered anonymously.

packet
A Sphinx packet, of fixed length for each class of traffic, carrying a message payload and metadata for routing. Packets are routed anonymously through the mixnet and cryptographically transformed at each hop.

Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in RFC2119.

The C style Presentation Language, as described in Β§4 of RFC5246, is used to represent data structures, except for cryptographic attributes, which are specified as opaque byte vectors.

  • x | y denotes the concatenation of x and y.

  • x ^ y denotes the bitwise XOR of x and y.

  • byte an 8-bit octet.

  • x[a:b] denotes the sub-vector of x where a/b denote the start/end byte indexes (inclusive-exclusive); a/b may be omitted to signify the start/end of the vector x respectively.

  • x[y] denotes the yth element of list x.

  • x.len denotes the length of list x.

  • ZEROBYTES(N) denotes N bytes of 0x00.

  • RNG(N) denotes N bytes of cryptographic random data.

  • LEN(N) denotes the length in bytes of N.

  • CONSTANT_TIME_CMP(x, y) denotes a constant time comparison between the byte vectors x and y, returning true iff x and y are equal.

Introduction

The Sphinx cryptographic packet format is a compact and provably secure design introduced by George Danezis and Ian Goldberg SPHINX09. It supports a full set of security features: indistinguishable replies, hiding the path length and relay position, detection of tagging attacks and replay attacks, as well as providing unlinkability for each leg of the packet’s journey over the network.

Cryptographic primitives

This specification uses the following cryptographic primitives as the foundational building blocks for Sphinx:

  • H(M) – A cryptographic hash function which takes an octet array M to produce a digest consisting of a HASH_LENGTH byte octet array. H(M) MUST be pre-image and collision resistant.

  • MAC(K, M) – A cryptographic message authentication code function which takes a M_KEY_LENGTH byte octet array key K and arbitrary length octet array message M to produce an authentication tag consisting of a MAC_LENGTH byte octet array.

  • KDF(SALT, IKM) – A key derivation function which takes an arbitrary length octet array salt SALT and an arbitrary length octet array initial key IKM, to produce an octet array of arbitrary length.

  • S(K, IV) – A pseudo-random generator (stream cipher) which takes a S_KEY_LENGTH byte octet array key K and a S_IV_LENGTH byte octet array initialization vector IV to produce an octet array key stream of arbitrary length.

  • SPRP_Encrypt(K, M)/SPRP_Decrypt(K, M) – A strong pseudo-random permutation (SPRP) which takes a SPRP_KEY_LENGTH byte octet array key K and arbitrary length message M, and produces the encrypted ciphertext or decrypted plaintext respectively.

    When used with the default payload authentication mechanism, the SPRP MUST be “fragile” in that any amount of modifications to M results in a large number of unpredictable changes across the whole message upon a SPRP_Encrypt() or SPRP_Decrypt() operation.

  • EXP(X, Y) – An exponentiation function which takes the GROUP_ELEMENT_LENGTH byte octet array group elements X and Y, and returns X ^^ Y as a GROUP_ELEMENT_LENGTH byte octet array.

    Let G denote the generator of the group, and EXP_KEYGEN() return a GROUP_ELEMENT_LENGTH byte octet array group element usable as private key.

    The group defined by G and EXP(X, Y) MUST satisfy the Decision Diffie-Hellman problem.

  • EXP_KEYGEN() – Returns a new “suitable” private key for EXP().

Sphinx key derivation function

Sphinx packet creation and processing uses a common Key Derivation Function (KDF) to derive the required MAC and symmetric cryptographic keys from a per-hop shared secret.

The output of the KDF is partitioned according to the following structure:

struct {
opaque header_mac[M_KEY_LENGTH];
opaque header_encryption[S_KEY_LENGTH];
opaque header_encryption_iv[S_IV_LENGTH];
opaque payload_encryption[SPRP_KEY_LENGTH]
opaque blinding_factor[GROUP_ELEMENT_LENGTH];
} SphinxPacketKeys;

Sphinx_KDF( info, shared_secret ) -> packet_keys

Inputs:

  • info – The optional context and application specific information.

  • shared_secret – The per-hop shared secret derived from the Diffie-Hellman key exchange.

Outputs:

  • packet_keys The SphinxPacketKeys required to handle packet creation or processing.

The output packet_keys is calculated as follows:

kdf_out = KDF( info, shared_secret )
packet_keys = kdf_out[:LEN( SphinxPacketKeys )]

Sphinx packet parameters

Sphinx parameter constants

The Sphinx packet Format is parameterized by the implementation based on the application and security requirements.

  • AD_LENGTH – The constant amount of per-packet unencrypted additional data in bytes.

  • PAYLOAD_TAG_LENGTH – The length of the message payload authentication tag in bytes. This SHOULD be set to at least 16 bytes (128 bits).

  • PER_HOP_RI_LENGTH – The length of the the section called β€œPer-hop routing information” in bytes.

  • NODE_ID_LENGTH – The node identifier length in bytes.

  • RECIPIENT_ID_LENGTH – The recipient identifier length in bytes.

  • SURB_ID_LENGTH – The single use reply block identifier length in bytes.

  • MAX_HOPS – The maximum number of hops a packet can traverse.

  • PAYLOAD_LENGTH – The per-packet message payload length in bytes, including a PAYLOAD_TAG_LENGTH byte authentication tag.

  • KDF_INFO – A constant opaque byte vector used as the info parameter to the KDF for the purpose of domain separation.

Sphinx packet geometry

The Sphinx packet geometry is derived from the the section called β€œSphinx parameter constants”. These are all derived parameters, and are primarily of interest to implementers.

  • ROUTING_INFO_LENGTH – The total length of the “routing information” Sphinx packet header component in bytes:
ROUTING_INFO_LENGTH = PER_HOP_RI_LENGTH * MAX_HOPS
  • HEADER_LENGTH – The length of the Sphinx packet header in bytes:
HEADER_LENGTH = AD_LENGTH + GROUP_ELEMENT_LENGTH + ROUTING_INFO_LENGTH + MAC_LENGTH
  • PACKET_LENGTH – The length of the Sphinx packet in bytes:
PACKET_LENGTH = HEADER_LENGTH + PAYLOAD_LENGTH

The Sphinx cryptographic packet Structure

Each Sphinx packet consists of two parts: the Sphinx packet header and the Sphinx packet payload:

struct {
opaque header[HEADER_LENGTH];
opaque payload[PAYLOAD_LENGTH];
} SphinxPacket;
  • header – The packet header consists of several components, which convey the information necessary to verify packet integrity and correctly process the packet.

  • payload – The application message data.

Sphinx packet header

The Sphinx packet header refers to the block of data immediately preceding the Sphinx packet payload in a Sphinx packet.

The structure of the Sphinx packet header is defined as follows:

struct {
opaque additional_data[AD_LENGTH]; /* Unencrypted. */
opaque group_element[GROUP_ELEMENT_LENGTH];
opaque routing_information[ROUTING_INFO_LENGTH];
opaque MAC[MAC_LENGTH];
} SphinxHeader;
  • additional_data – Unencrypted per-packet additional data (AD) that is visible to every hop. The AD is authenticated on a per-hop basis.

    As the additional_data is sent in the clear and traverses the network unaltered, implementations MUST take care to ensure that the field cannot be used to track individual packets.

  • group_element – An element of the cyclic group, used to derive the per-hop key material required to authenticate and process the rest of the SphinxHeader and decrypt a single layer of the Sphinx packet payload encryption.

  • routing_information – A vector of per-hop routing information, encrypted and authenticated in a nested manner. Each element of the vector consists of a series of routing commands, specifying all of the information required to process the packet.

    The precise encoding format is specified in the section called β€œPer-hop routing information”.

  • MAC – A message authentication code tag covering the additional_data, group_element, and routing_information.

Per-hop routing information

The routing information component of the Sphinx packet header contains a vector of per-hop routing information. When processing a packet, the per-hop processing is set up such that the first element in the vector contains the routing commands for the current hop.

The structure of the routing information is as follows:

struct {
RoutingCommand routing_commands<1..2^8-1>; /* PER_HOP_RI_LENGTH bytes */
opaque encrypted_routing_commands[ROUTING_INFO_LENGTH - PER_HOP_RI_LENGTH];
} RoutingInformation;

The structure of a single RoutingCommand is as follows:

struct {
RoutingCommandType command;
select (RoutingCommandType) {
case null:               NullCommand;
case next_node_hop:      NextNodeHopCommand;
case recipient:          RecipientCommand;
case surb_reply:         SURBReplyCommand;
};
} RoutingCommand;

The following routing commands are currently defined:

enum {
null(0),
next_node_hop(1),
recipient(2),
surb_reply(3),

/* Routing commands between 0 and 0x7f are reserved. */

(255)
} RoutingCommandType;

The NullCommand structure is as follows:

struct {
opaque padding<0..PER_HOP_RI_LENGTH-1>;
} NullCommand;

The NextNodeHopCommand structure is as follows:

struct {
opaque next_hop[NODE_ID_LENGTH];
opaque MAC[MAC_LENGTH];
} NextNodeHopCommand;

The RecipientCommand structure is as follows:

struct {
opaque recipient[RECIPEINT_ID_LENGTH];
} RecipientCommand;

The SURBReplyCommand structure is as follows:

struct {
opaque id[SURB_ID_LENGTH];
} SURBReplyCommand;

While the NullCommand padding field is specified as opaque, implementations SHOULD zero fill the padding. The choice of 0x00 as the terminal NullCommand is deliberate to ease implementation, as ZEROBYTES(N) produces a valid NullCommandRoutingCommand, resulting in β€œappending zero filled padding” producing valid output.

Implementations MUST pad the RoutingCommands vector so that it is exactly PER_HOP_RI_LENGTH bytes, by appending a terminal NullCommand if necessary.

Every non-terminal hop’s RoutingCommands MUST include a NextNodeHopCommand.

Sphinx packet payload

The Sphinx packet payload refers to the block of data immediately following the Sphinx packet header in a Sphinx packet.

For most purposes the structure of the Sphinx packet payload can be treated as a single contiguous byte vector of opaque data.

Upon packet creation, the payload is repeatedly encrypted (unless it is a SURB reply as described in the section called β€œSingle-use reply block (SURB) creation”) using keys derived from the Diffie-Hellman key exchange between the packet’s group_element and the public key of each node in the path.

Authentication of packet integrity is done by prepending a tag set to a known value to the plaintext prior to the first encrypt operation. By virtue of the fragile nature of the SPRP function, any alteration to the encrypted payload as it traverses the network will result in an irrecoverably corrupted plaintext when the payload is decrypted by the recipient.

Sphinx packet creation

For the sake of brevity, the pseudo-code for all of the operations will take a vector of the following PathHop structure as a parameter named path[] to specify the path a packet will traverse, along with the per-hop routing commands and per-hop public keys.

struct {
/* There is no need for a node_id here, as
routing_commands[0].next_hop specifies that
information for all non-terminal hops. */
opaque public_key[GROUP_ELEMENT_LENGTH];
RoutingCommand routing_commands<1...2^8-1>;
} PathHop;

It is assumed that each RoutingCommands vector except for the terminal entry contains at least a RoutingCommand consisting of a partially assembled NextNodeHopCommand with the next_hop element filled in with the identifier of the next hop.

Create a Sphinx packet header

Both the creation of a Sphinx packet and the creation of a SURB requires the generation of a Sphinx packet header, so it is specified as a distinct operation.

Sphinx_Create_Header( additional_data, path[] ) -> sphinx_header,
               payload_keys

Inputs:

  • additional_data – The additional data that is visible to every node along the path in the header.

  • path – The vector of PathHop structures in hop order, specifying the node id, public key, and routing commands for each hop.

Outputs:

  • sphinx_header – The resulting Sphinx packet header.

  • payload_keys – The vector of SPRP keys used to encrypt the Sphinx packet payload, in hop order.

The Sphinx_Create_Header operation consists of the following steps:

  1. Derive the key material for each hop.
num_hops = route.len
route_keys = [ ]
route_group_elements = [ ]
priv_key = EXP_KEYGEN()

/* Calculate the key material for the 0th hop. */
group_element = EXP( G, priv_key )
route_group_elements += group_element
shared_secret = EXP( path[0].public_key, priv_key )
route_keys += Sphinx_KDF( KDF_INFO, shared_secret )
blinding_factor = keys[0].blinding_factor

/* Calculate the key material for rest of the hops. */
for i = 1; i < num_hops; ++i:
shared_secret = EXP( path[i].public_key, priv_key )
for j = 0; j < i; ++j:
shared_secret = EXP( shared_secret, keys[j].blinding_factor )
route_keys += Sphinx_KDF( KDF_INFO, shared_secret )
group_element = EXP( group_element, keys[i-1].blinding_factor )
route_group_elements += group_element

At the conclusion of the derivation process:

  • route_keys – A vector of per-hop SphinxKeys.

  • route_group_elements – A vector of per-hop group elements.

  1. Derive the routing_information keystream and encrypted padding for each hop.

    ri_keystream = [ ]
    ri_padding = [ ]
    
    for i = 0; i < num_hops; ++i:
    keystream = ZEROBYTES( ROUTING_INFO_LENGTH + PER_HOP_RI_LENGTH ) ^
    S( route_keys[i].header_encryption,
    route_keys[i].header_encryption_iv )
    ks_len = LEN( keystream ) - (i + 1) * PER_HOP_RI_LENGTH
    
    padding = keystream[ks_len:]
    if i > 0:
    prev_pad_len = LEN( ri_padding[i-1] )
    padding = padding[:prev_pad_len] ^ ri_padding[i-1] |
    padding[prev_pad_len]
    
    ri_keystream += keystream[:ks_len]
    ri_padding += padding
    
    At the conclusion of the derivation process:
    ri_keystream - A vector of per-hop routing_information
    encryption keystreams.
    ri_padding   - The per-hop encrypted routing_information
    padding.
    
  2. Create the routing_information block.

    /* Start with the terminal hop, and work backwards. */
    i = num_hops - 1
    
    /* Encode the terminal hop's routing commands. As the
    terminal hop can never have a NextNodeHopCommand, there
    are no per-hop alterations to be made. */
    ri_fragment = path[i].routing_commands |
    ZEROBYTES( PER_HOP_RI_LENGTH - LEN( path[i].routing_commands ) )
    
    /* Encrypt and MAC. */
    ri_fragment ^= ri_keystream[i]
    mac = MAC( route_keys[i].header_mac, additional_data |
    route_group_elements[i] | ri_fragment |
    ri_padding[i-1] )
    routing_info = ri_fragment
    if num_hops < MAX_HOPS:
    pad_len = (MAX_HOPS - num_hops) * PER_HOP_RI_LENGTH
    routing_info = routing_info | RNG( pad_len )
    
    /* Calculate the routing info for the rest of the hops. */
    for i = num_hops - 2; i >= 0; --i:
    cmds_to_encode = [ ]
    
    /* Find and finalize the NextNodeHopCommand. */
    for j = 0; j < LEN( path[i].routing_commands; j++:
    cmd = path[i].routing_commands[j]
    if cmd.command == next_node_hop:
    /* Finalize the NextNodeHopCommand. */
    cmd.MAC = mac
    cmds_to_encode = cmds_to_encode + cmd /* Append */
    
    /* Append a terminal NullCommand. */
    ri_fragment = cmds_to_encode |
    ZEROBYTES( PER_HOP_RI_LENGTH - LEN( cmds_to_encode ) )
    
    /* Encrypt and MAC */
    routing_info = ri_fragment | routing_info /* Prepend. */
    routing_info ^= ri_keystream[i]
    if i > 0:
    mac = MAC( route_keys[i].header_mac, additional_data |
    route_group_elements[i] | routing_info |
    ri_padding[i-1] )
    else:
    mac = MAC( route_keys[i].header_mac, additional_data |
    route_group_elements[i] | routing_info )
    
    At the conclusion of the derivation process:
    routing_info - The completed routing_info block.
    mac          - The MAC for the 0th hop.
    
  3. Assemble the completed Sphinx packet header and Sphinx packet payload SPRP key vector.

    /* Assemble the completed Sphinx packet header. */
    SphinxHeader sphinx_header
    sphinx_header.additional_data = additional_data
    sphinx_header.group_element = route_group_elements[0] /* From step 1. */
    sphinx_header.routing_info = routing_info   /* From step 3. */
    sphinx_header.mac = mac                     /* From step 3. */
    
    /* Preserve the Sphinx Payload SPRP keys, to return to the
    caller. */
    payload_keys = [ ]
    for i = 0; i < nr_hops; ++i:
    payload_keys += route_keys[i].payload_encryption
    
    At the conclusion of the assembly process:
    sphinx_header - The completed sphinx_header, to be returned.
    payload_keys  - The vector of SPRP keys, to be returned.
    

Create a Sphinx packet

Sphinx_Create_Packet( additional_data, path[], payload ) -> sphinx_packet

Inputs:

  • additional_data – The additional data that is visible to every node along the path in the header.

  • path – The vector of PathHop structures in hop order, specifying the node id, public key, and routing commands for each hop.

  • payload – The packet payload message plaintext.

Outputs:

  • sphinx_packet – The resulting Sphinx packet.

The Sphinx_Create_Packet operation consists of the following steps:

  1. Create the Sphinx packet header and SPRP key vector.

    sphinx_header, payload_keys =
    Sphinx_Create_Header( additional_data, path )
    
  2. Prepend the authentication tag, and append padding to the payload.

    payload = ZERO_BYTES( PAYLOAD_TAG_LENGTH ) | payload
    payload = payload | ZERO_BYTES( PAYLOAD_LENGTH - LEN( payload ) )
    
  3. Encrypt the payload.

    for i = nr_hops - 1; i >= 0; --i:
    payload = SPRP_Encrypt( payload_keys[i], payload )
    
  4. Assemble the completed Sphinx packet.

    SphinxPacket sphinx_packet
    sphinx_packet.header = sphinx_header
    sphinx_packet.payload = payload
    

Sphinx packet processing

Mix nodes process incoming packets first by performing the Sphinx_Unwrap operation to authenticate and decrypt the packet, and, if applicable, prepare the packet to be forwarded to the next node.

If Sphinx_Unwrap returns an error for any given packet, the packet MUST be discarded with no additional processing.

After a packet has been unwrapped successfully, a replay detection tag is checked to ensure that the packet has not been seen before. If the packet is a replay, the packet MUST be discarded with no additional processing.

The routing commands for the current hop are interpreted and executed, and finally the packet is forwarded to the next mix node over the network or presented to the application if the current node is the final recipient.

Sphinx_Unwrap operation

The Sphinx_Unwrap operation contains the majority of the per-hop packet processing, handling authentication, decryption, and modifying the packet prior to forwarding it to the next node.

Sphinx_Unwrap( routing_private_key, sphinx_packet ) -> sphinx_packet,
                  routing_commands,
                  replay_tag

Inputs:

  • private_routing_key – A group element GROUP_ELEMENT_LENGTH bytes in length that contains the unwrapped content of the mix node’s private key.

  • sphinx_packet – A Sphinx packet to unwrap.

Outputs:

  • error – Indicating a unsuccessful unwrap operation if applicable.

  • sphinx_packet – The resulting Sphinx packet.

  • routing_commands – A vector of RoutingCommand, specifying the post unwrap actions to be taken on the packet.

  • replay_tag – A tag used to detect whether this packet was processed before.

The Sphinx_Unwrap operation consists of the following steps:

  1. (Optional) Examine the Sphinx packet header’s additional data.

    hdr = sphinx_packet.header
    shared_secret = EXP( hdr.group_element, private_routing_key )
    replay_tag = H( shared_secret )
    

    If the header’s additional_data element contains information required to complete the unwrap operation, such as specifying the packet format version or the cryptographic primitives used examine it now.

    Implementations MUST NOT treat the information in the additional_data element as trusted until after the completion of Step 3 (β€œValidate the Sphinx packet header”).

  2. Derive the various keys required for packet processing.

    keys = Sphinx_KDF( KDF_INFO, shared_secret )
    
  3. Validate the Sphinx packet header.

    derived_mac = MAC( keys.header_mac, hdr.additional_data |
    hdr.group_element |
    hdr.routing_information )
    if !CONSTANT_TIME_CMP( derived_mac, hdr.MAC):
    /* MUST abort processing if the header is invalid. */
    return ErrorInvalidHeader
    
  4. Extract the per-hop routing commands for the current hop.

    /* Append padding to preserve length-invariance, as the routing
    commands for the current hop will be removed. */
    padding = ZEROBYTES( PER_HOP_RI_LENGTH )
    B = hdr.routing_information | padding
    
    /* Decrypt the entire routing_information block. */
    B = B ^ S( keys.header_encryption, keys.header_encryption_iv ) 
    
  5. Parse the per-hop routing commands.

    cmd_buf = B[:PER_HOP_RI_LENGTH]
    new_routing_information = B[PER_HOP_RI_LENGTH:]
    
    next_mix_command_idx = -1
    routing_commands = [ ]
    for idx = 0; idx < PER_HOP_RI_LENGTH {
    /* WARNING: Bounds checking omitted for brevity. */
    cmd_type = b[idx]
    cmd = NULL
    switch cmd_type {
    case null: goto done  /* No further commands. */
    
    case next_node_hop:
    cmd = RoutingCommand( B[idx:idx+1+LEN( NextNodeHopCommand )] )
    next_mix_command_idx = i /* Save for step 7. */
    idx += 1 + LEN( NextNodeHopCommand )
    break
    
    case recipient:
    cmd = RoutingCommand( B[idx:idx+1+LEN( FinalDestinationCommand )] )
    idx += 1 + LEN( RecipientCommand )
    break
    
    case surb_reply:
    cmd = RoutingCommand( B[idx:idx+1+LEN( SURBReplyCommand )] )
    idx += 1 + LEN( SURBReplyCommand )
    break
    
    default:
    /* MUST abort processing on unrecognized commands. */
    return ErrorInvalidCommand
    }
    routing_commands += cmd /* Append cmd to the tail of the list. */
    }
    done:
    
  6. Apply this hop’s routing_commands (a vector of SphinxRoutingCommand) and decrypt the Sphinx packet payload.

    payload = sphinx_packet.payload
    payload = SPRP_Decrypt( key.payload_encryption, payload )
    sphinx_packet.payload = payload 
    
  7. If the routing_commands vector included aNextNodeHopCommand, transform the packet for forwarding to the next mix node by applying new_routing_information.

    if next_mix_command_idx != -1:
    cmd = routing_commands[next_mix_command_idx]
    hdr.group_element = EXP( hdr.group_element, keys.blinding_factor )
    hdr.routing_information = new_routing_information
    hdr.mac = cmd.MAC
    sphinx_packet.hdr = hdr
    
Post-Sphinx_Unwrap processing

Upon the completion of the Sphinx_Unwrap operation, implementations MUST take several additional steps. As the exact behavior is mostly implementation specific, pseudo-code will not be provided for most of the post processing steps.

  1. Apply replay detection to the packet.

    The replay_tag value returned by Sphinx_Unwrap MUST be unique across all packets processed with a given private_routing_key.

    The exact specifics of how to detect replays is left up to the implementation, however any replays that are detected MUST be discarded immediately.

  2. Act on the routing commands, if any.

    The exact specifics of how implementations chose to apply routing commands is deliberately left unspecified, however in general:

    • If there is a NextNodeHopCommand, the packet should be forwarded to the next node based on the next_hop field upon completion of the post processing.

      The lack of a NextNodeHopCommand indicates that the packet is destined for the current node.

    • If there is a SURBReplyCommand, the packet should be treated as a SURBReply destined for the current node, and decrypted accordingly as described in the section called β€œDecrypt a Sphinx reply originating from a SURB”.

    • If the implementation supports multiple recipients on a single node, the RecipientCommand command should be used to determine the correct recipient for the packet, and the payload delivered as appropriate.

      It is possible for both a RecipientCommand and a NextNodeHopCommand to be present simultaneously in the routing commands for a given hop. The behavior when this situation occurs is implementation defined.

  3. Authenticate the packet if required.

    If the packet is destined for the current node, the integrity of the payload MUST be authenticated.

    The authentication is done as follows:

    derived_tag = sphinx_packet.payload[:PAYLOAD_TAG_LENGTH]
    expected_tag = ZEROBYTES( PAYLOAD_TAG_LENGTH )
    if !CONSTANT_TIME_CMP( derived_tag, expected_tag ):
    /* Discard the packet with no further processing. */
    return ErrorInvalidPayload
    

    Remove the authentication tag before presenting the payload to the application.

    sphinx_packet.payload = sphinx_packet.payload[PAYLOAD_TAG_LENGTH:]
    

Single-use reply block (SURB) creation

A single use reply block (SURB) is a delivery token with a short lifetime, that can be used by the recipient to reply to the initial sender.

SURBs allow for anonymous replies, when the recipient does not know the sender of the message. Usage of SURBs guarantees anonymity properties but also makes the reply messages indistinguishable from forward messages both to external adversaries as well as the mix nodes.

When a SURB is created, a matching reply block decryption token is created, which is used to decrypt the reply message that is produced and delivered via the SURB.

The Sphinx SURB wire encoding is implementation defined, but for the purposes of illustrating creation and use, the following will be used:

struct {
SphinxHeader sphinx_header;
opaque first_hop[NODE_ID_LENGTH];
opaque payload_key[SPRP_KEY_LENGTH];
} SphinxSURB;
Create a Sphinx SURB and decryption token

Structurally a SURB consists of three parts, a pre-generated Sphinx packet header, a node identifier for the first hop to use when using the SURB to reply, and cryptographic keying material by which to encrypt the reply’s payload. All elements must be securely transmitted to the recipient, perhaps as part of a forward Sphinx packet’s Payload, but the exact specifics on how to accomplish this is left up to the implementation.

When creating a SURB, the terminal routing_commands vector SHOULD include a SURBReplyCommand, containing an identifier to ensure that the payload can be decrypted with the correct set of keys (decryption token). The routing command is left optional, as it is conceivable that implementations may chose to use trial decryption, and or limit the number of outstanding SURBs to solve this problem.

Sphinx_Create_SURB( additional_data, first_hop, path[] ) ->
             sphinx_surb,
             decryption_token

Inputs:

  • additional_data – The additional data that is visible to every node along the path in the header.

  • first_hop – The node id of the first hop the recipient must use when replying via the SURB.

  • path – The vector of PathHop structures in hop order, specifying the node id, public key, and routing commands for each hop.

Outputs:

  • sphinx_surb – The resulting Sphinx SURB.

  • decryption_token – The decryption token associated with the SURB.

The Sphinx_Create_SURB operation consists of the following steps:

  1. Create the Sphinx packet header and SPRP key vector.

    sphinx_header, payload_keys =
    Sphinx_Create_Header( additional_data, path )
    
  2. Create a key for the final layer of encryption.

    final_key = RNG( SPRP_KEY_LENGTH )
    
  3. Build the SURB and Decryption Token.

    SphinxSURB sphinx_surb;
    sphinx_surb.sphinx_header = sphinx_header
    sphinx_surb.first_hop = first_hop
    sphinx_surb.payload_key = final_key
    
    decryption_token = final_key + payload_keys /* Prepend */
    
Decrypt a Sphinx reply originating from a SURB

A Sphinx reply packet that was generated using a SURB is externally indistinguishable from a forward Sphinx packet as it traverses the network. However, the recipient of the reply has an additional decryption step, the packet starts off unencrypted, and accumulates layers of Sphinx packet payload decryption as it traverses the network.

Determining which decryption token to use when decrypting the SURB reply can be done via the SURBReplyCommand’s id field, if one is included at the time of the SURB’s creation.

Sphinx_Decrypt_SURB_Reply( decryption_token, payload ) -> message

Inputs:

  • decryption_token – The vector of keys allowing a client to decrypt the reply ciphertext payload. This decryption_token is generated when the SURB is created.

  • payload – The Sphinx packet ciphertext payload.

Outputs:

  • error – Indicating a unsuccessful unwrap operation if applicable.

  • message – The plaintext message.

The Sphinx_Decrypt_SURB_Reply operation consists of the following steps:

  1. Encrypt the message to reverse the decrypt operations the payload acquired as it traversed the network.

    for i = LEN( decryption_token ) - 1; i > 0; --i:
    payload = SPRP_Encrypt( decryption_token[i], payload )
    
  2. Decrypt and authenticate the message ciphertext.

    message = SPRP_Decrypt( decryption_token[0], payload )
    
    derived_tag = message[:PAYLOAD_TAG_LENGTH]
    expected_tag = ZEROBYTES( PAYLOAD_TAG_LENGTH )
    if !CONSTANT_TIME_CMP( derived_tag, expected_tag ):
    return ErrorInvalidPayload
    
    message = message[PAYLOAD_TAG_LENGTH:]
    

SURB replies

The process for using a SURB to reply anonymously is slightly different from the standard packet creation process, as the Sphinx packet header is already generated (as part of the SURB), and there is an additional layer of Sphinx packet payload encryption that must be performed.

Sphinx_Create_SURB_Reply( sphinx_surb, payload ) -> sphinx_packet

Inputs:

  • sphinx_surb – The SphinxSURB structure, decoded from the implementation defined wire encoding.

  • payload – The packet payload message plaintext.

The Sphinx_Create_SURB_Reply operation consists of the following steps:

  1. Prepend the authentication tag, and append padding to the payload.

    payload = ZERO_BYTES( PAYLOAD_TAG_LENGTH ) | payload
    payload = payload | ZERO_BYTES( PAYLOAD_LENGTH - LEN( payload ) ) 
    
  2. Encrypt the payload.

    payload = SPRP_Encrypt( sphinx_surb.payload_key, payload )
    
  3. Assemble the completed Sphinx packet.

    SphinxPacket sphinx_packet
    sphinx_packet.header = sphinx_surb.sphinx_header
    sphinx_packet.payload = payload
    

The completed sphinx_packet MUST be sent to the node specified via sphinx_surb.node_id, as the entire reply sphinx_packet’s header is pre-generated.

Anonymity considerations

Optional non-constant length Sphinx packet header padding

Depending on the mix topology, there is no hard requirement that the per-hop routing info is padded to one fixed constant length.

For example, assuming a layered topology (referred to as stratified topology in the literature) MIXTOPO10, where the layer of any given mix node is public information, as long as the following two invariants are maintained, there is no additional information available to an adversary:

  1. All packets entering any given mix node in a certain layer are uniform in length.

  2. All packets leaving any given mix node in a certain layer are uniform in length.

The only information available to an external or internal observer is the layer of any given mix node (via the packet length), which is information they are assumed to have by default in such a design.

Additional data field considerations

The Sphinx packet construct is crafted such that any given packet is bitwise unlinkable after a Sphinx_Unwrap operation, provided that the optional additional data (AD) facility is not used. This property ensures that external passive adversaries are unable to track a packet based on content as it traverses the network. As the on-the-wire AD field is static through the lifetime of a packet (that is, left unaltered by the Sphinx_Unwrap operation), implementations and applications that wish to use this facility MUST NOT transmit AD that can be used to distinctly identify individual packets.

Forward secrecy considerations

Each node acting as a mix MUST regenerate their asymmetric key pair relatively frequently. Upon key rotation the old private key MUST be securely destroyed. As each layer of a Sphinx packet is encrypted via key material derived from the output of an ephemeral/static Diffie-Hellman key exchange, without the rotation, the construct does not provide Perfect Forward Secrecy. Implementations SHOULD implement defense-in-depth mitigations, for example by using strongly forward-secure link protocols to convey Sphinx packets between nodes.

This frequent mix routing key rotation can limit SURB usage by directly reducing the lifetime of SURBs. In order to have a strong Forward Secrecy property while maintaining a higher SURB lifetime, designs such as forward secure mixes SFMIX03 could be used.

Compulsion threat considerations

Reply Blocks (SURBs), forward and reply Sphinx packets are all vulnerable to the compulsion threat, if they are captured by an adversary. The adversary can request iterative decryptions or keys from a series of honest mixes in order to perform a deanonymizing trace of the destination.

While a general solution to this class of attacks is beyond the scope of this document, applications that seek to mitigate or resist compulsion threats could implement the defenses proposed in COMPULS05 via a series of routing command extensions.

SURB usage considerations for volunteer-operated mix networks

Given a hypothetical scenario where Alice and Bob both wish to keep their location on the mix network hidden from the other, and Alice has somehow received a SURB from Bob, Alice MUST not utilize the SURB directly because in the volunteer operated mix network the first hop specified by the SURB could be operated by Bob for the purpose of deanonymizing Alice.

This problem could be solved via the incorporation of a “cross-over point” such as that described in MIXMINION, for example by having Alice delegating the transmission of a SURB Reply to a randomly selected cross-over point in the mix network, so that if the first hop in the SURB’s return path is a malicious mix, the only information gained is the identity of the cross-over point.

Security considerations

Sphinx payload encryption considerations

The payload encryption’s use of a fragile (non-malleable) SPRP is deliberate and implementations SHOULD NOT substitute it with a primitive that does not provide such a property (such as a stream cipher based PRF). In particular there is a class of correlation attacks (tagging attacks) targeting anonymity systems that involve modification to the ciphertext that are mitigated if alterations to the ciphertext result in unpredictable corruption of the plaintext (avalanche effect).

Additionally, as the PAYLOAD_TAG_LENGTH based tag-then-encrypt payload integrity authentication mechanism is predicated on the use of a non-malleable SPRP, implementations that substitute a different primitive MUST authenticate the payload using a different mechanism.

Alternatively, extending the MAC contained in the Sphinx packet header to cover the Sphinx packet payload will both defend against tagging attacks and authenticate payload integrity. However, such an extension does not work with the SURB construct presented in this specification, unless the SURB is only used to transmit payload that is known to the creator of the SURB.

References

COMPULS05

Danezis, G., Clulow, J., β€œCompulsion Resistant Anonymous Communications”, Proceedings of Information Hiding Workshop, June 2005, https://www.freehaven.net/anonbib/cache/ih05-danezisclulow.pdf.

MIXMINION

Danezis, G., Dingledine, R., Mathewson, N., β€œMixminion: Design of a Type III Anonymous Remailer Protocol”, https://www.mixminion.net/minion-design.pdf.

MIXTOPO10

Diaz, C., Murdoch, S., Troncoso, C., β€œImpact of Network Topology on Anonymity and Overhead in Low-Latency Anonymity Networks”, PETS, July 2010, https://www.esat.kuleuven.be/cosic/publications/article-1230.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5246

Dierks, T. and E. Rescorla, β€œThe Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246, DOI 10.17487/RFC5246, August 2008, http://www.rfc-editor.org/info/rfc5246.

SFMIX03

Danezis, G., β€œForward Secure Mixes”, Proceedings of 7th Nordic Workshop on Secure IT Systems, 2002, https://www.freehaven.net/anonbib/cache/Dan:SFMix03.pdf.

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

16 -

For the application, kpclientd and gateway arrangement, see client software and configuration. kpclientd verifies consensus documents using the directory authority protocol.

Thin client specification

David Stainton

Abstract

This document describes the design of the new Katzenpost mixnet thin client. In particular we discuss its multiplexing and privilege-separation design elements as well as the protocol used by the thin-client library.


Introduction

A Katzenpost mixnet client has several responsibilities at a minimum:

  • composing Sphinx packets

  • decrypting SURB replies

  • sending and receiving Noise protocol messages

  • keeping up to date with the latest PKI document

The thin-client library enables applications to talk with the connector daemon and in that way interact with the mix network. The library itself does not do any mixnet-related cryptography since that is already handled by the thin-client daemon. In particular, the daemon strips cryptographic signatures from the PKI document before passing data to clients using the connector library. Noise- and Sphinx-related cryptography are also handled by the daemon.

Thin-client library and daemon protocol

The thin-client daemon protocol uses a local network socket, either a Unix domain socket or TCP.

Messages

There are two protocol message types, both CBOR encoded.

  • The client sends Request messages.

  • The daemon sends Response messages.

Messages are length-prefixed CBOR blobs, that is, a blob is prefixed with a big-endian unsigned four-byte integer (uint32) that encodes the blob length.

Protocol description

The protocol has two phases.

Phase 1

Upon connecting to the daemon socket, the thin client waits for two messages.

  • First message

    • The is_status field must be set to true

    • The is_connected field must indicate whether or not the daemon has a mixnet PQ Noise protocol connection to a mixnet gateway node.

  • Second message

    • The message contains the PKI document in its payload field. This marks the end of the initial connection sequence.

    • The PKI document is stripped of cryptographic signatures as noted above.

Phase 2

  • The thin client may send Request messages to the daemon, which encapsulates the provided payload in a Sphinx packet and sends it to the gateway node.

  • The daemon may send Response messages to the client at any time during this phase. Response messages may communicate a connection status change, a new PKI document, or a message-sent or message-reply event.

17 -

The Namenlos profile defines the v0.0.107 link KEM, handshake, authentication payloads, and transport keys. The directory wire protocol defines directory commands and consensus retrieval over direct authority or persistent gateway sessions.

Mix network wire protocol specification

Yawning Angel

David Stainton

Abstract

This document defines the Katzenpost mix network wire protocol for use in all network communications to, from, and within the Katzenpost Mix Network.


Conventions used in this document

The key words β€œMUST”, β€œMUST NOT”, β€œREQUIRED”, β€œSHALL”, β€œSHALL NOT”, β€œSHOULD”, β€œSHOULD NOT”, β€œRECOMMENDED”, β€œMAY”, and β€œOPTIONAL” in this document are to be interpreted as described in the section called β€œReferences”.

The β€œC” style Presentation Language as described in the section called β€œReferences” Section 4 is used to represent data structures, except for cryptographic attributes, which are specified as opaque byte vectors.

x | y denotes the concatenation of x and y.

Introduction

The Katzenpost mix network wire protocol (KMNWP) is the custom wire protocol for all network communications to, from, and within the Katzenpost mix network. This protocol provides mutual authentication along with an additional layer of cryptographic security and forward secrecy.

Key encapsulation mechanism

This protocol uses ANY key encapsulation mechanism. However, it is recommended that most users select a hybrid post-quantum KEM such as Xwing. the section called β€œReferences”

Core protocol

The protocol is based on Kyber and Trevor Perrin’s Noise protocol framework the section called β€œReferences” along with β€œPost Quantum Noise” paper PQNOISE. Older previous versions of our transport were based on NOISEHFS.

Our transport protocol begins with a prologue, Noise handshake, followed by a stream of Noise transport messages in a minimal framing layer, over a TCP/IP connection.

The TOML configuration selects the KEM. This protocol string gives an Xwing example:

Noise_pqXX_Xwing_ChaChaPoly_BLAKE2b

Namenlos uses the canonical KEM name Kyber768-X25519 in its protocol name. The baseline KEM profile defines its key encodings, adapter, and combiner.

The protocol string is a very condensed description of our protocol. We use the pqXX two-way Noise pattern which is described as follows:

pqXX: -> e <- ekem, s -> skem, s <- skem

The next part of the protocol string specifies the KEM, Xwing which is a hybrid KEM where the share secret outputs of both X25519 and MLKEM768 are combined.

Finally the ChaChaPoly_BLAKE2b parts of the protocol string indicate which stream cipher and hash function we are using.

Katzenpost permits larger transport messages than Noise’s 65535-byte default. Directory sessions apply the configured receive size ceiling.

Each peer has a static key pair for the configured KEM. The authentication rules determine whether a peer needs a configured key pin.

Handshake phase

All sessions start in the handshake phase, in which an anonymous authenticated handshake is conducted.

The pqXX handshake starts with a fixed prologue before the initiator’s first Noise message. Both peers also use this byte as the Noise prologue input.

The prologue is defined to be the following structure:

struct {
uint8_t protocol_version; /* 0x03 */
} Prologue;

As all Noise handshake messages are fixed sizes, no additional framing is required for the handshake.

Implementations MUST preserve the Noise handshake hash [h] for the purpose of implementing authentication (Section 2.3).

Implementations MUST reject handshake attempts by terminating the session immediately upon any Noise protocol handshake failure and when, as a responder, they receive a Prologue containing an unknown protocol_version value.

Implementations SHOULD impose reasonable timeouts for the handshake process, and SHOULD terminate sessions that are taking too long to handshake.

Handshake authentication

Mutual authentication is done via exchanging fixed sized payloads as part of the pqXX handshake consisting of the following structure:

struct {
uint8_t ad_len;
opaque additional_data[ad_len];
opaque padding[255 - ad_len];
uint32_t unix_time;
} AuthenticateMessage;

Where:

  • ad_len - The length of the optional additional data.

  • additional_data - Optional additional data, such as a username, if any.

  • unix_time - 0 for the initiator, the approximate number of seconds since 1970-01-01 00:00:00 UTC for the responder.

The responder sends AuthenticateMessage in flight 2. The initiator sends it in flight 3. The four-flight profile defines the tokens, payloads, and byte counts. The authentication payload profile defines the padding, integer encoding, and role-specific additional data.

The receiver MUST authenticate the remote static link key and additional data under the peer’s role. If authentication fails, the receiver MUST terminate the session immediately.

The responder sends its current time in whole seconds as unix_time.

Data transfer phase

Upon successfully concluding the handshake the session enters the data transfer phase, where the initiator and responder can exchange KMNWP messages.

A KMNWP message is defined to be the following structure:

enum {
no_op(0),
disconnect(1),
send_packet(2),

(255),
} Command;

struct {
Command command;
uint8_t reserved;    /* MUST be '0x00' */
uint32_t msg_length; /* bounded by the session message limit */
opaque message[msg_length];
opaque padding[];    /* length is implicit */
} Message;

Notes:

  • The padding field, if any MUST be padded with '0x00' bytes.

All outgoing Message(s) are encrypted and authenticated into a pair of Noise Transport messages, each containing one of the following structures:

struct {
uint32_t ciphertext_length;
} CiphertextHeader;

struct {
opaque message[ciphertext_length-16];
} Ciphertext;

Notes:

  • The ciphertext_length field includes the Noise protocol overhead of 16 bytes, for the Noise Transport message containing the Ciphertext.

All outgoing Message(s) are preceded by a Noise Transport Message containing a CiphertextHeader, indicating the size of the Noise Transport Message transporting the Message Ciphertext. After generating both Noise Transport Messages, the sender MUST call the Noise CipherState Rekey() operation.

To receive incoming Ciphertext messages, first the Noise Transport Message containing the CiphertextHeader is consumed off the network, authenticated and decrypted, giving the receiver the length of the Noise Transport Message containing the actual message itself. The second Noise Transport Message is consumed off the network, authenticated and decrypted, with the resulting message being returned to the caller for processing. After receiving both Noise Transport Messages, the receiver MUST call the Noise CipherState Rekey() operation.

Implementations MUST immediately terminate the session any of the DecryptWithAd() operations fails.

Implementations MUST immediately terminate the session if an unknown command is received in a Message, or if the Message is otherwise malformed in any way.

Implementations MAY impose a reasonable idle timeout, and terminate the session if it expires.

Predefined commands

The no_op command

The no_op command is a command that explicitly is a No Operation, to be used to implement functionality such as keep-alives and or application layer padding.

Implementations MUST NOT send any message payload accompanying this command, and all received command data MUST be discarded without interpretation.

The disconnect command

The disconnect command is a command that is used to signal explicit session termination. Upon receiving a disconnect command, implementations MUST interpret the command as a signal from the peer that no additional commands will be sent, and destroy the cryptographic material in the receive CipherState.

While most implementations will likely wish to terminate the session upon receiving this command, any additional behavior is explicitly left up to the implementation and application.

Implementations MUST NOT send any message payload accompanying this command, and MUST not send any further traffic after sending a disconnect command.

The send_packet command

The send_packet command is the command that is used by the initiator to transmit a Sphinx Packet over the network. The command’s message is the Sphinx packet destined for the responder.

Initiators MUST terminate the session immediately upon reception of a send_packet command.

Command padding

We use traffic padding to prevent a passive network observer from observing what command has been sent or received. The following commands are padded:

Client-to-server commands

  • GetConsensus

  • NoOp

  • Disconnect

  • SendPacket

  • RetrieveMessage

Server-to-client commands

  • MessageACK

  • Message

  • MessageEmpty

We exclude the Consensus command because its contents are a very large payload that is usually many times larger than our Sphinx packets.

The GetConsensus command is a special case because we only pad it when sending it over the mixnet. We don’t pad it when sending to directory authorities, because that would needlessly take up bandwidth without providing privacy benefits.

Anonymity considerations

Adversaries being able to determine that two parties are communicating via KMNWP is beyond the threat model of this protocol. At a minimum, it is trivial to determine that a KMNWP handshake is being performed, due to the length of each handshake message, and the fixed positions of the various public keys.

Security considerations

Each handshake MUST use a fresh ephemeral key pair. Each KEM encapsulation MUST use fresh randomness. Static link keys identify peers under the authentication rules.

Kyber was chosen as the KEM algorithm due to it’s conservative parameterization, simplicity of implementation, and high performance in software. It is hoped that the addition of a quantum resistant algorithm will provide forward secrecy even in the event that large scale quantum computers are applied to historical intercepts.

Acknowledgments

I would like to thank Trevor Perrin for providing feedback during the design of this protocol, and answering questions regarding Noise.

References

NOISE

Perrin, T., β€œThe Noise Protocol Framework”, May 2017, https://noiseprotocol.org/noise.pdf.

NOISEHFS

Weatherley, R., β€œNoise Extension: Hybrid Forward Secrecy”, https://github.com/noiseprotocol/noise_hfs_spec/blob/master/output/noise_hfs.pdf.

PQNOISE

Yawning Angel, Benjamin Dowling, Andreas HΓΌlsing, Peter Schwabe and Florian Weber, β€œPost Quantum Noise”, September 2023, https://eprint.iacr.org/2022/539.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5246

Dierks, T. and E. Rescorla, β€œThe Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246, DOI 10.17487/RFC5246, August 2008, http://www.rfc-editor.org/info/rfc5246.

RFC7748

Langley, A., Hamburg, M., and S. Turner, β€œElliptic Curves for Security”, RFC 7748, January 2016, https://www.rfc-editor.org/info/rfc7748.

XWING

Manuel Barbosa, Deirdre Connolly, JoΓ£o Diogo Duarte, Aaron Kaiser, Peter Schwabe, Karoline Varner, Bas Westerbaan, β€œX-Wing: The Hybrid KEM You’ve Been Looking For”, https://eprint.iacr.org/2024/039.pdf.

Appendix: Namenlos wire profile

This profile defines the Namenlos link KEM and handshake with Katzenpost v0.0.107. Implementations MUST use these formats with that configuration. The linked primitive specifications define X25519, Kyber, BLAKE2, and ChaCha20-Poly1305. The composition rules below define their use on the wire.

Here, || means byte concatenation, empty means zero bytes, and zero[n] means n zero bytes. BE32, LE32, and LE64 encode unsigned integers in the indicated byte order. ASCII literals have no terminating zero.

H256 means unkeyed BLAKE2b with a 32-byte output. H512 means unkeyed BLAKE2b with a 64-byte output. Configure BLAKE2b-256 for 32 output bytes. Do not truncate BLAKE2b-512 to produce BLAKE2b-256. Both use the sequential mode in RFC 7693.

Key Value Explanation
Wire KEM Kyber768-X25519 This is the canonical name used in the Noise protocol name. Configuration lookup also accepts the Namenlos spelling KYBER768-X25519.
Public key 1216 bytes X25519 public key, 32 bytes, followed by Kyber768 public key, 1184 bytes.
Private key 2432 bytes X25519 scalar, 32 bytes, followed by Kyber768 secret key, 2400 bytes.
Ciphertext 1120 bytes X25519 adapter ciphertext, 32 bytes, followed by Kyber768 ciphertext, 1088 bytes.
Shared secret 32 bytes The combiner output defined below.

Keys use raw component encodings without ASN.1, text encoding, or length prefixes. X25519 uses the 32-byte little-endian u-coordinate encoding and scalar operation in RFC 7748, Section 5. The scheme definition fixes the component order.

The public PEM label is KYBER768-X25519 PUBLIC KEY. The private PEM label is KYBER768-X25519 PRIVATE KEY. A PEM payload is the base64 encoding of the corresponding raw key. There are no PEM headers or ASN.1 fields. This encoding permits import of reference key files. An implementation can use its own internal storage format.

The PEM codec wraps raw key bytes.

The Kyber component is Kyber768.CCAKEM from the round 3 specification, version 3.02, with its public-key, secret-key, and ciphertext encodings. Use Algorithms 7-9 and the Kyber768 parameters. This is the round 3 Kyber KEM, not ML-KEM. The pinned Kyber implementation identifies that primitive and its sizes.

For the X25519 component, let X_s be the recipient’s public key. Generate a fresh ephemeral scalar x_e and its public key X_e. Compute:

Z    = X25519(x_e, X_s)
ct_X = X_e
ss_X = BLAKE2Xb(output_length=32, key=Z, message=X_s || X_e)

Reject an all-zero Z. Decapsulation computes Z = X25519(x_s, ct_X) and uses the same recipient-public-key-first input. The output length supplied to BLAKE2Xb is exactly 32. Its unknown-length mode produces different bytes and MUST NOT be used here. The adapter defines the inputs. Its PRF selects the fixed-length keyed XOF.

For this invocation, BLAKE2Xb first produces a keyed 64-byte root over X_s || X_e. It then produces one unkeyed 32-byte output block over that root. Each BLAKE2 parameter block is 64 bytes and starts with all bytes zero. Set these fields:

Key Value Explanation
Root bytes 0-3 64, 32, 1, 1 Digest length, key length, fanout, and depth. The key is Z, with standard BLAKE2b key-block padding.
Root bytes 12-15 LE32(32) XOF output length.
Output byte 0 32 Output digest length.
Output bytes 4-7 LE32(64) Leaf length.
Output bytes 8-11 LE32(0) Output block number.
Output bytes 12-15 LE32(32) XOF output length.
Output byte 17 64 Inner digest length.

All unlisted fields, salt, and personalization remain zero. Set the normal final-block flag for each hash. Do not set the last-node flag. The fixed-output BLAKE2Xb implementation gives these parameter blocks.

Encapsulate independently to each component public key. Let (ct_X, ss_X) be the adapter output and (ct_K, ss_K) be the Kyber output. Both component shared secrets are 32 bytes. Define:

T = ASCII("splitprf-v1") || BE32(2)
    || BE32(32) || ct_X || BE32(1088) || ct_K

k_X = H256(ss_X)
k_K = H256(ss_K)
ss  = BLAKE2b-256(key=k_X, message=T)
      XOR BLAKE2b-256(key=k_K, message=T)
ct  = ct_X || ct_K

The two keyed hashes use BLAKE2b’s native keyed mode. They are not HMAC. XOR is bytewise across all 32 bytes.

Decapsulation splits the 1120-byte ciphertext at byte 32, decapsulates both components, and applies the same formula to the received component ciphertexts. Reject an incorrect total length or an error from either component. Kyber’s implicit-rejection behavior remains part of the Kyber primitive. The combiner definition fixes the label, length fields, prehashes, and XOR.

PQ Noise handshake

Use the symmetric operations from Noise revision 34, with the KEM token rules below. The exact protocol name is the following 45 ASCII bytes:

Noise_pqXX_Kyber768-X25519_ChaChaPoly_BLAKE2b

There are no pre-messages or pre-shared keys. Both peers have a static link KEM key pair. An anonymous fetcher generates one fresh link key pair per fetch and uses it for every authority connection of that fetch. The initiator also generates a fresh ephemeral KEM key pair for token e. Each KEM encapsulation uses fresh randomness.

Initialize h to the protocol name followed by 19 zero bytes. Copy this value to ck. Start with no cipher key and nonce zero. Then set h = H512(h || 0x03).

The single byte 0x03 is the Noise prologue and is also sent in clear before the first flight. It is not a token or payload byte. The responder MUST reject any other version byte.

The following rules define the symmetric operations. HMAC uses BLAKE2b-512. For HKDF(ck, input, count), compute:

prk = HMAC-BLAKE2b-512(key=ck, message=input)
t1  = HMAC-BLAKE2b-512(key=prk, message=0x01)
t2  = HMAC-BLAKE2b-512(key=prk, message=t1 || 0x02)
t3  = HMAC-BLAKE2b-512(key=prk, message=t2 || 0x03)

Return the first count values, each 64 bytes. No HKDF info bytes are present.

Key Value Explanation
MixHash(x) h = H512(h || x) This operation also runs when x is empty.
MixKey(x) (ck, t) = HKDF(ck, x, 2) Set the cipher key to the first 32 bytes of t, and reset the nonce to zero.
MixKeyAndHash(x) (ck, u, t) = HKDF(ck, x, 3) Run MixHash(u), set the cipher key to the first 32 bytes of t, and reset the nonce to zero.
EncryptAndHash(p) Encrypt p with associated data h, then run MixHash(ciphertext) Without a cipher key, use p unchanged as the ciphertext.
DecryptAndHash(c) Decrypt c with associated data equal to the prior h, then run MixHash(c) Without a cipher key, the plaintext is c. Reject an AEAD failure.

Encryption uses IETF ChaCha20-Poly1305, a 32-byte key, and the 12-byte nonce zero[4] || LE64(n). Its 16-byte tag follows the ciphertext. Increase n after each keyed encryption or successful keyed decryption. Do not increase it for an unkeyed operation. A normal encryption or decryption MUST fail when n = 2^64-1.

The symmetric-state implementation defines these operations. The cipher implementation fixes the AEAD and nonce bytes.

Each flight processes its tokens in order, then calls EncryptAndHash on its payload. The receiver performs the inverse operations in the same order.

Key Value Explanation
e Raw ephemeral public key, 1216 bytes Send the initiator’s ephemeral KEM public key, and run MixHash on those bytes.
ekem Raw KEM ciphertext, 1120 bytes Encapsulate to the peer’s ephemeral key. Send the ciphertext, run MixHash(ciphertext), then MixKey(shared_secret). The receiver decapsulates with its ephemeral private key.
s EncryptAndHash(static_public_key) Send the sender’s 1216-byte static link public key. The receiver decrypts it and retains it as the peer’s static key.
skem EncryptAndHash(KEM_ciphertext) Encapsulate to the peer’s static key. Encrypt and hash the ciphertext with the current cipher state, then run MixKeyAndHash(shared_secret). The receiver decrypts, decapsulates with its static private key, then runs MixKeyAndHash.

The pqXX pattern and KEM token processing define this order. In particular, skem updates both ck and h after its encrypted ciphertext.

Let P=1216, C=1120, A=260, and T=16. Each length below is an exact stream byte count. There is no handshake length prefix or padding outside the stated fields.

Key Value Explanation
Flight 1, initiator -> responder 0x03, e, empty payload. 1217 bytes. 1+P. The empty payload emits no bytes but still runs MixHash(empty).
Flight 2, responder -> initiator ekem, s, responder authentication. 2628 bytes. C + (P+T) + (A+T). After ekem, the static key and payload use nonces 0 and 1.
Flight 3, initiator -> responder skem, s, initiator authentication. 2644 bytes. (C+T) + (P+T) + (A+T). skem uses nonce 2 from flight 2. Its key update resets the nonce, so s and the payload use 0 and 1.
Flight 4, responder -> initiator skem, empty payload. 1152 bytes. (C+T) + T. skem uses nonce 2 from flight 3. Its key update resets the nonce. The empty payload uses nonce 0 and emits a 16-byte tag.

The session uses these flight lengths and payload assignments. The payload operation runs once after every flight’s tokens, including flights 1 and 4.

Authentication payload

An AuthenticateMessage is exactly 260 bytes:

u8(length(AD)) || AD || zero[255-length(AD)] || BE32(unix_time)

AD has 0 to 255 bytes. The timestamp starts at offset 256. The initiator sends timestamp zero. The responder sends its current Unix time in whole seconds, encoded as an unsigned 32-bit value.

Senders MUST set the padding to zero. The v0.0.107 receiver extracts the length, AD, and timestamp. It ignores the padding bytes. The authentication codec defines this layout.

Key Value Explanation
Authority responder 32 raw identity-hash bytes Its static Noise key is its configured link public key.
Authority or node uploading to an authority 32 raw identity-hash bytes The identity hash selects the configured peer. Authority peers also require the pinned link key. Node uploads bind the descriptor link key to this session.
Anonymous direct directory fetcher Empty AD Use one fresh link key pair per fetch. The authority grants the anonymous fetch role.
Client daemon connecting to a gateway 32 lowercase ASCII hexadecimal bytes Encode 16 fresh random bytes as two hex characters per byte. This is the gateway queue identifier. It is not a raw identity hash.

The identity signature profile defines identity hashes. The authority’s responder configuration, initiator configuration, and peer classification distinguish these roles. The client connection defines the queue identifier.

The initiator authenticates the responder after flight 2. It uses the responder’s static key and authentication payload. The responder authenticates the initiator after flight 3.

For an authority responder, the v0.0.107 client requires at least 32 AD bytes. It compares the first 32 bytes with the configured identity hash. It also compares the full static link key with its configured pin. Authority receivers accept only empty or 32-byte initiator AD and apply the role requirements above. Any failed authentication terminates the session. The authority authenticator implements these requirements.

Transport keys and framing

After flight 4’s empty payload, compute (t_I, t_R) = HKDF(ck, empty, 2). The first 32 bytes of t_I are the initiator-to-responder transport key. The first 32 bytes of t_R are the responder-to-initiator key. Each direction starts with nonce zero. Transport encryption uses empty AEAD associated data. It does not continue EncryptAndHash or update the handshake hash.

For each command, encrypt BE32(length(serialized_command)+16) as a separate 20-byte header. Then encrypt the serialized command with the next nonce. Both operations use the same direction’s cipher key. After the body, update that direction’s key to the first 32 bytes of ChaCha20-Poly1305(key, zero[4] || LE64(2^64-1), empty_AD, zero[32]). Keep the ordinary nonce counter at its value after the two frame operations. The receiver follows the same sequence.

The responder’s first transport command MUST be NoOp. The initiator MUST receive it before using the session. The session implements transport sending, rekeying, and handshake finalization.

18 -

For the v0.0.107 directory protocol, begin with the directory overview. Its roles and epoch definition supply the directory-specific context.

Glossary


KEM
Key encapsulation mechanism. See the wire KEM interface and KEM Sphinx.

NIKE
Non-interactive key exchange. A party combines its private key with another party’s public key to derive a shared secret. See Sphinx cryptographic primitives and directory scheme selection.

BlockSphinxPlaintext
The payload structure that is encapsulated by the Sphinx body.

classes of traffic
We distinguish the following classes of traffic:

  • SURB replies (sometimes referred to as ACKs)

  • Forwarded messages

client
Software run by a user on their local device to participate in the mixnet. A client is not considered a node in the network for purposes of analysis of the core mnixnet protocols.

directory authority system
The fixed authorities that publish a signed network directory. See the Katzenpost directory authority protocol.

entry mix, entry node
A mix node that has some additional features:

  • An entry mix is always the first hop in routes where the message originates from a client.

  • An entry mix authenticates client’s direct connections via the mixnet’s wire protocol.

  • An entry mix queues reply messages and allows clients to retrieve them later.

epoch
A configured time interval, 20 minutes by default, for which authorities publish a consensus document. See directory epochs and document retention.

family
Identifier of security domains or entities operating one or more mix nodes in the network. This is used to inform the path selection algorithm.

gateway
A mix node on the edge of the mixnet that is the first hop for messages coming from clients. The gateway authenticates client connections and queues reply messages (SURBs) for retrieval.

group
A finite set of elements and a binary operation that satisfy the properties of closure, associativity, invertability, and the presence of an identity element.

group element
An individual element of a group.

group generator
A group element capable of generating any other element of a group, via repeated applications of the generator and the group operation.

header
The packet header consisting of several components which convey the information necessary to verify packet integrity and to correctly process the packet.

KiB
Defined as 1024 8 bit octets.

layer
The layer value indicates which network topology layer a particular mix node resides in.

message
A variable-length sequence of octets sent anonymously through the network. Short messages are sent in a single packet; long messages are fragmented across multiple packets.

mix descriptor
A database record that describes a mixnet server component.

mix node
A cryptographic router that is used to compose a mixnet. Mix nodes use a cryptographic operation on messages being routed which provides bitwise unlinkability with respect to input versus output messages. Katzenpost is a decryption mixnet that uses the Sphinx cryptographic packet format.

mixnet
A mixnet, or mix network, is a network of mix servers that can be used to build various privacy-preserving protocols.

MSL
Maximum segment lifetime, currently set to 120 seconds.

nickname
A component nickname string that must be unique in the consensus document.

node
Clients are NOT considered nodes in the mix network. However, network protocols are often layered. In our design documents, we describe “mixnet hidden services” that can be operated by mixnet clients. Therefore if you are using the term “node” in some adherence to mathematical terminology, one could conceivably designate a client as a node. However, in discussion of our core mixnet protocol, it is inappropriate to refer to clients as nodes.

packet
A Sphinx packet, of fixed length for each class of traffic, carrying a message payload and metadata for routing. Packets are routed anonymously through the mixnet and cryptographically transformed at each hop.

payload
The fixed-length portion of a packet containing an encrypted message or part of a message, to be delivered anonymously.

PKI
Public key infrastructure.

SEDA
Staged Event Driven Architecture. 1. A highly parallelizable computation model. 2. A computational pipeline composed of multiple stages connected by queues utilizing active queue-management algorithms that can evict items from a queue based on dwell time or other criteria where each stage is a thread pool. 3. The only correct way to efficiently implement a software based router on general purpose computing hardware.

service node
A service node is a mix node with additional features:

  • A service node is always the last hop in routes where the message originates from a client.

  • A service node runs mixnet services which use a Sphinx SURB-based protocol.

SURB
Single use reply block. SURBs are used to achieve recipient anonymity, that is to say, SURBs function as a cryptographic delivery token that you can give to another client entity so that they can send you a message without them knowing your identity or location on the network. See SPHINXSPEC and SPHINX.

user
An agent using the Katzenpost system.

wire protocol
Refers to our PQ Noise-based protocol that currently uses TCP but in the near future will optionally use QUIC. This protocol has messages known as wire protocol commands, which are used for various mixnet functions such as sending or retrieving a message and dirauth voting. For more information, see our wire protocol specification.

19 -

References


AB96

Anderson, R., Biham, E., β€œTwo Practical and Provably Secure Block Ciphers: BEAR and LION”, 1996.

AEZV5

Hoang, V., Krovetz, T., Rogaway, P., β€œAEZ v5: Authenticated Encryption by Enciphering”, March 2017, http://web.cs.ucdavis.edu/~rogaway/aez/aez.pdf.

BRIDGING

Danezis, G., Syverson, P., β€œBridging and Fingerprinting: Epistemic Attacks on Route Selection”, Proceedings of PETS 2008, Leuven, Belgium, July 2008, https://www.freehaven.net/anonbib/cache/danezis-pet2008.pdf.

COMPULS05

Danezis, G., Clulow, J., β€œCompulsion Resistant Anonymous Communications”, Proceedings of Information Hiding Workshop, June 2005, https://www.freehaven.net/anonbib/cache/ih05-danezisclulow.pdf.

ED25519

https://www.rfc-editor.org/rfc/rfc8032.

FINGERPRINTING

Danezis, G., Clayton, R., β€œRoute Finger printing in Anonymous Communications”, https://www.cl.cam.ac.uk/~rnc1/anonroute.pdf.

KATZMIXNET

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/mixnet.pdf.

KATZMIXPKI

Angel, Y., Piotrowska, A., Stainton, D., β€œKatzenpost Mix Network Public Key Infrastructure Specification”, December 2017, https://katzenpost.network/docs/specs/pdf/pki.pdf.

The Directory authority specification describes the current protocol and its relation to this design.

KATZMIXWIRE

Angel, Y., β€œKatzenpost Mix Network Wire Protocol Specification”, June 2017, https://katzenpost.network/docs/specs/pdf/wire.pdf.

KEMCOMB

Federico Giacon, Felix Heuer, Bertram Poettering, β€œKEM Combiners”, 2018, https://link.springer.com/chapter/10.1007/978-3-319-76578-5_7

LOCALVIEW

Gogolewski, M., Klonowski, M., Kutylowsky, M., β€œLocal View Attack on Anonymous Communication”, https://cs.pwr.edu.pl/kutylowski/articles/LocalView-WWW.pdf.

LOOPIX

Piotrowska, A., Hayes, J., Elahi, T., Meiser, S., Danezis, G., β€œThe Loopix Anonymity System”USENIX, August 2017, https://arxiv.org/pdf/1703.00536.pdf.

MIRANDA

Leibowitz, H., Piotrowska, A., Danezis, G., Herzberg, A., β€œNo right to remain silent: Isolating Malicious Mixes”, 2017, https://eprint.iacr.org/2017/1000.pdf.

MIXMINION

Danezis, G., Dingledine, R., Mathewson, N., β€œMixminion: Design of a Type III Anonymous Remailer Protocol”, https://www.mixminion.net/minion-design.pdf.

MIXMINIONDIRAUTH

Danezis, G., Dingledine, R., Mathewson, N., β€œType III (Mixminion) Mix Directory Specification”, December 2005, https://www.mixminion.net/dir-spec.txt.

MIXRELIABLE

Dingledine, R., Freedman, M., Hopwood, D., Molnar, D., β€œA Reputation System to Increase MIX-Net Reliability”, 2001, Information Hiding, 4th International Workshop, https://www.freehaven.net/anonbib/cache/mix-acc.pdf.

MIXTOPO10

Diaz, C., Murdoch, S., Troncoso, C., β€œImpact of Network Topology on Anonymity and Overhead in Low-Latency Anonymity Networks”, PETS, July 2010, https://www.esat.kuleuven.be/cosic/publications/article-1230.pdf.

MPRA11

Maines, L., Piva, M., Rimoldi, A., Sala, M., β€œOn the provable security of BEAR and LION schemes”, May 2011, arXiv:1105.0259, https://arxiv.org/abs/1105.0259.

NOISE

Perrin, T., β€œThe Noise Protocol Framework”, May 2017, https://noiseprotocol.org/noise.pdf.

NOISEHFS

Weatherley, R., β€œNoise Extension: Hybrid Forward Secrecy”, https://github.com/noiseprotocol/noise_hfs_spec/blob/master/output/noise_hfs.pdf.

PEERFLOW

Johnson, A., Jansen, R., Segal, A., Syverson, P., β€œPeerFlow: Secure Load Balancing in Tor”, July 2017, Proceedings on Privacy Enhancing Technologies, https://petsymposium.org/2017/papers/issue2/paper12-2017-2-source.pdf.

PQNOISE

Yawning Angel, Benjamin Dowling, Andreas HΓΌlsing, Peter Schwabe and Florian Weber, β€œPost Quantum Noise”, September 2023, https://eprint.iacr.org/2022/539.pdf.

RFC2119

Bradner, S., β€œKey words for use in RFCs to Indicate Requirement Levels”, BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, http://www.rfc-editor.org/info/rfc2119.

RFC5246

Dierks, T. and E. Rescorla, β€œThe Transport Layer Security (TLS) Protocol Version 1.2”, RFC 5246, DOI 10.17487/RFC5246, August 2008, http://www.rfc-editor.org/info/rfc5246.

RFC5322

Resnick, P., Ed., β€œInternet Message Format”, RFC 5322, DOI 10.17487/RFC5322, October 2008, https://www.rfc-editor.org/info/rfc5322.

RFC6234

Eastlake 3rd, D. and T. Hansen, β€œUS Secure Hash Algorithms (SHA and SHA-based HMAC and HKDF)”, RFC 6234, DOI 10.17487/RFC6234, May 2011, https://www.rfc-editor.org/info/rfc6234.

RFC7049

C. Bormann, P. Hoffman, β€œConcise Binary Object Representation (CBOR)”, Internet Engineering Task Force (IETF), October 2013, https://www.rfc-editor.org/info/rfc7049.

RFC7515

Jones, M., Bradley, J., Sakimura, N., β€œJSON Web Signature (JWS)”, May 2015, https://www.rfc-editor.org/info/rfc7515.

RFC7539

Nir, Y. and A. Langley, β€œChaCha20 and Poly1305 for IETF Protocols”, May 2015, RFC 7539, DOI 10.17487/RFC7539, http://www.rfc-editor.org/info/rfc7539.

RFC7693

Saarinen, M-J., Ed., and J-P. Aumasson, β€œThe BLAKE2 Cryptographic Hash and Message Authentication Code (MAC)”, RFC 7693, DOI 10.17487/RFC7693, November 2015, http://www.rfc-editor.org/info/rfc7693.

RFC7748

Langley, A., Hamburg, M., and S. Turner, β€œElliptic Curves for Security”, RFC 7748, January 2016, https://www.rfc-editor.org/info/rfc7748.

SECNOTSEP

Miller, M., Tulloh, B., Shapiro, J., β€œThe Structure of Authority: Why Security Is not a Separable Concer”, http://www.erights.org/talks/no-sep/secnotsep.pdf.

SEDA

Welsh, M., Culler, D., Brewer, E., β€œSEDA: An Architecture for Well-Conditioned, Scalable Internet Services”, 2001, ACM Symposium on Operating Systems Principles, http://www.sosp.org/2001/papers/welsh.pdf.

SFMIX03

Danezis, G., β€œForward Secure Mixes”, Proceedings of 7th Nordic Workshop on Secure IT Systems, 2002, https://www.freehaven.net/anonbib/cache/Dan:SFMix03.pdf.

SP80038A

Dworkin, M., β€œRecommendation for Block Cipher Modes of Operation”, SP800-38A, 10.6028/NIST.SP.800, December 2001, https://doi.org/10.6028/NIST.SP.800-38A.

SPHINCS256

Bernstein, D., Hopwood, D., Hulsing, A., Lange, T., Niederhagen, R., Papachristodoulou, L., Schwabe, P., Wilcox O’Hearn, Z., β€œSPHINCS: practical stateless hash-based signatures”, http://sphincs.cr.yp.to/sphincs-20141001.pdf.

SPHINX09

Danezis, G., Goldberg, I., β€œSphinx: A Compact and Provably Secure Mix Format”, DOI 10.1109/SP.2009.15, May 2009, https://cypherpunks.ca/~iang/pubs/Sphinx_Oakland09.pdf.

SPHINXSPEC

Angel, Y., Danezis, G., Diaz, C., Piotrowska, A., Stainton, D., β€œSphinx Mix Network Cryptographic Packet Format Specification”, July 2017, https://katzenpost.network/docs/specs/pdf/sphinx.pdf.

TORDIRAUTH

β€œTor directory protocol, version 3”, https://spec.torproject.org/dir-spec/index.html.

TORSRV

β€œTor Shared Random Subsystem Specification”, https://spec.torproject.org/srv-spec/index.html.

XWING

Manuel Barbosa, Deirdre Connolly, JoΓ£o Diogo Duarte, Aaron Kaiser, Peter Schwabe, Karoline Varner, Bas Westerbaan, β€œX-Wing: The Hybrid KEM You’ve Been Looking For”, https://eprint.iacr.org/2024/039.pdf.

20 -

katzenqt1 chat over Reticulum

katzenqt1 is the group chat protocol of katzenqt, hushphone, jam, frank_chat and katzenrns. It is CBOR messages of a few types, each written as one BACAP box in the writer’s own stream. A client keeps a read capability and an index for every member and reads each member’s next box; acknowledgements inside the messages tell it where others have got to. All of that logic is in a sans-IO state machine, written once per language, and the same machine runs on the Katzenpost mixnet and on a Reticulum mesh. What differs is the transport beneath it: on the mixnet the client daemon does BACAP and carries the boxes; on a mesh the transport does BACAP itself and sends the boxes of Pigeonhole protocols over Reticulum. So a new mesh client is a transport, or a transport and a state machine that matches these:

language repository state machine
Lean katzenpost/cryptlib Cryptlib/Protocols/GroupComm/GroupChatKatzenqt1/Member/
Rust katzenpost/pigeonhole_protocols rust/src/protocols/katzenqt1/member/
Go katzenpost/pigeonhole_protocols go/protocols/katzenqt1/member/
Python katzenpost/pigeonhole_protocols py/pigeonhole_protocols/katzenqt1/member/

The transports written so far, one interface per language, which the state machine’s engine drives:

transport who does BACAP implementations
Katzenpost mixnet, through kpclientd the client daemon Rust rust/src/transport/thinclient/, Go go/transport/thinclient/, Lean GroupChatKatzenqt1/Daemon.lean in katzenpost/group_chat_client
Reticulum mesh the transport, with hpqc Python transport/reticulum.py and katzenrns/transport.py over pigeonhole_rns, Rust reticulum_rs.rs on Reticulum-rs and reticulum_py.rs over the Python one
in memory, for tests the transport Rust sim.rs, Go go/transport/sim/, Python transport/sim.py, Lean GroupChatKatzenqt1/Sim.lean

The interface itself: Rust transport/mod.rs, Go transport/transport.go, Python transport/base.py, Lean GroupChatKatzenqt1/Backend.lean.

The protocol’s rules, rosters, acknowledgements, backfill, numbering and rewrite, are the Group chat specification; vouchers are the Contact voucher specification.

This document gives versions 4 and 5 of the wire format. Version 5 is version 4 with one more message type, packed texts. Readers accept version 3 as well, whose encodings are at the end. A client has one setting, the wire version it writes, 3, 4 or 5 (default 5): it chooses the encoding of the messages and ReplyWho the client writes and the voucher form it mints, compact under 4 and 5. Answering someone else’s voucher always follows that voucher’s form. A group can mix members writing any of these versions; a reader of version 4 cannot show packed texts.

Model

  • A member is a BACAP stream that it alone writes. Its key is the first 32 bytes of its read capability.
  • A group is a set of members. Reading the group is reading every member’s stream from the position given for it.
  • Membership changes are Introductions written in members’ streams. Every box carries its writer’s membership hash.
  • A newcomer joins through a voucher that a member answers.
  • The mesh has no copy command: every box is written directly, in the order given here.

What the state machine asks of the transport

operation on the mesh
new stream a fresh write capability
write box (write capability, index, plaintext) pad, encrypt, sign, write query; ALREADY_EXISTS is a refusal
read box (read capability, index) read query: NOT_FOUND, TOMBSTONE, or the plaintext
watch box (read capability, index) after a miss, subscribe to its Box ID when the replica serves push; false otherwise
next index the BACAP derivation
epoch ⌊(now βˆ’ 1496275200) / 604800βŒ‹
box size the one the group’s members pad to
voucher mint, derive stream, induct, open below

Vouchers

voucher 32 bytes, BLAKE2b-256 of the VoucherPayload; shown and carried as standard base64
voucher stream a write capability generated from SHAKE256(voucher): Ed25519 seed 32 bytes, KDF state 32, counter 16
box 0 the VoucherPayload, at the stream’s first index
boxes 1 on the sealed VoucherReply, framed, from the next index
forms compact, minted under wire versions 4 and 5; long, the version 3 form, minted under 3
seal sealed box over the hybrid NIKE X25519 then CTIDH1024, CTIDH1024-X25519
salt 32 bytes

The newcomer:

  1. Makes a stream S and a seal keypair (sk, pk).
  2. Signs [S’s read capability, pk, display name], as CBOR, with S’s root key (Ed25519), and builds VoucherPayload [S’s read capability, pk, display name, signature]. voucher = BLAKE2b-256 of its bytes.
  3. Writes the VoucherPayload to box 0, at the smallest allowed box size that holds it, 1549 on a mesh. Gives the voucher away.
  4. Reads box 1, then each next box while the one before began with C. NOT_FOUND: wait. TOMBSTONE: withdrawn, the join failed. The boxes’ bytes after their first byte, joined, are the sealed reply: open with sk, giving VoucherReply [WhoReply, salt].
  5. Mutates S’s write capability KDF state by the salt and writes the group there from its first index. WhoReply is a ReplyWho: reads each member from the position it gives.

A member answering the voucher:

  1. Derives the voucher stream and reads box 0. NOT_FOUND: wait. TOMBSTONE: withdrawn.
  2. Checks BLAKE2b-256(payload) = voucher, and the signature under the root key in the payload’s read capability. Otherwise stops.
  3. Draws a 32 byte salt. The newcomer’s live read capability is the payload’s read capability with its KDF state mutated by the salt. Builds the ReplyWho: every member with the position the newcomer starts from, its own stream at the index its Introduction will occupy.
  4. Writes Introduction [display name, live read capability] to its own stream at that index, ahead of anything queued.
  5. Seals VoucherReply [WhoReply, salt] to pk, cuts the sealed bytes into pieces of the box size less one, and writes them from box 1 on, each behind C, the last behind F, each until a replica holds it. ALREADY_EXISTS on box 1: another member answered; stop.
  6. Reads the newcomer at the live read capability from its first index.

A member answers one voucher at a time. Box 0 is left as written; the voucher is spent once box 1 exists. A payload in the version 3 form, a CBOR map, is answered wholly in version 3: the map form of the ReplyWho, sealed with the MKEM, unframed, in box 1 alone.

ReplyWho

A CBOR array of one to five elements; trailing Nones are left out.

struct ReplyWho(
    /// One per place: `None` for a member the sender cannot name.
    Vec<Option<PleaseAdd>>,
    /// One per place, the member's roster; `None` while unknown.
    Option<Vec<Option<Vec<RosterEntry>>>>,
    Option<Vec<IntroAt>>,
    /// The sender's place.
    Option<usize>,
    /// The newcomer's place: the number of places.
    Option<usize>,
);
struct PleaseAdd(String, [u8; 136]);        // [display_name of 1 to 30 characters, read_cap]: key, then the start index
struct RosterEntry(usize, Option<usize>);   // [roster index, place]
struct IntroAt(usize, [u8; 104], usize);    // [by, index, member]

A reply is refused when a place’s name is empty or longer than 30 characters or its read capability is not 136 bytes. Without rosters, the newcomer takes the places named, the first as introducer, every roster unknown. With rosters, the reply is refused when the newcomer’s place is not the number of places, the introducer’s is not below it, there are 256 places or more, two places share a key, a roster names a place that does not exist, or the introducer’s place is None.

A pigeonhole service that admits a client answers with one place and no rosters.

Boxes

plaintext meaning
F, GroupChatMessage CBOR a message
empty tombstone: the message was deleted, step past
C, bytes a chunk of a long message, in its own stream
I, 4 byte big endian chunk count, 136 byte read capability of the chunk stream; or I and the read capability a long message: its chunks are C boxes ending in an F box

A message fits the box size less one byte. A reader that does not fetch chunks steps past an I box.

GroupChatMessage, a CBOR array of four to seven elements; trailing Nones are left out:

struct GroupChatMessage(
    u64,                 // version: 4 or 5
    [u8; 32],            // membership_hash
    u64,                 // msg_type, below
    Body,                // the type's payload; None for control
    Option<Vec<u8>>,     // acks, layout under Encoding
    Option<Vec<u8>>,     // numbers, layout under Encoding
    Option<Vec<u8>>,     // introduced_at, layout under Encoding
);
enum Body {
    Text(String),                                   // type 0
    Introduction(String, [u8; 136]),                // type 1: [display_name, read_cap]
    FileUpload(Vec<u8>, String, String),            // type 2: [payload, filetype, basename]
    Who(Value), ReplyWho(Value), Tally(Value),      // types 3, 4, 5 to 9: as the app defines
    None,                                           // types 10 and 11
    Packed(Vec<String>),                            // type 12, version 5
}
msg_type message body
0 text the text
1 introduction [display_name of 1 to 30 characters, read_cap]
2 file upload [payload, filetype, basename]
3 who the app’s value
4 reply who the app’s value
5 to 9 tally the app’s value
10 control None; only acks, numbers, introduced_at
11 ping None
12 packed texts [text, …], one or more; version 5 only

Any message may carry acks, numbers and introduced_at. A ping is content with no body: its readers play their ping sound and show that the sender pinged, instead of a line of text.

A packed box holds several texts in the order they were sent, and reads as that many messages.

A reader refuses a message of a version above its own, of a type above 12, or of type 12 in a version below 5; a reader built for version 4 therefore refuses a packed box, with the texts and acknowledgements in it. When a refused message is in the array form and its first three elements are a version, a byte string and a type, the client tells the user who sent it, its type and its version, and that the app needs updating, rather than dropping it silently.

membership_hash = SHA-256(KP:membership:v1 β€– the members’ keys, each once, in byte order, one’s own included). The sentinel for an unknown membership is 32 zero bytes; readers also accept TODO repeated to 32 bytes. Content carries the hash as written; control messages and Introductions carry the writer’s current membership, or the sentinel.

Geometry

B is the box size of Pigeonhole protocols over Reticulum. A member’s box holds B βˆ’ 1 bytes of GroupChatMessage CBOR; each box of a sealed reply holds B βˆ’ 1 bytes of it. The costs below are measured on the encoders. h is a string’s CBOR header: 1, 2 or 3 bytes below 24, 256 and 65536 bytes.

field or message bytes
GroupChatMessage: array, version, membership_hash, msg_type 37
text of n bytes h + n
introduction, name of n bytes 139 + h + n
file upload, payload n, type t, name b 1 + (h + n) + (h + t) + (h + b)
control or ping body 1
acks of k members h + 1 + naming + 8k; naming ≀ k
numbers of m prefixes h + 1 + 8m
introduced_at 107
each absent field before a present one 1
ReplyWho without rosters, places with names of n 2 + Ξ£ (139 + h + n)
ReplyWho with rosters and p βˆ’ 1 introductions, names of 10 160, 429, 1272, 2797 for p = 1, 2, 5, 10
VoucherPayload, name of n bytes 367 + h + n
sealed VoucherReply WhoReply + 211 + h
seal public key, X25519 β€– CTIDH1024 160

Version 3 cost 70 for the fixed fields, 5 + h + n for a text, 174 + h + n for an introduction, 451 + h + n for a VoucherPayload and 359 for the seal, so version 4 saves 33 bytes on every box, 35 on an introduction, 84 on a voucher and 146 on its reply. With B = 1000: a text is at most 959 bytes; a control message acknowledges up to 117 members; a sealed reply that fits box 1 alone holds a WhoReply of 786 bytes, five places without rosters or three with; a larger reply continues in box 2 and on. With B = 256, the mesh size: a text is at most 216 bytes, and a sealed reply always spans several boxes.

Reading

  • Each member’s stream from the position given: a box or a tombstone advances, NOT_FOUND waits with the backoff.
  • On a replica that serves push, a NOT_FOUND also subscribes to that box, and the stream waits for the push instead, reading again every 5 minutes in case the push was lost.
  • acks, numbers and introduced_at let a reader jump and backfill, as the state machine does.
  • Replicas keep a box for one to two weeks; older boxes are recovered only through acknowledgements.

Sending

  • A text sent while the box before it still waits to be written goes into that box, as a packed box, as long as the texts fit the box size. Only a client writing version 5 packs.
  • What a member has read is acknowledged in its next message. When it has sent nothing 30 seconds after reading something, a control message carries the acknowledgements alone.
  • A box that carries no content, such as a control message, is not acknowledged on its own, so receipts do not answer receipts.

Vectors

In katzenpost/pigeonhole_protocols: testdata/katzenqt1/groupchat_wire_vectors_v5.json and groupchat_wire_vectors_v4.json (messages, written boxes, membership hashes, replies, refused arrays) for versions 5 and 4, testdata/katzenqt1/groupchat_wire_vectors.json for version 3, and testdata/katzenqt1/acks_vectors.json. Voucher vectors are voucher/testdata/voucher_vectors_v2.json in katzenpost/hpqc, beside the version 3 file; BACAP vectors are in the same repository. katzenrns is a client of this protocol on meshes: match the above, not its storage or interface. pigeonhole_protocols main writes version 5. The compact voucher form is on the branch katzenqt1-v4 of hpqc (hpqc#154, not merged), which the Go module of pigeonhole_protocols pins; the deployed version 3 is the branch katzenqt1-v3 of hpqc, cryptlib, group_chat_client, katzenqt and katzenrns.

Encoding

Byte fields are CBOR byte strings; names are text strings; integers are unsigned.

Acks

Empty when nothing is acknowledged. Otherwise:

  • one byte, the number of members acknowledged, less one,
  • the members by roster index, ascending: a list of one byte each when no longer than the bitmap, else a bitmap, most significant bit first, as many bytes as the highest index needs,
  • one 8 byte little endian counter per member, same order: the counter of the acknowledged box.

A naming as long as the count is a list, shorter is a bitmap. Refused: a list out of order, a bitmap ending in a zero byte, a bitmap naming other than the count.

Numbers

  • one byte, the first roster index assigned,
  • one 8 byte key prefix per member numbered, consecutive indexes.

A single byte is the roster index of the member the same message’s Introduction introduces. Refused when the indexes would pass 255 or a prefix repeats.

IntroducedAt

  • one byte, the introducer’s roster index in the sender’s roster,
  • the 104 byte index of the Introduction in the introducer’s stream.

Voucher stream

  • VoucherPayload, box 0: array [read_cap 136 bytes, seal public key 160 bytes, display_name, signature 64 bytes]; the signature is Ed25519 by the read cap’s root key over the CBOR array of the first three.
  • Voucher: BLAKE2b-256 of the VoucherPayload’s bytes.
  • VoucherReply: array [WhoReply, the ReplyWho’s CBOR; salt 32 bytes]. Sealed as a sealed box to the seal public key: the sender’s ephemeral hybrid public key, 160 bytes, then XSalsa20-Poly1305 of the plaintext under a key and nonce derived from the shared secret, as hpqc’s sealedbox defines, with the NIKE named CTIDH1024-X25519.
  • Boxes 1 on: the sealed bytes in pieces of B βˆ’ 1, each behind one byte, C for a piece that continues, F for the last.

Version 3

Readers accept these as well; a version 3 message is a CBOR map, a version 4 one an array, which the first byte tells apart.

GroupChatMessage: map version 3, membership_hash, msg_type, then the type’s field under its name, text, introduction (map display_name, read_cap), file_upload (map payload, filetype, basename), who, reply_who, tally, and optional acks, numbers, introduced_at. A map without msg_type has the type of the field it carries, text when none.

ReplyWho: map please_adds (array of map display_name, read_cap, or null), rosters, introductions, introducer, newcomer, the last four absent when unknown.

Vouchers: PleaseAdd is map DisplayName, MessageReadCap; SignedPleaseAdd map PleaseAdd (its CBOR), Signature (64 bytes, Ed25519 by the stream’s root key over the PleaseAdd bytes); VoucherPayload map SignedPleaseAdd (its CBOR), VoucherPubKey; all canonical CBOR. VoucherReply is map WhoReply, VoucherSalt, sealed with hpqc’s MKEM to the seal public key and written unframed to box 1 alone. A member answers a version 3 payload in this form, with the map form of the ReplyWho inside.

21 -

Pigeonhole protocols over Reticulum

A client on a Reticulum mesh reads and writes Pigeonhole boxes through one replica. This is what it sends and receives. Replicas, replication and bridges are in Pigeonhole replicas over Reticulum; BACAP is in the Echomix paper; a protocol on top, such as katzenqt1 chat, adds only what it writes into boxes.

Replicas

destination application name pigeonhole, aspect replica, single
announce application data 1 byte of flags, under Encoding; a bridge’s 16 byte mixnet id after it
identity the first heard for a destination; a later one for it is ignored
entry replica any announced replica with a path, the fewest hops by preference

A client uses one replica at a time. The mesh runs in open mode: every replica holds every box, and the replica forwards, syncs and finds boxes, and fetches from the mixnet through a bridge; the client does none of that. Replicas are authenticated only by Reticulum’s classical link encryption.

Requests

A query reaches a replica over a Link to its destination, in one of two modes. One Link per replica, reopened when it closes. Both modes carry the same query and reply bytes.

Request mode, every replica not announcing bit 3: the query is a Link request; a request above the MTU travels as a Resource. Up to 8 queries in flight per Link.

path query reply
write write query result code
read read query result code, and the box when found

A query whose first byte disagrees with its path is answered INVALID_PAYLOAD. subscribe and unsubscribe are under Push; the other paths are replica to replica. Reference timeouts: 20 s to establish a Link, 60 s per request; an unanswered request is asked again later, not repeated at once.

Channel mode, replicas announcing bit 1: the query goes as Channel messages on the Link, for Reticulum stacks with Links and Channel but no requests or Resources. A client uses it only with replicas that announce bit 1, and only it with those announcing bit 3. The query’s first byte picks read, write, subscribe or unsubscribe. A query or reply larger than one Channel message is cut into pieces, each a frame carrying the query’s request id; the reply comes back with the same request id. A replica drops a request whose pieces do not all arrive within 60 s. Bytes on the air for one box of 1000 bytes, client sent / received:

link MTU query request mode channel mode
500 write 1484 / 297 1468 / 428
500 read 329 / 1436 480 / 1456

Channel mode costs about 100 to 150 bytes more per query, mostly the proof every Channel packet gets. With the mesh box size a write is one packet in either mode.

Choosing a mode. The reference clients have a mode setting: request, Channel, or auto, the default. Auto asks a replica with a Link request first and, when a replica announcing bit 1 does not answer, asks again in Channel mode, and keeps per replica the mode that answered. The Rust client on Reticulum-rs speaks Channel mode alone. Replicas asking each other go Channel first, as Pigeonhole replicas over Reticulum describes.

Push

A client on a replica announcing bit 4 need not poll. After a read answers NOT_FOUND, the client subscribes to that Box ID on the same Link, in Channel mode, since pushes come only over Channel. When the replica holds the box it pushes it down the Link, and the client takes the box from the push with no further query. The client then subscribes to the stream’s next Box ID.

Pushes are best effort, so a stream waiting for a push still reads again every 5 minutes, which also renews the subscription. A subscription lasts at most an hour, and a Link waits for at most 256 Box IDs. Against a replica that does not announce push, and on the mixnet, the client polls with backoff.

Boxes

BACAP under the context string pigeonhole context.

bytes
Box ID: a blinded Ed25519 public key 32
signature: Ed25519 over the ciphertext, verifying under the Box ID 64
MessageBoxIndex: counter 8, blinding factor 32, encryption key 32, KDF state 32 104
read capability: root public key, MessageBoxIndex 136
write capability: root private key as seed then public key, MessageBoxIndex 168
ciphertext box size + 20

For the context string, the index’s blinding factor and encryption key each go through HKDF-BLAKE2b-512 with the string as salt. Box ID = root public key blinded by the derived factor. Ciphertext = AES-GCM-SIV under the derived key, nonce the first 12 bytes of the Box ID, the Box ID as associated data. Signature = root private key blinded by the derived factor, over the ciphertext. A capability’s index is its first box; each next index derives from the one before it.

The box size B is the plaintext a box carries. Two sizes are fixed: mesh, 256 bytes, the largest multiple of 16 whose write fits one Link packet at the 500 byte MTU with 16 bytes to spare in either mode, and mixnet, 1549 bytes, the Katzenpost box. A client on Reticulum writes at mesh. A box too long for that size is written at the smallest allowed size that holds it; only a voucher’s box 0 ever is, since it is never split. A reader takes a box of any allowed size: the two above, the size its transport was given (1549 by default) and any configured extra, each from 141 to 16384 bytes. It refuses any other size, and a replica answers a box of a size it does not take with INVALID_PAYLOAD.

Geometry

Every size on the mesh follows from the box size B, the plaintext a box carries.

quantity bytes
plaintext a protocol may put in a box B
padded plaintext B + 4
ciphertext B + 20
read query 33
write query B + 117
reply to a write, or a miss 1
reply with a box B + 85
tombstone write 97

With B = 256 that is a 276 byte ciphertext and a 373 byte write query; with B = 1549, 1569 and 1666. The seal scheme the protocols on top use is the hybrid NIKE CTIDH1024-X25519 and the signature scheme is Ed25519, as on the mixnet.

Writing, reading, deleting

action send outcome
write write query SUCCESS or ALREADY_EXISTS: the replica holds a box under that ID. Same bytes: SUCCESS. Different bytes: ALREADY_EXISTS, nothing changes; use the next index.
read read query SUCCESS with the box: verify, open. NOT_FOUND: not written yet; poll with backoff, reference 2 s doubling to 60 s. TOMBSTONE: deleted.
delete write query, empty ciphertext replaces the box; a box never replaces a tombstone

A client that has seen a tombstone treats that Box ID as deleted from then on; other replicas may hold the box until the tombstone reaches them.

Epochs

The mesh carries no epoch. Replica epoch = ⌊(now βˆ’ 1496275200) / 604800βŒ‹, Unix seconds. A replica keeps a box for the replica epoch it arrived in and the next, one to two weeks, and may evict the oldest boxes earlier when its disk is full.

Overhead

This section gives the bytes every operation puts on a Reticulum link at the 500 byte MTU, for the two box sizes a mesh carries, B = 256 and B = 1549.

The Pigeonhole bytes are measured: queries and replies come from the encoders of pigeonhole_rns with BACAP from hpqc, and agree with testdata/box_sizes.json in pigeonhole_protocols. The voucher sizes are measured from voucher_vectors_v2.json in hpqc. Reticulum’s encryption overhead is measured by encrypting with RNS 1.5.7’s own Token.

The packets are computed from RNS 1.5.7’s framing: the header and proof sizes are its constants, and its own ResourceAdvertisement packs the advertisements. The same computation reproduces, byte for byte, the eight figures tests/e2e_two_replicas.py measured for a 1000 byte box in the table under Requests, once a 20 byte keepalive in each direction is counted in the Channel read. Airtime is computed from these sizes with the LoRa formula below; it has not been measured on radios.

Layers

layer bytes
read query 33
write query B + 117: 373 for B = 256, 1666 for B = 1549
tombstone write 97
reply: written, not found, tombstone 1
reply with a box B + 85: 341, 1634
subscribe to n Box IDs, and its answer 3 + 32n, and 5
push 32 + the reply with the box: 373, 1666
Link request: msgpack of [time, path hash, query] the query plus 30, or 31 from 256 bytes
Link response: msgpack of [request id, reply] the reply plus 21, or 22 from 256 bytes
Channel: envelope and frame header, per piece of at most 421 bytes 10
Link encryption of n bytes: IV, padding to 16 bytes, HMAC 48 + 16 Γ— (⌊n / 16βŒ‹ + 1)
packet header: flags, hops, link id, context 19
proof of a Channel packet: header, packet hash, signature 115

A Link request or response larger than the Link MDU, 431 bytes, travels as a Resource: the sender encrypts it once with 4 random bytes before it and sends an advertisement (211 bytes here) and parts of up to 464 bytes, each behind a header; the receiver sends a request for the parts (131 bytes for four) and an 83 byte proof. Requests and responses that fit one packet are not proven; every Channel packet is.

The mesh write in request mode, layer by layer: the 373 byte query becomes 404 bytes of msgpack, 464 of ciphertext and a 483 byte packet; the one byte reply becomes 22, 64 and 83. In Channel mode the query becomes 383 bytes framed, 432 encrypted and a 451 byte packet; the reply becomes 11, 64 and 83, and each side proves the other’s packet with 115 bytes.

Opening a Link costs 86 bytes from the client, 118 from the replica and 83 from the client again, once per Link. An open Link sends a 20 byte keepalive each way at most every 360 seconds.

Per operation

Each cell lists the packets one side sends, in bytes. bytes is the sum of both sides. The LoRa frames column is for an RNode, which sends a packet of up to 254 bytes in one frame and a larger one in two, each frame carrying one byte of its own header, so the LoRa payload on the air is the bytes plus one per frame.

B = 256

operation mode client sends replica sends bytes packets LoRa frames
write Link request 483 99 582 1 + 1 2 + 1
write Channel 451 + 115 83 + 115 764 2 + 2 3 + 2
tombstone Link request 195 99 294 1 + 1 1 + 1
tombstone Channel 179 + 115 83 + 115 492 2 + 2 2 + 2
read, found Link request 131 435 566 1 + 1 1 + 2
read, found Channel 115 + 115 419 + 115 764 2 + 2 2 + 3
read, not found Link request 131 99 230 1 + 1 1 + 1
read, not found Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
read, tombstone Link request 131 99 230 1 + 1 1 + 1
read, tombstone Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
subscribe Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
push Channel 115 451 566 1 + 1 1 + 2

B = 1549

operation mode client sends replica sends bytes packets LoRa frames
write Link request 211 + 483 + 483 + 483 + 387 131 + 83 + 99 2360 5 + 3 9 + 3
write Channel 499 + 499 + 499 + 483 + 115 83 + 115 + 115 + 115 + 115 2638 5 + 5 9 + 5
tombstone Link request 195 99 294 1 + 1 1 + 1
tombstone Channel 179 + 115 83 + 115 492 2 + 2 2 + 2
read, found Link request 131 + 131 + 83 211 + 483 + 483 + 483 + 339 2344 3 + 5 3 + 9
read, found Channel 115 + 115 + 115 + 115 + 115 499 + 499 + 499 + 451 + 115 2638 5 + 5 5 + 9
read, not found Link request 131 99 230 1 + 1 1 + 1
read, not found Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
read, tombstone Link request 131 99 230 1 + 1 1 + 1
read, tombstone Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
subscribe Channel 115 + 115 83 + 115 428 2 + 2 2 + 2
push Channel 115 + 115 + 115 + 115 499 + 499 + 499 + 483 2440 4 + 4 4 + 8

A read that finds nothing costs the same at either size. Only a found box, a write and a push grow with B. At B = 256 every query and reply fits one packet, but a packet over 254 bytes, such as a mesh write, takes two LoRa frames. A write fits one LoRa frame only for B ≀ 48 in Channel mode and B ≀ 27 in request mode.

Joining through a voucher

The voucher protocol is reads and writes of boxes, so its cost is the sum of the rows above. The newcomer writes the VoucherPayload to box 0 and reads the sealed reply from box 1 on. The inductor reads box 0, writes its Introduction to its own stream, and writes the sealed reply in pieces of B βˆ’ 1 bytes, one box each. The compact VoucherPayload is 373 bytes for a five letter display name, 368 bytes plus a name shorter than 24 bytes, so it does not fit a 256 byte box. It is written at the smallest allowed size that holds it, 1549 by default, and read at that size. An Introduction with a name of 10 bytes is 188 bytes and takes one mesh box.

The sealed reply is the ReplyWho plus 213 bytes for a ReplyWho of 24 to 255 bytes, plus 214 from 256: the vectors’ 37 byte ReplyWho seals to 250. The ReplyWho grows with the group; with rosters and names of 10 bytes it is 160 bytes for a group of one member, 429 for two and 1272 for five. The table gives each side’s bytes, with the reply boxes written at B = 256; in the last three columns the newcomer’s exchange comes first, then the inductor’s.

members sealed reply boxes mode newcomer sends inductor sends replicas send packets LoRa frames
1 373 2 Link request 2309 1794 1183 + 2296 12 + 14 18 + 21
1 373 2 Channel 2555 2273 1611 + 2657 18 + 22 24 + 29
2 643 3 Link request 2440 2277 1618 + 2395 14 + 16 21 + 24
2 643 3 Channel 2785 2839 2145 + 2855 22 + 26 29 + 34
5 1486 6 Link request 2833 3726 2923 + 2692 20 + 22 30 + 33
5 1486 6 Channel 3475 4537 3747 + 3449 34 + 38 44 + 49

These assume every box is there when read. Each read that finds a box not yet written adds a miss, and a subscription adds the subscribe and the push in place of the second read. The inductor then reads the newcomer’s stream like any other member’s.

LoRa airtime

A LoRa frame of PL bytes takes, by Semtech’s formula,

T_sym      = 2^SF / BW
n_payload  = 8 + max(⌈(8 PL βˆ’ 4 SF + 28 + 16 CRC βˆ’ 20 IH) / (4 (SF βˆ’ 2 DE))βŒ‰ Γ— (CR + 4), 0)
T_frame    = (n_preamble + 4.25) Γ— T_sym + n_payload Γ— T_sym

with RNode’s settings: CRC on (CRC = 1), explicit header (IH = 0), low data rate optimization (DE = 1) when a symbol lasts more than 16 ms, and n_preamble = max(18, ⌈24 ms / T_symβŒ‰). CR = 1 is coding rate 4/5. The firmware programs four symbols fewer into SX127x radios, so their frames are four symbols shorter than given here. Channel access waits are not counted.

setting symbol preamble low data rate optimization 21 B frame 116 B frame 255 B frame
SF7, 125 kHz, 4/5 1.024 ms 24 symbols off 73 ms 211 ms 416 ms
SF9, 125 kHz, 4/5 4.096 ms 18 symbols off 226 ms 656 ms 1291 ms
SF12, 125 kHz, 4/5 32.768 ms 18 symbols on 1810 ms 4923 ms 9347 ms

The 21, 116 and 255 byte frames are a keepalive, a proof and a full frame, each with RNode’s header byte.

Under the EU 868 MHz duty cycle of 1 %, each transmitter may be on the air 36 seconds an hour. The table gives, per operation, the client’s and then the replica’s airtime, and how many such operations each could send an hour on that budget alone, one hop apart. A replica answering several clients shares one budget among them, and every transport node on a longer path spends its own.

operation B mode SF7 SF9 SF12
write 256 Link request 0.80 / 0.19 s; 45 / 188 2.48 / 0.59 s; 14 / 60 17.87 / 4.27 s; 2 / 8
write 256 Channel 0.96 / 0.38 s; 37 / 95 2.99 / 1.17 s; 12 / 30 21.82 / 8.70 s; 1 / 4
read, found 256 Link request 0.24 / 0.72 s; 152 / 49 0.74 / 2.25 s; 48 / 15 5.41 / 16.40 s; 6 / 2
read, found 256 Channel 0.42 / 0.91 s; 85 / 39 1.31 / 2.85 s; 27 / 12 9.85 / 20.83 s; 3 / 1
read, not found 256 Link request 0.24 / 0.19 s; 152 / 188 0.74 / 0.59 s; 48 / 60 5.41 / 4.27 s; 6 / 8
read, not found 256 Channel 0.42 / 0.38 s; 85 / 95 1.31 / 1.17 s; 27 / 30 9.85 / 8.70 s; 3 / 4
subscribe 256 Channel 0.42 / 0.38 s; 85 / 95 1.31 / 1.17 s; 27 / 30 9.85 / 8.70 s; 3 / 4
push 256 Channel 0.21 / 0.75 s; 170 / 47 0.66 / 2.34 s; 54 / 15 4.92 / 16.89 s; 7 / 2
write 1549 Link request 3.40 / 0.59 s; 10 / 60 10.58 / 1.85 s; 3 / 19 76.42 / 13.46 s; < 1 / 2
write 1549 Channel 3.46 / 1.01 s; 10 / 35 10.76 / 3.14 s; 3 / 11 78.39 / 23.47 s; < 1 / 1
read, found 1549 Link request 0.64 / 3.33 s; 56 / 10 1.99 / 10.37 s; 18 / 3 14.61 / 74.95 s; 2 / < 1
read, found 1549 Channel 1.06 / 3.41 s; 34 / 10 3.28 / 10.62 s; 10 / 3 24.62 / 77.41 s; 1 / < 1
join, newcomer Link request 3.87 / 2.04 s; 9 / 17 12.05 / 6.36 s; 2 / 5 87.25 / 46.26 s; < 1 / < 1
join, inductor Link request 3.03 / 3.90 s; 11 / 9 9.43 / 12.16 s; 3 / 2 68.23 / 87.75 s; < 1 / < 1
join, newcomer Channel 4.30 / 2.83 s; 8 / 12 13.39 / 8.84 s; 2 / 4 98.08 / 65.13 s; < 1 / < 1
join, inductor Channel 3.94 / 4.54 s; 9 / 7 12.26 / 14.13 s; 2 / 2 90.06 / 103.51 s; < 1 / < 1

The join rows are for a group of one member, request mode on a Python replica and Channel mode on a Rust one. At SF7 a client can write a mesh box about 40 times an hour; at SF9, about 13; at SF12, once or twice. A 1549 byte box costs three to four times a mesh box to write. At SF12 that write takes over 76 seconds, more than two hours of a 1 % budget, and every join makes one.

Encoding

All integers are big endian.

Queries

A read is 33 bytes:

  • one byte, 0,
  • the 32 byte Box ID.

A read with anything after the Box ID is refused.

A write is 97 bytes and the ciphertext:

  • one byte, 1,
  • the 32 byte Box ID,
  • the 64 byte signature,
  • the ciphertext, to the end of the message. There is no length field.

A tombstone is a write with an empty ciphertext: 97 bytes.

Replies

  • one byte, the result code,
  • for SUCCESS to a read that found a box, the 64 byte signature and the ciphertext.

A reply to a write is the one byte. A reply to a read of an unwritten box, or of a tombstone, is the one byte.

The codes are the Pigeonhole replica’s: 0 SUCCESS, 1 NOT_FOUND, 2 INVALID_BOX_ID, 3 INVALID_SIGNATURE, 4 DATABASE_FAILURE, 5 INVALID_PAYLOAD, 6 STORAGE_FULL, 7 INTERNAL_ERROR, 8 INVALID_EPOCH, 9 REPLICATION_FAILED, 10 ALREADY_EXISTS, 11 TOMBSTONE.

Padding

The plaintext of a box is:

  • a 4 byte length,
  • the data,
  • zeros, to the box size plus 4 bytes.

The ciphertext is that plaintext encrypted, with a 16 byte tag: the box size plus 20 bytes.

Channel frames

Each Channel message has MSGTYPE 0x5048 and carries one frame:

  • a 2 byte request id,
  • a 1 byte piece index, from 0,
  • a 1 byte piece count, 1 to 255,
  • the piece: the next slice of the query or reply.

A frame whose count is 0 or differs from earlier pieces of its request, or whose index is not below its count, is dropped with its request. Duplicate pieces are ignored.

Piece size is computed from the Link: link MDU = ⌊(MTU βˆ’ 68) / 16βŒ‹ Γ— 16 βˆ’ 1, Channel MDU = link MDU βˆ’ 6, piece = Channel MDU βˆ’ 4. At MTU 500 that is 431, 425 and 421 bytes, so a write of one 1000 byte box takes three frames.

Channel messages

A stack that is not RNS must match RNS’s Channel on the wire:

  • each message is one Link packet with context CHANNEL (0x0E), its payload a 6 byte envelope, MSGTYPE, sequence number and length, each 2 bytes, then the message;
  • sequence numbers wrap at 65536; messages are delivered in order, and out-of-order ones buffered up to 48 ahead; duplicates and ones behind are dropped;
  • every Channel packet is acknowledged by the Link’s packet proof, and a receiver must prove each one, or the sender retries and then closes the Link;
  • the sender keeps at most its window of unproven packets in flight: 2 at first, one more per delivery, one less per timeout;
  • a packet is resent with the same sequence number up to 5 tries, after 1.5^(tries βˆ’ 1) Γ— max(2.5 Γ— RTT, 0.025 s) Γ— (packets in flight + 1.5).

Subscribe and push

Subscribe is the Channel query 6 | ttl u16 seconds | box id 32 | ..., or the Link request subscribe with the bytes after the 6. The answer is 0 | taken u16 | ttl u16: the first taken IDs are subscribed, for the granted ttl, the asked one capped at 3600, 0 asking for 3600. A malformed subscribe is answered with the one byte 5. Unsubscribe is 7 | box id 32 | ..., or the request unsubscribe, dropping those IDs, or all when none is named; the answer is the one byte 0.

A push is a run of Channel messages of MSGTYPE 0x5049, framed as Channel frames are under request ids the replica counts, carrying box id 32 | reply, the reply as for a read: 0 with the signature and ciphertext, or 11 for a tombstone.

Announce

The application data of a replica’s announce is one byte of flags: bit 0 a bridge, bit 1 serves Channel mode, bit 2 serves the replica paths in Channel mode, bit 3 serves no Link requests, bit 4 serves push. A bridge follows it with its 16 byte mixnet id.

Vectors

testdata/bridge_socket_vectors.json in pigeonhole_rns holds query and reply bytes (queries, replies), queries a replica refuses (malformed) and boxes with what a replica answers each (boxes). The frames in it, a 4 byte length before each message, are the socket between a bridge’s two halves and are not sent by a client.

22 -

Pigeonhole replicas over Reticulum

Abstract

Pigeonhole replicas on a Reticulum mesh give Reticulum users asynchronous group communication with post quantum confidentiality, and let them share groups with users of a Katzenpost mixnet. The mesh side spends as few bytes as it can. The mixnet side keeps its metadata privacy. Neither needs the other to work.

This document assumes the Echomix paper and the Pigeonhole specification. The client’s side is Pigeonhole protocols over Reticulum. The reference implementation is pigeonhole_rns, a Python replica on RNS and a Rust replica on Reticulum-rs.

What crosses over

A box is a Box ID, a ciphertext and a signature. The Box ID is the public key that verifies the signature, the ciphertext is readable only with the read capability, and consecutive boxes of one writer are unlinkable to anyone without it. A replica keeps a box because it verifies, not because of who brought it. Both networks carry this same object, so a box written on one can be carried to the other by anyone who can read it, and it arrives as authentic as it left.

Everything above the box is shared: BACAP, the box sizes, the group chat message format. Everything below it differs, and this document describes the mesh half.

Priorities

The mesh carries bytes over LoRa and similar links, so the design pays public key cryptography as rarely as it can, carries no padding beyond the box, and hides nothing from replicas that the mixnet hides from couriers. The mixnet makes the opposite trade in its own specification.

BACAP is already quantum annoying: without the blinding factor an adversary cannot read a box or link two of them, quantum computer or not. The one thing a quantum adversary can do is forge a write into a box whose ID it has seen. That holds on both networks. What BACAP does not cover is the traffic that carries it: which Box IDs are written and read, and when.

Open mode

A mesh runs in open mode. A replica is any node that announces a Reticulum destination under application name pigeonhole, aspect replica. There is no list to join. The replica’s Reticulum identity is its identity, and the Link a client opens to it is authenticated by it.

Queries travel on that Link as they are. Reticulum’s link encryption gives them X25519 forward secrecy and Ed25519 authentication of the replica, both classical. There is no post quantum authentication of replicas. No session layer is added, because anyone may announce a replica, and a replica sees the Box IDs and read times of the boxes it handles, as a replica does on the mixnet. What remains post quantum is what BACAP protects: contents and linkage.

Every replica holds every box. There is no placement on the mesh; sharding happens only on the Katzenpost side of a bridge, as the mixnet places boxes.

Replication

A replica that newly holds a box, written by a client, forwarded by another replica, pulled from a mixnet or fetched for a reader, forwards it to every replica it has heard except the one it came from. It retries each with backoff until that replica acknowledges with SUCCESS or ALREADY_EXISTS, and stops for one that answers INVALID_SIGNATURE, INVALID_BOX_ID or INVALID_PAYLOAD. A box it already held is not passed on again, so a forward is never forwarded twice. Forwards to different replicas run at once.

Replicas identify themselves on the Links they open to each other, so a receiver knows a forward’s sender and does not send the box back. A write from an unidentified Link is a client’s.

When a replica first hears another, and again after it found it unreachable or either restarted, the two sync. The initiator walks the Box ID space in pages of up to 128 Box IDs, each with a tombstone flag. The responder answers each page with its own page over the same range, cut short when full, and each side enqueues for the other the boxes it lacks. A tombstone is sent where the other holds the box it replaces. An unidentified initiator gets the answer but no boxes.

Reading

A client sends a read to one replica, its entry replica. The entry replica answers from its own store. Otherwise it asks every reachable replica with peek, in the box’s order, which answers from that replica’s store only, a bridge also asking its mixnet. The first box or tombstone found is returned, kept and forwarded like a write, so the next reader finds it on the mesh. If none has it and the entry replica is itself a bridge, it asks its mixnet. Otherwise the reply is NOT_FOUND, and the client asks again later or subscribes to the box.

A box’s order ranks replicas by SHA-256 of the replica’s identity hash and the Box ID, lowest first; anyone who has heard the same replicas computes the same order.

Deleting

A tombstone is a write with an empty ciphertext. It replaces the box and is forwarded and synced like one; a box never replaces a tombstone. A read of a tombstoned box answers TOMBSTONE. A reader that has seen a box’s tombstone treats the box as deleted thereafter, since another replica may hold the box until the tombstone reaches it.

Epochs and garbage collection

Boxes are kept by replica epoch, as on Katzenpost replicas. An epoch is 1200 seconds, a replica epoch is 504 epochs, one week, both counted from 2017-06-01 00:00 UTC (Unix 1496275200):

replica epoch = ⌊(now βˆ’ 1496275200) / 604800βŒ‹

A box is stamped with the replica epoch it arrived in and kept for that epoch and the next, so one to two weeks. Collection runs at start and at every replica epoch boundary. Both periods are settings; a bridge keeps the defaults, since the bridge socket carries no timing and Katzenpost’s replica epoch is a week. The group chat protocol’s acknowledgements and backfill cover readers who were away longer.

Disk limits

A replica keeps every box while there is room. Two limits bound the room: a cap on the box database and its write-ahead log (none by default), and a minimum of free space on its filesystem (500 MiB by default, as Katzenpost’s MinFreeStorageMiB). A new box that would cross either first evicts the oldest boxes, by replica epoch and then arrival, tombstones only after every box, until a sixteenth of the limit is spare. Only when nothing is left to evict is the write answered STORAGE_FULL. Evicted pages are returned to the filesystem, so the limits measure real disk use.

Box sizes

A replica takes boxes of several sizes at once, since meshes and mixnets joined through bridges need not share one. A box’s size is its plaintext size; the ciphertext is the plaintext with a 4 byte length before it, zero padded, plus a 16 byte AEAD tag.

profile plaintext ciphertext write query
mesh 256 276 373
mixnet 1549 1569 1666

mixnet is the Katzenpost box, fixed by the Sphinx geometry. mesh is the largest multiple of 16 whose write, and a replica’s forward of it, fit one Link packet at Reticulum’s 500 byte MTU in either query mode: the 431 byte Link MDU, less request mode’s 31 byte msgpack framing (more than Channel mode’s 11), less the forward’s 113 byte head, less the 20 bytes of box overhead, is 267, rounded down to 256. It is the size clients on Reticulum write.

A replica always takes mesh and mixnet and up to 8 more sizes its operator lists, none over 1549. It refuses a box of any other ciphertext size with INVALID_PAYLOAD, and a replica forwarding such a box drops it. A tombstone is always taken. An operator may instead take any size, which on a bridge leaves the check to the Katzenpost replica.

Bridges

A bridge joins a mesh to a Katzenpost mixnet so that a group can have members on both without either side doing anything. It is a mesh replica like any other, holding every box and syncing, beside a Katzenpost replica that is a member of the mixnet’s PKI. The two share a host and talk over a unix socket on the Katzenpost replica. What the bridge brings in enters under that already vetted replica’s name; this is why the crossing happens between replicas and not through a new door into the mixnet.

A bridge announces the mixnet it serves as an id: the first 16 bytes of SHA-256 of a name its operator gives, such as the mixnet’s PKI identity, the same on every bridge to that mixnet.

Out of the mesh. A bridge writes each box it holds into its mixnet, where the Katzenpost replica treats it as a replication write and dispatches it to the box’s shards. Bridges serving the same mixnet first ask each other, with pushed, whether one has written the box: the first in the box’s order among them writes, and the others wait up to three retries for it and then write themselves. A duplicate write is harmless. A bridge may instead write only the boxes clients write to it, while still pulling for reads.

Into the mesh. Nothing on the mixnet announces a write, so the mixnet cannot push. A read that misses on the mesh reaches the bridges, which ask their Katzenpost replicas; the bridge that finds the box keeps it, with its mixnet as the box’s origin, and it replicates from there. The read is the watch list: no one tells a bridge what to look for.

A box carries its origin through replication, as a forward prefixed with the mixnet id, so no bridge writes a box back into the mixnet it came from, while bridges to other mixnets do. One mesh can join several mixnets without loops. A tombstone written directly on a mixnet does not cross to the mesh, since the bridge socket returns a tombstone without its signature; one written on the mesh reaches every mixnet.

A box from the mixnet appears on the mesh only because a mesh node holding its read capability asked for it by ID, which is a group member; the consent is the membership. A mesh box written into the mixnet shows mixnet replicas nothing the mesh did not already show every mesh replica. What a bridge costs is paid by its operator: a mixnet round trip the first time a mixnet box is read on the mesh, and a replication write into the mixnet for every box the mesh writes.

A bridge carries boxes of every size it takes unchanged.

Query modes

A query reaches a replica over a Link to its destination in one of two modes. Both carry the same query and reply bytes, and one destination serves both.

  1. Request mode. Each query is a Link request to a path; one larger than the Link MDU travels as a Resource.
  2. Channel mode. Each query and reply is a run of Channel messages on the Link, for Reticulum stacks with Links and Channel but no Link requests or Resources. The message’s first byte picks the handler.
path Channel op between
read 0 client and replica
write 1 client and replica
peek 2 replicas
sync 3 replicas
forward 4 replicas
pushed 5 bridges
subscribe 6 client and replica
unsubscribe 7 client and replica

For ops 0 and 1 the op is the query’s own first byte. For the others the op byte is followed by that path’s request bytes and answered with its response bytes.

Replicas ask each other in Channel mode first, when the other announces that it serves the replica paths there (or Channel mode at all, for a write), and fall back to a Link request when it does not or the Channel query fails. A replica announcing that it serves no Link requests, such as the Rust replica, is asked only in Channel mode. The Python replica serves both modes.

A replica handles Channel queries on 2 workers per Link, with up to 16 more queued per Link and at most 32 at once across the replica. A query arriving to a full queue is dropped; the asker times out and asks again.

Push

A client can subscribe on its Link to the Box IDs it waits for, and the replica pushes each box down that Link when it newly holds it: written by a client, arriving by forward or sync, or pulled from a mixnet. A client that never subscribes polls.

A Link waits for at most 256 Box IDs. A subscription lasts the granted time, at most an hour; the client renews it by subscribing again. A Box ID already held is pushed at once. A push consumes the subscription, since a box is written once; the client then subscribes to the stream’s next Box ID. Closing the Link drops every subscription.

Pushes go only over Channel, also to a Link that subscribed with a Link request. They are best effort: a push sent as the Link closes is lost, and one is dropped when 256 wait to be sent on its Link, so a client that reconnects reads what it waits for once and subscribes again.

Group chat on the mesh

The group chat protocol depends only on reading and writing boxes. On the mixnet it writes several boxes through a temporary stream and a copy command so that a courier sees one action; the mesh has no courier to hide from, so a mesh client writes the same boxes directly. Readers on either network see identical streams, and a group whose members are on both talks through a bridge without knowing it.

Encoding

All integers are big endian.

Announce

The application data of a replica’s announce is one byte of flags:

bit set when the replica
0 is a bridge
1 serves Channel mode
2 serves the replica paths in Channel mode too
3 serves no Link requests
4 serves push

A bridge follows the byte with its 16 byte mixnet id. A bridge announcing none serves the mixnet of the empty name. The Rust replica announces bits 1, 2, 3 and 4.

Queries and replies

A read query is one byte 0 and the 32 byte Box ID. A write query is one byte 1, the 32 byte Box ID, the 64 byte signature and the ciphertext to the end of the message; a tombstone has an empty ciphertext. A reply is one result byte, a Pigeonhole replica code, and for a box found the 64 byte signature and the ciphertext. The codes and their meaning for a client are in Pigeonhole protocols over Reticulum.

Replica paths

  • peek: a read query, answered with a reply.
  • forward: the 16 byte mixnet id of the box’s origin, then a write query; answered with a reply. The origin is kept only from an identified replica.
  • sync: a 32 byte lower bound, exclusive, a 32 byte upper bound, inclusive, then up to 128 entries of a 32 byte Box ID and one byte, 1 for a tombstone, in Box ID order. The answer is the 32 byte Box ID where its page ends and up to 128 entries of the same form. A malformed page is answered with nothing.
  • pushed: a 32 byte Box ID and one byte, 1 for a tombstone; answered with one byte, 1 when this bridge has written that box or tombstone into its mixnet or pulled it from there.

Subscribe and unsubscribe

Subscribe, after the op byte in Channel mode or as the subscribe request:

ttl u16 seconds | box id 32 | box id 32 | ...

The answer is 0 | taken u16 | ttl u16. The first taken IDs are subscribed; the rest are refused because the Link already waits for 256. An ID the Link already waits for is renewed and counts as taken. The granted ttl is the asked one capped at 3600, 0 asking for 3600. A malformed subscribe is answered with the one byte 5, INVALID_PAYLOAD.

Unsubscribe, after op 7 or as the unsubscribe request, is a run of 32 byte Box IDs, dropping those from the Link, or every one when none is named. The answer is the one byte 0.

Channel frames

A Channel mode query or reply is one or more Channel messages of MSGTYPE 0x5048:

request id u16 | piece index u8 | piece count u8 | piece

A query or reply is cut into 1 to 255 pieces of at most the Channel MDU less 4 bytes, the Channel MDU being the Link MDU less Channel’s 6 byte envelope: 421 bytes on a 500 byte MTU link. A reply carries its query’s request id. A receiver drops a request whose pieces disagree on the count, or that is incomplete after 60 seconds.

A push is a run of Channel messages of MSGTYPE 0x5049, framed the same way under request ids the replica counts per Link. Its bytes are:

box id 32 | reply

the reply as for a read: 0 with the signature and ciphertext, or 11, TOMBSTONE.

Bridge socket

The two halves of a bridge exchange the same write and read queries and replies over their unix socket, each message behind a 4 byte length. The mesh replica sends writes to put boxes into the mixnet and reads to pull them out. testdata/bridge_socket_vectors.json in pigeonhole_rns, read by the tests of both implementations, pins the format and what a replica answers each box.

Pending

Not merged at the time of writing: Katzenpost replicas holding box sizes besides their geometry’s (katzenpost#1265), so that a bridged mixnet also takes the mesh size.