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
| Purpose | Primitive | Crate |
|---|---|---|
| Key agreement | X25519 | x25519-dalek 2.0 |
| Key derivation | HKDF-SHA256 | hkdf 0.12 |
| Pad generation | AES-128 in CTR mode | aes 0.8 + ctr 0.9 |
| Falsification tag | keyed-blake3 | blake3 1.8 |
| ID derivation | keyed-blake3 | blake3 1.8 |
| Peer-entry signatures | ed25519 | via 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 differentRoundParamsderives a different pad; in the XOR algebra this reduces the colliding result to noise, not to a silent crypto vulnerability. TheWIRE_VERSIONin 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 shareround, the AES key–IV pair is never reused. Theround: u64ensures 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_secretcompromises every past and future round for that(client, server)pair until the secret is rotated. v2 ratchetsshared_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 toroundandclientvia 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-dalekis constant-time by design. - AES-128-CTR in
aes+ctruses 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::eq— not 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.