From 4b8378c4c36ef54c44e36c16868d650dec71862e Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Fri, 3 Jul 2026 20:06:11 +0200 Subject: [PATCH] feat(credential): Enforce Null Key check at startup FernetKeyRepository::load() already detected the well-known Null Key, but only lazily on first credential access. Add check_startup_null_key(), wired into the keystone binary's main() right after config load, so a misconfigured credential key repository refuses to start immediately instead of failing on the first request. Assisted-By: Claude Sonnet 5 Signed-off-by: Artem Goncharov --- crates/credential-driver-sql/src/fernet.rs | 64 ++++++++++++++++++++++ crates/keystone/src/bin/keystone.rs | 11 ++++ 2 files changed, 75 insertions(+) diff --git a/crates/credential-driver-sql/src/fernet.rs b/crates/credential-driver-sql/src/fernet.rs index 6efab9781..c5d62d431 100644 --- a/crates/credential-driver-sql/src/fernet.rs +++ b/crates/credential-driver-sql/src/fernet.rs @@ -221,6 +221,38 @@ impl FernetKeyRepository { }) } + /// Startup-time Null Key check (ADR 0019 §4, Security). + /// + /// Unlike [`Self::load`], this does not require any key files to be + /// present — an unconfigured repository (before `credential_setup` has + /// run) is not itself a Null Key problem. It only inspects whatever key + /// files already exist and, for any that decode to the well-known Null + /// Key, emits a hard warning log and — unless `insecure_allow_null_key` + /// is set — returns an error so the caller can refuse to start the + /// service. + /// + /// # Errors + /// [`CredentialFernetError::NullKeyDetected`] if a key file decodes to + /// the Null Key and `insecure_allow_null_key` is `false`. + pub fn check_startup_null_key( + &self, + insecure_allow_null_key: bool, + ) -> Result<(), CredentialFernetError> { + for (idx, raw) in self.read_key_files()? { + if Self::is_null_key(&raw) { + tracing::error!( + key_index = idx, + insecure_allow_null_key, + "credential key repository contains the well-known Null Key at startup" + ); + if !insecure_allow_null_key { + return Err(CredentialFernetError::NullKeyDetected); + } + } + } + Ok(()) + } + /// Promote the staged key `0` to primary, stage a fresh key `0`, and /// prune beyond [`MAX_ACTIVE_KEYS`]. /// @@ -348,6 +380,38 @@ mod tests { assert!(repo.load(true).is_ok()); } + #[test] + fn test_check_startup_null_key_on_unconfigured_repository() { + let dir = tempfile::tempdir().unwrap(); + let repo = FernetKeyRepository::new(dir.path().join("does-not-exist")); + // No key files at all (repository not yet set up) is not itself a + // Null Key problem. + assert!(repo.check_startup_null_key(false).is_ok()); + } + + #[test] + fn test_check_startup_null_key_refuses_by_default() { + let dir = tempfile::tempdir().unwrap(); + let repo = FernetKeyRepository::new(dir.path().to_path_buf()); + let null_key = URL_SAFE.encode([0u8; 32]); + std::fs::write(dir.path().join("0"), null_key).unwrap(); + + assert!(matches!( + repo.check_startup_null_key(false), + Err(CredentialFernetError::NullKeyDetected) + )); + // Explicit opt-in allows startup to proceed. + assert!(repo.check_startup_null_key(true).is_ok()); + } + + #[test] + fn test_check_startup_null_key_passes_with_real_key() { + let dir = tempfile::tempdir().unwrap(); + let repo = FernetKeyRepository::new(dir.path().to_path_buf()); + repo.setup().unwrap(); + assert!(repo.check_startup_null_key(false).is_ok()); + } + #[test] fn test_rotate_promotes_staged_key_and_keeps_old_primary_decryptable() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/keystone/src/bin/keystone.rs b/crates/keystone/src/bin/keystone.rs index b40989459..1e58e9ac5 100644 --- a/crates/keystone/src/bin/keystone.rs +++ b/crates/keystone/src/bin/keystone.rs @@ -85,6 +85,7 @@ use openstack_keystone_core::auth::ExecutionContext; use openstack_keystone_core::cadf_hook::CadfAuditHook; use openstack_keystone_core::db::sync_schema; use openstack_keystone_core::error::KeystoneError; +use openstack_keystone_credential_driver_sql::fernet::FernetKeyRepository; use openstack_keystone_distributed_storage::{StorageApi, app::Storage}; // Default body limit 256kB @@ -174,6 +175,16 @@ async fn main() -> Result<(), Report> { info!("Starting Keystone..."); + // ADR 0019 §4: refuse to start if the credential Fernet key repository + // contains the well-known Null Key, unless the operator has explicitly + // opted in via [credential] insecure_allow_null_key. This is a startup + // check in addition to the check `FernetKeyRepository::load` already + // performs lazily on first credential access, so a misconfigured + // repository is caught immediately rather than on first request. + FernetKeyRepository::new(cfg.credential.key_repository.clone()) + .check_startup_null_key(cfg.credential.insecure_allow_null_key) + .wrap_err("credential key repository failed startup check")?; + let token = CancellationToken::new(); let cloned_token = token.clone();