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.
A deep dive into modern cryptographic primitives — ECDSA, SHA-256, ChaCha20-Poly1305, AES-GCM, Argon2id, X25519, HKDF, and more. With runnable code examples.
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.
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.
All cryptographic operations require random numbers that are computationally indistinguishable from true randomness. Never use Math.random() — it is not cryptographically secure.
// 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 Uint8Arrayuse 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.
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.
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:
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("");
}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).
import { createHash } from "node:crypto";
function sha512(data: string): string {
return createHash("sha512").update(data, "utf-8").digest("hex");
}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).
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");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.
import { createHash } from "node:crypto"; // Node 21+ supports blake2b512 / blake2s256
const h = createHash("blake2b512").update("hello").digest("hex");use blake2::{Blake2b512, Digest};
let mut h = Blake2b512::new();
h.update(b"hello world");
let result = h.finalize();Not SHA, but worth mentioning — a tree-hashable, parallelizable hash function. Significantly faster than SHA-256 on modern hardware.
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:
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");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.
// 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.
Hash-based Message Authentication Code. Proves both integrity and authenticity — the sender must know the secret key.
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 comparesTiming-safe comparison is critical — never use === for MAC verification:
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.
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.
use std::collections::HashMap;
// HashMap uses SipHash-1-3 by default — you get it for free
let mut map: HashMap<String, i32> = HashMap::new();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).
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:
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+:
m=65536, t=3, p=4m=262144, t=4, p=4m=16384, t=2, p=1Colin 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).
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,
});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, ¶ms, &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.
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.
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):
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).
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:
// 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");Older but still widely used. Iterative hashing — less memory-hard than Argon2, but universally supported.
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):
Higher iteration count is always better; calibrate to ~300ms on your server.
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.
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.
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:
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.
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:
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)
}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.
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.
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.
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 operationUsed to encrypt keys with keys — the standard for envelope encryption and secure key export.
// 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 dataThe 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.
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);
}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.
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:
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")
}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.
// 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 failCommon 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.
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); // => trueThe Bitcoin/Ethereum curve. Not in Web Crypto; use @noble/curves.
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);Higher security levels. P-384 is used in NSS Suite B (government-grade). P-521 provides ~260-bit security — overkill for almost everything.
const pair = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-384" },
true,
["sign", "verify"]
);Edwards-curve Digital Signature Algorithm. Faster than ECDSA, deterministic (no random nonce needed per signature), and resistant to side-channel attacks.
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); // => trueIn Rust:
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:
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.
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 keysFor browser-native key agreement without external libraries:
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 messagesThe classic. Still used for key wrapping and legacy systems. Larger keys (2048-4096 bit) compared to ECC (256 bit for equivalent security).
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.
The correct padding for RSA signatures. RSA-PKCS1v1.5 signatures are deterministic and potentially malleable; PSS adds randomness and a proof of security.
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 | Equivalent ECC | Security Bits | Year Until Broken (est.) |
|---|---|---|---|
| 1024-bit | — | ~80 | Already broken |
| 2048-bit | P-224 | ~112 | ~2030 |
| 3072-bit | P-256 | ~128 | ~2040 |
| 4096-bit | P-384 | ~140 | >2050 |
For new systems: 4096-bit RSA or P-256/Ed25519 (equivalent security, far smaller keys).
Keys must be stored and transmitted in standard formats. Never invent your own format.
The standard JSON representation for Web Crypto keys. Portable across platforms.
// 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"]
);Binary DER-encoded formats — the standard for PEM files. What OpenSSL, TLS certificates, and SSH use under the hood.
// 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");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-----...
}For symmetric keys and raw ECC key material (no headers, pure bytes):
// 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"]
);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.
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.
A signer signs a message without seeing its content. Used in anonymous credential systems and e-cash (Chaum 1982).
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.
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);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).
DH + signatures. Each party signs the transcript to prove identity. The foundation of SSH key authentication.
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
The gold standard for end-to-end encrypted messaging. Combines:
Every message uses a different key, derived by advancing the ratchet. Compromise of one message key does not compromise past or future messages.
A signed (or encrypted) claim payload. Three base64url-encoded parts: header.payload.signature.
// 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:
alg in the header — the classic attack sets "alg": "none".iss, aud, exp, nbf.RS256 or ES256 (asymmetric) for tokens verified by multiple services. HS256 requires sharing the secret.The underlying spec for JWT signatures. Supports multiple signers (JSON serialization).
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.
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);RFC 4226. A counter-based OTP: HOTP(K, C) = Truncate(HMAC-SHA1(K, C)).
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");
}RFC 6238. HOTP where the counter is replaced by Math.floor(Date.now() / 1000 / 30) — a 30-second time step.
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.
Prove you know a secret without revealing the secret. Examples:
A primitive underlying many ZKPs. Commit to a value without revealing it; later reveal and prove your commitment was to that value.
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;
}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
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).
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.
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.
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 };
}Changing the active encryption key without decrypting and re-encrypting all data immediately. Strategies:
kid). The key store serves any version still in use.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 "";
}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).
// 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 useuse 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.
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);
}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.
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:
TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256A complete end-to-end encrypted message flow — the pattern used in Occlude:
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 | Type | Key Size | Speed | Use Case |
|---|---|---|---|---|
| SHA-256 | Hash | — | Fast | Integrity, content addressing |
| SHA-512 | Hash | — | Fast (64-bit) | Larger digest, Ed25519 internal |
| SHA-3-256 | Hash | — | Medium | Length-extension immunity |
| BLAKE2b | Hash | — | Very fast | File hashing, general purpose |
| BLAKE3 | Hash | — | Very fast | File hashing, Merkle trees |
| HMAC-SHA-256 | MAC | 256-bit | Fast | API authentication, webhooks |
| Poly1305 | MAC | 256-bit (one-time) | Very fast | ChaCha20-Poly1305 auth tag |
| bcrypt | KDF | — | Slow | Legacy password hashing |
| scrypt | KDF | — | Slow + memory-hard | Password hashing, Ethereum keystore |
| Argon2id | KDF | — | Slow + memory-hard | Password hashing (preferred) |
| HKDF | KDF | — | Fast | Key expansion |
| PBKDF2 | KDF | — | Slow | Password hashing (legacy) |
| AES-256-GCM | AEAD | 256-bit | Fast (AES-NI) | Data encryption (hardware) |
| AES-256-CBC + HMAC | Enc+MAC | 256-bit | Medium | Legacy, interop |
| ChaCha20-Poly1305 | AEAD | 256-bit | Fast | Data encryption (software) |
| XChaCha20-Poly1305 | AEAD | 256-bit | Fast | Random-nonce encryption |
| ECDSA P-256 | Signature | 256-bit | Medium | TLS, JWT, certificates |
| ECDSA secp256k1 | Signature | 256-bit | Medium | Bitcoin/Ethereum |
| Ed25519 | Signature | 256-bit | Fast | SSH keys, package signing |
| RSA-PSS 4096 | Signature | 4096-bit | Slow | Legacy PKI |
| X25519 | Key exchange | 256-bit | Fast | TLS, E2EE key agreement |
| ECDH P-256 | Key exchange | 256-bit | Medium | Web Crypto DH |
| RSA-OAEP 4096 | Encryption | 4096-bit | Slow | Key wrapping, legacy |
| ECIES (X25519+XChaCha) | Hybrid enc | 256-bit | Fast | Asymmetric payload encryption |
@noble/*, libsodium, ring (Rust).=== leaks information.Math.random() for cryptographic purposes. Use crypto.getRandomValues or OsRng.alg field. The "alg": "none" attack bypasses signature verification.Uint8Array.fill(0) in JS, zeroize crate in Rust.