A deep dive into modern cryptographic primitives — ECDSA, SHA-256, ChaCha20-Poly1305, AES-GCM, Argon2id, X25519, HKDF, and more. With runnable code examples.
Rust · TypeScript · WebAssembly · Node.js · Web Crypto API · noble-ciphers · noble-hashes · noble-curves
Every encryption library is a black box until you understand the primitives underneath. This document breaks down the algorithms I use across my projects — Occlude, ATU Humidor, Fuarcat — and shows how each one works at the code level.
Before primitives, internalize what problem each family solves:
Property
Means
Primitive
Confidentiality
Only intended recipients can read
Symmetric/asymmetric encryption
Integrity
Data was not tampered with
Hash functions, MACs
Authenticity
Sender is who they claim to be
MACs, digital signatures
Non-repudiation
Sender cannot deny sending
Digital signatures (not MACs)
Forward secrecy
Compromise of long-term key doesn't expose past sessions
Ephemeral key exchange (ECDH)
MACs provide authenticity but not non-repudiation — both parties share the key, so either could have produced the tag. Signatures use asymmetric keys, so only the private key holder could have signed.
Security must rest entirely in the key, not the algorithm. Assume the attacker knows every detail of your cipher. If security depends on keeping the algorithm secret ("security through obscurity"), it will fail.
All cryptographic operations require random numbers that are computationally indistinguishable from true randomness. Never use Math.random() — it is not cryptographically secure.
csprng.ts
// Node.js / Edge runtimesimport { randomBytes } from "node:crypto";const key = randomBytes(32); // 256-bit keyconst nonce = randomBytes(12); // 96-bit nonce for GCMconst salt = randomBytes(16); // 128-bit salt// Web Crypto API (browser / Cloudflare Workers / Deno)const key2 = crypto.getRandomValues(new Uint8Array(32));const nonce2 = crypto.getRandomValues(new Uint8Array(12));// Noble (universal — uses WebCrypto under the hood)import { randomBytes as nobleRandom } from "@noble/ciphers/webcrypto";const nonce3 = nobleRandom(24); // returns Uint8Array
csprng.rs
use rand::rngs::OsRng;use rand::RngCore;fn generate_key() -> [u8; 32] { let mut key = [0u8; 32]; OsRng.fill_bytes(&mut key); key}// Or with the rand crate's random() helperuse rand::random;let nonce: [u8; 24] = random();
Rule: Always use OS-backed entropy (OsRng, crypto.getRandomValues, /dev/urandom). Thread-local PRNGs seeded with time are broken.
The OS CSPRNG draws from hardware events (interrupt timing, CPU jitter, RDRAND on x86). On Linux: /dev/urandom (non-blocking, suitable for all crypto). /dev/random blocks — never use it in modern code, it provides no additional security.
The workhorse of integrity verification. Produces a fixed 256-bit digest from arbitrary input. Used in HMAC, key derivation, digital signatures, and content addressing.
Double the digest size. Used as the internal hash for HMAC-SHA-512 in Ed25519 signature schemes and for higher-security KDFs. Also available: SHA-384 (truncated SHA-512, slightly faster on 64-bit hardware).
An entirely different construction from SHA-2 — sponge-based, not Merkle-Damgård. SHA-3 is not vulnerable to length-extension attacks that plague SHA-2 when used without HMAC. Ethereum uses Keccak-256 (a slightly different parameterization from NIST SHA-3).
use sha3::{Sha3_256, Digest};fn main() { let mut hasher = Sha3_256::new(); hasher.update(b"hello world"); let result = hasher.finalize(); println!("{:x}", result);}
SHA-3 vs SHA-2: SHA-2 is faster on hardware with SHA-NI extensions. SHA-3 is preferred when length-extension resistance is needed without HMAC overhead, or when a second-preimage-resistant alternative to SHA-2 is needed in a diversity-of-algorithms argument.
Faster than SHA-2 in software, with a security margin comparable to SHA-3. BLAKE2b is optimized for 64-bit; BLAKE2s for 32-bit and embedded. Used by Argon2 internally.
blake2.ts
import { createHash } from "node:crypto"; // Node 21+ supports blake2b512 / blake2s256const h = createHash("blake2b512").update("hello").digest("hex");
blake2.rs
use blake2::{Blake2b512, Digest};let mut h = Blake2b512::new();h.update(b"hello world");let result = h.finalize();
SHA-256 and SHA-512 (Merkle-Damgård family) are vulnerable: given H(secret || message) and the message length, an attacker can compute H(secret || message || extension) without knowing the secret. Always use HMAC — never bare SHA-2 for authentication.
length-extension-mitigation.ts
// WRONG — vulnerable to length-extensionconst mac = sha256(secret + message);// CORRECT — HMAC is immuneimport { createHmac } from "node:crypto";const mac2 = createHmac("sha256", secret).update(message).digest("hex");
SHA-3 and BLAKE3 are immune to length-extension attacks.
Hash-based Message Authentication Code. Proves both integrity and authenticity — the sender must know the secret key.
hmac.ts
import { createHmac } from "node:crypto";function hmacSha256(key: string, message: string): string { return createHmac("sha256", key).update(message).digest("hex");}const mac = hmacSha256("my-secret-key", "payment:1000:USD");// Verifier with the same key recomputes and compares
Timing-safe comparison is critical — never use === for MAC verification:
timing-safe.ts
import { timingSafeEqual } from "node:crypto";function verifyMac(expected: string, received: string): boolean { const a = Buffer.from(expected, "hex"); const b = Buffer.from(received, "hex"); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
A one-time MAC. Extremely fast — designed as the authentication component of ChaCha20-Poly1305. Cannot be used standalone as a general-purpose MAC because the key must never be reused.
The authentication component of AES-GCM. A GHASH-based polynomial MAC over GF(2^128). Fast with hardware support (PCLMULQDQ). The "authentication tag" in AES-GCM is the GMAC tag.
The gold standard for password hashing. Memory-hard, resistant to GPU and ASIC brute-force attacks. Argon2id combines Argon2i (side-channel resistant) and Argon2d (GPU resistant).
Colin Percival's memory-hard KDF. Predates Argon2. Still widely used (OpenSSL, libsodium, Ethereum keystore v3). Three parameters: N (CPU/memory cost), r (block size), p (parallelism).
scrypt.ts
import { scrypt, scryptSync } from "node:crypto";// Asyncscrypt("password", "salt", 64, { N: 16384, r: 8, p: 1 }, (err, derived) => { if (err) throw err; console.log(derived.toString("hex")); // 64-byte key});// Sync (blocks event loop — only for CLI tools)const key = scryptSync("password", "salt-buffer", 32, { N: 131072, // 2^17 — more secure than default r: 8, p: 1,});
scrypt vs Argon2id: Argon2id is preferred for new systems. scrypt is acceptable and widely supported. scrypt's cache-timing side-channels make Argon2id strictly better. Use scrypt only when interoperability with existing systems demands it.
The legacy standard. Based on Blowfish. Cost factor doubles work per increment. Max input: 72 bytes (silently truncates — a footgun). Not memory-hard. Still acceptable for legacy systems; prefer Argon2id for new ones.
bcrypt.ts
import bcrypt from "bcrypt";const ROUNDS = 12; // 2^12 iterations — ~250ms on modern hardwareconst hashed = await bcrypt.hash("user-password", ROUNDS);const valid = await bcrypt.compare("user-password", hashed);
bcrypt's 72-byte limit: If passwords may exceed 72 bytes (e.g., passphrases), pre-hash with SHA-256 before bcrypt — but use the base64 encoding of the digest, not the raw bytes (which may contain null bytes Blowfish mishandles):
bcrypt-prehash.ts
import { createHash } from "node:crypto";import bcrypt from "bcrypt";function prehash(password: string): string { return createHash("sha256").update(password).digest("base64");}const hashed = await bcrypt.hash(prehash("very long passphrase..."), 12);const valid = await bcrypt.compare(prehash("very long passphrase..."), hashed);
Extracts and expands keying material. Two phases: extract (concentrate entropy) and expand (derive multiple keys from one source).
hkdf.ts
import { hkdf } from "node:crypto";async function deriveKeys( inputKey: Buffer, salt: Buffer, info: string): Promise<Buffer> { return new Promise((resolve, reject) => { hkdf("sha256", inputKey, salt, info, 32, (err, derivedKey) => { if (err) reject(err); else resolve(Buffer.from(derivedKey)); }); });}// Derive separate keys for encryption and authenticationconst masterKey = Buffer.from("shared-secret-from-ecdh");const salt = crypto.getRandomValues(new Uint8Array(32));const encKey = await deriveKeys(masterKey, Buffer.from(salt), "enc");const macKey = await deriveKeys(masterKey, Buffer.from(salt), "mac");
The info parameter binds the derived key to its context. Use distinct values for each purpose:
hkdf-context-binding.ts
// After X25519 key exchange — derive separate keys for each directionconst sharedSecret = x25519.getSharedSecret(myPrivate, theirPublic);const salt = Buffer.alloc(32); // All-zero salt is fine for HKDF when IKM has full entropyconst encKeyAtoB = await deriveKey(sharedSecret, salt, "app-v1:enc:a-to-b");const encKeyBtoA = await deriveKey(sharedSecret, salt, "app-v1:enc:b-to-a");const macKeyAtoB = await deriveKey(sharedSecret, salt, "app-v1:mac:a-to-b");
A salt is a random, per-credential value stored alongside the hash. Without salts, identical passwords produce identical hashes — enabling batch attacks and rainbow table lookups.
salting.ts
import { randomBytes } from "node:crypto";// WRONG — no salt, identical passwords produce identical hashesconst broken = sha256(password);// WRONG — static salt (global salt = extended password, not a true salt)const alsoWrong = sha256("static-salt" + password);// CORRECT — random per-credential salt, stored with the hashconst salt = randomBytes(16).toString("hex");const hash = sha256(salt + password); // Using PBKDF2/Argon2 in practice// Store: { salt, hash }
Argon2/bcrypt/scrypt manage salts internally and encode them into the output string. You never need to manage salts manually with these functions.
The industry standard for authenticated encryption. 256-bit key, 96-bit nonce, produces ciphertext + 128-bit authentication tag. GCM mode provides both confidentiality and integrity.
CBC (Cipher Block Chaining) — each plaintext block is XORed with the previous ciphertext block before encryption. Requires PKCS#7 padding and an authentication MAC. Only use when interoperability demands it.
aes-cbc.ts
import { createCipheriv, createDecipheriv, randomBytes, createHmac, timingSafeEqual } from "node:crypto";// Encrypt-then-MAC construction — the correct orderfunction encryptCBC(plaintext: Buffer, encKey: Buffer, macKey: Buffer): { iv: Buffer; ciphertext: Buffer; mac: Buffer;} { const iv = randomBytes(16); // 128-bit IV for CBC const cipher = createCipheriv("aes-256-cbc", encKey, iv); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); // MAC over IV + ciphertext (Encrypt-then-MAC) const mac = createHmac("sha256", macKey) .update(Buffer.concat([iv, ciphertext])) .digest(); return { iv, ciphertext, mac };}function decryptCBC(iv: Buffer, ciphertext: Buffer, mac: Buffer, encKey: Buffer, macKey: Buffer): Buffer { // Verify MAC before decrypting — prevents padding oracle const expected = createHmac("sha256", macKey) .update(Buffer.concat([iv, ciphertext])) .digest(); if (!timingSafeEqual(mac, expected)) throw new Error("MAC verification failed"); const decipher = createDecipheriv("aes-256-cbc", encKey, iv); return Buffer.concat([decipher.update(ciphertext), decipher.final()]);}
Padding Oracle Attack: If a decryption oracle reveals whether padding is valid (even through timing differences or error messages), an attacker can decrypt any CBC ciphertext without the key. Always verify MAC before decrypting. Always use AES-GCM instead of AES-CBC for new code.
Counter mode — turns AES into a stream cipher. The nonce/counter combination must never repeat for the same key. No padding. Not authenticated — must combine with HMAC.
aes-ctr.ts
import { createCipheriv, randomBytes } from "node:crypto";// CTR uses the same function for encrypt and decryptfunction aesCtr(data: Buffer, key: Buffer, counter: Buffer): Buffer { const cipher = createCipheriv("aes-256-ctr", key, counter); return Buffer.concat([cipher.update(data), cipher.final()]);}const key = randomBytes(32);const counter = randomBytes(16); // 128-bit counter blockconst ciphertext = aesCtr(Buffer.from("plaintext"), key, counter);const plaintext = aesCtr(ciphertext, key, counter); // same operation
The modern alternative to AES-GCM. No hardware acceleration needed (unlike AES-NI), constant-time by design, and resistant to timing attacks on any architecture. Used in TLS 1.3, WireGuard, and SSH.
Extended nonce variant — 192-bit nonce instead of 96-bit. Eliminates nonce collision risk entirely, making it safe to generate nonces randomly without a counter. This is what Occlude uses.
AEAD ciphers authenticate both the ciphertext and any additional plaintext metadata. The AAD is not encrypted but is bound to the ciphertext — altering AAD causes decryption failure.
aead-aad.ts
// AES-GCM with AAD — the AAD is authenticated but not encryptedasync function encryptWithAAD( plaintext: string, key: CryptoKey, aad: Uint8Array // e.g. record ID, user ID, schema version): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> { const nonce = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(plaintext); const ciphertext = new Uint8Array( await crypto.subtle.encrypt( { name: "AES-GCM", iv: nonce, additionalData: aad }, key, encoded ) ); return { nonce, ciphertext };}// Use: bind ciphertext to its record ID to prevent cut-and-paste attacksconst recordId = new TextEncoder().encode("record-uuid-1234");const { nonce, ciphertext } = await encryptWithAAD(secret, key, recordId);// Attacker cannot paste this ciphertext under a different record ID — decryption will fail
Common AAD candidates: record/row ID, user ID, schema version, protocol version, timestamp (coarse).
Used for digital signatures. The signer has a private key; anyone can verify with the public key. secp256k1 is used by Bitcoin/Ethereum, P-256 (secp256r1) is the NIST standard.
The Bitcoin/Ethereum curve. Not in Web Crypto; use @noble/curves.
secp256k1.ts
import { secp256k1 } from "@noble/curves/secp256k1";const privateKey = secp256k1.utils.randomPrivateKey();const publicKey = secp256k1.getPublicKey(privateKey);// Signconst msgHash = new Uint8Array(32); // must be the hash of the message, not rawcrypto.getRandomValues(msgHash); // placeholder — use sha256(message) in practiceconst sig = secp256k1.sign(msgHash, privateKey);// Verifyconst isValid = secp256k1.verify(sig, msgHash, publicKey);// Ethereum-style recoveryconst recovered = sig.recoverPublicKey(msgHash);
Edwards-curve Digital Signature Algorithm. Faster than ECDSA, deterministic (no random nonce needed per signature), and resistant to side-channel attacks.
use ed25519_dalek::{SigningKey, Signer, Verifier};use rand::rngs::OsRng;fn main() { let signing_key = SigningKey::generate(&mut OsRng); let verifying_key = signing_key.verifying_key(); let message = b"authenticate this request"; let signature = signing_key.sign(message); assert!(verifying_key.verify(message, &signature).is_ok());}
ECDSA vs Ed25519:
ECDSA requires a random nonce per signature — a broken RNG produces a broken (private-key-leaking) signature. The PS3 was broken this way.
Ed25519 is deterministic — same key + same message = same signature. No RNG dependence.
Diffie-Hellman key agreement on Curve25519. Two parties derive the same shared secret without ever transmitting it. The shared secret is then fed into HKDF to produce encryption keys.
x25519.ts
import { x25519 } from "@noble/curves/ed25519";// Alice generates her keypairconst alicePrivate = x25519.utils.randomPrivateKey();const alicePublic = x25519.getPublicKey(alicePrivate);// Bob generates his keypairconst bobPrivate = x25519.utils.randomPrivateKey();const bobPublic = x25519.getPublicKey(bobPrivate);// Both derive the same shared secretconst aliceShared = x25519.getSharedSecret(alicePrivate, bobPublic);const bobShared = x25519.getSharedSecret(bobPrivate, alicePublic);// aliceShared === bobShared (same 32 bytes)// Feed into HKDF to derive encryption keys
RSA-OAEP vs RSA-PKCS1v1.5: PKCS1v1.5 is vulnerable to BLEICHENBACHER'S attack (adaptive chosen-ciphertext). Never use it for new encryption. RSA-OAEP is the correct padding.
The correct padding for RSA signatures. RSA-PKCS1v1.5 signatures are deterministic and potentially malleable; PSS adds randomness and a proof of security.
RSA can only encrypt small amounts of data (limited by key size). Asymmetric crypto is slow. The solution: use asymmetric crypto to encrypt a random symmetric key, then use that symmetric key to encrypt the actual data. This is hybrid encryption — the basis of TLS, PGP, and every real-world E2EE system.
The ephemeral keypair provides forward secrecy — if the recipient's long-term private key is later compromised, past messages remain secure because each encryption used a different ephemeral key.
A k-of-n scheme where k parties must cooperate to produce a valid signature. No single party holds the full private key. Used in multisig wallets and HSM clusters.
Simpler than ECDSA, provably secure in the random oracle model, and supports signature aggregation (multiple signatures combine into one). Bitcoin Taproot uses Schnorr.
schnorr.ts
import { schnorr } from "@noble/curves/secp256k1";const privateKey = schnorr.utils.randomPrivateKey();const publicKey = schnorr.getPublicKey(privateKey);const message = sha256("hello"); // Schnorr signs the hashconst sig = schnorr.sign(message, privateKey);const valid = schnorr.verify(sig, message, publicKey);
Two parties agree on a shared secret over a public channel. Neither transmits the secret. The discrete logarithm problem prevents an eavesdropper from computing the secret.
Classic DH uses multiplicative groups mod a prime. Modern systems use elliptic curves (ECDH) — much smaller keys for equivalent security.
DH is unauthenticated — susceptible to MitM without an additional authentication layer (signatures, PKI, pre-shared keys).
A modern framework for building secure, authenticated, and optionally forward-secret handshakes. WireGuard uses the Noise_IKpsk2 pattern. Each pattern is described by a sequence of tokens (e.g., XX, IK, NX) describing which keys are transmitted and when.
The gold standard for end-to-end encrypted messaging. Combines:
X3DH (Extended Triple Diffie-Hellman) for initial key agreement
Double Ratchet for ongoing message encryption — a combination of a Diffie-Hellman ratchet (provides break-in recovery) and a symmetric-key ratchet (provides forward secrecy per message)
Every message uses a different key, derived by advancing the ratchet. Compromise of one message key does not compromise past or future messages.
Encrypts the payload. The full flow: generate a random Content Encryption Key (CEK), encrypt payload with CEK using AES-GCM or ChaCha20-Poly1305, encrypt CEK with the recipient's public key using RSA-OAEP or ECDH-ES.
RFC 6238. HOTP where the counter is replaced by Math.floor(Date.now() / 1000 / 30) — a 30-second time step.
totp.ts
import { createHmac, randomBytes } from "node:crypto";function totp(secret: Buffer, window = 0): string { const time = Math.floor(Date.now() / 1000 / 30) + window; return hotp(secret, time);}function verifyTotp(secret: Buffer, code: string, drift = 1): boolean { // Check current and ±drift windows to handle clock skew for (let w = -drift; w <= drift; w++) { if (totp(secret, w) === code) return true; } return false;}// Generate a secret and QR URI for authenticator appsfunction generateTotpSecret(): { secret: string; uri: string } { const secret = randomBytes(20).toString("base32"); // base32 for authenticator app compat const uri = `otpauth://totp/MyApp:user@example.com?secret=${secret}&issuer=MyApp&algorithm=SHA1&digits=6&period=30`; return { secret, uri };}
TOTP in production: Use the otplib or @oslojs/otp library rather than rolling your own. Always implement rate limiting and lockout — TOTP is only 6 digits.
RFC 2945. A password authentication protocol that never transmits the password or a password hash to the server. Based on DH. Even if the server is compromised, the attacker cannot learn the password.
Client:A = g^a mod N (sends to server)
Server:B = kv + g^b mod N (v is password verifier stored on server)
A binary tree where every leaf node is the hash of a data block, and every non-leaf node is the hash of its children. The root hash commits to the entire dataset. Used in Git, Bitcoin, Ethereum, certificate transparency, and Tailscale.
merkle.ts
import { createHash } from "node:crypto";function sha256(data: Buffer): Buffer { return createHash("sha256").update(data).digest();}function merkleRoot(leaves: Buffer[]): Buffer { if (leaves.length === 0) throw new Error("empty"); if (leaves.length === 1) return leaves[0]; const next: Buffer[] = []; for (let i = 0; i < leaves.length; i += 2) { const left = leaves[i]; const right = leaves[i + 1] ?? left; // duplicate last if odd next.push(sha256(Buffer.concat([left, right]))); } return merkleRoot(next);}function merkleProof(leaves: Buffer[], index: number): Buffer[] { const proof: Buffer[] = []; let current = leaves; let idx = index; while (current.length > 1) { const next: Buffer[] = []; for (let i = 0; i < current.length; i += 2) { const left = current[i]; const right = current[i + 1] ?? left; if (i === idx || i + 1 === idx) { // sibling goes into proof proof.push(idx % 2 === 0 ? right : left); } next.push(sha256(Buffer.concat([left, right]))); } idx = Math.floor(idx / 2); current = next; } return proof;}
A sequence H(H(H(...H(seed)...))). Revoking a credential means revealing the preimage at position N; subsequent positions are unrevocable. Used in S/KEY (OTP), Lamport clocks, and append-only logs.
The pattern used by AWS KMS, Google Cloud KMS, and HashiCorp Vault. A Data Encryption Key (DEK) encrypts the data; the DEK itself is encrypted with a Key Encryption Key (KEK). The KEK lives in hardware (HSM/KMS) and never leaves it.
envelope.ts
import { randomBytes } from "node:crypto";interface EnvelopeEncrypted { encryptedDek: Buffer; // DEK encrypted with KEK — safe to store alongside data nonce: Buffer; ciphertext: Buffer; tag: Buffer;}async function envelopeEncrypt( plaintext: Buffer, kek: Buffer // Key Encryption Key — lives in KMS/HSM): Promise<EnvelopeEncrypted> { // 1. Generate a fresh DEK for this record const dek = randomBytes(32); // 2. Encrypt the data with the DEK const { nonce, ciphertext, tag } = aesGcmEncrypt(plaintext, dek); // 3. Encrypt the DEK with the KEK (key wrapping) const encryptedDek = aesGcmEncrypt(dek, kek); // 4. Store encryptedDek alongside the ciphertext // The raw DEK is discarded from memory after use dek.fill(0); // zeroize return { encryptedDek: Buffer.concat([encryptedDek.nonce, encryptedDek.tag, encryptedDek.ciphertext]), nonce, ciphertext, tag };}
Sensitive key material must be wiped from memory after use. JavaScript's GC makes this imperfect — use Uint8Array.fill(0) and avoid placing secrets in string (immutable, no guaranteed erasure).
zeroize.ts
// TypedArrays can be zeroedfunction zeroize(buf: Uint8Array): void { buf.fill(0);}// Avoid this pattern — strings cannot be zeroedconst key = "secret"; // ← string is immutable, lives in heap arbitrarily long// Prefer this patternconst key2 = new Uint8Array([...new TextEncoder().encode("secret")]);// ... use key2 ...zeroize(key2); // wipe after use
zeroize.rs
use zeroize::Zeroize;fn main() { let mut key = vec![0u8; 32]; // ... use key ... key.zeroize(); // overwrites with zeros, prevents compiler from optimizing away}
The zeroize crate in Rust guarantees the compiler won't elide the zeroing. In C/C++, use explicit_bzero or SecureZeroMemory — memset may be optimized out.
An HSM is a tamper-resistant hardware device that generates and stores private keys. Keys never leave the HSM in plaintext. Operations (sign, decrypt) happen inside the HSM. Used for CA root keys, payment processing (PCI-DSS), and government-grade key management.
Cloud equivalents: AWS CloudHSM, Google Cloud HSM, Azure Dedicated HSM. Simpler KMS APIs: AWS KMS, GCP KMS (keys may be HSM-backed or software).
CPU execution time leaks information. If a comparison exits early on the first differing byte, timing measurements reveal the correct value byte by byte. This is the basis of timing side-channel attacks.
constant-time.ts
import { timingSafeEqual } from "node:crypto";// WRONG — short-circuits on first differencefunction badCompare(a: string, b: string): boolean { return a === b; // leaks how many bytes match}// CORRECT — always compares all bytes regardless of where difference isfunction safeCompare(a: Buffer, b: Buffer): boolean { if (a.length !== b.length) { // Length check is fine to do upfront — attacker already knows the expected length return false; } return timingSafeEqual(a, b);}// In practice:function verifyHmac(expected: string, received: string): boolean { const a = Buffer.from(expected, "hex"); const b = Buffer.from(received, "hex"); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
constant-time.rs
use subtle::ConstantTimeEq;fn verify_mac(expected: &[u8], received: &[u8]) -> bool { expected.ct_eq(received).into()}
ChaCha20 is designed to be constant-time on all architectures — no table lookups, no data-dependent branches. AES requires AES-NI hardware instructions to be constant-time; without them, AES table lookups leak key bits through cache timing (the AES cache-timing attack, 2005).
This is why ChaCha20 is preferred on devices without AES-NI (older ARM, IoT hardware).
With a 128-bit output space, you expect a collision after ~2^64 operations (not 2^128). This is why AES-GCM nonces must not be randomly generated for the same key if you encrypt more than ~2^32 messages (nonce collision probability reaches ~1% at 2^32 with 96-bit nonces). Use a counter nonce, or use XChaCha20 (192-bit nonce — collision at 2^96).
If a system reveals whether decryption padding is valid (through different error messages, response times, or behavior), an attacker can decrypt any CBC ciphertext byte-by-byte using adaptive chosen-ciphertext queries. The fix: use AEAD (AES-GCM, ChaCha20-Poly1305).
In AES-GCM and ChaCha20-Poly1305: reusing a (key, nonce) pair exposes the XOR of the two plaintexts and destroys authentication. In ECDSA: reusing the random nonce k exposes the private key (the PS3 hack, 2010). Mitigations: counters for AES-GCM, deterministic signatures (Ed25519), XChaCha20 for random nonces.
An attacker captures a valid authenticated message and retransmits it. Mitigation: include a timestamp and/or nonce in the authenticated payload, reject messages outside a time window or with seen nonces.
If the CSPRNG is seeded with low-entropy data (time, PID), keys are guessable. Mitigation: always use OS-backed entropy. Never seed from Date.now() alone.
AES-GCM does not commit to the key — it's possible (with effort) to construct a ciphertext that decrypts under two different keys. This breaks multi-recipient protocols. Use AES-GCM-SIV or add an explicit key commitment. This is an active research area (2023+); most deployed systems are unaffected because they use single recipients.