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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 20:13:12 +02:00
parent 4bf518c86f
commit 8e68f3a1cb

View File

@@ -277,10 +277,30 @@ impl MasterKey {
let dev_ack = std::env::var("PICLOUD_DEV_INSECURE_KEY") let dev_ack = std::env::var("PICLOUD_DEV_INSECURE_KEY")
.map(|v| v == "i-understand-this-is-insecure") .map(|v| v == "i-understand-this-is-insecure")
.unwrap_or(false); .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); return Err(MasterKeyError::DevModeUnacknowledged);
} }
Self::resolve(secret.as_deref(), dev_mode) Ok(())
} }
/// Pure resolution logic, factored out of [`Self::from_env`] so it's /// 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_eq!(real.as_bytes(), &raw);
assert_ne!(real.as_bytes(), a.as_bytes()); 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());
}
} }