yigityalim/twitter/github/share
Back to Projects

Cryptography Handbook

A deep dive into modern cryptographic primitives — ECDSA, SHA-256, ChaCha20-Poly1305, AES-GCM, Argon2id, X25519, HKDF, and more. With runnable code examples.

Overview

Tech Stack

RustTypeScriptWebAssemblyNode.jsWeb Crypto APInoble-ciphersnoble-hashesnoble-curves

Links

GitHub
© 2026 Yiğit Yalım.All rights reserved.

Contents

  • Why This Exists
  • Foundational Concepts
    • What Cryptography Solves
    • Kerckhoffs's Principle
    • The Cryptographic Stack
  • Randomness
    • CSPRNG (Cryptographically Secure Pseudorandom Number Generator)
    • Entropy Sources
  • Hash Functions
    • SHA-256
    • SHA-512
    • SHA-3 (Keccak)
    • BLAKE2b / BLAKE2s
    • BLAKE3
    • Length-Extension Attacks
  • Message Authentication Codes
    • HMAC-SHA-256
    • Poly1305
    • GMAC (Galois MAC)
    • SipHash
  • Key Derivation Functions
    • Argon2id
    • scrypt
    • bcrypt
    • HKDF (HMAC-based Key Derivation Function)
    • PBKDF2
    • Salting
  • Symmetric Encryption
    • Block Ciphers vs Stream Ciphers
    • AES-256-GCM
    • AES-CBC (for Legacy Systems)
    • AES-CTR
    • AES Key Wrapping (RFC 3394)
    • ChaCha20-Poly1305
    • XChaCha20-Poly1305
    • Associated Data (AAD)
  • Asymmetric Cryptography
    • ECDSA (Elliptic Curve Digital Signature Algorithm)
    • ECDSA secp256k1
    • ECDSA P-384 / P-521
    • Ed25519
    • X25519 (ECDH Key Exchange)
    • ECDH P-256 (Web Crypto)
    • RSA-OAEP
    • RSA-PSS (Probabilistic Signature Scheme)
    • RSA Key Size vs Security Level
  • Key Serialization & Encoding
    • JWK (JSON Web Key)
    • PKCS#8 (Private Key) and SPKI (Public Key)
    • Raw Format
  • Hybrid Encryption (ECIES Pattern)
  • Digital Signatures: Advanced Patterns
    • Blind Signatures
    • Threshold Signatures
    • Schnorr Signatures
  • Authenticated Key Exchange
    • Diffie-Hellman (Classical)
    • Station-to-Station (STS) Protocol
    • The Noise Protocol Framework
    • Signal Double Ratchet
  • JWT, JWS, and JWE (JOSE)
    • JWT (JSON Web Token)
    • JWS (JSON Web Signature)
    • JWE (JSON Web Encryption)
  • TOTP / HOTP (Two-Factor Authentication)
    • HOTP (HMAC-based One-Time Password)
    • TOTP (Time-based One-Time Password)
  • Zero-Knowledge Proofs (Concepts)
    • What ZKP Solves
    • Commitment Schemes
    • Sigma Protocols (Schnorr PoK)
    • SRP (Secure Remote Password)
  • Data Structures
    • Merkle Trees
    • Hash Chains
  • Key Management
    • Envelope Encryption
    • Key Rotation
    • Memory Zeroization
    • Hardware Security Modules (HSMs)
  • Constant-Time Programming
    • Why It Matters
    • Constant-Time Algorithms
  • Common Attacks
    • Birthday Attack
    • Padding Oracle Attack
    • Nonce Reuse (Catastrophic)
    • Replay Attack
    • Weak Entropy Attack
    • Key Commitment Failure (AES-GCM)
  • TLS 1.3 in Brief
  • Putting It All Together
  • Algorithm Comparison
  • Security Rules

Why This Exists

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.


Foundational Concepts

What Cryptography Solves

Before primitives, internalize what problem each family solves:

PropertyMeansPrimitive
ConfidentialityOnly intended recipients can readSymmetric/asymmetric encryption
IntegrityData was not tampered withHash functions, MACs
AuthenticitySender is who they claim to beMACs, digital signatures
Non-repudiationSender cannot deny sendingDigital signatures (not MACs)
Forward secrecyCompromise of long-term key doesn't expose past sessionsEphemeral 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.

Kerckhoffs's Principle

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.

The Cryptographic Stack

Application Layer    → Envelope encryption, key management, rotation
Protocol Layer       → TLS 1.3, Noise Protocol, Signal Protocol
Construction Layer   → AEAD, HKDF, HMAC, authenticated DH
Primitive Layer      → AES, ChaCha20, SHA-256, Curve25519, P-256

Never drop to a lower layer than necessary. Use AEAD, not raw ciphers. Use a protocol, not raw ECDH.


Randomness

CSPRNG (Cryptographically Secure Pseudorandom Number Generator)

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 runtimes
import { randomBytes } from "node:crypto";
 
const key = randomBytes(32);         // 256-bit key
const nonce = randomBytes(12);       // 96-bit nonce for GCM
const 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() helper
use 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.

Entropy Sources

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.


Hash Functions

SHA-256

The workhorse of integrity verification. Produces a fixed 256-bit digest from arbitrary input. Used in HMAC, key derivation, digital signatures, and content addressing.

sha256.ts
import { createHash } from "node:crypto";
 
function sha256(data: string): string {
  return createHash("sha256").update(data, "utf-8").digest("hex");
}
 
const hash = sha256("hello world");
// => "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

The same thing using the Web Crypto API — available in browsers and edge runtimes where Node.js crypto is not:

sha256-webcrypto.ts
async function sha256(data: string): Promise<string> {
  const encoded = new TextEncoder().encode(data);
  const buffer = await crypto.subtle.digest("SHA-256", encoded);
  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

SHA-512

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).

sha512.ts
import { createHash } from "node:crypto";
 
function sha512(data: string): string {
  return createHash("sha512").update(data, "utf-8").digest("hex");
}

SHA-3 (Keccak)

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).

sha3.ts
import { createHash } from "node:crypto";
 
// SHA3-256 (NIST standard)
const sha3_256 = createHash("sha3-256").update("hello").digest("hex");
 
// SHA3-512
const sha3_512 = createHash("sha3-512").update("hello").digest("hex");
sha3.rs
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.

BLAKE2b / BLAKE2s

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 / blake2s256
 
const 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();

BLAKE3

Not SHA, but worth mentioning — a tree-hashable, parallelizable hash function. Significantly faster than SHA-256 on modern hardware.

blake3_hash.rs
use blake3;
 
fn main() {
    let hash = blake3::hash(b"hello world");
    println!("{}", hash.to_hex());
    // => "d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24"
}

BLAKE3 also supports keyed hashing (replacing HMAC) and key derivation (replacing HKDF) natively:

blake3-keyed.rs
use blake3;
 
// Keyed hashing — replaces HMAC
let key: [u8; 32] = *b"an example very very secret key!";
let mac = blake3::keyed_hash(&key, b"message");
 
// Key derivation — replaces HKDF
let derived = blake3::derive_key("app context string", b"input key material");

Length-Extension Attacks

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-extension
const mac = sha256(secret + message);
 
// CORRECT — HMAC is immune
import { createHmac } from "node:crypto";
const mac2 = createHmac("sha256", secret).update(message).digest("hex");

SHA-3 and BLAKE3 are immune to length-extension attacks.


Message Authentication Codes

HMAC-SHA-256

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);
}

Poly1305

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.

GMAC (Galois MAC)

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.

SipHash

A fast, keyed hash for hash table use — not a MAC in the cryptographic sense. Prevents hash-flooding DoS attacks. Used by Rust's HashMap by default.

siphash.rs
use std::collections::HashMap;
// HashMap uses SipHash-1-3 by default — you get it for free
let mut map: HashMap<String, i32> = HashMap::new();

Key Derivation Functions

Argon2id

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).

argon2id.ts
import { hash, verify } from "@node-rs/argon2";
 
// Hashing a password — tune memory/iterations for your hardware
const hashed = await hash("user-password-here", {
  memoryCost: 65536,   // 64 MB
  timeCost: 3,         // 3 iterations
  parallelism: 4,      // 4 threads
  algorithm: 2,        // argon2id
});
 
// Verification — constant-time internally
const isValid = await verify(hashed, "user-password-here");

In Rust — using the argon2 crate for the Occlude WASM crypto engine:

argon2_kdf.rs
use argon2::{Argon2, Algorithm, Version, Params};
use argon2::password_hash::rand_core::OsRng;
 
fn derive_key(password: &[u8], salt: &[u8]) -> [u8; 32] {
    let params = Params::new(65536, 3, 4, Some(32))
        .expect("valid params");
    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
 
    let mut key = [0u8; 32];
    argon2.hash_password_into(password, salt, &mut key)
        .expect("hash failed");
    key
}

Argon2 parameters for 2025+:

  • Interactive logins: m=65536, t=3, p=4
  • High-security offline keys: m=262144, t=4, p=4
  • Embedded/WASM constrained: m=16384, t=2, p=1

scrypt

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";
 
// Async
scrypt("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.rs
use scrypt::{scrypt, Params};
 
fn derive_key(password: &[u8], salt: &[u8]) -> [u8; 32] {
    let params = Params::new(17, 8, 1, 32).unwrap(); // N=2^17
    let mut key = [0u8; 32];
    scrypt(password, salt, &params, &mut key).unwrap();
    key
}

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.

bcrypt

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 hardware
 
const 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);

HKDF (HMAC-based Key Derivation Function)

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 authentication
const 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 direction
const sharedSecret = x25519.getSharedSecret(myPrivate, theirPublic);
const salt = Buffer.alloc(32); // All-zero salt is fine for HKDF when IKM has full entropy
 
const 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");

PBKDF2

Older but still widely used. Iterative hashing — less memory-hard than Argon2, but universally supported.

pbkdf2.ts
import { pbkdf2Sync } from "node:crypto";
 
function deriveKey(password: string, salt: Buffer): Buffer {
  return pbkdf2Sync(password, salt, 600_000, 32, "sha256");
  //                                 ^^^^^^^ OWASP 2023 minimum
}

Iteration counts (OWASP 2023):

  • PBKDF2-SHA1: 1,300,000 iterations
  • PBKDF2-SHA256: 600,000 iterations
  • PBKDF2-SHA512: 210,000 iterations

Higher iteration count is always better; calibrate to ~300ms on your server.

Salting

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 hashes
const 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 hash
const 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.


Symmetric Encryption

Block Ciphers vs Stream Ciphers

Block ciphers (AES) operate on fixed-size blocks (128 bits for AES). Require a mode of operation to encrypt data longer than one block.

Stream ciphers (ChaCha20) generate a pseudorandom keystream XORed with plaintext. No padding required.

AES modes:

  • ECB: Never use. Each block encrypted independently — identical plaintext blocks produce identical ciphertext blocks. The ECB penguin illustrates this.
  • CBC: Requires padding, vulnerable to padding oracle attacks without MAC. Do not use without a MAC.
  • CTR: Turns AES into a stream cipher. No padding. XOR-malleable without authentication — must be combined with HMAC.
  • GCM: CTR mode + GMAC. The right default.

AES-256-GCM

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.

aes-gcm.ts
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
 
interface Encrypted {
  nonce: Buffer;
  ciphertext: Buffer;
  tag: Buffer;
}
 
function encrypt(plaintext: string, key: Buffer): Encrypted {
  const nonce = randomBytes(12); // 96-bit nonce for GCM
  const cipher = createCipheriv("aes-256-gcm", key, nonce);
 
  const ciphertext = Buffer.concat([
    cipher.update(plaintext, "utf-8"),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();
 
  return { nonce, ciphertext, tag };
}
 
function decrypt(encrypted: Encrypted, key: Buffer): string {
  const decipher = createDecipheriv("aes-256-gcm", key, encrypted.nonce);
  decipher.setAuthTag(encrypted.tag);
 
  return Buffer.concat([
    decipher.update(encrypted.ciphertext),
    decipher.final(),
  ]).toString("utf-8");
}

Using the Web Crypto API — the only symmetric cipher available in browsers:

aes-gcm-webcrypto.ts
async function encryptWebCrypto(
  plaintext: string,
  rawKey: Uint8Array
): Promise<{ nonce: Uint8Array; ciphertext: Uint8Array }> {
  const key = await crypto.subtle.importKey(
    "raw",
    rawKey,
    { name: "AES-GCM" },
    false,
    ["encrypt"]
  );
 
  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 }, key, encoded)
  );
 
  return { nonce, ciphertext };
  // Note: GCM tag is appended to ciphertext by Web Crypto (last 16 bytes)
}

AES-CBC (for Legacy Systems)

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 order
function 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.

AES-CTR

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 decrypt
function 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 block
const ciphertext = aesCtr(Buffer.from("plaintext"), key, counter);
const plaintext = aesCtr(ciphertext, key, counter); // same operation

AES Key Wrapping (RFC 3394)

Used to encrypt keys with keys — the standard for envelope encryption and secure key export.

aes-keywrap.ts
// Web Crypto supports AES-KW natively
const wrapKey = await crypto.subtle.generateKey(
  { name: "AES-KW", length: 256 },
  false,
  ["wrapKey", "unwrapKey"]
);
 
const keyToWrap = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true, // must be extractable to wrap
  ["encrypt", "decrypt"]
);
 
const wrapped = await crypto.subtle.wrapKey("raw", keyToWrap, wrapKey, "AES-KW");
// wrapped is an ArrayBuffer — safe to store alongside encrypted data

ChaCha20-Poly1305

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.

chacha20.ts
import { chacha20poly1305 } from "@noble/ciphers/chacha";
import { randomBytes } from "@noble/ciphers/webcrypto";
 
function encrypt(plaintext: Uint8Array, key: Uint8Array): {
  nonce: Uint8Array;
  ciphertext: Uint8Array;
} {
  const nonce = randomBytes(12); // 96-bit nonce
  const cipher = chacha20poly1305(key, nonce);
  const ciphertext = cipher.encrypt(plaintext);
  return { nonce, ciphertext };
}
 
function decrypt(
  ciphertext: Uint8Array,
  key: Uint8Array,
  nonce: Uint8Array
): Uint8Array {
  const cipher = chacha20poly1305(key, nonce);
  return cipher.decrypt(ciphertext);
}

XChaCha20-Poly1305

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.

xchacha20.ts
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { randomBytes } from "@noble/ciphers/webcrypto";
 
const key = randomBytes(32);    // 256-bit key
const nonce = randomBytes(24);  // 192-bit nonce — safe to randomize
const message = new TextEncoder().encode("secret note");
 
const cipher = xchacha20poly1305(key, nonce);
const encrypted = cipher.encrypt(message);
 
// Decrypt
const decipher = xchacha20poly1305(key, nonce);
const decrypted = decipher.decrypt(encrypted);
console.log(new TextDecoder().decode(decrypted));
// => "secret note"

In Rust — the same algorithm powering the @occlude/crypto WASM module:

xchacha20_rust.rs
use chacha20poly1305::{
    XChaCha20Poly1305, XNonce,
    aead::{Aead, KeyInit, OsRng},
};
 
fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> (Vec<u8>, [u8; 24]) {
    let cipher = XChaCha20Poly1305::new(key.into());
    let nonce_bytes: [u8; 24] = rand::random();
    let nonce = XNonce::from_slice(&nonce_bytes);
 
    let ciphertext = cipher.encrypt(nonce, plaintext)
        .expect("encryption failed");
 
    (ciphertext, nonce_bytes)
}
 
fn decrypt(ciphertext: &[u8], key: &[u8; 32], nonce: &[u8; 24]) -> Vec<u8> {
    let cipher = XChaCha20Poly1305::new(key.into());
    let nonce = XNonce::from_slice(nonce);
 
    cipher.decrypt(nonce, ciphertext)
        .expect("decryption failed — wrong key or tampered data")
}

Associated Data (AAD)

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 encrypted
async 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 attacks
const 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).


Asymmetric Cryptography

ECDSA (Elliptic Curve Digital Signature Algorithm)

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.

ecdsa-p256.ts
async function generateKeyPair() {
  return crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    true,
    ["sign", "verify"]
  );
}
 
async function sign(
  privateKey: CryptoKey,
  data: Uint8Array
): Promise<ArrayBuffer> {
  return crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    privateKey,
    data
  );
}
 
async function verify(
  publicKey: CryptoKey,
  signature: ArrayBuffer,
  data: Uint8Array
): Promise<boolean> {
  return crypto.subtle.verify(
    { name: "ECDSA", hash: "SHA-256" },
    publicKey,
    signature,
    data
  );
}
 
const { privateKey, publicKey } = await generateKeyPair();
const message = new TextEncoder().encode("transfer 100 USD to Alice");
const sig = await sign(privateKey, message);
const valid = await verify(publicKey, sig, message);
console.log(valid); // => true

ECDSA secp256k1

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);
 
// Sign
const msgHash = new Uint8Array(32); // must be the hash of the message, not raw
crypto.getRandomValues(msgHash); // placeholder — use sha256(message) in practice
const sig = secp256k1.sign(msgHash, privateKey);
 
// Verify
const isValid = secp256k1.verify(sig, msgHash, publicKey);
 
// Ethereum-style recovery
const recovered = sig.recoverPublicKey(msgHash);

ECDSA P-384 / P-521

Higher security levels. P-384 is used in NSS Suite B (government-grade). P-521 provides ~260-bit security — overkill for almost everything.

ecdsa-p384.ts
const pair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-384" },
  true,
  ["sign", "verify"]
);

Ed25519

Edwards-curve Digital Signature Algorithm. Faster than ECDSA, deterministic (no random nonce needed per signature), and resistant to side-channel attacks.

ed25519.ts
import { ed25519 } from "@noble/curves/ed25519";
 
// Key generation
const privateKey = ed25519.utils.randomPrivateKey();
const publicKey = ed25519.getPublicKey(privateKey);
 
// Sign
const message = new TextEncoder().encode("authenticate this request");
const signature = ed25519.sign(message, privateKey);
 
// Verify
const isValid = ed25519.verify(signature, message, publicKey);
console.log(isValid); // => true

In Rust:

ed25519_sign.rs
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.
  • Prefer Ed25519 for new systems.

X25519 (ECDH Key Exchange)

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 keypair
const alicePrivate = x25519.utils.randomPrivateKey();
const alicePublic = x25519.getPublicKey(alicePrivate);
 
// Bob generates his keypair
const bobPrivate = x25519.utils.randomPrivateKey();
const bobPublic = x25519.getPublicKey(bobPrivate);
 
// Both derive the same shared secret
const aliceShared = x25519.getSharedSecret(alicePrivate, bobPublic);
const bobShared = x25519.getSharedSecret(bobPrivate, alicePublic);
 
// aliceShared === bobShared (same 32 bytes)
// Feed into HKDF to derive encryption keys

ECDH P-256 (Web Crypto)

For browser-native key agreement without external libraries:

ecdh-webcrypto.ts
async function generateECDHPair() {
  return crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    true,
    ["deriveKey", "deriveBits"]
  );
}
 
async function deriveSharedKey(
  myPrivate: CryptoKey,
  theirPublic: CryptoKey
): Promise<CryptoKey> {
  return crypto.subtle.deriveKey(
    { name: "ECDH", public: theirPublic },
    myPrivate,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );
}
 
// Usage
const alice = await generateECDHPair();
const bob = await generateECDHPair();
 
const aliceKey = await deriveSharedKey(alice.privateKey, bob.publicKey);
const bobKey = await deriveSharedKey(bob.privateKey, alice.publicKey);
// aliceKey and bobKey encrypt/decrypt each other's messages

RSA-OAEP

The classic. Still used for key wrapping and legacy systems. Larger keys (2048-4096 bit) compared to ECC (256 bit for equivalent security).

rsa-oaep.ts
async function rsaEncrypt(
  publicKey: CryptoKey,
  plaintext: Uint8Array
): Promise<ArrayBuffer> {
  return crypto.subtle.encrypt(
    { name: "RSA-OAEP" },
    publicKey,
    plaintext
  );
}
 
async function rsaDecrypt(
  privateKey: CryptoKey,
  ciphertext: ArrayBuffer
): Promise<ArrayBuffer> {
  return crypto.subtle.decrypt(
    { name: "RSA-OAEP" },
    privateKey,
    ciphertext
  );
}
 
// Generate 4096-bit RSA key pair
const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-OAEP",
    modulusLength: 4096,
    publicExponent: new Uint8Array([1, 0, 1]), // 65537
    hash: "SHA-256",
  },
  true,
  ["encrypt", "decrypt"]
);

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.

RSA-PSS (Probabilistic Signature Scheme)

The correct padding for RSA signatures. RSA-PKCS1v1.5 signatures are deterministic and potentially malleable; PSS adds randomness and a proof of security.

rsa-pss.ts
const keyPair = await crypto.subtle.generateKey(
  {
    name: "RSA-PSS",
    modulusLength: 4096,
    publicExponent: new Uint8Array([1, 0, 1]),
    hash: "SHA-256",
  },
  true,
  ["sign", "verify"]
);
 
const signature = await crypto.subtle.sign(
  { name: "RSA-PSS", saltLength: 32 }, // saltLength = hash output length
  keyPair.privateKey,
  new TextEncoder().encode("message to sign")
);
 
const valid = await crypto.subtle.verify(
  { name: "RSA-PSS", saltLength: 32 },
  keyPair.publicKey,
  signature,
  new TextEncoder().encode("message to sign")
);

RSA Key Size vs Security Level

RSA Key SizeEquivalent ECCSecurity BitsYear Until Broken (est.)
1024-bit—~80Already broken
2048-bitP-224~112~2030
3072-bitP-256~128~2040
4096-bitP-384~140>2050

For new systems: 4096-bit RSA or P-256/Ed25519 (equivalent security, far smaller keys).


Key Serialization & Encoding

Keys must be stored and transmitted in standard formats. Never invent your own format.

JWK (JSON Web Key)

The standard JSON representation for Web Crypto keys. Portable across platforms.

jwk.ts
// Export to JWK
const keyPair = await crypto.subtle.generateKey(
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["sign", "verify"]
);
 
const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
 
// privateJwk looks like:
// { kty: "EC", crv: "P-256", d: "...", x: "...", y: "...", key_ops: ["sign"] }
 
// Import from JWK
const importedKey = await crypto.subtle.importKey(
  "jwk",
  publicJwk,
  { name: "ECDSA", namedCurve: "P-256" },
  true,
  ["verify"]
);

PKCS#8 (Private Key) and SPKI (Public Key)

Binary DER-encoded formats — the standard for PEM files. What OpenSSL, TLS certificates, and SSH use under the hood.

pkcs8.ts
// Export private key as PKCS#8 DER
const pkcs8 = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
 
// Export public key as SubjectPublicKeyInfo (SPKI) DER
const spki = await crypto.subtle.exportKey("spki", keyPair.publicKey);
 
// PEM encoding — wrap DER in base64 with headers
function toPEM(der: ArrayBuffer, type: "PRIVATE KEY" | "PUBLIC KEY"): string {
  const b64 = btoa(String.fromCharCode(...new Uint8Array(der)));
  const lines = b64.match(/.{1,64}/g)!.join("\n");
  return `-----BEGIN ${type}-----\n${lines}\n-----END ${type}-----`;
}
 
const privatePEM = toPEM(pkcs8, "PRIVATE KEY");
const publicPEM = toPEM(spki, "PUBLIC KEY");
pem.rs
use rsa::{RsaPrivateKey, RsaPublicKey, pkcs8::EncodePrivateKey, pkcs8::LineEnding};
use rand::rngs::OsRng;
 
fn main() {
    let private_key = RsaPrivateKey::new(&mut OsRng, 4096).unwrap();
    let pem = private_key.to_pkcs8_pem(LineEnding::LF).unwrap();
    println!("{}", pem.as_str()); // -----BEGIN PRIVATE KEY-----...
}

Raw Format

For symmetric keys and raw ECC key material (no headers, pure bytes):

raw-key.ts
// Export AES key as raw bytes
const aesKey = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,
  ["encrypt", "decrypt"]
);
 
const rawKey = await crypto.subtle.exportKey("raw", aesKey);
// rawKey is a 32-byte ArrayBuffer
 
// Store it encrypted (never store raw keys in plaintext)
// Import back
const importedKey = await crypto.subtle.importKey(
  "raw",
  rawKey,
  { name: "AES-GCM" },
  false, // not re-exportable
  ["encrypt", "decrypt"]
);

Hybrid Encryption (ECIES Pattern)

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.

ecies.ts
import { x25519 } from "@noble/curves/ed25519";
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha2";
import { randomBytes } from "@noble/ciphers/webcrypto";
 
// Sender encrypts to recipient's public key
function eciesEncrypt(
  plaintext: Uint8Array,
  recipientPublicKey: Uint8Array
): {
  ephemeralPublic: Uint8Array;
  nonce: Uint8Array;
  ciphertext: Uint8Array;
} {
  // 1. Generate ephemeral keypair
  const ephemeralPrivate = x25519.utils.randomPrivateKey();
  const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate);
 
  // 2. ECDH with recipient's public key
  const sharedSecret = x25519.getSharedSecret(ephemeralPrivate, recipientPublicKey);
 
  // 3. Derive encryption key via HKDF
  const encKey = hkdf(sha256, sharedSecret, ephemeralPublic, "ecies-v1", 32);
 
  // 4. Encrypt
  const nonce = randomBytes(24);
  const cipher = xchacha20poly1305(encKey, nonce);
  const ciphertext = cipher.encrypt(plaintext);
 
  return { ephemeralPublic, nonce, ciphertext };
}
 
// Recipient decrypts with their private key
function eciesDecrypt(
  ephemeralPublic: Uint8Array,
  nonce: Uint8Array,
  ciphertext: Uint8Array,
  recipientPrivateKey: Uint8Array
): Uint8Array {
  // 1. ECDH — same shared secret as sender derived
  const sharedSecret = x25519.getSharedSecret(recipientPrivateKey, ephemeralPublic);
 
  // 2. Derive same encryption key
  const encKey = hkdf(sha256, sharedSecret, ephemeralPublic, "ecies-v1", 32);
 
  // 3. Decrypt
  const cipher = xchacha20poly1305(encKey, nonce);
  return cipher.decrypt(ciphertext);
}

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.


Digital Signatures: Advanced Patterns

Blind Signatures

A signer signs a message without seeing its content. Used in anonymous credential systems and e-cash (Chaum 1982).

Threshold Signatures

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.

Schnorr Signatures

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 hash
const sig = schnorr.sign(message, privateKey);
const valid = schnorr.verify(sig, message, publicKey);

Authenticated Key Exchange

Diffie-Hellman (Classical)

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).

Station-to-Station (STS) Protocol

DH + signatures. Each party signs the transcript to prove identity. The foundation of SSH key authentication.

The Noise Protocol Framework

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.

Noise_XX pattern (mutual authentication, forward secrecy):
  -> e
  <- e, ee, s, es
  -> s, se

Signal Double Ratchet

The gold standard for end-to-end encrypted messaging. Combines:

  1. X3DH (Extended Triple Diffie-Hellman) for initial key agreement
  2. 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.


JWT, JWS, and JWE (JOSE)

JWT (JSON Web Token)

A signed (or encrypted) claim payload. Three base64url-encoded parts: header.payload.signature.

jwt.ts
// Using jose library — the most complete JOSE implementation
import { SignJWT, jwtVerify, importJWK } from "jose";
 
const secret = new TextEncoder().encode("your-256-bit-secret-here-padding!");
 
// Sign
const token = await new SignJWT({ userId: "usr_123", role: "admin" })
  .setProtectedHeader({ alg: "HS256" })
  .setIssuedAt()
  .setExpirationTime("2h")
  .setIssuer("https://yourapp.com")
  .setAudience("https://api.yourapp.com")
  .sign(secret);
 
// Verify
const { payload } = await jwtVerify(token, secret, {
  issuer: "https://yourapp.com",
  audience: "https://api.yourapp.com",
});
console.log(payload.userId); // => "usr_123"

JWT security rules:

  • Always validate alg in the header — the classic attack sets "alg": "none".
  • Always validate iss, aud, exp, nbf.
  • Use RS256 or ES256 (asymmetric) for tokens verified by multiple services. HS256 requires sharing the secret.
  • Do not put sensitive data in JWT payloads — they are only signed, not encrypted.

JWS (JSON Web Signature)

The underlying spec for JWT signatures. Supports multiple signers (JSON serialization).

JWE (JSON Web Encryption)

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.

jwe.ts
import { EncryptJWT, jwtDecrypt } from "jose";
 
const keyPair = await crypto.subtle.generateKey(
  { name: "RSA-OAEP", modulusLength: 4096, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
  true,
  ["encrypt", "decrypt"]
);
 
// Encrypt
const encrypted = await new EncryptJWT({ userId: "usr_123" })
  .setProtectedHeader({ alg: "RSA-OAEP-256", enc: "A256GCM" })
  .setExpirationTime("1h")
  .encrypt(keyPair.publicKey);
 
// Decrypt
const { payload } = await jwtDecrypt(encrypted, keyPair.privateKey);

TOTP / HOTP (Two-Factor Authentication)

HOTP (HMAC-based One-Time Password)

RFC 4226. A counter-based OTP: HOTP(K, C) = Truncate(HMAC-SHA1(K, C)).

hotp.ts
import { createHmac } from "node:crypto";
 
function hotp(secret: Buffer, counter: number): string {
  const counterBuffer = Buffer.alloc(8);
  counterBuffer.writeBigUInt64BE(BigInt(counter));
 
  const mac = createHmac("sha1", secret).update(counterBuffer).digest();
 
  // Dynamic truncation
  const offset = mac[19] & 0x0f;
  const code = ((mac[offset] & 0x7f) << 24) |
               (mac[offset + 1] << 16) |
               (mac[offset + 2] << 8) |
               mac[offset + 3];
 
  return String(code % 1_000_000).padStart(6, "0");
}

TOTP (Time-based One-Time Password)

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 apps
function 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.


Zero-Knowledge Proofs (Concepts)

What ZKP Solves

Prove you know a secret without revealing the secret. Examples:

  • Prove you know a password without sending the password
  • Prove you are over 18 without revealing your birthdate
  • Prove a transaction is valid without revealing transaction amounts (Zcash)

Commitment Schemes

A primitive underlying many ZKPs. Commit to a value without revealing it; later reveal and prove your commitment was to that value.

commitment.ts
import { randomBytes, createHash } from "node:crypto";
 
// Commit: hash(value || random_blinding_factor)
function commit(value: string): { commitment: string; blinding: string } {
  const blinding = randomBytes(32).toString("hex");
  const commitment = createHash("sha256")
    .update(value + blinding)
    .digest("hex");
  return { commitment, blinding };
}
 
// Open: reveal value and blinding, verifier recomputes
function verify(value: string, blinding: string, commitment: string): boolean {
  const expected = createHash("sha256")
    .update(value + blinding)
    .digest("hex");
  return expected === commitment;
}

Sigma Protocols (Schnorr PoK)

Prove knowledge of a discrete logarithm (i.e., "I know the private key corresponding to this public key") without revealing the private key:

1. Prover: pick random r, send commitment R = r·G
2. Verifier: send challenge c
3. Prover: send response s = r + c·x (where x is private key)
4. Verifier: check s·G == R + c·X

SRP (Secure Remote Password)

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.

1. Client: A = g^a mod N  (sends to server)
2. Server: B = kv + g^b mod N  (v is password verifier stored on server)
3. Both compute: S = (shared secret from DH)
4. Both derive: K = H(S), M1 = H(A, B, K), M2 = H(A, M1, K)
5. Client sends M1; Server sends M2 — mutual proof of K

SRP is complex to implement correctly — use a library (tssrp6a for TypeScript, srp crate for Rust).


Data Structures

Merkle Trees

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;
}

Hash Chains

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.


Key Management

Envelope Encryption

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 };
}

Key Rotation

Changing the active encryption key without decrypting and re-encrypting all data immediately. Strategies:

  1. Re-encrypt on write: When a record is written, use the new key. Old records are gradually migrated.
  2. Dual-read period: During rotation, the system accepts both old and new keys. After all records are migrated, retire the old key.
  3. Versioned keys: Every encrypted record stores the key version (kid). The key store serves any version still in use.
key-rotation.ts
interface KeyStore {
  [kid: string]: Buffer;
}
 
interface Ciphertext {
  kid: string;   // key ID — tells decryption which key to use
  nonce: string;
  ciphertext: string;
  tag: string;
}
 
const keys: KeyStore = {
  "v1": Buffer.from("old-key-32-bytes-xxxxxxxxxxxxxxxx"),
  "v2": Buffer.from("new-key-32-bytes-yyyyyyyyyyyyyyyy"),
};
 
const ACTIVE_KEY = "v2";
 
function decrypt(ct: Ciphertext): string {
  const key = keys[ct.kid];
  if (!key) throw new Error(`Unknown key version: ${ct.kid}`);
  // ... decrypt using key
  return "";
}

Memory Zeroization

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 zeroed
function zeroize(buf: Uint8Array): void {
  buf.fill(0);
}
 
// Avoid this pattern — strings cannot be zeroed
const key = "secret"; // ← string is immutable, lives in heap arbitrarily long
 
// Prefer this pattern
const 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.

Hardware Security Modules (HSMs)

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).


Constant-Time Programming

Why It Matters

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 difference
function badCompare(a: string, b: string): boolean {
  return a === b; // leaks how many bytes match
}
 
// CORRECT — always compares all bytes regardless of where difference is
function 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()
}

Constant-Time Algorithms

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).


Common Attacks

Birthday Attack

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).

Padding Oracle Attack

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).

Nonce Reuse (Catastrophic)

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.

Replay Attack

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.

Weak Entropy Attack

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.

Key Commitment Failure (AES-GCM)

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.


TLS 1.3 in Brief

Understanding TLS 1.3 is understanding modern cryptography in practice.

Client                                    Server
  |— ClientHello (supported ciphers) ——→  |
  |← ServerHello (chosen cipher)         |
  |← Certificate (public key)            |
  |← CertificateVerify (signature)       |
  |← Finished (HMAC over transcript)     |
  |— Finished ————————————————————————→  |
  |== Encrypted application data ======  |

TLS 1.3 mandatory changes:

  • No more RSA key exchange — only ECDHE (forward secrecy is mandatory)
  • Cipher suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256
  • Certificate signature: Ed25519, ECDSA P-256, RSA-PSS
  • Key derivation: HKDF throughout
  • Removed: RSA encryption, static DH, DSA, SHA-1, MD5, RC4, 3DES, CBC suites

Putting It All Together

A complete end-to-end encrypted message flow — the pattern used in Occlude:

e2e-flow.ts
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { randomBytes } from "@noble/ciphers/webcrypto";
import { hkdf } from "@noble/hashes/hkdf";
import { sha256 } from "@noble/hashes/sha2";
import { argon2id } from "@noble/hashes/argon2";
 
// Step 1: Derive master key from password
const password = new TextEncoder().encode("user-password");
const salt = randomBytes(16);
const masterKey = argon2id(password, salt, {
  t: 3,
  m: 65536,
  p: 4,
  dkLen: 32,
});
 
// Step 2: Derive per-message encryption key via HKDF
const messageKey = hkdf(sha256, masterKey, randomBytes(32), "message-v1", 32);
 
// Step 3: Encrypt with XChaCha20-Poly1305
const nonce = randomBytes(24);
const plaintext = new TextEncoder().encode("top secret note");
const cipher = xchacha20poly1305(messageKey, nonce);
const ciphertext = cipher.encrypt(plaintext);
 
// Step 4: Store { salt, nonce, ciphertext } — server never sees plaintext
const envelope = {
  salt: Buffer.from(salt).toString("base64"),
  nonce: Buffer.from(nonce).toString("base64"),
  ciphertext: Buffer.from(ciphertext).toString("base64"),
};

Algorithm Comparison

AlgorithmTypeKey SizeSpeedUse Case
SHA-256Hash—FastIntegrity, content addressing
SHA-512Hash—Fast (64-bit)Larger digest, Ed25519 internal
SHA-3-256Hash—MediumLength-extension immunity
BLAKE2bHash—Very fastFile hashing, general purpose
BLAKE3Hash—Very fastFile hashing, Merkle trees
HMAC-SHA-256MAC256-bitFastAPI authentication, webhooks
Poly1305MAC256-bit (one-time)Very fastChaCha20-Poly1305 auth tag
bcryptKDF—SlowLegacy password hashing
scryptKDF—Slow + memory-hardPassword hashing, Ethereum keystore
Argon2idKDF—Slow + memory-hardPassword hashing (preferred)
HKDFKDF—FastKey expansion
PBKDF2KDF—SlowPassword hashing (legacy)
AES-256-GCMAEAD256-bitFast (AES-NI)Data encryption (hardware)
AES-256-CBC + HMACEnc+MAC256-bitMediumLegacy, interop
ChaCha20-Poly1305AEAD256-bitFastData encryption (software)
XChaCha20-Poly1305AEAD256-bitFastRandom-nonce encryption
ECDSA P-256Signature256-bitMediumTLS, JWT, certificates
ECDSA secp256k1Signature256-bitMediumBitcoin/Ethereum
Ed25519Signature256-bitFastSSH keys, package signing
RSA-PSS 4096Signature4096-bitSlowLegacy PKI
X25519Key exchange256-bitFastTLS, E2EE key agreement
ECDH P-256Key exchange256-bitMediumWeb Crypto DH
RSA-OAEP 4096Encryption4096-bitSlowKey wrapping, legacy
ECIES (X25519+XChaCha)Hybrid enc256-bitFastAsymmetric payload encryption

Security Rules

  1. Never reuse a nonce with the same key. Use XChaCha20 if you cannot guarantee uniqueness.
  2. Never roll your own crypto. Use audited libraries: @noble/*, libsodium, ring (Rust).
  3. Always use authenticated encryption (GCM, Poly1305). ECB and CBC without HMAC are broken.
  4. Timing-safe comparison for all secret-dependent comparisons. === leaks information.
  5. Argon2id for passwords. Not bcrypt, not SHA-256, not PBKDF2 (unless forced by compliance).
  6. Rotate keys. Even perfect crypto fails if a key is compromised and never rotated.
  7. Never use Math.random() for cryptographic purposes. Use crypto.getRandomValues or OsRng.
  8. Validate JWT alg field. The "alg": "none" attack bypasses signature verification.
  9. Use AAD when available. Bind ciphertext to its context (record ID, user ID) to prevent cut-and-paste attacks.
  10. Use HMAC, not bare SHA-2 for authentication. SHA-2 is vulnerable to length-extension attacks.
  11. Zeroize key material after use. Use Uint8Array.fill(0) in JS, zeroize crate in Rust.
  12. Prefer Ed25519 over ECDSA. ECDSA depends on per-signature random nonces; a broken RNG leaks the private key.
  13. Use envelope encryption. DEKs encrypt data; KEKs (in HSM/KMS) encrypt DEKs. Compromise is scoped.
  14. In TLS: require TLS 1.3. Remove all CBC suites, SHA-1, and static RSA key exchange.
  15. Salt passwords with per-credential random salts. Never hash passwords without a salt; never use a static global salt.