//! AES-256-GCM encryption envelope + master-key sourcing (v1.1.7). //! //! Two responsibilities: //! //! 1. [`encrypt`] / [`decrypt`] — the at-rest envelope used by per-app //! `secrets`, the encrypted `inbound_secret` on email triggers, and //! the realtime signing key. `Aes256Gcm` with a 96-bit (12-byte) //! random nonce and a 128-bit auth tag **appended to the //! ciphertext** (the RustCrypto `Aead`-trait layout — `encrypt` //! returns `ciphertext || tag`, `decrypt` consumes the same). Both //! the ciphertext (tag included) and the nonce are stored. //! //! 2. [`MasterKey`] — the process-wide 32-byte key, sourced once at //! startup from `PICLOUD_SECRET_KEY` (base64 of exactly 32 bytes). //! A deterministic in-memory dev key is allowed ONLY when the env //! var is unset AND `PICLOUD_DEV_MODE=true`; otherwise an unset key //! is fatal (no quiet "your secrets are unencrypted" mode). //! //! **Key rotation is out of scope for v1.1.7.** Changing //! `PICLOUD_SECRET_KEY` between deploys orphans every existing //! ciphertext (it can no longer be decrypted). v1.2+ adds key-version //! columns + a re-encryption pass. use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Key, Nonce}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine as _; use rand::RngCore; use sha2::{Digest, Sha256}; use thiserror::Error; /// Master-key length in bytes (AES-256 → 32-byte key). pub const KEY_LEN: usize = 32; /// GCM nonce length in bytes (96-bit nonce, the AES-GCM standard). pub const NONCE_LEN: usize = 12; /// Output of [`encrypt`]: the ciphertext (auth tag appended) plus the /// randomly-generated nonce. Both must be persisted; `decrypt` needs /// the nonce to recover the plaintext. #[derive(Debug, Clone)] pub struct EncryptResult { /// Ciphertext with the 16-byte GCM auth tag appended. pub ciphertext: Vec, /// The 12-byte nonce used for this encryption. pub nonce: [u8; NONCE_LEN], } /// Errors from the encryption envelope. #[derive(Debug, Error)] pub enum CryptoError { /// Authentication failed — wrong key, corrupted ciphertext, or a /// tampered nonce/tag. GCM does not distinguish these (by design), /// so neither do we. #[error("decryption failed: authentication tag mismatch (wrong key, corrupted ciphertext, or tampered nonce)")] Decrypt, /// The stored nonce was not exactly [`NONCE_LEN`] bytes — a sign of /// row corruption. #[error("invalid nonce length: expected {NONCE_LEN} bytes, got {0}")] InvalidNonce(usize), } /// Encrypt `plaintext` under `key`, generating a fresh random nonce. /// /// The auth tag is appended to the returned ciphertext (RustCrypto /// `Aead` layout). Encryption with a valid 32-byte key and 12-byte /// nonce is infallible in `aes-gcm`, so this returns a value rather /// than a `Result`. #[must_use] pub fn encrypt(plaintext: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult { let cipher = Aes256Gcm::new(Key::::from_slice(key)); let mut nonce_bytes = [0u8; NONCE_LEN]; // CSPRNG nonce. `thread_rng` is seeded from the OS CSPRNG; a fresh // 96-bit nonce per encryption keeps the (key, nonce) pair unique. rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt(nonce, plaintext) .expect("AES-256-GCM encryption is infallible for a valid key + 12-byte nonce"); EncryptResult { ciphertext, nonce: nonce_bytes, } } /// Decrypt `ciphertext` (auth tag appended) with the stored `nonce` /// under `key`. /// /// # Errors /// /// Returns [`CryptoError::InvalidNonce`] if `nonce` is the wrong length, /// or [`CryptoError::Decrypt`] if authentication fails for any reason /// (wrong key, corruption, tampering). pub fn decrypt( ciphertext: &[u8], nonce: &[u8], key: &[u8; KEY_LEN], ) -> Result, CryptoError> { if nonce.len() != NONCE_LEN { return Err(CryptoError::InvalidNonce(nonce.len())); } let cipher = Aes256Gcm::new(Key::::from_slice(key)); let nonce = Nonce::from_slice(nonce); cipher .decrypt(nonce, ciphertext) .map_err(|_| CryptoError::Decrypt) } /// The process-wide master key. Sourced once at startup and threaded /// into the secrets service, the email-trigger receiver, and the /// realtime signing-key migration. /// /// Cheap to clone (32 bytes). `Debug` is redacted so the key never /// lands in a log line. #[derive(Clone)] pub struct MasterKey { key: [u8; KEY_LEN], } impl std::fmt::Debug for MasterKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MasterKey") .field("key", &"") .finish() } } /// Failure modes for master-key sourcing. Every variant is a fatal /// startup error — there is no fallback to a quiet plaintext mode. #[derive(Debug, Error)] pub enum MasterKeyError { /// `PICLOUD_SECRET_KEY` is unset/empty and dev mode is off. #[error( "PICLOUD_SECRET_KEY is required but unset. Generate one with `openssl rand -base64 32`, \ or set PICLOUD_DEV_MODE=true to use an insecure deterministic dev key (never in production)." )] Missing, /// `PICLOUD_SECRET_KEY` was not valid base64. #[error("PICLOUD_SECRET_KEY is not valid base64 (expected base64 of 32 bytes — `openssl rand -base64 32`)")] Malformed, /// Decoded to the wrong number of bytes. #[error("PICLOUD_SECRET_KEY must decode to exactly {KEY_LEN} bytes, got {0}")] WrongLength(usize), } impl MasterKey { /// Borrow the raw 32-byte key for the crypto envelope. #[must_use] pub const fn as_bytes(&self) -> &[u8; KEY_LEN] { &self.key } /// Build a key directly from 32 bytes (used by the realtime /// migration's tests and by [`Self::from_base64`]). #[must_use] pub const fn from_bytes(key: [u8; KEY_LEN]) -> Self { Self { key } } /// Decode a base64-encoded 32-byte key. /// /// # Errors /// /// [`MasterKeyError::Malformed`] for non-base64 input, /// [`MasterKeyError::WrongLength`] when the decoded length is not 32. pub fn from_base64(s: &str) -> Result { let decoded = B64 .decode(s.trim().as_bytes()) .map_err(|_| MasterKeyError::Malformed)?; let len = decoded.len(); let key: [u8; KEY_LEN] = decoded .try_into() .map_err(|_| MasterKeyError::WrongLength(len))?; Ok(Self { key }) } /// Source the master key from the process environment per the /// v1.1.7 rules. See [`Self::resolve`] for the decision logic. /// /// # Errors /// /// Propagates [`MasterKeyError`] when the key is absent (and dev /// mode is off) or malformed. pub fn from_env() -> Result { let secret = std::env::var("PICLOUD_SECRET_KEY").ok(); let dev_mode = std::env::var("PICLOUD_DEV_MODE") .map(|v| is_truthy(&v)) .unwrap_or(false); Self::resolve(secret.as_deref(), dev_mode) } /// Pure resolution logic, factored out of [`Self::from_env`] so it's /// testable without mutating process-global env vars. /// /// * `secret` present + non-empty → parse it (fatal if malformed). /// * `secret` absent/empty + `dev_mode` → deterministic dev key + /// a prominent warning. /// * `secret` absent/empty + no dev mode → fatal. /// /// # Errors /// /// See [`Self::from_env`]. pub fn resolve(secret: Option<&str>, dev_mode: bool) -> Result { match secret { Some(v) if !v.trim().is_empty() => Self::from_base64(v), _ if dev_mode => { tracing::warn!( "PICLOUD_SECRET_KEY is unset and PICLOUD_DEV_MODE=true: using a DETERMINISTIC \ in-memory dev master key. At-rest secrets are NOT secure in this mode. \ Never run a real deployment without PICLOUD_SECRET_KEY." ); Ok(Self::dev_key()) } _ => Err(MasterKeyError::Missing), } } /// Deterministic dev key: SHA-256 of a fixed label. Stable across /// restarts so dev secrets survive a reboot, but obviously not a /// real secret (the input is public). #[must_use] fn dev_key() -> Self { let digest = Sha256::digest(b"picloud-dev-master-key-v1.1.7"); let mut key = [0u8; KEY_LEN]; key.copy_from_slice(&digest); Self { key } } } /// Common env-var truthiness check shared with the other config knobs. fn is_truthy(v: &str) -> bool { matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes") } #[cfg(test)] mod tests { use super::*; fn test_key() -> [u8; KEY_LEN] { let mut k = [0u8; KEY_LEN]; for (i, b) in k.iter_mut().enumerate() { *b = u8::try_from(i).unwrap_or(0); } k } #[test] fn round_trip_recovers_plaintext() { let key = test_key(); let plaintext = b"sk_live_super_secret_value"; let enc = encrypt(plaintext, &key); let dec = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap(); assert_eq!(dec, plaintext); // Tag is appended → ciphertext is longer than plaintext. assert!(enc.ciphertext.len() > plaintext.len()); } #[test] fn round_trip_empty_plaintext() { let key = test_key(); let enc = encrypt(b"", &key); let dec = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap(); assert!(dec.is_empty()); } #[test] fn tampered_ciphertext_fails() { let key = test_key(); let mut enc = encrypt(b"hello world", &key); enc.ciphertext[0] ^= 0xff; let err = decrypt(&enc.ciphertext, &enc.nonce, &key).unwrap_err(); assert!(matches!(err, CryptoError::Decrypt)); } #[test] fn tampered_nonce_fails() { let key = test_key(); let enc = encrypt(b"hello world", &key); let mut nonce = enc.nonce; nonce[0] ^= 0xff; let err = decrypt(&enc.ciphertext, &nonce, &key).unwrap_err(); assert!(matches!(err, CryptoError::Decrypt)); } #[test] fn wrong_key_fails() { let key = test_key(); let mut other = test_key(); other[31] ^= 0xff; let enc = encrypt(b"hello world", &key); let err = decrypt(&enc.ciphertext, &enc.nonce, &other).unwrap_err(); assert!(matches!(err, CryptoError::Decrypt)); } #[test] fn wrong_length_nonce_rejected() { let key = test_key(); let enc = encrypt(b"hi", &key); let err = decrypt(&enc.ciphertext, &enc.nonce[..8], &key).unwrap_err(); assert!(matches!(err, CryptoError::InvalidNonce(8))); } #[test] fn distinct_nonces_per_encryption() { let key = test_key(); let a = encrypt(b"same plaintext", &key); let b = encrypt(b"same plaintext", &key); // Random nonce → ciphertext differs even for identical input. assert_ne!(a.nonce, b.nonce); assert_ne!(a.ciphertext, b.ciphertext); } #[test] fn master_key_from_valid_base64() { let raw = [7u8; KEY_LEN]; let b64 = B64.encode(raw); let mk = MasterKey::from_base64(&b64).unwrap(); assert_eq!(mk.as_bytes(), &raw); } #[test] fn master_key_malformed_base64() { let err = MasterKey::from_base64("not valid base64 !!!").unwrap_err(); assert!(matches!(err, MasterKeyError::Malformed)); } #[test] fn master_key_wrong_length() { let b64 = B64.encode([1u8; 16]); // 16 bytes, not 32 let err = MasterKey::from_base64(&b64).unwrap_err(); assert!(matches!(err, MasterKeyError::WrongLength(16))); } #[test] fn resolve_missing_without_dev_is_fatal() { let err = MasterKey::resolve(None, false).unwrap_err(); assert!(matches!(err, MasterKeyError::Missing)); // Empty string counts as missing too. let err = MasterKey::resolve(Some(" "), false).unwrap_err(); assert!(matches!(err, MasterKeyError::Missing)); } #[test] fn resolve_dev_fallback_only_with_dev_mode() { // Dev mode on + no key → deterministic dev key. let a = MasterKey::resolve(None, true).unwrap(); let b = MasterKey::resolve(None, true).unwrap(); assert_eq!(a.as_bytes(), b.as_bytes(), "dev key must be deterministic"); // A real key always wins over dev mode. let raw = [9u8; KEY_LEN]; let real = MasterKey::resolve(Some(&B64.encode(raw)), true).unwrap(); assert_eq!(real.as_bytes(), &raw); assert_ne!(real.as_bytes(), a.as_bytes()); } }