Pp2pbuilders
learn › The P2P Engineer's Field Manual

10 min read · also at #/learn/fm-day5

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

JobPrimitiveQuestion it answers
Identity / authenticityEd25519 signatures"Did the holder of this key really produce this?"
Key agreementX25519 (Curve25519 ECDH)"How do two strangers derive a shared secret over a public channel?"
Confidentiality + integrityChaCha20-Poly1305 / AES-GCM (AEAD)"How do we encrypt so tampering is detectable?"
Fingerprinting / commitmentBLAKE2b 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:

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 through hyperdht/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 via sodium-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

5.8 Self-check & interview drill

« Day 4: Binary Protocols & Serialization Day 6: High-Performance JS Networking »