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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 20:50:22 +02:00
parent 64ad978a89
commit dc2e4fa01f
5 changed files with 472 additions and 0 deletions

105
Cargo.lock generated
View File

@@ -2,6 +2,41 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -400,6 +435,16 @@ dependencies = [
"phf_codegen", "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]] [[package]]
name = "clap" name = "clap"
version = "4.6.1" version = "4.6.1"
@@ -560,9 +605,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -880,6 +935,16 @@ dependencies = [
"wasip3", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -1201,6 +1266,15 @@ version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" 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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -1477,6 +1551,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -1770,15 +1850,18 @@ dependencies = [
name = "picloud-shared" name = "picloud-shared"
version = "1.1.6" version = "1.1.6"
dependencies = [ dependencies = [
"aes-gcm",
"async-trait", "async-trait",
"base64", "base64",
"chrono", "chrono",
"hmac", "hmac",
"rand 0.8.6",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tracing",
"uuid", "uuid",
] ]
@@ -1821,6 +1904,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -3229,6 +3324,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@@ -81,6 +81,13 @@ sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
base64 = "0.22" base64 = "0.22"
data-encoding = "2.6" 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 # Stdlib utility crates (v1.1.0 stdlib PR — registered into the
# Rhai engine as the regex::/random::/etc. namespaces) # Rhai engine as the regex::/random::/etc. namespaces)

View File

@@ -21,6 +21,10 @@ tokio = { workspace = true, features = ["sync"] }
hmac.workspace = true hmac.workspace = true
sha2.workspace = true sha2.workspace = true
base64.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] [dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] } tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "sync"] }

354
crates/shared/src/crypto.rs Normal file
View File

@@ -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<u8>,
/// 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::<Aes256Gcm>::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<Vec<u8>, CryptoError> {
if nonce.len() != NONCE_LEN {
return Err(CryptoError::InvalidNonce(nonce.len()));
}
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::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", &"<redacted 32 bytes>")
.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<Self, MasterKeyError> {
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<Self, MasterKeyError> {
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<Self, MasterKeyError> {
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());
}
}

View File

@@ -6,6 +6,7 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod crypto;
pub mod dead_letters; pub mod dead_letters;
pub mod docs; pub mod docs;
pub mod error; pub mod error;
@@ -35,6 +36,7 @@ pub mod version;
pub use app::{App, AppDomain, DomainShape}; pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; 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 dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService}; pub use docs::{DocId, DocRow, DocsError, DocsListPage, DocsService, NoopDocsService};
pub use error::Error; pub use error::Error;