Extracts the dev-mode acknowledgement decision into a pure check_dev_acknowledgement(secret, dev_mode, dev_ack) called by from_env before key resolution, and pins that dev mode without a secret and without PICLOUD_DEV_INSECURE_KEY is refused. Behavior-preserving. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
550 lines
21 KiB
Rust
550 lines
21 KiB
Rust
//! 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`.
|
|
///
|
|
/// 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::<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)
|
|
}
|
|
|
|
/// 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::<Aes256Gcm>::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<Vec<u8>, CryptoError> {
|
|
use aes_gcm::aead::Payload;
|
|
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,
|
|
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", &"<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),
|
|
|
|
/// 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<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);
|
|
// 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);
|
|
Self::check_dev_acknowledgement(secret.as_deref(), dev_mode, dev_ack)?;
|
|
Self::resolve(secret.as_deref(), dev_mode)
|
|
}
|
|
|
|
/// The dev-mode acknowledgement gate (F-S-009), factored out of [`Self::from_env`]
|
|
/// so it is testable without mutating process-global env vars — the same
|
|
/// reason [`Self::resolve`] is a separate pure function. With no real secret,
|
|
/// `dev_mode` alone is NOT enough to boot on the world-known deterministic dev
|
|
/// key; the caller must also acknowledge the risk (`dev_ack`). A present,
|
|
/// non-empty secret makes the gate a no-op (a real key needs no acknowledgement).
|
|
///
|
|
/// # Errors
|
|
/// [`MasterKeyError::DevModeUnacknowledged`] when `dev_mode` is set and no
|
|
/// secret is present and the risk was not acknowledged.
|
|
pub fn check_dev_acknowledgement(
|
|
secret: Option<&str>,
|
|
dev_mode: bool,
|
|
dev_ack: bool,
|
|
) -> Result<(), MasterKeyError> {
|
|
let secret_empty = secret.map_or("", str::trim).is_empty();
|
|
if dev_mode && secret_empty && !dev_ack {
|
|
return Err(MasterKeyError::DevModeUnacknowledged);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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 = 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());
|
|
}
|
|
|
|
/// F-S-009: dev mode alone must NOT be enough to boot on the world-known dev
|
|
/// key. This is the mitigation for a dev docker-compose copied into prod
|
|
/// encrypting every secret with a public value. `check_dev_acknowledgement`
|
|
/// pins the gate; a defaulted-true ack or an inverted comparison fails here.
|
|
#[test]
|
|
fn dev_mode_without_acknowledgement_is_refused() {
|
|
// dev_mode + no secret + NOT acknowledged → refused.
|
|
assert!(matches!(
|
|
MasterKey::check_dev_acknowledgement(None, true, false),
|
|
Err(MasterKeyError::DevModeUnacknowledged)
|
|
));
|
|
// An empty/whitespace secret is treated as absent.
|
|
assert!(matches!(
|
|
MasterKey::check_dev_acknowledgement(Some(" "), true, false),
|
|
Err(MasterKeyError::DevModeUnacknowledged)
|
|
));
|
|
|
|
// Acknowledged → allowed (from_env then resolves onto the dev key).
|
|
assert!(MasterKey::check_dev_acknowledgement(None, true, true).is_ok());
|
|
// A real secret makes the gate a no-op regardless of the ack.
|
|
assert!(MasterKey::check_dev_acknowledgement(Some("realkey"), true, false).is_ok());
|
|
// No dev mode → the gate never fires (resolve then returns Missing).
|
|
assert!(MasterKey::check_dev_acknowledgement(None, false, false).is_ok());
|
|
}
|
|
}
|