Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cryptography

audience: contributors

All cryptographic primitives live in zipnet-proto. This chapter is a rationale + proof-sketch document; correctness tests are in zipnet-proto::crypto::tests and the end-to-end algebraic test is zipnet_core::server::tests::e2e_two_servers_three_clients. Nothing on this page is deployment-topology-specific — the KDF schedule and falsification-tag construction are identical under any instance layout. See design-intro for how the instance salt (and hence schedule_hash, once footprint scheduling lands in v2) attaches to a deployment.

Primitives

PurposePrimitiveCrate
Key agreementX25519x25519-dalek 2.0
Key derivationHKDF-SHA256hkdf 0.12
Pad generationAES-128 in CTR modeaes 0.8 + ctr 0.9
Falsification tagkeyed-blake3blake3 1.8
ID derivationkeyed-blake3blake3 1.8
Peer-entry signaturesed25519via iroh

Notable negatives: no signatures from the prototype itself — clients do not ed25519-sign their envelopes because iroh already signs the PeerEntry that carries their bundle and the stream transport is authenticated QUIC. We rely on mosaik’s session security, not on an application-level signature scheme.

Per-round key schedule

For each (client, server, round) pair the protocol computes a one-time pad P of length B = num_slots * slot_bytes:

  shared  = X25519(client_sk, server_pk)                // 32 bytes
  salt    = params_prefix ‖ round ‖ schedule_hash       // 56 bytes
  prk     = HKDF-Extract(salt, shared)                  // 32 bytes
  key     = HKDF-Expand(prk, "zipnet/pad/v1", 16)       // 16 bytes
  iv      = round_le ‖ zeros                            // 16 bytes
  P       = AES-128-CTR(key, iv, zeros of length B)

where params_prefix is a little-endian encoding of (wire_version, num_slots, slot_bytes, tag_len) and schedule_hash is the 32-byte NO_SCHEDULE constant in v1 (the footprint scheduling reservation vector hash in v2).

Why this structure

  • Salt over (params, round, schedule_hash) binds the pad to every negotiated round parameter. A client or server computing with a different RoundParams derives a different pad; in the XOR algebra this reduces the colliding result to noise, not to a silent crypto vulnerability. The WIRE_VERSION in the salt prefix extends this to major-version boundaries.
  • HKDF-Extract over the raw DH shared secret, not a hash of it. X25519 shared secrets are uniform in the twist-restricted subgroup; HKDF’s extract step is the standard step to convert that into a uniform PRK.
  • AES-128-CTR with a round-prefixed IV. A fresh IV = (round‖0⁸) gives every round a non-overlapping counter space; the sequence of counters within a round is (round‖0⁸) + 0, 1, 2, .... As long as two rounds never share round, the AES key–IV pair is never reused. The round: u64 ensures uniqueness across realistic deployments.
  • HKDF-Expand labelled "zipnet/pad/v1". The label guards against accidental reuse of the same PRK across crypto contexts; bumping it to "zipnet/pad/v2" is free domain separation.
  • AES-128 over a stream cipher. AES-NI accelerated; output is pseudorandom; the commutativity that DC nets require (XOR) is immediate.

What this buys

For any honest client C and honest server S that agree on the five inputs (shared_secret, wire_version, num_slots, slot_bytes, tag_len, round, schedule_hash), they derive byte-identical pads. The XOR operation is commutative, so the order in which the aggregator and the committee XOR in their contributions is irrelevant.

For any adversary who does not know shared_secret, the pad is indistinguishable from uniformly random under the standard DDH assumption on Curve25519 (for the X25519 step) and the PRF security of AES-128 (for the expansion step), given a secure HKDF.

What this does not buy

  • Forward secrecy. A compromise of shared_secret compromises every past and future round for that (client, server) pair until the secret is rotated. v2 ratchets shared_secret ← HKDF-Extract(shared_secret, "ratchet") at each round boundary.
  • Authentication of the envelope itself. The mosaik transport authenticates the sender PeerId (ed25519); the pad binds the envelope to round and client via the KDF inputs. But an adversary who can inject bytes at the transport layer as a specific peer can replay or mutate envelopes. We rely on iroh’s QUIC/TLS.

Falsification tags

The paper’s §3 “falsification tag” is a keyed-blake3 XOF of the plaintext message:

pub fn falsification_tag(message: &[u8], tag_len: usize) -> Vec<u8> {
    let key = blake3::derive_key("zipnet:falsification-tag:v1", &[]);
    let mut h = blake3::Hasher::new_keyed(&key);
    h.update(message);
    let mut buf = vec![0u8; tag_len];
    h.finalize_xof().fill(&mut buf);
    buf
}

Why keyed-blake3, not HMAC

  • Keyed-blake3 is a PRF under the standard security argument for blake3-keyed and is enormously faster than HMAC-SHA256 at the sizes involved.
  • The key is a domain-separating constant ("zipnet:falsification-tag:v1") not a secret; the goal is not authentication from an adversary, it’s cross-slot collision resistance.

What the tag protects against

  • Malicious client corrupting another honest client’s slot. Slots are deterministically assigned (v1) or reservation-checked (v2). Collisions across clients overwrite both messages with their XOR. An honest client’s tag is computed on its original message; after the XOR with garbage, the tag at the published slot no longer matches the visible payload bytes → any observer rejects the slot as corrupted.
  • Malicious client writing garbage in an unused slot. The unused-slot hypothesis fails the tag check; observers skip it.

What the tag does not protect against

  • A malicious client corrupting its own slot by writing nonsense and computing a tag over that nonsense. In v1 this is a trivial DoS against the client itself; the protocol treats the published broadcast as authoritative.
  • Cross-round correlation attacks based on message length or pattern.

Identity derivation

ClientId = blake3_keyed("zipnet:client:id-v1", dh_pub), ServerId = blake3_keyed("zipnet:server:id-v1", dh_pub), both XOF’d to 32 bytes.

Separate domain strings per role prevent an adversary who harvests a client’s dh_pub from spoofing a server with the same identifier, which would matter if we ever compared ClientIds and ServerIds inside the state machine (we don’t, but the separation is free).

Constant-time concerns

  • X25519 in x25519-dalek is constant-time by design.
  • AES-128-CTR in aes + ctr uses AES-NI on recent x86_64 / ARM — the assembly path is constant-time.
  • HKDF (SHA-256) is constant-time over inputs of a fixed length.
  • XOR buffers are word-wise and constant-time.
  • The equality check for tag verification is Vec::eqnot constant-time. This is fine: tag comparison is against a public broadcast, not against a secret.

If a contributor adds a secret comparison path, they should reach for subtle::ConstantTimeEq rather than ==.

Cryptographic agility

None. The prototype nails down curve (X25519), hash (blake3, SHA-256), and cipher (AES-128) because each choice is folded into a string constant in the KDF. To change any of them, bump WIRE_VERSION and the corresponding label ("zipnet/pad/v1""zipnet/pad/v2").

Rotating the curve to, say, X448 would require a new DhSecret type and a corresponding ClientBundle / ServerBundle layout change. There is no on-wire negotiation of crypto parameters — nodes that disagree are isolated into disjoint groups by construction.