Day 05: Cryptographic Primitives
Signatures, key exchange, AEAD, and the Noise handshake that secures every Holepunch connection.
⏱ ~90 min · theory 50 / lab 40
In a P2P network there's no certificate authority, no trusted server, no login. Identity, integrity, and
confidentiality all come from cryptography applied directly between peers. You will not implement crypto
primitives yourself (rule one of applied crypto: don't roll your own), but you must understand what each primitive
does, why it's there, and how they compose into the Noise Protocol handshake that hyperdht
uses to secure connections. Today is concept-heavy with a lab using sodium-native, the same library the
Holepunch stack relies on.
5.1 The four jobs crypto does here
| Job | Primitive | Question it answers |
|---|---|---|
| Identity / authenticity | Ed25519 signatures | "Did the holder of this key really produce this?" |
| Key agreement | X25519 (Curve25519 ECDH) | "How do two strangers derive a shared secret over a public channel?" |
| Confidentiality + integrity | ChaCha20-Poly1305 / AES-GCM (AEAD) | "How do we encrypt so tampering is detectable?" |
| Fingerprinting / commitment | BLAKE2b hashing, Merkle trees | "How do we name and verify data compactly?" |
5.2 Ed25519 — identity as a keypair
In Hypercore the **public key is the address** of a feed, and the secret key authorizes
appends. Ed25519 is the signature scheme behind this: fast, deterministic (no risky randomness at signing time, the
flaw that has broken ECDSA implementations), small keys and signatures (32 and 64 bytes). A signature proves a
message was produced by the holder of the secret key and hasn't been altered. This is the entire basis of "single
writer" — only one party can sign valid appends, and every reader can verify them with just the public key.
5.3 X25519 — agreeing on a secret in the open
Two peers who have never met need a shared symmetric key to encrypt their session. Diffie–Hellman
over Curve25519 (X25519) lets each side combine its own secret with the other's public value to compute the
same shared secret, while an eavesdropper who sees only the public values cannot. Using fresh
ephemeral keypairs per session gives forward secrecy: even if a long-term key leaks
later, past sessions stay private because their ephemeral secrets are already gone.
Signing vs key-exchange keys
Ed25519 (signing) and X25519 (key exchange) are sister curves — same underlying Curve25519 math, different jobs.
Don't conflate them: you sign with Ed25519 to prove identity, and DH with X25519 to derive session keys.
5.4 AEAD — encrypt and authenticate together
Plain encryption hides content but doesn't stop an attacker flipping bits. AEAD (Authenticated
Encryption with Associated Data) — ChaCha20-Poly1305 or AES-GCM — encrypts and produces an authentication
tag, so any tampering is detected on decryption. "Associated data" lets you bind unencrypted context (like a header)
to the ciphertext so it can't be swapped. Every byte of an established Noise session rides inside AEAD frames.
5.5 BLAKE2b and Merkle trees, revisited
You met Merkle trees on Day 2. The hash underneath them in Hypercore is BLAKE2b — faster than
SHA-256, with strong security margins. Hashing gives you fixed-size, collision-resistant fingerprints used for
content addressing, the discovery key derived from a feed's public key, and the block/tree hashes that make partial
replication verifiable.
5.6 The Noise Protocol Framework
Noise is a framework for building secure handshakes by composing the primitives above into a fixed
sequence of DH operations. It's what WireGuard, WhatsApp, and Holepunch's transport use. A Noise handshake is named
by a pattern of message tokens:
- XX: both parties exchange and authenticate static keys during the handshake. Mutual authentication without either side knowing the other's key in advance — the general-purpose default.
- IK: the initiator already knows the responder's static key (e.g. you're dialing a specific peer whose public key you looked up in the DHT). Faster, fewer round trips, and hides the initiator's identity from passive eavesdroppers.
- NK / NN / KK …: other combinations of who-knows-what ahead of time.
Each handshake message mixes new DH outputs into a running shared state; by the end both sides have derived
matching symmetric keys for an AEAD-encrypted session with forward secrecy. The elegance is that the pattern
declares the security properties, and the framework guarantees them.
Holepunch note
When you connect throughhyperdht/hyperswarm, the peers run a Noise handshake (an
IK-style pattern fits the "I looked up your key in the DHT, now I'm dialing you" model) to
authenticate and establish an encrypted, forward-secret channel — before any application data flows. The
primitives are Ed25519/X25519/BLAKE2b/ChaCha20-Poly1305 viasodium-native. Being able to say "Noise IK,
because the dialer already has the responder's static key from the DHT lookup" is a top-tier interview answer.
5.7 Lab — signatures, key exchange, AEAD, and a mini DH handshake
Goal
Use real, production primitives to (1) sign and verify like a Hypercore writer, (2) perform an X25519 key
exchange and confirm both sides derive the same secret, (3) AEAD-encrypt with tamper detection, and (4) sketch the
shape of a Noise-style handshake.
Setup
npm install sodium-native
sodium-native is libsodium bindings — the exact crypto layer under Hypercore. (If it fails to build
in your environment, Node's built-in crypto also supports Ed25519/X25519; the concepts are identical.)
Step 1 — Ed25519 sign & verify (the "single writer" proof)
// sign.js
const sodium = require('sodium-native');
// generate a signing keypair
const pk = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES); // 32
const sk = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES); // 64
sodium.crypto_sign_keypair(pk, sk);
const message = Buffer.from('append: block #42');
const sig = Buffer.alloc(sodium.crypto_sign_BYTES); // 64
sodium.crypto_sign_detached(sig, message, sk);
const ok = sodium.crypto_sign_verify_detached(sig, message, pk);
console.log('signature valid:', ok);
// tamper -> verification fails (integrity + authenticity)
const forged = Buffer.from('append: block #43');
console.log('forged valid :', sodium.crypto_sign_verify_detached(sig, forged, pk));
Step 2 — X25519 key exchange (two strangers, one secret)
// dh.js
const sodium = require('sodium-native');
function keypair() {
const pk = Buffer.alloc(sodium.crypto_kx_PUBLICKEYBYTES);
const sk = Buffer.alloc(sodium.crypto_kx_SECRETKEYBYTES);
sodium.crypto_kx_keypair(pk, sk);
return { pk, sk };
}
const alice = keypair();
const bob = keypair();
// each derives session keys from own secret + peer's public key
const aRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const aTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
sodium.crypto_kx_client_session_keys(aRx, aTx, alice.pk, alice.sk, bob.pk);
sodium.crypto_kx_server_session_keys(bRx, bTx, bob.pk, bob.sk, alice.pk);
// alice's TX key == bob's RX key -> shared secret agreed over a public channel
console.log('keys match:', aTx.equals(bRx) && bTx.equals(aRx));
Step 3 — AEAD encryption with tamper detection
// aead.js
const sodium = require('sodium-native');
const key = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_KEYBYTES);
sodium.randombytes_buf(key);
const nonce = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES);
sodium.randombytes_buf(nonce); // MUST be unique per message under a key
const plaintext = Buffer.from('secret peer payload');
const ad = Buffer.from('msg-type:3'); // associated data: authenticated, not encrypted
const ct = Buffer.alloc(plaintext.length + sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_chacha20poly1305_ietf_encrypt(ct, plaintext, ad, null, nonce, key);
const out = Buffer.alloc(ct.length - sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
console.log('decrypted:', out.toString());
// flip one ciphertext byte -> decrypt throws (the Poly1305 tag fails)
ct[0] ^= 0xff;
try {
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
console.log('tamper undetected (BAD)');
} catch {
console.log('tamper detected: decryption rejected ✅');
}
Step 4 — sketch a Noise-style handshake
This is a conceptual 2-message exchange (not a spec-compliant Noise impl) to show how DH + hashing
compose into a session. Each side mixes ephemeral DH output into a running hash to derive a shared key.
// mini-noise.js — illustrative only; use the 'noise-protocol' / hyperswarm libs for real
const sodium = require('sodium-native');
const hash = (b) => { const o = Buffer.alloc(32); sodium.crypto_generichash(o, b); return o; };
function eph() {
const pk = Buffer.alloc(32), sk = Buffer.alloc(32);
sodium.crypto_box_keypair(pk, sk); return { pk, sk };
}
function dh(sk, pk) {
const out = Buffer.alloc(32);
sodium.crypto_scalarmult(out, sk, pk); return out;
}
const i = eph(), r = eph(); // initiator + responder ephemerals
// -> e : initiator sends its ephemeral public key
// <- e : responder sends its ephemeral public key
// both compute the same DH(e_i, e_r) and mix it into a session key
const kI = hash(dh(i.sk, r.pk));
const kR = hash(dh(r.sk, i.pk));
console.log('handshake keys match:', kI.equals(kR));
console.log('(real Noise also mixes static keys + transcript hash for auth)');
Experiments
- In Step 3, reuse the same nonce for two messages. Crypto won't error, but you've just broken the cipher's security — this is why nonce management is the #1 AEAD footgun. Note how real protocols derive per-message nonces from a counter.
- In Step 4, generate fresh ephemerals each run and confirm the derived key changes every time — that's forward secrecy in action.
- Look up the
noise-protocolnpm package and identify which functions correspond to the XX vs IK patterns.
5.8 Self-check & interview drill
- I can map each primitive (Ed25519, X25519, AEAD, BLAKE2b) to the job it does.
- I signed and verified a message and watched a forged one fail.
- I ran an X25519 exchange and confirmed both sides derived the same secret.
- I can explain Noise patterns and why IK suits DHT-based dialing.