From 8e68f3a1cb04fced864cb631122126c41b77f248 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 15 Jul 2026 20:13:12 +0200 Subject: [PATCH] test(crypto): pin the dev-mode acknowledgement gate 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) --- crates/shared/src/crypto.rs | 49 +++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/shared/src/crypto.rs b/crates/shared/src/crypto.rs index f44123f..d361520 100644 --- a/crates/shared/src/crypto.rs +++ b/crates/shared/src/crypto.rs @@ -277,10 +277,30 @@ impl MasterKey { 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 { + 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); } - Self::resolve(secret.as_deref(), dev_mode) + Ok(()) } /// Pure resolution logic, factored out of [`Self::from_env`] so it's @@ -501,4 +521,29 @@ mod tests { 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()); + } }