//! 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`. /// /// Legacy (v0) layout — no Associated Authentication Data binding. /// Pre-2026-06-11 rows still exist with this layout; new writes use /// [`encrypt_with_aad`] (v1) so the ciphertext is bound to its /// `(app_id, name)`-or-equivalent identity and can't be swapped. #[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) } /// Encrypt `plaintext` with `aad` (Associated Authentication Data) /// bound into the GCM auth tag. The AAD is not stored — both seal and /// open sides must reconstruct it from out-of-band context. /// /// Audit 2026-06-11 H-D1 — at-rest data was previously sealed with no /// AAD, so any party with Postgres write access could ciphertext-swap /// rows across apps or rename-via-row-edit; the decrypt would succeed /// under the wrong identity and the caller would silently receive the /// attacker-chosen plaintext. Callers should bind a stable identity /// string (e.g. `"secret:{app_id}:{name}"`) so a swap fails open. /// /// Encryption is infallible for a valid 32-byte key + 12-byte nonce. #[must_use] pub fn encrypt_with_aad(plaintext: &[u8], aad: &[u8], key: &[u8; KEY_LEN]) -> EncryptResult { use aes_gcm::aead::Payload; let cipher = Aes256Gcm::new(Key::::from_slice(key)); let mut nonce_bytes = [0u8; NONCE_LEN]; rand::thread_rng().fill_bytes(&mut nonce_bytes); let nonce = Nonce::from_slice(&nonce_bytes); let ciphertext = cipher .encrypt( nonce, Payload { msg: plaintext, aad, }, ) .expect("AES-256-GCM encryption is infallible for a valid key + 12-byte nonce"); EncryptResult { ciphertext, nonce: nonce_bytes, } } /// Decrypt `ciphertext` with the same `aad` it was sealed with. Any /// drift in the AAD (including swapping ciphertexts across rows) makes /// the GCM auth tag fail and surfaces as [`CryptoError::Decrypt`]. /// /// # Errors /// /// Same as [`decrypt`]; in addition, a wrong/mismatched `aad` fails /// authentication and returns [`CryptoError::Decrypt`]. pub fn decrypt_with_aad( ciphertext: &[u8], nonce: &[u8], aad: &[u8], key: &[u8; KEY_LEN], ) -> Result, CryptoError> { use aes_gcm::aead::Payload; 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, Payload { msg: ciphertext, aad, }, ) .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), /// F-S-009: PICLOUD_DEV_MODE=true was set without PICLOUD_SECRET_KEY, /// AND without the explicit PICLOUD_DEV_INSECURE_KEY acknowledgement. #[error( "PICLOUD_DEV_MODE=true without PICLOUD_SECRET_KEY requires an explicit acknowledgement. \ Set PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure to confirm — but never in \ production: the dev master key is a fully public deterministic value." )] DevModeUnacknowledged, } 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); // F-S-009: PICLOUD_DEV_MODE alone falls through to a fully // public deterministic key (the warning is correct but the gate // is a single env var — copying a dev docker-compose file into // prod silently encrypts everything with a world-known key). // Require an explicit second knob acknowledging the risk before // we accept dev mode without a real secret. let dev_ack = std::env::var("PICLOUD_DEV_INSECURE_KEY") .map(|v| v == "i-understand-this-is-insecure") .unwrap_or(false); if dev_mode && secret.as_deref().map_or("", str::trim).is_empty() && !dev_ack { return Err(MasterKeyError::DevModeUnacknowledged); } 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 aad_round_trip_recovers_plaintext() { let key = test_key(); let pt = b"sk_live_xxx"; let aad = b"secret:app-uuid:stripe_key"; let enc = encrypt_with_aad(pt, aad, &key); let dec = decrypt_with_aad(&enc.ciphertext, &enc.nonce, aad, &key).unwrap(); assert_eq!(dec, pt); } #[test] fn aad_mismatch_fails_decryption() { // Audit 2026-06-11 H-D1 closure — swapping the AAD (e.g. moving // a ciphertext to a different (app, name) slot) fails the GCM // auth tag. let key = test_key(); let pt = b"value"; let enc = encrypt_with_aad(pt, b"secret:A:foo", &key); let err = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"secret:B:foo", &key) .expect_err("AAD swap must fail"); assert!(matches!(err, CryptoError::Decrypt)); let err = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"secret:A:bar", &key) .expect_err("AAD swap must fail"); assert!(matches!(err, CryptoError::Decrypt)); } #[test] fn empty_aad_round_trips() { let key = test_key(); let pt = b"value"; let enc = encrypt_with_aad(pt, b"", &key); let dec = decrypt_with_aad(&enc.ciphertext, &enc.nonce, b"", &key).unwrap(); assert_eq!(dec, pt); } #[test] fn empty_aad_v1_is_distinct_from_v0() { // encrypt() (no AAD) and encrypt_with_aad(_, b"", _) are NOT // interchangeable — they use different `Aead::encrypt` overloads // internally and so the resulting ciphertexts decrypt only with // the matching opener. Pin this behavior to catch any future // attempt to "transparently" upgrade v0 reads through the v1 path. let key = test_key(); let pt = b"v"; let v0 = encrypt(pt, &key); // decrypt with v1 + empty AAD should NOT succeed because the // underlying Aead `encrypt(nonce, msg)` and // `encrypt(nonce, Payload { msg, aad: b"" })` actually agree — // RustCrypto treats the former as Payload { msg, aad: &[] }. // So this test pins the *equivalence*, not the *distinction*. // The version column dispatches by stored value, not by ABI. let dec = decrypt_with_aad(&v0.ciphertext, &v0.nonce, b"", &key).unwrap(); assert_eq!(dec, pt); } #[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()); } }