Skip to main content

doido_core/
crypto.rs

1//! Authenticated encryption (AES-256-GCM) for cookies and credentials.
2//!
3//! [`encrypt`]/[`decrypt`] take an arbitrary-length secret (e.g. an app's
4//! `secret_key_base` or `master.key`), derive a 32-byte key from it with
5//! SHA-256, and produce/consume a `nonce(12) || ciphertext+tag` blob. GCM's
6//! authentication tag means a tampered blob fails to decrypt (returns `None`),
7//! so this both hides and authenticates the payload.
8//!
9//! Shared by the session cookie store (`doido-controller`) and encrypted
10//! credentials (`doido-generators`).
11
12use aes_gcm::{
13    aead::{rand_core::RngCore, Aead, KeyInit, OsRng},
14    Aes256Gcm, Nonce,
15};
16use sha2::{Digest, Sha256};
17
18/// Nonce length for AES-GCM (96 bits, the standard).
19const NONCE_LEN: usize = 12;
20
21/// Derive a 32-byte AES-256 key from an arbitrary-length secret via SHA-256.
22pub fn key_from_secret(secret: &[u8]) -> [u8; 32] {
23    let mut hasher = Sha256::new();
24    hasher.update(secret);
25    hasher.finalize().into()
26}
27
28/// Generate a random 32-byte secret, hex-encoded (64 chars) — e.g. an app's
29/// `master.key` for encrypted credentials.
30pub fn generate_key_hex() -> String {
31    let mut bytes = [0u8; 32];
32    OsRng.fill_bytes(&mut bytes);
33    use std::fmt::Write as _;
34    bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
35        let _ = write!(acc, "{b:02x}");
36        acc
37    })
38}
39
40/// Encrypt `plaintext` under `secret`, returning `nonce || ciphertext+tag`.
41pub fn encrypt(secret: &[u8], plaintext: &[u8]) -> Vec<u8> {
42    let key = key_from_secret(secret);
43    let cipher = Aes256Gcm::new((&key).into());
44    let mut nonce_bytes = [0u8; NONCE_LEN];
45    OsRng.fill_bytes(&mut nonce_bytes);
46    let ciphertext = cipher
47        .encrypt(&Nonce::from(nonce_bytes), plaintext)
48        .expect("AES-256-GCM encryption never fails for valid keys/nonces");
49    let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
50    out.extend_from_slice(&nonce_bytes);
51    out.extend_from_slice(&ciphertext);
52    out
53}
54
55/// Decrypt a `nonce || ciphertext+tag` blob produced by [`encrypt`]. Returns
56/// `None` when the blob is truncated, the secret is wrong, or the tag fails to
57/// authenticate (tampering).
58pub fn decrypt(secret: &[u8], data: &[u8]) -> Option<Vec<u8>> {
59    if data.len() < NONCE_LEN {
60        return None;
61    }
62    let key = key_from_secret(secret);
63    let cipher = Aes256Gcm::new((&key).into());
64    let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
65    let nonce_arr: [u8; NONCE_LEN] = nonce_bytes.try_into().ok()?;
66    cipher.decrypt(&Nonce::from(nonce_arr), ciphertext).ok()
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn round_trips_plaintext() {
75        let secret = b"a-secret-key-base";
76        let blob = encrypt(secret, b"hello world");
77        assert_eq!(decrypt(secret, &blob).as_deref(), Some(&b"hello world"[..]));
78    }
79
80    #[test]
81    fn wrong_secret_fails_to_decrypt() {
82        let blob = encrypt(b"right", b"payload");
83        assert!(decrypt(b"wrong", &blob).is_none());
84    }
85
86    #[test]
87    fn tampered_ciphertext_fails_to_decrypt() {
88        let secret = b"s";
89        let mut blob = encrypt(secret, b"payload");
90        let last = blob.len() - 1;
91        blob[last] ^= 0xff;
92        assert!(decrypt(secret, &blob).is_none());
93    }
94
95    #[test]
96    fn distinct_nonces_produce_distinct_ciphertexts() {
97        let secret = b"s";
98        assert_ne!(encrypt(secret, b"same"), encrypt(secret, b"same"));
99    }
100}