From dc2e4fa01fb34b8203e87a12321867eda41872a7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 4 Jun 2026 20:50:22 +0200 Subject: [PATCH] feat(v1.1.7-crypto): master-key infra + encryption helpers Add picloud_shared::crypto: AES-256-GCM encrypt/decrypt envelope (12-byte CSPRNG nonce, 128-bit tag appended to ciphertext) plus a MasterKey sourced from PICLOUD_SECRET_KEY (base64 of 32 bytes), with a deterministic dev-key fallback gated on PICLOUD_DEV_MODE=true. Unset key without dev mode is fatal. Key rotation is out of v1.1.7 scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 105 +++++++++++ Cargo.toml | 7 + crates/shared/Cargo.toml | 4 + crates/shared/src/crypto.rs | 354 ++++++++++++++++++++++++++++++++++++ crates/shared/src/lib.rs | 2 + 5 files changed, 472 insertions(+) create mode 100644 crates/shared/src/crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 203e733..8def767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -400,6 +435,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -560,9 +605,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -880,6 +935,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1201,6 +1266,15 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1477,6 +1551,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1770,15 +1850,18 @@ dependencies = [ name = "picloud-shared" version = "1.1.6" dependencies = [ + "aes-gcm", "async-trait", "base64", "chrono", "hmac", + "rand 0.8.6", "serde", "serde_json", "sha2", "thiserror 1.0.69", "tokio", + "tracing", "uuid", ] @@ -1821,6 +1904,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3229,6 +3324,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index de58d5e..9e64a16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,13 @@ sha2 = "0.10" hmac = "0.12" base64 = "0.22" data-encoding = "2.6" +# AES-256-GCM at-rest encryption for per-app secrets + the realtime +# signing key (v1.1.7). Audited, pure-Rust RustCrypto AEAD. +aes-gcm = { version = "0.10", features = ["aes", "alloc"] } + +# Outbound SMTP email (v1.1.7). Async transport over the Tokio runtime +# with rustls TLS; built messages for text + multipart-alternative. +lettre = { version = "0.11", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "builder", "hostname"] } # Stdlib utility crates (v1.1.0 stdlib PR — registered into the # Rhai engine as the regex::/random::/etc. namespaces) diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 47e2b70..d3fa561 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -21,6 +21,10 @@ tokio = { workspace = true, features = ["sync"] } hmac.workspace = true sha2.workspace = true base64.workspace = true +# AES-256-GCM envelope + master-key sourcing (v1.1.7 crypto module). +aes-gcm.workspace = true +rand.workspace = true +tracing.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] } diff --git a/crates/shared/src/crypto.rs b/crates/shared/src/crypto.rs new file mode 100644 index 0000000..e0db2fd --- /dev/null +++ b/crates/shared/src/crypto.rs @@ -0,0 +1,354 @@ +//! 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 = i as u8; + } + 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()); + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index e30a46f..486000e 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -6,6 +6,7 @@ pub mod app; pub mod auth; +pub mod crypto; pub mod dead_letters; pub mod docs; pub mod error; @@ -35,6 +36,7 @@ pub mod version; pub use app::{App, AppDomain, DomainShape}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; +pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError}; pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService}; pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService}; pub use error::Error;