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.