Frontend Labs
⋯

52. Cryptography done right: AEAD, password hashing, HMAC, constant-time, zeroize

The rule from Security from Zero applies in every language: do not design cryptography, and do not assemble primitives yourself. Use an AEAD for encryption, a password hash for passwords, and HMAC for message authentication, from audited crates.

Need Crate Notes
Symmetric encryption aes-gcm, chacha20poly1305 (RustCrypto); aws-lc-rs, ring AEAD only. Nonces must never repeat under one key
Password hashing argon2 Argon2id; scrypt and bcrypt are acceptable alternatives
MAC hmac + sha2 verify in constant time
Signatures ed25519-dalek, p256, aws-lc-rs
TLS rustls Ch 49
Random rand (OsRng / rand::random), getrandom never a seeded PRNG for secrets
Constant time, wiping memory subtle, zeroize

52.1 AES-256-GCM with associated data

use aes_gcm::aead::{Aead, AeadCore, Generate, Key, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};

const NONCE_LEN: usize = 12;

/// Output format: nonce (12 bytes) || ciphertext || tag (16 bytes).
fn seal(key: &Key<Aes256Gcm>, plaintext: &[u8], aad: &[u8]) -> Vec<u8> {
    let cipher = Aes256Gcm::new(key);
    let nonce = Nonce::generate();                      // 96 random bits per message
    let ct = cipher.encrypt(&nonce, Payload { msg: plaintext, aad }).expect("encryption cannot fail for in-memory input");
    let mut out = Vec::with_capacity(NONCE_LEN + ct.len());
    out.extend_from_slice(&nonce);
    out.extend_from_slice(&ct);
    out
}

fn open(key: &Key<Aes256Gcm>, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, &'static str> {
    let (nonce, ct) = sealed.split_at_checked(NONCE_LEN).ok_or("too short")?;
    let nonce = Nonce::try_from(nonce).map_err(|_| "bad nonce")?;
    Aes256Gcm::new(key)
        .decrypt(&nonce, Payload { msg: ct, aad })
        .map_err(|_| "authentication failed")           // one opaque error: no oracle for attackers
}

fn main() {
    let key = Key::<Aes256Gcm>::generate();             // in production: from a KMS, never hard-coded
    // AAD binds the ciphertext to its context: this blob is user 42's SSN, nothing else.
    let sealed = seal(&key, b"123-45-6789", b"user:42/field:ssn");
    println!("sealed {} bytes", sealed.len());          // 12 + 11 + 16 = 39
    println!("{:?}", open(&key, &sealed, b"user:42/field:ssn").map(String::from_utf8));
    println!("{:?}", open(&key, &sealed, b"user:43/field:ssn"));   // moved to another row: rejected

    let mut tampered = sealed.clone();
    tampered[15] ^= 1;                                   // flip one ciphertext bit
    println!("{:?}", open(&key, &tampered, b"user:42/field:ssn"));
}
sealed 39 bytes
Ok(Ok("123-45-6789"))
Err("authentication failed")
Err("authentication failed")

Random 96-bit nonces are safe up to about 2³² messages per key. Beyond that, the chance of a nonce repeating becomes significant, and a repeated nonce breaks GCM completely. High-volume systems rotate keys or use envelope encryption with a fresh data key per object (Security in Depth Ch 9).

52.2 Password hashing with Argon2id

use argon2::password_hash::{phc::PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::{Algorithm, Argon2, Params, Version};

fn hasher() -> Argon2<'static> {
    // OWASP's minimum for Argon2id: 19 MiB memory, 2 iterations, 1 lane. Tune upward to about
    // 100–500 ms per hash on your production hardware.
    Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::new(19 * 1024, 2, 1, None).unwrap())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let stored = hasher().hash_password(b"correct horse battery staple")?.to_string();
    println!("{stored}");      // $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>: params and salt travel with the hash

    let parsed = PasswordHash::new(&stored)?;
    let t = std::time::Instant::now();
    println!("right password: {}", hasher().verify_password(b"correct horse battery staple", &parsed).is_ok());
    println!("wrong password: {}", hasher().verify_password(b"Tr0ub4dor&3", &parsed).is_ok());
    println!("two verifications took {:?}", t.elapsed()); // slow on purpose: that is what stops offline cracking
    Ok(())
}

In a debug build the two verifications took about 430 ms. Optimized builds are several times faster, which is why you tune the parameters against a --release binary on production hardware. Argon2 is deliberately slow and memory-hard, which is the point. In an async server, run hash_password/verify_password inside spawn_blocking (§31.1). Otherwise every login stalls a Tokio worker for the length of a hash. Security from Zero Ch 10 explains why fast hashes such as SHA-256 are wrong for passwords.

52.3 HMAC webhook verification, in constant time

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;

type HmacSha256 = Hmac<Sha256>;

fn hex_decode(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 { return None; }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

/// Verifies a GitHub-style header: "X-Hub-Signature-256: sha256=<hex>".
fn verify_webhook(secret: &[u8], body: &[u8], header: &str) -> bool {
    let Some(sig) = header.strip_prefix("sha256=").and_then(hex_decode) else { return false };
    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(body);
    mac.verify_slice(&sig).is_ok()      // constant-time comparison inside
}

fn sign(secret: &[u8], body: &[u8]) -> String {
    let mut mac = HmacSha256::new_from_slice(secret).unwrap();
    mac.update(body);
    let tag = mac.finalize().into_bytes();
    format!("sha256={}", tag.iter().map(|b| format!("{b:02x}")).collect::<String>())
}

fn main() {
    let secret = b"whsec_lab_only";
    let body = br#"{"action":"push","ref":"main"}"#;
    let header = sign(secret, body);
    println!("{header}");
    println!("valid:      {}", verify_webhook(secret, body, &header));
    println!("tampered:   {}", verify_webhook(secret, br#"{"action":"push","ref":"prod"}"#, &header));
    println!("garbage:    {}", verify_webhook(secret, body, "sha256=zz"));

    // Comparing secrets yourself? Use subtle, never `==` (which can stop at the first differing byte).
    let (token, presented) = (b"api-key-7f3a".as_slice(), b"api-key-7f3b".as_slice());
    println!("token match: {}", bool::from(token.ct_eq(presented)));
}

== on byte slices returns as soon as it finds a difference, so response time reveals how many leading bytes were correct. Over many requests an attacker can recover a token byte by byte. verify_slice and subtle::ConstantTimeEq take the same time whatever the input. Security from Zero Ch 11 covers HMAC itself.

52.4 Wiping secrets from memory with zeroize

use zeroize::{Zeroize, ZeroizeOnDrop};

#[derive(Zeroize, ZeroizeOnDrop)]
struct SessionKeys { enc: [u8; 32], mac: [u8; 32] }

fn main() {
    let mut pin = String::from("4921");
    pin.zeroize();                                    // overwritten with zeros, with writes the optimizer may not remove
    println!("pin after zeroize: {pin:?}");           // ""
    let keys = SessionKeys { enc: [7; 32], mac: [9; 32] };
    println!("using keys {} {}", keys.enc[0], keys.mac[0]);
}   // `keys` is wiped here, automatically

Zeroizing reduces how long a secret sits in memory, where it could end up in a core dump, swap, or a heartbleed-style over-read. It is not a guarantee. Moves and Vec reallocation can leave old copies behind, so allocate secret buffers once at their final size.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36