From 788d6346a3bdda78f8f1a5be7f7277a809fd4d38 Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Sat, 4 Jul 2026 09:40:40 +0200 Subject: [PATCH] feat(fernet): Unify credential/token key repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds openstack-keystone-key-repository: a backend-agnostic KeySource trait (FilesystemKeySource today, Vault-ready) plus KeyRepository/CachedKeyRepository, implementing the key parsing, key_hash, Null Key detection, and staged-rotation logic that previously existed twice (once in credential-driver-sql, once, partially, in token-driver-fernet). - credential-driver-sql's FernetKeyRepository becomes a thin async adapter over the shared crate; behavior-neutral (same MAX_ACTIVE_KEYS=3 hard cap). - token-driver-fernet gains full parity with the credential key repository: Null Key detection (new [fernet_tokens] insecure_allow_null_key config, default false), real key rotation (max_active_keys was previously unused), and an auto-refreshing cached key snapshot (CachedKeyRepository) replacing the old load-once-never-reload / reload-from-disk-every-call split — this removes the long-standing "TODO: implement fernet keys change watching" and makes token key rotation take effect without a service restart. - The filesystem key source watches for changes via inotify with a polling fallback (same debounce pattern as ConfigManager's existing config-file watcher), so the same watch/reload contract will hold for a future Vault-backed KeySource without changing KeyRepository/CachedKeyRepository. - keystone-manage gains `token setup`/`token rotate` (mirroring the existing `credential setup`/`migrate`/`rotate`; no `migrate` for tokens since they are never re-encrypted, just expire). - keystone's startup Null Key check now runs for both key repositories. Assisted-By: Claude Sonnet 5 Signed-off-by: Artem Goncharov --- Cargo.lock | 25 +- Cargo.toml | 2 + crates/cli-manage/Cargo.toml | 1 + crates/cli-manage/src/credential.rs | 1 + crates/cli-manage/src/main.rs | 6 + crates/cli-manage/src/token.rs | 144 +++++ crates/config/src/fernet_token.rs | 9 + crates/credential-driver-sql/Cargo.toml | 4 +- .../src/credential/create.rs | 3 +- .../src/credential/get.rs | 16 +- .../src/credential/list.rs | 7 +- .../src/credential/update.rs | 11 +- crates/credential-driver-sql/src/error.rs | 21 + crates/credential-driver-sql/src/fernet.rs | 326 +++--------- crates/credential-driver-sql/src/migrate.rs | 16 +- crates/credential-driver-sql/src/rotate.rs | 12 +- crates/key-repository/Cargo.toml | 30 ++ crates/key-repository/src/error.rs | 57 ++ crates/key-repository/src/filesystem.rs | 391 ++++++++++++++ crates/key-repository/src/lib.rs | 496 ++++++++++++++++++ crates/key-repository/src/source.rs | 79 +++ crates/keystone/src/bin/keystone.rs | 16 +- crates/keystone/src/plugin_manager.rs | 30 +- crates/token-driver-fernet/Cargo.toml | 5 +- .../benches/fernet_token.rs | 5 +- crates/token-driver-fernet/src/error.rs | 35 ++ crates/token-driver-fernet/src/lib.rs | 95 ++-- crates/token-driver-fernet/src/utils.rs | 334 ++++-------- tests/integration/src/common.rs | 4 +- tests/integration/src/credential.rs | 4 +- 30 files changed, 1580 insertions(+), 605 deletions(-) create mode 100644 crates/cli-manage/src/token.rs create mode 100644 crates/key-repository/Cargo.toml create mode 100644 crates/key-repository/src/error.rs create mode 100644 crates/key-repository/src/filesystem.rs create mode 100644 crates/key-repository/src/lib.rs create mode 100644 crates/key-repository/src/source.rs diff --git a/Cargo.lock b/Cargo.lock index 400a58ef8..9cc23d662 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4538,6 +4538,7 @@ dependencies = [ "openstack-keystone-distributed-storage", "openstack-keystone-federation-driver-sql", "openstack-keystone-k8s-auth-driver-sql", + "openstack-keystone-token-driver-fernet", "openstack-keystone-token-restriction-driver-sql", "openstack-keystone-webauthn", "reqwest", @@ -4660,15 +4661,13 @@ dependencies = [ "base64 0.22.1", "fernet", "inventory", - "nix 0.31.3", "openstack-keystone-config", "openstack-keystone-core", "openstack-keystone-core-types", - "scopeguard", + "openstack-keystone-key-repository", "sea-orm", "secrecy", "serde_json", - "sha1", "sha2 0.11.0", "tempfile", "thiserror 2.0.18", @@ -4829,6 +4828,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "openstack-keystone-key-repository" +version = "0.1.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "fernet", + "nix 0.31.3", + "notify", + "scopeguard", + "sha1", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "openstack-keystone-mapping-driver-raft" version = "0.1.0" @@ -4937,9 +4953,8 @@ dependencies = [ "openstack-keystone-config", "openstack-keystone-core", "openstack-keystone-core-types", + "openstack-keystone-key-repository", "rmp", - "scopeguard", - "secrecy", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e8776d194..670484a67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/idmapping-driver-sql", "crates/k8s-auth-driver-raft", "crates/k8s-auth-driver-sql", + "crates/key-repository", "crates/keystone", "crates/mapping-driver-raft", "crates/resource-driver-sql", @@ -115,6 +116,7 @@ openstack-keystone-storage-crypto = { version = "0.1.0", path = "crates/storage- openstack-keystone-federation-driver-sql = { version = "0.1", path = "crates/federation-driver-sql" } openstack-keystone-k8s-auth-driver-sql = { version = "0.1", path = "crates/k8s-auth-driver-sql/" } openstack-keystone-k8s-auth-driver-raft = { version = "0.1", path = "crates/k8s-auth-driver-raft/" } +openstack-keystone-key-repository = { version = "0.1.0", path = "crates/key-repository" } openstack-keystone-mapping-driver-raft = { version = "0.1", path = "crates/mapping-driver-raft/" } openstack-keystone-identity-driver-sql = { version = "0.1", path = "crates/identity-driver-sql" } openstack-keystone-idmapping-driver-sql = { version = "0.1", path = "crates/idmapping-driver-sql/" } diff --git a/crates/cli-manage/Cargo.toml b/crates/cli-manage/Cargo.toml index 544bace6d..dbe01a27f 100644 --- a/crates/cli-manage/Cargo.toml +++ b/crates/cli-manage/Cargo.toml @@ -30,6 +30,7 @@ openstack-keystone-credential-driver-sql.workspace = true openstack-keystone-distributed-storage.workspace = true openstack-keystone-federation-driver-sql.workspace = true openstack-keystone-k8s-auth-driver-sql.workspace = true +openstack-keystone-token-driver-fernet.workspace = true openstack-keystone-token-restriction-driver-sql.workspace = true openstack-keystone-webauthn.workspace = true sea-orm = { workspace = true, features = ["sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "runtime-tokio", "runtime-tokio-native-tls"] } diff --git a/crates/cli-manage/src/credential.rs b/crates/cli-manage/src/credential.rs index a27f356e8..ea7d18ca4 100644 --- a/crates/cli-manage/src/credential.rs +++ b/crates/cli-manage/src/credential.rs @@ -105,6 +105,7 @@ impl PerformAction for CredentialCommand { CredentialCommands::Setup => { let repo = FernetKeyRepository::new(config.credential.key_repository.clone()); repo.setup() + .await .wrap_err("setting up credential key repository")?; println!( "credential key repository initialized at {}", diff --git a/crates/cli-manage/src/main.rs b/crates/cli-manage/src/main.rs index e7fad6acd..ca2358e3c 100644 --- a/crates/cli-manage/src/main.rs +++ b/crates/cli-manage/src/main.rs @@ -27,11 +27,13 @@ mod common; mod credential; mod db; mod storage; +mod token; use crate::bootstrap::*; use crate::credential::*; use crate::db::*; use crate::storage::*; +use crate::token::*; /// OpenStack Keystone management CLI. /// @@ -66,6 +68,9 @@ enum Command { /// Distributed storage management. Storage(StorageCommand), + + /// Fernet token encryption key management (ADR 0019 §4). + Token(TokenCommand), } #[async_trait] @@ -83,6 +88,7 @@ async fn main() -> Result<(), Report> { Command::Credential(x) => x.take_action(&cfg).await?, Command::Db(x) => x.take_action(&cfg).await?, Command::Storage(x) => x.take_action(&cfg).await?, + Command::Token(x) => x.take_action(&cfg).await?, } Ok(()) } diff --git a/crates/cli-manage/src/token.rs b/crates/cli-manage/src/token.rs new file mode 100644 index 000000000..42c0e34b2 --- /dev/null +++ b/crates/cli-manage/src/token.rs @@ -0,0 +1,144 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! Fernet token encryption key management commands (ADR 0019 §4). +//! +//! Mirrors [`crate::credential::CredentialCommand`], backed by the same +//! shared [`openstack_keystone_key_repository`] logic. Unlike the +//! credential key repository, there is no `migrate` subcommand here: tokens +//! are short-lived and are never re-encrypted, they simply stop decrypting +//! once their key is pruned by a later rotation. + +use async_trait::async_trait; +use clap::{Parser, Subcommand}; +use color_eyre::{Report, eyre::WrapErr}; +use eyre::Result; + +use openstack_keystone_config::Config; +use openstack_keystone_token_driver_fernet::utils::FernetUtils; + +use crate::PerformAction; +use crate::common::setup_logging; + +/// Fernet token encryption key management. +#[derive(Parser)] +pub struct TokenCommand { + /// Verbosity level. Repeat to increase level. + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, + + #[command(subcommand)] + command: TokenCommands, +} + +#[derive(Subcommand)] +enum TokenCommands { + /// Populate the token key repository with an initial staged key. + /// + /// Must be run once during deployment, before any tokens are issued via + /// either service. + Setup, + + /// Promote the staged key to Primary. + /// + /// Aborts nothing else first — unlike `credential rotate`, there is no + /// stale-data check to run: tokens issued under a key that gets pruned + /// simply fail to decrypt (they expire long before `max_active_keys` + /// rotations typically elapse). + Rotate, +} + +fn utils_from(config: &Config) -> FernetUtils { + FernetUtils { + key_repository: config.fernet_tokens.key_repository.clone(), + max_active_keys: config.fernet_tokens.max_active_keys, + } +} + +#[async_trait] +impl PerformAction for TokenCommand { + async fn take_action(self, config: &Config) -> Result<(), Report> { + setup_logging(self.verbose); + + let utils = utils_from(config); + + match self.command { + TokenCommands::Setup => { + utils + .initialize_key_repository() + .await + .wrap_err("setting up token key repository")?; + println!( + "token key repository initialized at {}", + config.fernet_tokens.key_repository.display() + ); + Ok(()) + } + + TokenCommands::Rotate => { + utils.rotate().await.wrap_err("running token_rotate")?; + println!("token key repository rotated"); + Ok(()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config(key_repo: &std::path::Path) -> Config { + let mut cfg = Config::default(); + cfg.fernet_tokens.key_repository = key_repo.to_path_buf(); + cfg + } + + fn command(command: TokenCommands) -> TokenCommand { + TokenCommand { + verbose: 0, + command, + } + } + + #[tokio::test] + async fn test_setup_creates_staged_key() { + let key_dir = tempfile::tempdir().unwrap(); + let cfg = test_config(key_dir.path()); + + command(TokenCommands::Setup) + .take_action(&cfg) + .await + .unwrap(); + + assert!(key_dir.path().join("0").exists()); + } + + #[tokio::test] + async fn test_setup_rotate_roundtrip() { + let key_dir = tempfile::tempdir().unwrap(); + let cfg = test_config(key_dir.path()); + + command(TokenCommands::Setup) + .take_action(&cfg) + .await + .unwrap(); + command(TokenCommands::Rotate) + .take_action(&cfg) + .await + .unwrap(); + + assert!(key_dir.path().join("1").exists(), "staged key promoted"); + assert!(key_dir.path().join("0").exists(), "fresh key staged"); + } +} diff --git a/crates/config/src/fernet_token.rs b/crates/config/src/fernet_token.rs index 0a1542a33..8a42965ff 100644 --- a/crates/config/src/fernet_token.rs +++ b/crates/config/src/fernet_token.rs @@ -26,6 +26,14 @@ pub struct FernetTokenProvider { /// Maximal number of fernet keys to keep as active. #[serde(default = "default_fernet_max_active_keys")] pub max_active_keys: usize, + + /// Allow starting (and encrypting/decrypting with) the well-known Null + /// Key (`base64.urlsafe_b64encode(b'\x00' * 32)`). This exists solely as + /// a transient migration aid; it must be `false` in any real deployment. + /// Defaults to `false` (refuse to start if a key decodes to the Null + /// Key). Mirrors `[credential] insecure_allow_null_key`. + #[serde(default)] + pub insecure_allow_null_key: bool, } fn default_fernet_key_repository() -> PathBuf { @@ -41,6 +49,7 @@ impl Default for FernetTokenProvider { Self { key_repository: default_fernet_key_repository(), max_active_keys: default_fernet_max_active_keys(), + insecure_allow_null_key: false, } } } diff --git a/crates/credential-driver-sql/Cargo.toml b/crates/credential-driver-sql/Cargo.toml index 61ecdc2af..0139a9c14 100644 --- a/crates/credential-driver-sql/Cargo.toml +++ b/crates/credential-driver-sql/Cargo.toml @@ -15,15 +15,13 @@ async-trait.workspace = true base64 = { workspace = true } fernet = { workspace = true, features = ["rustcrypto"] } inventory.workspace = true -nix = { workspace = true, features = ["fs", "user"] } openstack-keystone-config.workspace = true openstack-keystone-core.workspace = true openstack-keystone-core-types.workspace = true -scopeguard = { workspace = true } +openstack-keystone-key-repository.workspace = true sea-orm = { workspace = true, features = ["default"] } secrecy = { workspace = true } serde_json.workspace = true -sha1 = { workspace = true } sha2.workspace = true tempfile = { workspace = true } thiserror.workspace = true diff --git a/crates/credential-driver-sql/src/credential/create.rs b/crates/credential-driver-sql/src/credential/create.rs index 9e6647263..2eff4eba5 100644 --- a/crates/credential-driver-sql/src/credential/create.rs +++ b/crates/credential-driver-sql/src/credential/create.rs @@ -45,7 +45,7 @@ pub async fn create( .ok_or(CredentialProviderError::MissingUserId)?; let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(cfg.credential.insecure_allow_null_key)?; + let keys = repo.load(cfg.credential.insecure_allow_null_key).await?; let encrypted_blob = keys.multi_fernet.encrypt(rec.blob.as_bytes()); let extra = rec.extra.as_ref().map(serde_json::to_string).transpose()?; @@ -101,6 +101,7 @@ mod tests { let db = test_db().await; crate::fernet::FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); let rec = CredentialCreate { diff --git a/crates/credential-driver-sql/src/credential/get.rs b/crates/credential-driver-sql/src/credential/get.rs index 88c1f11f7..706e5a9c1 100644 --- a/crates/credential-driver-sql/src/credential/get.rs +++ b/crates/credential-driver-sql/src/credential/get.rs @@ -25,12 +25,12 @@ use crate::entity::{credential as db_credential, prelude::Credential as DbCreden use crate::fernet::FernetKeyRepository; /// Decrypt a stored credential row into the plaintext API representation. -pub fn to_plaintext( +pub async fn to_plaintext( cfg: &Config, model: db_credential::Model, ) -> Result { let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(cfg.credential.insecure_allow_null_key)?; + let keys = repo.load(cfg.credential.insecure_allow_null_key).await?; let plaintext = keys .multi_fernet .decrypt(&model.encrypted_blob) @@ -64,7 +64,7 @@ pub async fn get>( let select = DbCredential::find().filter(db_credential::Column::Id.eq(id.as_ref())); if let Some(model) = select.one(db).await.context("fetching credential by id")? { - return Ok(Some(to_plaintext(cfg, model)?)); + return Ok(Some(to_plaintext(cfg, model).await?)); } Ok(None) } @@ -124,19 +124,19 @@ mod tests { ); } - #[test] - fn test_to_plaintext_roundtrip() { + #[tokio::test] + async fn test_to_plaintext_roundtrip() { let dir = tempfile::tempdir().unwrap(); let mut cfg = Config::default(); cfg.credential.key_repository = dir.path().to_path_buf(); let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - repo.setup().unwrap(); - let keys = repo.load(false).unwrap(); + repo.setup().await.unwrap(); + let keys = repo.load(false).await.unwrap(); let mut model = mock_model(); model.encrypted_blob = keys.multi_fernet.encrypt(br#"{"access":"AKIA"}"#); - let cred = to_plaintext(&cfg, model).unwrap(); + let cred = to_plaintext(&cfg, model).await.unwrap(); assert_eq!(cred.blob, r#"{"access":"AKIA"}"#); assert_eq!(cred.id, "cred_id"); } diff --git a/crates/credential-driver-sql/src/credential/list.rs b/crates/credential-driver-sql/src/credential/list.rs index 81bdef65a..ead582f84 100644 --- a/crates/credential-driver-sql/src/credential/list.rs +++ b/crates/credential-driver-sql/src/credential/list.rs @@ -43,7 +43,7 @@ pub async fn list( let mut result = Vec::with_capacity(models.len()); for model in models { - result.push(to_plaintext(cfg, model)?); + result.push(to_plaintext(cfg, model).await?); } Ok(result) } @@ -98,7 +98,7 @@ mod tests { plaintext: &[u8], ) { let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(false).unwrap(); + let keys = repo.load(false).await.unwrap(); db_credential::ActiveModel { id: Set(id.to_string()), user_id: Set(user_id.to_string()), @@ -120,6 +120,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); insert_credential(&cfg, &db, "cred-1", "user-1", "totp", b"a").await; insert_credential(&cfg, &db, "cred-2", "user-2", "ec2", b"b").await; @@ -137,6 +138,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); insert_credential(&cfg, &db, "cred-1", "user-1", "totp", b"a").await; insert_credential(&cfg, &db, "cred-2", "user-1", "ec2", b"b").await; @@ -158,6 +160,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); insert_credential(&cfg, &db, "cred-1", "user-1", "totp", b"a").await; insert_credential(&cfg, &db, "cred-2", "user-2", "totp", b"b").await; diff --git a/crates/credential-driver-sql/src/credential/update.rs b/crates/credential-driver-sql/src/credential/update.rs index 33b2abb67..e9ebb0e67 100644 --- a/crates/credential-driver-sql/src/credential/update.rs +++ b/crates/credential-driver-sql/src/credential/update.rs @@ -48,14 +48,14 @@ pub async fn update( } if let Some(new_blob) = rec.blob { let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(cfg.credential.insecure_allow_null_key)?; + let keys = repo.load(cfg.credential.insecure_allow_null_key).await?; active.encrypted_blob = Set(keys.multi_fernet.encrypt(new_blob.as_bytes())); active.key_hash = Set(keys.primary_key_hash); } let updated = active.update(db).await.context("updating credential")?; - to_plaintext(cfg, updated) + to_plaintext(cfg, updated).await } #[cfg(test)] @@ -83,7 +83,7 @@ mod tests { async fn insert_credential(cfg: &Config, db: &DatabaseConnection, id: &str, plaintext: &[u8]) { let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(false).unwrap(); + let keys = repo.load(false).await.unwrap(); db_credential::ActiveModel { id: Set(id.to_string()), user_id: Set("user-1".to_string()), @@ -105,6 +105,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); let err = update(&cfg, &db, "missing", CredentialUpdate::default()) @@ -120,6 +121,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); insert_credential(&cfg, &db, "cred-1", b"original-secret").await; @@ -139,6 +141,7 @@ mod tests { let db = test_db().await; FernetKeyRepository::new(key_dir.path().to_path_buf()) .setup() + .await .unwrap(); insert_credential(&cfg, &db, "cred-1", b"original-secret").await; @@ -150,7 +153,7 @@ mod tests { assert_eq!(updated.blob, "new-secret"); let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(false).unwrap(); + let keys = repo.load(false).await.unwrap(); let stored = DbCredential::find_by_id("cred-1".to_string()) .one(&db) .await diff --git a/crates/credential-driver-sql/src/error.rs b/crates/credential-driver-sql/src/error.rs index ea3e828ba..5701234c6 100644 --- a/crates/credential-driver-sql/src/error.rs +++ b/crates/credential-driver-sql/src/error.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use openstack_keystone_core::credential::CredentialProviderError; +use openstack_keystone_key_repository::KeyRepositoryError; use thiserror::Error; /// Errors from the Fernet key repository (ADR 0019 §4). @@ -58,6 +59,26 @@ pub enum CredentialFernetError { Persist(String), } +impl From for CredentialFernetError { + fn from(err: KeyRepositoryError) -> Self { + match err { + KeyRepositoryError::Io { source, path } => Self::Io { source, path }, + KeyRepositoryError::KeysMissing => Self::KeysMissing, + KeyRepositoryError::InvalidKey(idx) => Self::InvalidKey(idx), + KeyRepositoryError::NullKeyDetected => Self::NullKeyDetected, + KeyRepositoryError::IndexOverflow => Self::IndexOverflow, + KeyRepositoryError::Persist(msg) => Self::Persist(msg), + // Unreachable in practice: the credential repository never + // configures a `run_as` uid/gid, the only path that produces + // this variant. Mapped rather than left a `match` gap so this + // conversion stays exhaustive if that ever changes. + KeyRepositoryError::NixErrno { context, source } => { + Self::Persist(format!("{context}: {source}")) + } + } + } +} + impl From for CredentialProviderError { fn from(source: CredentialFernetError) -> Self { match source { diff --git a/crates/credential-driver-sql/src/fernet.rs b/crates/credential-driver-sql/src/fernet.rs index c5d62d431..b2b63dc04 100644 --- a/crates/credential-driver-sql/src/fernet.rs +++ b/crates/credential-driver-sql/src/fernet.rs @@ -13,26 +13,19 @@ // SPDX-License-Identifier: Apache-2.0 //! # Credential Fernet key repository (ADR 0019 §4) //! +//! Thin adapter over [`openstack_keystone_key_repository`], which owns the +//! actual key parsing/rotation/Null-Key-detection logic shared with the +//! Fernet token driver. This module exists to keep this crate's existing +//! public API (`FernetKeyRepository`, `LoadedKeys`, `MAX_ACTIVE_KEYS`, +//! `CredentialFernetError`) stable for its callers. +//! //! Separate from `[fernet_tokens] key_repository`. Hard-capped at //! [`MAX_ACTIVE_KEYS`] active keys, matching the Python Keystone constant — //! unlike token Fernet keys this is intentionally not configurable. -//! -//! Rotation is staged-key promotion, not primary renumbering: the staged key -//! `0` is renamed to `old_primary + 1` (becoming the new primary), the old -//! primary is left in place for decryption, a fresh key is staged as the new -//! `0`, and files beyond [`MAX_ACTIVE_KEYS`] are pruned. -use std::collections::BTreeMap; -use std::fs; -use std::io::Write; use std::path::PathBuf; -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE; -use fernet::{Fernet, MultiFernet}; -use nix::sys::stat::{Mode, umask}; -use sha1::{Digest, Sha1}; -use tempfile::NamedTempFile; -use tracing::{info, warn}; +pub use openstack_keystone_key_repository::LoadedKeys; +use openstack_keystone_key_repository::{FilesystemKeySource, KeyRepository}; use crate::error::CredentialFernetError; @@ -40,35 +33,20 @@ use crate::error::CredentialFernetError; /// keys (ADR 0019 §4). Intentionally not configurable. pub const MAX_ACTIVE_KEYS: usize = 3; -/// A loaded, ready-to-use view of the key repository. -pub struct LoadedKeys { - /// All active keys, primary first, wrapped for - /// decrypt-any/encrypt-with-primary use. - pub multi_fernet: MultiFernet, - - /// SHA-1 hex digest of the *raw base64url bytes* of the primary key - /// file (ADR 0019 §4, `key_hash` Specification) — not the decoded - /// 32-byte AES key. - pub primary_key_hash: String, - - /// Number of active key files loaded. - pub key_count: usize, -} - /// The Fernet key repository on the local filesystem. /// /// Per ADR 0019 §4, this directory must resolve to the identical file set on /// every Python and Rust node — that synchronization is an operational /// deployment requirement, not something this type enforces. -#[derive(Clone, Debug)] -pub struct FernetKeyRepository { - pub key_repository: PathBuf, -} +pub struct FernetKeyRepository(KeyRepository); impl FernetKeyRepository { /// Create a new repository handle for the given directory. pub fn new(key_repository: PathBuf) -> Self { - Self { key_repository } + Self(KeyRepository::new( + FilesystemKeySource::new(key_repository), + MAX_ACTIVE_KEYS, + )) } /// Compute `key_hash` per the ADR 0019 §4 specification: SHA-1 hex @@ -76,102 +54,21 @@ impl FernetKeyRepository { /// disk (i.e. *before* base64url-decoding). #[must_use] pub fn key_hash(raw_key_file_bytes: &[u8]) -> String { - let mut hasher = Sha1::new(); - hasher.update(raw_key_file_bytes); - hasher - .finalize() - .iter() - .map(|b| format!("{b:02x}")) - .collect() + KeyRepository::::key_hash(raw_key_file_bytes) } /// Whether the given raw (base64url-encoded) key-file bytes decode to /// the well-known Null Key (32 zero bytes). #[must_use] pub fn is_null_key(raw_key_file_bytes: &[u8]) -> bool { - match URL_SAFE.decode(raw_key_file_bytes) { - Ok(decoded) => decoded.len() == 32 && decoded.iter().all(|b| *b == 0), - Err(_) => false, - } - } - - /// Read all integer-named key files, keyed by their file-name index. - /// The raw bytes are exactly as read from disk with a single trailing - /// newline stripped (matching Python's `hashlib.sha1(keys[0]).hexdigest()` - /// over the file's content stripped of its trailing newline). - fn read_key_files(&self) -> Result>, CredentialFernetError> { - let mut keys = BTreeMap::new(); - if !self.key_repository.exists() { - return Ok(keys); - } - for entry in fs::read_dir(&self.key_repository).map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })? { - let entry = entry.map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - let Ok(fname) = entry.file_name().into_string() else { - continue; - }; - let Ok(idx) = fname.parse::() else { - continue; - }; - let mut raw = fs::read(entry.path()).map_err(|e| CredentialFernetError::Io { - source: e, - path: entry.path(), - })?; - if raw.last() == Some(&b'\n') { - raw.pop(); - } - keys.insert(idx, raw); - } - Ok(keys) - } - - /// Atomically write `contents` to `key_repository/` using a - /// temp-file-then-rename strategy with `umask 0o177` (ADR 0019 §4, - /// Security). - fn write_key_file(&self, name: &str, contents: &[u8]) -> Result<(), CredentialFernetError> { - fs::create_dir_all(&self.key_repository).map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - let old_umask = umask(Mode::from_bits_truncate(0o177)); - let _umask_guard = scopeguard::guard(old_umask, |old| { - umask(old); - }); - - let mut tmp_file = - NamedTempFile::new_in(&self.key_repository).map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - tmp_file - .write_all(contents) - .map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - tmp_file.flush().map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - tmp_file - .persist(self.key_repository.join(name)) - .map_err(|e| CredentialFernetError::Persist(e.to_string()))?; - Ok(()) + KeyRepository::::is_null_key(raw_key_file_bytes) } /// `credential_setup`: create the initial staged key (`0.tmp` -> `0`). /// Idempotent-ish: overwrites `0` if it already exists, matching the /// underlying atomic-rename semantics. - pub fn setup(&self) -> Result<(), CredentialFernetError> { - let key = Fernet::generate_key(); - self.write_key_file("0", key.as_bytes())?; - info!("Created new credential Fernet staged key at index 0"); - Ok(()) + pub async fn setup(&self) -> Result<(), CredentialFernetError> { + self.0.setup().await.map_err(Into::into) } /// Load all active keys, primary first (highest index). @@ -180,45 +77,14 @@ impl FernetKeyRepository { /// - [`CredentialFernetError::KeysMissing`] if the repository is empty. /// - [`CredentialFernetError::NullKeyDetected`] if any key file decodes to /// the Null Key and `insecure_allow_null_key` is `false`. - pub fn load(&self, insecure_allow_null_key: bool) -> Result { - let key_files = self.read_key_files()?; - if key_files.is_empty() { - return Err(CredentialFernetError::KeysMissing); - } - - for (idx, raw) in &key_files { - if Self::is_null_key(raw) { - if insecure_allow_null_key { - warn!( - key_index = idx, - "credential key repository contains the well-known Null Key \ - (insecure_allow_null_key=true — any credential encrypted with it \ - is effectively stored in plaintext)" - ); - } else { - return Err(CredentialFernetError::NullKeyDetected); - } - } - } - - let mut fernets = Vec::with_capacity(key_files.len()); - let mut primary_key_hash = None; - // Highest index first == primary first. - for (_idx, raw) in key_files.iter().rev() { - let key_str = String::from_utf8_lossy(raw); - let fernet = - Fernet::new(key_str.trim()).ok_or(CredentialFernetError::InvalidKey(*_idx))?; - if primary_key_hash.is_none() { - primary_key_hash = Some(Self::key_hash(raw)); - } - fernets.push(fernet); - } - - Ok(LoadedKeys { - multi_fernet: MultiFernet::new(fernets), - primary_key_hash: primary_key_hash.ok_or(CredentialFernetError::KeysMissing)?, - key_count: key_files.len(), - }) + pub async fn load( + &self, + insecure_allow_null_key: bool, + ) -> Result { + self.0 + .load_keys(insecure_allow_null_key) + .await + .map_err(Into::into) } /// Startup-time Null Key check (ADR 0019 §4, Security). @@ -234,23 +100,14 @@ impl FernetKeyRepository { /// # 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( + pub async 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(()) + self.0 + .check_startup_null_key(insecure_allow_null_key) + .await + .map_err(Into::into) } /// Promote the staged key `0` to primary, stage a fresh key `0`, and @@ -262,65 +119,18 @@ impl FernetKeyRepository { /// that credential once its key is pruned. Application code must not /// call this directly — use [`crate::rotate::rotate`], which performs /// the mandatory stale-credential check before promoting. - pub fn rotate(&self) -> Result<(), CredentialFernetError> { - let key_files = self.read_key_files()?; - if !key_files.contains_key(&0) { - return Err(CredentialFernetError::KeysMissing); - } - - let old_primary_idx = key_files - .keys() - .copied() - .filter(|i| *i != 0) - .max() - .unwrap_or(0); - let new_primary_idx = old_primary_idx - .checked_add(1) - .ok_or(CredentialFernetError::IndexOverflow)?; - - // 1. Promote: rename staged `0` -> new_primary_idx. This is a rename of the - // *staged* file, not the outgoing primary — the outgoing primary keeps its - // own file name and stays active for decryption. - fs::rename( - self.key_repository.join("0"), - self.key_repository.join(new_primary_idx.to_string()), - ) - .map_err(|e| CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - })?; - - // 2. Stage a fresh key `0` for the next rotation cycle. - self.setup()?; - - // 3. Prune beyond MAX_ACTIVE_KEYS, oldest (smallest positive index) first. `0` - // (staged) is never pruned. - let mut remaining = self.read_key_files()?; - remaining.remove(&0); - while remaining.len() + 1 > MAX_ACTIVE_KEYS { - if let Some((&oldest, _)) = remaining.iter().min_by_key(|(idx, _)| **idx) { - fs::remove_file(self.key_repository.join(oldest.to_string())).map_err(|e| { - CredentialFernetError::Io { - source: e, - path: self.key_repository.clone(), - } - })?; - remaining.remove(&oldest); - } else { - break; - } - } - - info!( - new_primary = new_primary_idx, - "Rotated credential Fernet key repository" - ); - Ok(()) + pub async fn rotate(&self) -> Result<(), CredentialFernetError> { + self.0.rotate().await.map_err(Into::into) } } #[cfg(test)] mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE; + use fernet::Fernet; + use openstack_keystone_key_repository::KeySource as _; + use super::*; #[test] @@ -350,13 +160,13 @@ mod tests { assert!(!FernetKeyRepository::is_null_key(real_key_raw.as_bytes())); } - #[test] - fn test_setup_load_roundtrip() { + #[tokio::test] + async fn test_setup_load_roundtrip() { let dir = tempfile::tempdir().unwrap(); let repo = FernetKeyRepository::new(dir.path().to_path_buf()); - repo.setup().unwrap(); + repo.setup().await.unwrap(); - let loaded = repo.load(false).unwrap(); + let loaded = repo.load(false).await.unwrap(); assert_eq!(loaded.key_count, 1); let plaintext = b"super secret ec2 key"; @@ -365,64 +175,64 @@ mod tests { assert_eq!(decrypted, plaintext); } - #[test] - fn test_load_refuses_null_key_by_default() { + #[tokio::test] + async fn test_load_refuses_null_key_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.load(false), + repo.load(false).await, Err(CredentialFernetError::NullKeyDetected) )); // Explicit opt-in still loads it. - assert!(repo.load(true).is_ok()); + assert!(repo.load(true).await.is_ok()); } - #[test] - fn test_check_startup_null_key_on_unconfigured_repository() { + #[tokio::test] + async 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()); + assert!(repo.check_startup_null_key(false).await.is_ok()); } - #[test] - fn test_check_startup_null_key_refuses_by_default() { + #[tokio::test] + async 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), + repo.check_startup_null_key(false).await, Err(CredentialFernetError::NullKeyDetected) )); // Explicit opt-in allows startup to proceed. - assert!(repo.check_startup_null_key(true).is_ok()); + assert!(repo.check_startup_null_key(true).await.is_ok()); } - #[test] - fn test_check_startup_null_key_passes_with_real_key() { + #[tokio::test] + async 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()); + repo.setup().await.unwrap(); + assert!(repo.check_startup_null_key(false).await.is_ok()); } - #[test] - fn test_rotate_promotes_staged_key_and_keeps_old_primary_decryptable() { + #[tokio::test] + async fn test_rotate_promotes_staged_key_and_keeps_old_primary_decryptable() { let dir = tempfile::tempdir().unwrap(); let repo = FernetKeyRepository::new(dir.path().to_path_buf()); - repo.setup().unwrap(); + repo.setup().await.unwrap(); // First rotation: 0 -> 1, new 0 staged. - repo.rotate().unwrap(); + repo.rotate().await.unwrap(); assert!(dir.path().join("1").exists()); assert!(dir.path().join("0").exists()); - let after_first_rotation = repo.load(false).unwrap(); + let after_first_rotation = repo.load(false).await.unwrap(); let token_v1 = after_first_rotation.multi_fernet.encrypt(b"payload-v1"); // key_hash after first rotation must reflect key `1` as primary. let key1_raw = std::fs::read(dir.path().join("1")).unwrap(); @@ -433,7 +243,7 @@ mod tests { // Second rotation: staged 0 -> 2 (not overwriting 1). Old primary // (1) must remain in place and still decryptable. - repo.rotate().unwrap(); + repo.rotate().await.unwrap(); assert!(dir.path().join("2").exists()); assert!( dir.path().join("1").exists(), @@ -441,7 +251,7 @@ mod tests { ); assert!(dir.path().join("0").exists(), "a fresh key must be staged"); - let after_second_rotation = repo.load(false).unwrap(); + let after_second_rotation = repo.load(false).await.unwrap(); assert_eq!(after_second_rotation.key_count, 3); // A blob encrypted with the now-superseded key `1` must still // decrypt via MultiFernet. @@ -452,17 +262,17 @@ mod tests { assert_eq!(decrypted, b"payload-v1"); } - #[test] - fn test_rotate_prunes_beyond_max_active_keys() { + #[tokio::test] + async fn test_rotate_prunes_beyond_max_active_keys() { let dir = tempfile::tempdir().unwrap(); let repo = FernetKeyRepository::new(dir.path().to_path_buf()); - repo.setup().unwrap(); + repo.setup().await.unwrap(); // Rotate MAX_ACTIVE_KEYS + 2 times; repository must never exceed // MAX_ACTIVE_KEYS files, and the oldest primaries get pruned first. for _ in 0..(MAX_ACTIVE_KEYS + 2) { - repo.rotate().unwrap(); + repo.rotate().await.unwrap(); } - let remaining = repo.read_key_files().unwrap(); + let remaining = repo.0.source().load().await.unwrap(); assert_eq!(remaining.len(), MAX_ACTIVE_KEYS); assert!( remaining.contains_key(&0), diff --git a/crates/credential-driver-sql/src/migrate.rs b/crates/credential-driver-sql/src/migrate.rs index 0d15cf00c..0edf76428 100644 --- a/crates/credential-driver-sql/src/migrate.rs +++ b/crates/credential-driver-sql/src/migrate.rs @@ -59,7 +59,7 @@ pub async fn migrate( } let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(cfg.credential.insecure_allow_null_key)?; + let keys = repo.load(cfg.credential.insecure_allow_null_key).await?; let mut total = 0u64; loop { @@ -166,11 +166,11 @@ mod tests { let db = test_db().await; let repo = FernetKeyRepository::new(key_dir.path().to_path_buf()); - repo.setup().unwrap(); + repo.setup().await.unwrap(); // First rotation: original key becomes primary (renamed 0 -> 1); a // fresh key is staged as the new 0. - repo.rotate().unwrap(); - let keys_after_first_rotate = repo.load(false).unwrap(); + repo.rotate().await.unwrap(); + let keys_after_first_rotate = repo.load(false).await.unwrap(); let old_primary_hash = keys_after_first_rotate.primary_key_hash.clone(); let blob = keys_after_first_rotate .multi_fernet @@ -180,8 +180,8 @@ mod tests { // Second rotation: the credential above is now stale relative to // the new primary, but its encrypting key is still active (within // MAX_ACTIVE_KEYS) so it remains decryptable. - repo.rotate().unwrap(); - let keys_after_second_rotate = repo.load(false).unwrap(); + repo.rotate().await.unwrap(); + let keys_after_second_rotate = repo.load(false).await.unwrap(); assert_ne!( old_primary_hash, keys_after_second_rotate.primary_key_hash, "test setup sanity: the credential must actually be stale" @@ -214,8 +214,8 @@ mod tests { let db = test_db().await; let repo = FernetKeyRepository::new(key_dir.path().to_path_buf()); - repo.setup().unwrap(); - let keys = repo.load(false).unwrap(); + repo.setup().await.unwrap(); + let keys = repo.load(false).await.unwrap(); let blob = keys.multi_fernet.encrypt(b"already-current"); insert_credential(&db, "cred-1", blob.clone(), keys.primary_key_hash.clone()).await; diff --git a/crates/credential-driver-sql/src/rotate.rs b/crates/credential-driver-sql/src/rotate.rs index 1486350a7..219007588 100644 --- a/crates/credential-driver-sql/src/rotate.rs +++ b/crates/credential-driver-sql/src/rotate.rs @@ -51,7 +51,7 @@ use crate::fernet::FernetKeyRepository; /// repository can't be loaded or rotated, or a database operation fails. pub async fn rotate(cfg: &Config, db: &DatabaseConnection) -> Result<(), CredentialProviderError> { let repo = FernetKeyRepository::new(cfg.credential.key_repository.clone()); - let keys = repo.load(cfg.credential.insecure_allow_null_key)?; + let keys = repo.load(cfg.credential.insecure_allow_null_key).await?; let stale = DbCredential::find() .filter(db_credential::Column::KeyHash.ne(keys.primary_key_hash.as_str())) @@ -65,7 +65,7 @@ pub async fn rotate(cfg: &Config, db: &DatabaseConnection) -> Result<(), Credent ))); } - repo.rotate()?; + repo.rotate().await?; Ok(()) } @@ -117,8 +117,8 @@ mod tests { let db = test_db().await; let repo = FernetKeyRepository::new(key_dir.path().to_path_buf()); - repo.setup().unwrap(); - let keys = repo.load(false).unwrap(); + repo.setup().await.unwrap(); + let keys = repo.load(false).await.unwrap(); let stale_blob = keys.multi_fernet.encrypt(b"stale-secret"); // Wrong key_hash: never matches the current primary. insert_credential(&db, "cred-1", stale_blob, "not-the-primary-hash".into()).await; @@ -139,8 +139,8 @@ mod tests { let db = test_db().await; let repo = FernetKeyRepository::new(key_dir.path().to_path_buf()); - repo.setup().unwrap(); - let keys = repo.load(false).unwrap(); + repo.setup().await.unwrap(); + let keys = repo.load(false).await.unwrap(); let blob = keys.multi_fernet.encrypt(b"current-secret"); insert_credential(&db, "cred-1", blob, keys.primary_key_hash.clone()).await; diff --git a/crates/key-repository/Cargo.toml b/crates/key-repository/Cargo.toml new file mode 100644 index 000000000..9469b4f95 --- /dev/null +++ b/crates/key-repository/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "openstack-keystone-key-repository" +description = "OpenStack Keystone shared Fernet key repository (filesystem, Vault-ready)" +version = "0.1.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +rust-version.workspace = true +repository.workspace = true +homepage.workspace = true +exclude.workspace = true + +[dependencies] +async-trait.workspace = true +base64.workspace = true +fernet = { workspace = true, features = ["rustcrypto"] } +nix = { workspace = true, features = ["fs", "user"] } +notify = "8.2" +scopeguard.workspace = true +sha1.workspace = true +tempfile.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["fs", "macros", "rt", "sync", "time"] } +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/key-repository/src/error.rs b/crates/key-repository/src/error.rs new file mode 100644 index 000000000..0fefa8967 --- /dev/null +++ b/crates/key-repository/src/error.rs @@ -0,0 +1,57 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Key repository error. +use std::path::PathBuf; + +use thiserror::Error; + +/// Error returned by a [`crate::KeySource`] or [`crate::KeyRepository`]. +#[derive(Error, Debug)] +pub enum KeyRepositoryError { + /// I/O error accessing the underlying key source. + #[error("key repository I/O error at {path:?}: {source}")] + Io { + source: std::io::Error, + path: PathBuf, + }, + + /// No key files/entries are present at all (repository not yet set up). + #[error("no keys found in the key repository")] + KeysMissing, + + /// A key entry's contents do not decode as a valid Fernet key. + #[error("key at index {0} is not a usable Fernet key")] + InvalidKey(i8), + + /// A key entry decodes to the well-known Null Key + /// (`base64.urlsafe_b64encode(b'\x00' * 32)`) and + /// `insecure_allow_null_key` was not set. + #[error( + "key repository contains the well-known Null Key; refusing to proceed \ + (set insecure_allow_null_key to override, at your own risk)" + )] + NullKeyDetected, + + /// Persisting a new key entry failed. + #[error("failed to persist key entry: {0}")] + Persist(String), + + /// Rotation would overflow the key index space. + #[error("key index overflow during rotation")] + IndexOverflow, + + /// Dropping to the configured `run_as` uid/gid failed. + #[error("{context}: {source}")] + NixErrno { context: String, source: nix::Error }, +} diff --git a/crates/key-repository/src/filesystem.rs b/crates/key-repository/src/filesystem.rs new file mode 100644 index 000000000..0a4517e9d --- /dev/null +++ b/crates/key-repository/src/filesystem.rs @@ -0,0 +1,391 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Filesystem-backed [`KeySource`] +//! +//! Key entries are files named by their integer index (`0`, `1`, `2`, ...) +//! in a single directory. Per ADR 0019 §4, that directory must resolve to +//! the identical file set on every Python and Rust node — that +//! synchronization is an operational deployment requirement, not something +//! this type enforces. +use std::collections::BTreeMap; +use std::io::Write as _; +use std::path::PathBuf; +use std::time::Duration; + +use async_trait::async_trait; +use nix::sys::stat::{Mode, umask}; +use nix::unistd::{Gid, Uid, getegid, geteuid, setegid, seteuid}; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use tempfile::NamedTempFile; +use tokio::sync::broadcast; +use tokio::task::spawn_blocking; +use tracing::{trace, warn}; + +use crate::error::KeyRepositoryError; +use crate::source::KeySource; + +/// How long to wait after a filesystem event before reloading, so a burst +/// of writes (e.g. an operator copying in several key files at once) is +/// coalesced into a single reload. Mirrors `ConfigManager`'s debounce. +const DEBOUNCE: Duration = Duration::from_millis(500); + +/// The Fernet key repository on the local filesystem. +#[derive(Clone)] +pub struct FilesystemKeySource { + key_repository: PathBuf, + /// Optional uid/gid to assume (via `seteuid`/`setegid`) while writing + /// new key files, restored immediately afterwards. + run_as: Option<(Uid, Gid)>, + change_tx: broadcast::Sender<()>, +} + +impl FilesystemKeySource { + /// Create a new source with no background watcher. Callers that only + /// need one-shot `load`/`write`/`remove` (e.g. CLI `setup`/`rotate` + /// commands) don't need to pay for a watcher task. + #[must_use] + pub fn new(key_repository: PathBuf) -> Self { + let (change_tx, _) = broadcast::channel(16); + Self { + key_repository, + run_as: None, + change_tx, + } + } + + /// Assume this uid/gid (via `seteuid`/`setegid`) while writing new key + /// files, matching the privilege-drop behavior Python Keystone offers + /// for `fernet_setup`/`fernet_rotate` run as root. + #[must_use] + pub fn with_run_as(mut self, uid: Uid, gid: Gid) -> Self { + self.run_as = Some((uid, gid)); + self + } + + /// Create a source and start its background watcher: an inotify watch + /// on the key repository directory for fast reaction, plus a + /// `poll_interval` fallback so a reload is never missed (e.g. on a + /// filesystem where inotify events aren't delivered, such as some + /// network mounts) and so the exact same contract will hold for a + /// future poll-only backend (e.g. Vault). + /// + /// Must be called from within a Tokio runtime (it spawns a background + /// task), matching `ConfigManager::watched`. + #[must_use] + pub fn watched(key_repository: PathBuf, poll_interval: Duration) -> Self { + let source = Self::new(key_repository); + source.spawn_watcher(poll_interval); + source + } + + fn spawn_watcher(&self, poll_interval: Duration) { + let dir = self.key_repository.clone(); + let change_tx = self.change_tx.clone(); + + tokio::spawn(async move { + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1); + + let mut watcher: Option = match notify::recommended_watcher( + move |res: notify::Result| { + if let Ok(event) = res + && (event.kind.is_modify() + || event.kind.is_create() + || event.kind.is_remove()) + { + let _ = event_tx.blocking_send(()); + } + }, + ) { + Ok(mut w) => { + // The directory may not exist yet (repository not set up). + // That's fine — the poll fallback below still covers us + // once it is, and we retry the inotify watch each poll + // tick until it succeeds. + let _ = w.watch(&dir, RecursiveMode::NonRecursive); + Some(w) + } + Err(e) => { + warn!(error = %e, "failed to create inotify watcher for key repository; relying on polling only"); + None + } + }; + + let mut interval = tokio::time::interval(poll_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + tokio::select! { + Some(()) = event_rx.recv() => { + // Drain any additional rapid-fire events. + while event_rx.try_recv().is_ok() {} + tokio::time::sleep(DEBOUNCE).await; + } + _ = interval.tick() => { + if watcher.is_none() { + watcher = notify::recommended_watcher(|_res: notify::Result| {}) + .ok() + .inspect(|_| trace!("retrying inotify watch registration")); + if let Some(w) = &mut watcher { + let _ = w.watch(&dir, RecursiveMode::NonRecursive); + } + } + } + } + let _ = change_tx.send(()); + } + }); + } + + fn path_for(&self, index: i8) -> PathBuf { + self.key_repository.join(index.to_string()) + } + + fn read_key_files_blocking(dir: &PathBuf) -> Result>, KeyRepositoryError> { + let mut keys = BTreeMap::new(); + if !dir.exists() { + return Ok(keys); + } + for entry in std::fs::read_dir(dir).map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })? { + let entry = entry.map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })?; + let Ok(fname) = entry.file_name().into_string() else { + continue; + }; + let Ok(idx) = fname.parse::() else { + continue; + }; + let mut raw = std::fs::read(entry.path()).map_err(|e| KeyRepositoryError::Io { + source: e, + path: entry.path(), + })?; + if raw.last() == Some(&b'\n') { + raw.pop(); + } + keys.insert(idx, raw); + } + Ok(keys) + } + + fn write_key_file_blocking( + dir: &PathBuf, + name: &str, + contents: &[u8], + run_as: Option<(Uid, Gid)>, + ) -> Result<(), KeyRepositoryError> { + std::fs::create_dir_all(dir).map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })?; + let old_umask = umask(Mode::from_bits_truncate(0o177)); + let _umask_guard = scopeguard::guard(old_umask, |old| { + umask(old); + }); + + if let Some((uid, gid)) = run_as { + let (old_euid, old_egid) = (geteuid(), getegid()); + setegid(gid).map_err(|e| KeyRepositoryError::NixErrno { + context: "setting effective process GID".into(), + source: e, + })?; + let _id_guard = scopeguard::guard((old_euid, old_egid), |(u, g)| { + let _ = seteuid(u); + let _ = setegid(g); + }); + seteuid(uid).map_err(|e| KeyRepositoryError::NixErrno { + context: "setting effective process UID".into(), + source: e, + })?; + + Self::write_key_file_inner(dir, name, contents) + } else { + Self::write_key_file_inner(dir, name, contents) + } + } + + fn write_key_file_inner( + dir: &PathBuf, + name: &str, + contents: &[u8], + ) -> Result<(), KeyRepositoryError> { + let mut tmp_file = NamedTempFile::new_in(dir).map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })?; + tmp_file + .write_all(contents) + .map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })?; + tmp_file.flush().map_err(|e| KeyRepositoryError::Io { + source: e, + path: dir.clone(), + })?; + tmp_file + .persist(dir.join(name)) + .map_err(|e| KeyRepositoryError::Persist(e.to_string()))?; + Ok(()) + } +} + +#[async_trait] +impl KeySource for FilesystemKeySource { + async fn load(&self) -> Result>, KeyRepositoryError> { + let dir = self.key_repository.clone(); + spawn_blocking(move || Self::read_key_files_blocking(&dir)) + .await + .map_err(|e| KeyRepositoryError::Io { + source: std::io::Error::other(e), + path: self.key_repository.clone(), + })? + } + + async fn write(&self, index: i8, contents: &[u8]) -> Result<(), KeyRepositoryError> { + let dir = self.key_repository.clone(); + let name = index.to_string(); + let contents = contents.to_vec(); + let run_as = self.run_as; + spawn_blocking(move || Self::write_key_file_blocking(&dir, &name, &contents, run_as)) + .await + .map_err(|e| KeyRepositoryError::Io { + source: std::io::Error::other(e), + path: self.key_repository.clone(), + })? + } + + async fn remove(&self, index: i8) -> Result<(), KeyRepositoryError> { + let path = self.path_for(index); + spawn_blocking(move || match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(KeyRepositoryError::Io { source: e, path }), + }) + .await + .map_err(|e| KeyRepositoryError::Io { + source: std::io::Error::other(e), + path: self.key_repository.clone(), + })? + } + + async fn promote(&self, from: i8, to: i8, _contents: &[u8]) -> Result<(), KeyRepositoryError> { + let from_path = self.path_for(from); + let to_path = self.path_for(to); + let join_err_path = to_path.clone(); + spawn_blocking(move || { + std::fs::rename(&from_path, &to_path).map_err(|e| KeyRepositoryError::Io { + source: e, + path: from_path, + }) + }) + .await + .map_err(|e| KeyRepositoryError::Io { + source: std::io::Error::other(e), + path: join_err_path, + })? + } + + fn subscribe(&self) -> broadcast::Receiver<()> { + self.change_tx.subscribe() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE; + use fernet::Fernet; + + #[tokio::test] + async fn test_write_load_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let source = FilesystemKeySource::new(dir.path().to_path_buf()); + + let key = Fernet::generate_key(); + source.write(0, key.as_bytes()).await.unwrap(); + + let loaded = source.load().await.unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded.get(&0).unwrap(), key.as_bytes()); + } + + #[tokio::test] + async fn test_load_on_unconfigured_directory_is_empty() { + let dir = tempfile::tempdir().unwrap(); + let source = FilesystemKeySource::new(dir.path().join("does-not-exist")); + assert!(source.load().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn test_remove_missing_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let source = FilesystemKeySource::new(dir.path().to_path_buf()); + source.remove(5).await.unwrap(); + } + + #[tokio::test] + async fn test_promote_renames_file() { + let dir = tempfile::tempdir().unwrap(); + let source = FilesystemKeySource::new(dir.path().to_path_buf()); + let key = Fernet::generate_key(); + source.write(0, key.as_bytes()).await.unwrap(); + + source.promote(0, 1, key.as_bytes()).await.unwrap(); + + assert!(!dir.path().join("0").exists()); + assert!(dir.path().join("1").exists()); + } + + #[tokio::test] + async fn test_trailing_newline_is_stripped() { + let dir = tempfile::tempdir().unwrap(); + let key = Fernet::generate_key(); + let mut with_newline = key.as_bytes().to_vec(); + with_newline.push(b'\n'); + std::fs::write(dir.path().join("0"), &with_newline).unwrap(); + + let source = FilesystemKeySource::new(dir.path().to_path_buf()); + let loaded = source.load().await.unwrap(); + assert_eq!(loaded.get(&0).unwrap(), key.as_bytes()); + } + + #[tokio::test] + async fn test_watched_source_notifies_on_change() { + let dir = tempfile::tempdir().unwrap(); + let source = + FilesystemKeySource::watched(dir.path().to_path_buf(), Duration::from_secs(3600)); + let mut rx = source.subscribe(); + + let key = Fernet::generate_key(); + source.write(0, key.as_bytes()).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("expected a change notification within 5s") + .unwrap(); + } + + #[test] + fn test_is_null_key_raw_bytes_helper_sanity() { + // Sanity check the encoding used across these tests matches the + // production Null Key encoding used in `KeyRepository::is_null_key`. + let null_key_raw = URL_SAFE.encode([0u8; 32]); + assert_eq!(null_key_raw.len(), 44); + } +} diff --git a/crates/key-repository/src/lib.rs b/crates/key-repository/src/lib.rs new file mode 100644 index 000000000..fc51746b0 --- /dev/null +++ b/crates/key-repository/src/lib.rs @@ -0,0 +1,496 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Shared Fernet key repository +//! +//! Both the credential provider (ADR 0019 §4) and the Fernet token provider +//! need the exact same thing: a directory of integer-indexed Fernet key +//! files, with index `0` always the staged key, the highest other index the +//! primary, and a `rotate` operation that promotes the staged key and prunes +//! beyond a configured maximum. This crate implements that once. +//! +//! The backend is abstracted behind [`KeySource`] so a future Vault-backed +//! (or other KV secret store) implementation can plug in without changing +//! [`KeyRepository`] or [`CachedKeyRepository`] at all — see [`filesystem`] +//! for the only implementation that exists today. +//! +//! [`KeyRepository`] is the mechanical, backend-agnostic operations +//! (`setup`/`load_keys`/`rotate`/Null Key checks). Driver-specific safety +//! checks that need other context (e.g. the credential driver's "refuse to +//! rotate while any credential is still encrypted with a non-primary key, +//! which needs a database query) stay in the driver crate, wrapping this +//! type rather than being reimplemented here. +use std::collections::BTreeMap; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE; +use fernet::{Fernet, MultiFernet}; +use sha1::{Digest, Sha1}; +use tokio::sync::watch; +use tracing::info; + +pub mod error; +pub mod filesystem; +mod source; + +pub use error::KeyRepositoryError; +pub use filesystem::FilesystemKeySource; +pub use source::KeySource; + +/// A loaded, ready-to-use view of the key repository. +pub struct LoadedKeys { + /// All active keys, primary first, wrapped for + /// decrypt-any/encrypt-with-primary use. + pub multi_fernet: MultiFernet, + + /// SHA-1 hex digest of the *raw base64url bytes* of the primary key + /// entry (ADR 0019 §4 `key_hash` specification) — not the decoded + /// 32-byte AES key. + pub primary_key_hash: String, + + /// Number of active keys loaded. + pub key_count: usize, +} + +/// Backend-agnostic Fernet key repository logic, generic over the +/// underlying [`KeySource`]. +pub struct KeyRepository { + source: S, + max_active_keys: usize, +} + +impl KeyRepository { + /// Wrap `source`, hard-capping active keys at `max_active_keys` on + /// [`Self::rotate`]. + pub fn new(source: S, max_active_keys: usize) -> Self { + Self { + source, + max_active_keys, + } + } + + /// The wrapped source, e.g. to call [`KeySource::subscribe`] directly. + pub fn source(&self) -> &S { + &self.source + } + + /// Compute `key_hash` per the ADR 0019 §4 specification: SHA-1 hex + /// digest over the raw base64url-encoded key bytes, as read from the + /// source (i.e. *before* base64url-decoding). + #[must_use] + pub fn key_hash(raw_key_bytes: &[u8]) -> String { + let mut hasher = Sha1::new(); + hasher.update(raw_key_bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect() + } + + /// Whether the given raw (base64url-encoded) key bytes decode to the + /// well-known Null Key (32 zero bytes). + #[must_use] + pub fn is_null_key(raw_key_bytes: &[u8]) -> bool { + match URL_SAFE.decode(raw_key_bytes) { + Ok(decoded) => decoded.len() == 32 && decoded.iter().all(|b| *b == 0), + Err(_) => false, + } + } + + /// `{credential,token}_setup`: create the initial staged key at index + /// `0`. Idempotent-ish: overwrites `0` if it already exists, matching + /// the underlying atomic-write semantics. + pub async fn setup(&self) -> Result<(), KeyRepositoryError> { + let key = Fernet::generate_key(); + self.source.write(0, key.as_bytes()).await?; + info!("Created new staged Fernet key at index 0"); + Ok(()) + } + + /// Load all active keys, primary first (highest index). + /// + /// # Errors + /// - [`KeyRepositoryError::KeysMissing`] if the repository is empty. + /// - [`KeyRepositoryError::NullKeyDetected`] if any key decodes to the + /// Null Key and `insecure_allow_null_key` is `false`. + pub async fn load_keys( + &self, + insecure_allow_null_key: bool, + ) -> Result { + let key_files = self.source.load().await?; + if key_files.is_empty() { + return Err(KeyRepositoryError::KeysMissing); + } + self.check_null_keys(&key_files, insecure_allow_null_key)?; + + let mut fernets = Vec::with_capacity(key_files.len()); + let mut primary_key_hash = None; + // Highest index first == primary first. + for (idx, raw) in key_files.iter().rev() { + let key_str = String::from_utf8_lossy(raw); + let fernet = Fernet::new(key_str.trim()).ok_or(KeyRepositoryError::InvalidKey(*idx))?; + if primary_key_hash.is_none() { + primary_key_hash = Some(Self::key_hash(raw)); + } + fernets.push(fernet); + } + + Ok(LoadedKeys { + multi_fernet: MultiFernet::new(fernets), + primary_key_hash: primary_key_hash.ok_or(KeyRepositoryError::KeysMissing)?, + key_count: key_files.len(), + }) + } + + /// Startup-time Null Key check (ADR 0019 §4, Security). + /// + /// Unlike [`Self::load_keys`], this does not require any keys to be + /// present — an unconfigured repository (before `setup` has run) is not + /// itself a Null Key problem. It only inspects whatever keys already + /// exist and, for any that decode to the well-known Null Key, emits a + /// hard error log and — unless `insecure_allow_null_key` is set — + /// returns an error so the caller can refuse to start the service. + /// + /// # Errors + /// [`KeyRepositoryError::NullKeyDetected`] if a key decodes to the Null + /// Key and `insecure_allow_null_key` is `false`. + pub async fn check_startup_null_key( + &self, + insecure_allow_null_key: bool, + ) -> Result<(), KeyRepositoryError> { + let key_files = self.source.load().await?; + self.check_null_keys(&key_files, insecure_allow_null_key) + } + + fn check_null_keys( + &self, + key_files: &BTreeMap>, + insecure_allow_null_key: bool, + ) -> Result<(), KeyRepositoryError> { + for (idx, raw) in key_files { + if Self::is_null_key(raw) { + tracing::error!( + key_index = idx, + insecure_allow_null_key, + "key repository contains the well-known Null Key" + ); + if !insecure_allow_null_key { + return Err(KeyRepositoryError::NullKeyDetected); + } + } + } + Ok(()) + } + + /// Promote the staged key `0` to primary, stage a fresh key `0`, and + /// prune beyond `max_active_keys`. + /// + /// # Warning + /// This performs **no safety check** beyond the mechanics themselves. + /// Calling it while any data is still encrypted with a non-primary key + /// can permanently strand that data once its key is pruned — driver + /// crates that need such a check (e.g. the credential driver, which + /// must check the database for stale-key credentials) must perform it + /// before calling this. + pub async fn rotate(&self) -> Result<(), KeyRepositoryError> { + let key_files = self.source.load().await?; + let staged = key_files + .get(&0) + .ok_or(KeyRepositoryError::KeysMissing)? + .clone(); + + let old_primary_idx = key_files + .keys() + .copied() + .filter(|i| *i != 0) + .max() + .unwrap_or(0); + let new_primary_idx = old_primary_idx + .checked_add(1) + .ok_or(KeyRepositoryError::IndexOverflow)?; + + // 1. Promote: move staged `0` -> new_primary_idx. The outgoing + // primary keeps its own entry and stays active for decryption. + self.source.promote(0, new_primary_idx, &staged).await?; + + // 2. Stage a fresh key `0` for the next rotation cycle. + self.setup().await?; + + // 3. Prune beyond max_active_keys, oldest (smallest positive index) + // first. `0` (staged) is never pruned. + let mut remaining = self.source.load().await?; + remaining.remove(&0); + while remaining.len() + 1 > self.max_active_keys { + let Some((&oldest, _)) = remaining.iter().min_by_key(|(idx, _)| **idx) else { + break; + }; + self.source.remove(oldest).await?; + remaining.remove(&oldest); + } + + info!( + new_primary = new_primary_idx, + "Rotated Fernet key repository" + ); + Ok(()) + } +} + +/// A [`KeyRepository`] wrapped with an always-fresh, cheaply-cloneable +/// snapshot kept up to date by a background task subscribed to +/// [`KeySource::subscribe`]. +/// +/// This replaces two anti-patterns that existed before this crate: reading +/// every key file from disk on every single encrypt/decrypt call, and +/// loading keys once at startup and never picking up a subsequent rotation +/// without a process restart. +pub struct CachedKeyRepository { + repo: Arc>, + keys: watch::Receiver>, +} + +impl CachedKeyRepository { + /// Load the initial snapshot and spawn the background refresh task. + /// Must be called from within a Tokio runtime. + /// + /// # Errors + /// Whatever [`KeyRepository::load_keys`] returns for the initial load. + pub async fn start( + repo: KeyRepository, + insecure_allow_null_key: bool, + ) -> Result { + let repo = Arc::new(repo); + let initial = repo.load_keys(insecure_allow_null_key).await?; + let (tx, rx) = watch::channel(Arc::new(initial)); + + let mut changes = repo.source().subscribe(); + let repo_for_task = Arc::clone(&repo); + tokio::spawn(async move { + loop { + // `Lagged` just means we missed some notifications while + // busy — since a reload always fetches the *current* state + // rather than replaying a diff, it's safe (and correct) to + // treat it the same as a normal notification rather than + // tearing down the refresh loop. + match changes.recv().await { + Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + match repo_for_task.load_keys(insecure_allow_null_key).await { + Ok(loaded) => { + let _ = tx.send(Arc::new(loaded)); + } + Err(e) => { + tracing::error!(error = %e, "failed to reload Fernet keys; keeping previous snapshot"); + } + } + } + }); + + Ok(Self { repo, keys: rx }) + } + + /// The current snapshot. Cheap (an `Arc` clone); safe to call on every + /// encrypt/decrypt. + pub fn current(&self) -> Arc { + self.keys.borrow().clone() + } + + /// The underlying repository, e.g. to call [`KeyRepository::rotate`]. + pub fn repository(&self) -> &KeyRepository { + &self.repo + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn repo(dir: &std::path::Path, max_active_keys: usize) -> KeyRepository { + KeyRepository::new(FilesystemKeySource::new(dir.to_path_buf()), max_active_keys) + } + + #[test] + fn test_key_hash_matches_python_semantics() { + let raw = Fernet::generate_key().into_bytes(); + let hash = KeyRepository::::key_hash(&raw); + assert_eq!(hash.len(), 40); + assert!( + hash.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + + let decoded = URL_SAFE.decode(&raw).unwrap(); + assert_ne!( + hash, + KeyRepository::::key_hash(&decoded) + ); + } + + #[test] + fn test_is_null_key_detection() { + let null_key_raw = URL_SAFE.encode([0u8; 32]); + assert!(KeyRepository::::is_null_key( + null_key_raw.as_bytes() + )); + let real_key_raw = Fernet::generate_key(); + assert!(!KeyRepository::::is_null_key( + real_key_raw.as_bytes() + )); + } + + #[tokio::test] + async fn test_setup_load_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + repo.setup().await.unwrap(); + + let loaded = repo.load_keys(false).await.unwrap(); + assert_eq!(loaded.key_count, 1); + + let plaintext = b"super secret payload"; + let token = loaded.multi_fernet.encrypt(plaintext); + assert_eq!(loaded.multi_fernet.decrypt(&token).unwrap(), plaintext); + } + + #[tokio::test] + async fn test_load_keys_refuses_null_key_by_default() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + let null_key = URL_SAFE.encode([0u8; 32]); + std::fs::write(dir.path().join("0"), null_key).unwrap(); + + assert!(matches!( + repo.load_keys(false).await, + Err(KeyRepositoryError::NullKeyDetected) + )); + assert!(repo.load_keys(true).await.is_ok()); + } + + #[tokio::test] + async fn test_check_startup_null_key_on_unconfigured_repository() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(&dir.path().join("does-not-exist"), 3); + assert!(repo.check_startup_null_key(false).await.is_ok()); + } + + #[tokio::test] + async fn test_check_startup_null_key_refuses_by_default() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + 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).await, + Err(KeyRepositoryError::NullKeyDetected) + )); + assert!(repo.check_startup_null_key(true).await.is_ok()); + } + + #[tokio::test] + async fn test_rotate_promotes_staged_key_and_keeps_old_primary_decryptable() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + repo.setup().await.unwrap(); + repo.rotate().await.unwrap(); + assert!(dir.path().join("1").exists()); + assert!(dir.path().join("0").exists()); + + let after_first = repo.load_keys(false).await.unwrap(); + let token_v1 = after_first.multi_fernet.encrypt(b"payload-v1"); + let key1_raw = std::fs::read(dir.path().join("1")).unwrap(); + assert_eq!( + after_first.primary_key_hash, + KeyRepository::::key_hash(&key1_raw) + ); + + repo.rotate().await.unwrap(); + assert!(dir.path().join("2").exists()); + assert!( + dir.path().join("1").exists(), + "old primary must be retained for decryption" + ); + assert!(dir.path().join("0").exists(), "a fresh key must be staged"); + + let after_second = repo.load_keys(false).await.unwrap(); + assert_eq!(after_second.key_count, 3); + assert_eq!( + after_second.multi_fernet.decrypt(&token_v1).unwrap(), + b"payload-v1" + ); + } + + #[tokio::test] + async fn test_rotate_prunes_beyond_max_active_keys() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + repo.setup().await.unwrap(); + for _ in 0..5 { + repo.rotate().await.unwrap(); + } + let remaining = repo.source().load().await.unwrap(); + assert_eq!(remaining.len(), 3); + assert!( + remaining.contains_key(&0), + "staged key must always survive pruning" + ); + } + + #[tokio::test] + async fn test_rotate_without_setup_fails() { + let dir = tempfile::tempdir().unwrap(); + let repo = repo(dir.path(), 3); + assert!(matches!( + repo.rotate().await, + Err(KeyRepositoryError::KeysMissing) + )); + } + + #[tokio::test] + async fn test_cached_repository_reflects_rotation_without_manual_reload() { + let dir = tempfile::tempdir().unwrap(); + let repo = KeyRepository::new( + FilesystemKeySource::watched(dir.path().to_path_buf(), Duration::from_millis(100)), + 3, + ); + repo.setup().await.unwrap(); + + let cached = CachedKeyRepository::start(repo, false).await.unwrap(); + assert_eq!(cached.current().key_count, 1); + + cached.repository().rotate().await.unwrap(); + + // Poll until the background task has picked up the rotation. Key + // count (not hash) is the reliable signal here: the first rotation + // promotes the staged key's *bytes* unchanged (just a rename), so + // `primary_key_hash` is identical before/after by design — only + // `key_count` (1 -> 2, staged + newly-promoted primary) is + // guaranteed to change. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if cached.current().key_count == 2 { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "cached snapshot never picked up the rotation" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} diff --git a/crates/key-repository/src/source.rs b/crates/key-repository/src/source.rs new file mode 100644 index 000000000..3b42a23c1 --- /dev/null +++ b/crates/key-repository/src/source.rs @@ -0,0 +1,79 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +//! # Key source abstraction +//! +//! [`KeySource`] is the backend-agnostic contract [`crate::KeyRepository`] +//! is built on. [`crate::filesystem::FilesystemKeySource`] is the only +//! implementation today; a future Vault-backed source (or any other KV +//! secret store) implements the same trait and plugs into +//! [`crate::KeyRepository`]/[`crate::CachedKeyRepository`] unchanged. +use std::collections::BTreeMap; + +use async_trait::async_trait; +use tokio::sync::broadcast; + +use crate::error::KeyRepositoryError; + +/// A source of indexed Fernet key material. +/// +/// Indices follow the Python Keystone convention: `0` is always the +/// "staged" key (queued for the next rotation, never used for encryption +/// until promoted), all other indices are active keys usable for +/// decryption, and the highest non-zero index is the current primary +/// (used for encryption). +#[async_trait] +pub trait KeySource: Send + Sync { + /// Read every key entry currently present, keyed by index. + /// + /// An empty map means the repository has not been set up yet — this is + /// a valid, non-error state; callers decide whether that's acceptable + /// (e.g. [`crate::KeyRepository::check_startup_null_key`] tolerates it, + /// [`crate::KeyRepository::load_keys`] does not). + async fn load(&self) -> Result>, KeyRepositoryError>; + + /// Atomically create or overwrite the entry at `index` with `contents`. + async fn write(&self, index: i8, contents: &[u8]) -> Result<(), KeyRepositoryError>; + + /// Remove the entry at `index`. Must not error if the entry is already + /// absent (rotation pruning may race with manual cleanup). + async fn remove(&self, index: i8) -> Result<(), KeyRepositoryError>; + + /// Move key material from `from` to `to` as atomically as the backend + /// allows, used by [`crate::KeyRepository::rotate`] to promote the + /// staged key without a window where it's briefly missing. + /// + /// The default implementation is write-then-remove: not atomic (a + /// crash between the two steps leaves the key present at both indices, + /// or briefly at neither), but safe for any backend that can only + /// write/delete individual entries (e.g. a Vault KV store) — a + /// duplicated entry is harmless and self-heals on the next rotation, and + /// [`crate::KeyRepository::rotate`] never removes `from` before `to` is + /// durably written. Backends capable of a true move (the filesystem, + /// via `rename(2)`) should override this for a stronger guarantee. + async fn promote(&self, from: i8, to: i8, contents: &[u8]) -> Result<(), KeyRepositoryError> { + self.write(to, contents).await?; + self.remove(from).await?; + Ok(()) + } + + /// Subscribe to change notifications. A message means "key material may + /// have changed, reload via [`Self::load`]" — it carries no payload. + /// + /// Contract: fires within the source's configured reload interval of an + /// actual change, sooner if the backend can detect changes natively + /// (e.g. filesystem inotify events). A source that never mutates after + /// construction may never send anything; that's a valid (if useless) + /// implementation. + fn subscribe(&self) -> broadcast::Receiver<()>; +} diff --git a/crates/keystone/src/bin/keystone.rs b/crates/keystone/src/bin/keystone.rs index 1e58e9ac5..590711191 100644 --- a/crates/keystone/src/bin/keystone.rs +++ b/crates/keystone/src/bin/keystone.rs @@ -87,6 +87,7 @@ 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}; +use openstack_keystone_token_driver_fernet::utils::FernetUtils; // Default body limit 256kB const DEFAULT_BODY_LIMIT: usize = 1024 * 256; @@ -183,8 +184,19 @@ async fn main() -> Result<(), Report> { // 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) + .await .wrap_err("credential key repository failed startup check")?; + // Same check for the token Fernet key repository (ADR 0019 §4, now + // shared logic with the credential key repository above). + FernetUtils { + key_repository: cfg.fernet_tokens.key_repository.clone(), + max_active_keys: cfg.fernet_tokens.max_active_keys, + } + .check_startup_null_key(cfg.fernet_tokens.insecure_allow_null_key) + .await + .wrap_err("token key repository failed startup check")?; + let token = CancellationToken::new(); let cloned_token = token.clone(); @@ -204,7 +216,9 @@ async fn main() -> Result<(), Report> { .wrap_err("failed to sync schema for in-memory database")?; }; - let plugin_manager = PluginManager::with_config(&cfg); + let plugin_manager = PluginManager::with_config(&cfg) + .await + .wrap_err("initializing plugin manager")?; let k8s_http_client: Arc = Arc::new(KeystoneK8sHttpClient::new()); let provider = Provider::new(&cfg, &plugin_manager, k8s_http_client)?; diff --git a/crates/keystone/src/plugin_manager.rs b/crates/keystone/src/plugin_manager.rs index efe751d01..e29d2c1d3 100644 --- a/crates/keystone/src/plugin_manager.rs +++ b/crates/keystone/src/plugin_manager.rs @@ -677,7 +677,9 @@ impl PluginManager { /// /// # Returns /// A new instance of `PluginManager`. - pub fn with_config(config: &Config) -> Self { + pub async fn with_config( + config: &Config, + ) -> Result { let mut slf = Self { api_key_backends: HashMap::new(), application_credential_backends: HashMap::new(), @@ -697,12 +699,14 @@ impl PluginManager { trust_backends: HashMap::new(), }; slf.register_sql_drivers(); - slf.register_token_backend( - "fernet", - Arc::new( - openstack_keystone_token_driver_fernet::FernetTokenProvider::new(config.clone()), - ), - ); + let mut fernet_token_provider = + openstack_keystone_token_driver_fernet::FernetTokenProvider::new(config.clone()); + // Eagerly start the auto-refreshing key cache here, before the + // provider is erased behind `Arc`: `decrypt`/ + // `encrypt` are synchronous (called on every request) and require + // `load_keys` to have already run. + fernet_token_provider.load_keys().await?; + slf.register_token_backend("fernet", Arc::new(fernet_token_provider)); slf.register_k8s_auth_backend( "raft", Arc::new(openstack_keystone_k8s_auth_driver_raft::RaftBackend::default()), @@ -715,16 +719,6 @@ impl PluginManager { "raft", Arc::new(openstack_keystone_api_key_driver_raft::RaftBackend::default()), ); - slf - } -} - -impl Default for PluginManager { - /// Returns the default instance of the [PluginManager]. - /// - /// # Returns - /// A `PluginManager` instance initialized with default configuration. - fn default() -> Self { - Self::with_config(&Config::default()) + Ok(slf) } } diff --git a/crates/token-driver-fernet/Cargo.toml b/crates/token-driver-fernet/Cargo.toml index 35e41caa4..2a486daa2 100644 --- a/crates/token-driver-fernet/Cargo.toml +++ b/crates/token-driver-fernet/Cargo.toml @@ -22,10 +22,9 @@ itertools.workspace = true openstack-keystone-config.workspace = true openstack-keystone-core.workspace = true openstack-keystone-core-types.workspace = true +openstack-keystone-key-repository.workspace = true nix = { workspace = true, features = ["fs", "user"] } rmp.workspace = true -scopeguard.workspace = true -secrecy = { workspace = true } tokio = { workspace = true, features = ["fs"] } tempfile.workspace = true thiserror.workspace = true @@ -38,7 +37,7 @@ base64urlsafedata.workspace = true config.workspace = true criterion = { workspace = true, features = ["async_tokio"] } tempfile.workspace = true -tokio = { workspace = true, features = ["macros"]} +tokio = { workspace = true, features = ["macros", "rt-multi-thread"]} [features] default = [] diff --git a/crates/token-driver-fernet/benches/fernet_token.rs b/crates/token-driver-fernet/benches/fernet_token.rs index 2096dfcd4..8c003c16a 100644 --- a/crates/token-driver-fernet/benches/fernet_token.rs +++ b/crates/token-driver-fernet/benches/fernet_token.rs @@ -31,7 +31,10 @@ fn bench_decrypt_token(c: &mut Criterion) { config.fernet_tokens.key_repository = tmp_dir.keep(); let mut backend = FernetTokenProvider::new(config.clone()); - backend.load_keys().unwrap(); + tokio::runtime::Runtime::new() + .unwrap() + .block_on(backend.load_keys()) + .unwrap(); let token = "gAAAAABns2ixy75K_KfoosWLrNNqG6KW8nm3Xzv0_2dOx8ODWH7B8i2g8CncGLO6XBEH_TYLg83P6XoKQ5bU8An8Kqgw9WX3bvmEQXphnwPM6aRAOQUSdVhTlUm_8otDG9BS2rc70Q7pfy57S3_yBgimy-174aKdP8LPusvdHZsQPEJO9pfeXWw"; diff --git a/crates/token-driver-fernet/src/error.rs b/crates/token-driver-fernet/src/error.rs index 4afece248..c8efc236a 100644 --- a/crates/token-driver-fernet/src/error.rs +++ b/crates/token-driver-fernet/src/error.rs @@ -15,6 +15,7 @@ use std::num::TryFromIntError; +use openstack_keystone_key_repository::KeyRepositoryError; use thiserror::Error; use openstack_keystone_core::token::TokenProviderError; @@ -39,6 +40,26 @@ pub enum FernetDriverError { #[error("no usable fernet keys has been found")] FernetKeysMissing, + /// A key's contents could not be parsed as a Fernet key. + #[error("fernet key at index {0} is not usable")] + InvalidKey(i8), + + /// Fernet index arithmetic would overflow the `i8` file-naming scheme. + #[error("fernet key rotation index overflow")] + IndexOverflow, + + /// Persisting a rotated/staged key entry failed. + #[error("failed to persist fernet key entry: {0}")] + KeyPersistFailed(String), + + /// A key decodes to the well-known Null Key and + /// `insecure_allow_null_key` is not set (ADR 0019 §4, Security). + #[error( + "token fernet key repository contains the well-known Null Key; refusing to start (set \ + [fernet_tokens] insecure_allow_null_key to override — production tolerance is zero)" + )] + NullKeyDetected, + /// Fernet key read error. #[error("fernet key read error: {}", source)] FernetKeyRead { @@ -119,6 +140,20 @@ pub enum FernetDriverError { Validation(#[from] validator::ValidationErrors), } +impl From for FernetDriverError { + fn from(err: KeyRepositoryError) -> Self { + match err { + KeyRepositoryError::Io { source, .. } => Self::Io { source }, + KeyRepositoryError::KeysMissing => Self::FernetKeysMissing, + KeyRepositoryError::InvalidKey(idx) => Self::InvalidKey(idx), + KeyRepositoryError::NullKeyDetected => Self::NullKeyDetected, + KeyRepositoryError::IndexOverflow => Self::IndexOverflow, + KeyRepositoryError::Persist(msg) => Self::KeyPersistFailed(msg), + KeyRepositoryError::NixErrno { context, source } => Self::NixErrno { context, source }, + } + } +} + impl From for TokenProviderError { fn from(value: FernetDriverError) -> Self { Self::Driver { diff --git a/crates/token-driver-fernet/src/lib.rs b/crates/token-driver-fernet/src/lib.rs index eb1a2d21c..1e0b89fc2 100644 --- a/crates/token-driver-fernet/src/lib.rs +++ b/crates/token-driver-fernet/src/lib.rs @@ -17,13 +17,14 @@ use std::collections::BTreeMap; use std::collections::HashSet; use std::fmt; use std::io::{Cursor, Write}; +use std::sync::Arc; use base64::Engine; use byteorder::ReadBytesExt; use bytes::Bytes; use chrono::{DateTime, Utc}; -use fernet::MultiFernet; use itertools::Itertools; +use openstack_keystone_key_repository::{CachedKeyRepository, FilesystemKeySource, LoadedKeys}; use rmp::{ Marker, decode::{ValueReadError, read_marker, read_u8}, @@ -61,11 +62,13 @@ pub mod utils; pub use error::FernetDriverError; /// Fernet token provider. -#[derive(Clone, Default)] pub struct FernetTokenProvider { config: Config, utils: FernetUtils, - fernet: Option, + /// Populated by [`Self::load_keys`]: an always-fresh, auto-refreshing + /// view of the key repository. `None` until then — `encrypt`/`decrypt` + /// error rather than silently reading the filesystem on every call. + cached: Option>, /// Map of the configured authentication methods. auth_map: BTreeMap, /// Cached permutations of auth_methods to the payload code. @@ -171,7 +174,7 @@ impl FernetTokenProvider { max_active_keys: config.fernet_tokens.max_active_keys, }, config, - fernet: None, + cached: None, auth_map: BTreeMap::new(), auth_methods_code_cache: BTreeMap::new(), }; @@ -401,25 +404,35 @@ impl FernetTokenProvider { Ok(buf.into()) } - /// Get MultiFernet initialized with repository keys. + /// The current key snapshot, kept fresh in the background once + /// [`Self::load_keys`] has started it. /// - /// # Returns - /// A `Result` containing the initialized `MultiFernet` if successful, or a - /// `FernetDriverError`. - #[tracing::instrument(level = "trace", skip(self))] - pub fn get_fernet(&self) -> Result { - Ok(MultiFernet::new( - self.utils.load_keys()?.into_iter().collect::>(), - )) + /// # Errors + /// [`FernetDriverError::FernetKeysMissing`] if [`Self::load_keys`] has + /// not been called yet. + fn current_keys(&self) -> Result, FernetDriverError> { + self.cached + .as_ref() + .map(CachedKeyRepository::current) + .ok_or(FernetDriverError::FernetKeysMissing) } - /// Load fernet keys from FS. + /// Load fernet keys from FS and start watching for changes: this is a + /// one-time initialization (call once before serving traffic) that + /// keeps `decrypt`/`encrypt` on a cheap, always-current cached snapshot + /// rather than reading the filesystem on every call, and picks up a + /// rotation without a service restart (ADR 0019 §4, shared with the + /// credential key repository). /// /// # Returns /// A `Result` indicating success or a `FernetDriverError`. #[tracing::instrument(level = "trace", skip(self))] - pub fn load_keys(&mut self) -> Result<(), FernetDriverError> { - self.fernet = Some(self.get_fernet()?); + pub async fn load_keys(&mut self) -> Result<(), FernetDriverError> { + self.cached = Some( + self.utils + .start_cached(self.config.fernet_tokens.insecure_allow_null_key) + .await?, + ); Ok(()) } @@ -435,13 +448,8 @@ impl FernetTokenProvider { /// A `Result` containing the decrypted `Token` if successful, or a /// `FernetDriverError`. pub fn decrypt(&self, credential: &str) -> Result { - // TODO: Implement fernet keys change watching. Keystone loads them from FS on - // every request and in the best case it costs 15µs. - let fernet = match &self.fernet { - Some(f) => f, - None => &self.get_fernet()?, - }; - let payload = fernet.decrypt(credential)?; + let keys = self.current_keys()?; + let payload = keys.multi_fernet.decrypt(credential)?; self.decode(&mut payload.as_slice(), get_fernet_timestamp(credential)?) } @@ -456,11 +464,8 @@ impl FernetTokenProvider { /// `FernetDriverError`. pub fn encrypt(&self, token: &FernetToken) -> Result { let payload = self.encode(token)?; - let res = match &self.fernet { - Some(fernet) => fernet.encrypt(&payload), - _ => self.get_fernet()?.encrypt(&payload), - }; - Ok(res) + let keys = self.current_keys()?; + Ok(keys.multi_fernet.encrypt(&payload)) } } @@ -613,7 +618,7 @@ pub mod tests { let token = "gAAAAABnt12vpnYCuUxl1lWQfTxwkBcZcgdK5wYons4BFHxxZLk326To5afinp29in7f5ZHR5K61Pl2voIjfbPKlL51KempshD4shfSje4RutbeXq-NT498eEcorzige5XBYGaoWuDTOKEDH2eXCMHhw9722j9iPP3Z4r_1Zlmcqq1n2tndmvsA"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::Unscoped(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); @@ -644,7 +649,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -656,7 +661,7 @@ pub mod tests { let token = "gAAAAABnt16C_ve4dDc7TeU857pwTXGJfGqNA4uJ308_2o_F9T_8WenNBatll0Q36wGz79dSI6RQnuN2PbK17wxQbn9jXscDh2ie3ZrW-WL5gG3gWK6FiPleAiU3kJN5mkskViJOIN-ZpP2B15fmZiYijelQ9TQuhQ"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::DomainScope(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); @@ -684,7 +689,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -696,7 +701,7 @@ pub mod tests { let token = "gAAAAABns2ixy75K_KfoosWLrNNqG6KW8nm3Xzv0_2dOx8ODWH7B8i2g8CncGLO6XBEH_TYLg83P6XoKQ5bU8An8Kqgw9WX3bvmEQXphnwPM6aRAOQUSdVhTlUm_8otDG9BS2rc70Q7pfy57S3_yBgimy-174aKdP8LPusvdHZsQPEJO9pfeXWw"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::ProjectScope(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); @@ -724,7 +729,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -736,7 +741,7 @@ pub mod tests { let token = "gAAAAABoMdfwBgwjAfYCp3RisL_XKSdGKmBqg7ia8jkfsKIXnap_bQ5gUTZGwgEERlpFKzbwpkV-cpiFDuhe9RAnCtbQxEhP7Rg1vt1VLm8afGTulDaLclqot2NC-BONFO2k3V3KyIa-Xrq0mCEGOk-BhNZy2C6iwrWanPCjCuZrWCq4FBirtMs2vrnZPWG5FTGqqkvdQvGj"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::FederationUnscoped(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "8980e124df5245509131bdc5c66c54cc"); @@ -772,7 +777,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -784,7 +789,7 @@ pub mod tests { let token = "gAAAAABoNdYE5zCP0qQtHqhdbZHQ7YdLvfDlUTpLou8FJFoMKsd4I9jyVyaWrluYXKXofnwzemA-wybhtbNruwqDYH-wmHdMlgYuZyy21o8ylphU5yd2b-5KvGpXo61fTVTzhdHFTzJKVit_7Lcwq0S45xQ9x14sVRd870NEwfmOvUVR5BGzmnpFLvWtkaPSpbxMAzfn_NSC"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::FederationProjectScope(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "8980e124df5245509131bdc5c66c54cc"); @@ -821,7 +826,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -833,7 +838,7 @@ pub mod tests { let token = "gAAAAABoNddwFaB2Oq26-4f8nRK3Bph7-QsIh30Rbefbb78owJXaQcjNQm5Qq1gHouS6JSqgfpdna3ML1vdTVnVnFScX-T-CZ-CqtBPUuEBHFEzdNBDKQHloYajZ2sknwbe_uIs1SDS9tBFLvkVth1eVjDhdEawINHjUCFhNPObZKas5V0j7bsvChNeZBKsznruJwCtcrWr5"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::FederationDomainScope(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "8980e124df5245509131bdc5c66c54cc"); @@ -871,7 +876,7 @@ pub mod tests { let config = setup_config(); let mut provider = FernetTokenProvider::new(config); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -883,7 +888,7 @@ pub mod tests { let token = "gAAAAABnt11m57ZlI9JU0g2BKJw2EN-InbAIijcIG7SxvPATntgTlcTMwha-Fh7isNNIwDq2WaWglV1nYgftfoUK245ZnEJ0_gXaIhl6COhNommYv2Bs9PnJqfgrrxrIrB8rh4pfeyCtMkv5ePYgFFPyRFE37l3k7qL5p7qVhYT37yT1-K5lYAV0f6Vy70h3KX1HO0m6Rl90"; let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); if let FernetToken::ApplicationCredential(decrypted) = provider.decrypt(token).unwrap() { assert_eq!(decrypted.user_id, "4b7d364ad87d400bbd91798e3c15e9c2"); @@ -916,7 +921,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -938,7 +943,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -958,7 +963,7 @@ pub mod tests { }); let mut provider = FernetTokenProvider::new(setup_config()); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let encrypted = provider.encrypt(&token).unwrap(); let dec_token = discard_issued_at(provider.decrypt(&encrypted).unwrap()); @@ -1012,7 +1017,7 @@ pub mod tests { config.fernet_tokens.key_repository = keys_dir.keep(); let mut provider = FernetTokenProvider::new(config); - provider.load_keys().unwrap(); + provider.load_keys().await.unwrap(); let token = FernetToken::Unscoped(UnscopedPayload { user_id: Uuid::new_v4().simple().to_string(), diff --git a/crates/token-driver-fernet/src/utils.rs b/crates/token-driver-fernet/src/utils.rs index c85fd5caf..01fcee7ba 100644 --- a/crates/token-driver-fernet/src/utils.rs +++ b/crates/token-driver-fernet/src/utils.rs @@ -14,27 +14,31 @@ //! # Fernet utils use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; -use fernet::Fernet; -use nix::sys::stat::{Mode, umask}; -use nix::unistd::{Gid, Uid, getegid, geteuid, setegid, seteuid}; use rmp::{ Marker, decode::{self, *}, encode::{self, *}, }; -use secrecy::{ExposeSecret, SecretString}; -use std::collections::BTreeMap; -use std::fs; -use std::io::{self, Read, Write}; +use std::io::{self, Read}; use std::path::PathBuf; -use tempfile::NamedTempFile; -use tokio::fs as fs_async; -use tracing::{error, info, trace, warn}; +use std::time::Duration; use uuid::Uuid; +use openstack_keystone_key_repository::{CachedKeyRepository, FilesystemKeySource, KeyRepository}; + use crate::error::FernetDriverError; -/// Fernet utils. +/// How often the filesystem watcher's poll fallback checks for changes if +/// inotify doesn't fire — only relevant to [`FernetUtils::start_cached`], +/// the one long-lived, auto-refreshing view a [`crate::FernetTokenProvider`] +/// holds. The one-shot operations below (`initialize_key_repository`, +/// `rotate`, `check_startup_null_key`) don't watch at all. +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +/// Fernet utils: a thin adapter over +/// [`openstack_keystone_key_repository`], which owns the actual key +/// parsing/rotation/Null-Key-detection logic shared with the credential +/// driver (ADR 0019 §4). #[derive(Clone, Debug, Default)] pub struct FernetUtils { pub key_repository: PathBuf, @@ -42,202 +46,57 @@ pub struct FernetUtils { } impl FernetUtils { - /// Validate fernet key key_repository. - /// - /// Perform validation of the fernet keys repository. - /// - /// # Returns - /// A `Result` containing a boolean indicating if the repository exists, or - /// a `FernetDriverError`. - fn validate_key_repository(&self) -> Result { - Ok(self.key_repository.exists()) - } - - /// Securely create a new tmp encryption key. - /// - /// This created key is not effective until `become_valid_new_key` method is - /// executed. - /// - /// # Parameters - /// - `user_id`: Optional user ID for file ownership. - /// - `group_id`: Optional group ID for file ownership. - /// - /// # Returns - /// A `Result` indicating success or a `FernetDriverError`. - fn create_tmp_new_key( - &self, - user_id: Option, - group_id: Option, - ) -> Result<(), FernetDriverError> { - // 1. Generate key and wrap in a secret-protecting type - let key = SecretString::new(Fernet::generate_key().into()); - let target_path = self.key_repository.join("0.tmp"); - - // 2. Set umask and handle privilege escalation using scope_guard - let old_umask = umask(Mode::from_bits_truncate(0o177)); - let _umask_guard = scopeguard::guard(old_umask, |old| { - umask(old); - }); - - if let (Some(uid), Some(gid)) = (user_id, group_id) { - let (old_euid, old_egid) = (geteuid(), getegid()); - - setegid(Gid::from_raw(gid)).map_err(|e| FernetDriverError::NixErrno { - context: "setting effective process GID".into(), - source: e, - })?; - - // Install the restore guard immediately after the first privilege - // transition succeeds, so a failure in the second transition below - // still restores the original ids via `?` instead of leaving the - // process running with a half-dropped privilege state (egid - // changed, euid not). - let _id_guard = scopeguard::guard((old_euid, old_egid), |(u, g)| { - let _ = seteuid(u); - let _ = setegid(g); - }); - - seteuid(Uid::from_raw(uid)).map_err(|e| FernetDriverError::NixErrno { - context: "setting effective process UID".into(), - source: e, - })?; - } - - // 3. Atomic Write: Create a temp file in the same directory - // This handles the "cleanup on failure" automatically. - let mut tmp_file = NamedTempFile::new_in(self.key_repository.clone())?; - - // Write the actual secret data - tmp_file.write_all(key.expose_secret().as_bytes())?; - tmp_file.flush()?; - - // 4. Atomically persist the file to "0" - // If persist() isn't called, the file is deleted when tmp_file goes out of - // scope. - info!("Created new Fernet key at {:?}", target_path); - tmp_file.persist(&target_path)?; - - Ok(()) + fn repo(&self) -> KeyRepository { + KeyRepository::new( + FilesystemKeySource::new(self.key_repository.clone()), + self.max_active_keys, + ) } - /// Make the tmp new key a valid new key. - /// - /// Renames '0.tmp' to '0' atomically. - /// - /// # Returns - /// A `Result` indicating success or a `FernetDriverError`. - fn become_valid_new_key(&self) -> Result<(), FernetDriverError> { - let tmp_key_file = self.key_repository.join("0.tmp"); - let valid_key_file = self.key_repository.join("0"); - - // Check if the source exists before attempting rename to provide better errors - if !tmp_key_file.exists() { - error!("Temporary key file not found: {:?}", tmp_key_file); - return Err(FernetDriverError::FernetKeysMissing); - } - - // std::fs::rename is atomic on most Unix-like systems. - // If '0' already exists, it will be overwritten in a single operation. - fs::rename(&tmp_key_file, &valid_key_file)?; - - // Sync the directory to ensure the rename is persisted to disk - // This is a "pro" Rust move for high-reliability systems. - let dir = fs::File::open(&self.key_repository)?; - dir.sync_all()?; - - info!("Become a valid new key: {:?}", valid_key_file); - Ok(()) + /// `token_setup`: create the initial staged key. Idempotent-ish: + /// overwrites the staged key if it already exists, matching the + /// underlying atomic-write semantics. + pub async fn initialize_key_repository(&self) -> Result<(), FernetDriverError> { + self.repo().setup().await.map_err(Into::into) } - /// Initialize the key repository by creating and validating a new key. - /// - /// # Returns - /// A `Result` indicating success or a `FernetDriverError`. - pub fn initialize_key_repository(&self) -> Result<(), FernetDriverError> { - self.create_tmp_new_key(None, None)?; - self.become_valid_new_key()?; - Ok(()) + /// Startup-time Null Key check (ADR 0019 §4, Security), mirroring the + /// credential key repository's equivalent check. + pub async fn check_startup_null_key( + &self, + insecure_allow_null_key: bool, + ) -> Result<(), FernetDriverError> { + self.repo() + .check_startup_null_key(insecure_allow_null_key) + .await + .map_err(Into::into) } - /// Load keys from the key repository. - /// - /// # Returns - /// A `Result` containing an iterator of `Fernet` keys if successful, or a - /// `FernetDriverError`. - pub fn load_keys(&self) -> Result, FernetDriverError> { - info!("loading keys from {:?}", self.key_repository); - let mut keys: BTreeMap = BTreeMap::new(); - if self.validate_key_repository()? { - for entry in fs::read_dir(&self.key_repository)? { - let entry = entry?; - if let Ok(fname) = entry.file_name().into_string() - && let Ok(key_order) = fname.parse::() - { - // We are only interested in files named as integer (0, 1, 2, ...) - trace!("Loading key {:?}", entry.file_name()); - if let Some(fernet) = Fernet::new( - fs::read_to_string(entry.path()) - .map_err(|e| FernetDriverError::FernetKeyRead { - source: e, - path: entry.path(), - })? - .trim_end(), - ) { - keys.insert(key_order, fernet); - } else { - warn!( - "The key {:?} is not usable for Fernet library", - entry.file_name() - ) - } - } - } - } - if keys.is_empty() { - return Err(FernetDriverError::FernetKeysMissing); - } - Ok(keys.into_values().rev()) + /// `token_rotate`: promote the staged key to primary, stage a fresh + /// key, and prune beyond `max_active_keys`. Unlike the credential + /// driver's rotate, there is no "still-encrypted-with-a-stale-key" + /// safety check to run first — expired tokens simply fail to decrypt, + /// they are never migrated. + pub async fn rotate(&self) -> Result<(), FernetDriverError> { + self.repo().rotate().await.map_err(Into::into) } - /// Load keys from the key repository asynchronously. - /// - /// # Returns - /// A `Result` containing an iterator of `Fernet` keys if successful, or a - /// `FernetDriverError`. - pub async fn load_keys_async( + /// Start a cached, auto-refreshing view of this key repository: the + /// initial load happens here, and a background task keeps it fresh as + /// keys are rotated on disk, so [`crate::FernetTokenProvider`] never + /// touches the filesystem on the encrypt/decrypt hot path and never + /// serves a stale key set after a rotation. + pub async fn start_cached( &self, - ) -> Result, FernetDriverError> { - let mut keys: BTreeMap = BTreeMap::new(); - if self.validate_key_repository()? { - let mut entries = fs_async::read_dir(&self.key_repository).await?; - while let Some(entry) = entries.next_entry().await? { - if let Ok(fname) = entry.file_name().into_string() - && let Ok(key_order) = fname.parse::() - { - // We are only interested in files named as integer (0, 1, 2, ...) - trace!("Loading key {:?}", entry.file_name()); - if let Some(fernet) = Fernet::new( - fs::read_to_string(entry.path()) - .map_err(|e| FernetDriverError::FernetKeyRead { - source: e, - path: entry.path(), - })? - .trim_end(), - ) { - keys.insert(key_order, fernet); - } else { - warn!( - "The key {:?} is not usable for Fernet library", - entry.file_name() - ) - } - } - } - } - if keys.is_empty() { - return Err(FernetDriverError::FernetKeysMissing); - } - Ok(keys.into_values().rev()) + insecure_allow_null_key: bool, + ) -> Result, FernetDriverError> { + let repo = KeyRepository::new( + FilesystemKeySource::watched(self.key_repository.clone(), POLL_INTERVAL), + self.max_active_keys, + ); + CachedKeyRepository::start(repo, insecure_allow_null_key) + .await + .map_err(Into::into) } } @@ -643,67 +502,64 @@ pub fn write_bool(wd: &mut W, data: bool) -> Result<(), FernetDrive mod tests { use super::FernetUtils; use chrono::{Local, SubsecRound}; - use std::fs::File; - use std::io::Write; use tempfile::tempdir; use super::*; + // Low-level key parsing/rotation/Null-Key-detection coverage lives with + // `openstack-keystone-key-repository`, which this adapter delegates to. + // These tests only cover the adapter wiring itself. + #[tokio::test] - async fn test_load_keys_valid() { + async fn test_initialize_and_rotate_roundtrip() { let tmp_dir = tempdir().unwrap(); - for i in 0..5 { - let file_path = tmp_dir.path().join(format!("{i}")); - let mut tmp_file = File::create(file_path).unwrap(); - write!(tmp_file, "{}", Fernet::generate_key()).unwrap(); - } let utils = FernetUtils { - key_repository: tmp_dir.keep(), - ..Default::default() + key_repository: tmp_dir.path().to_path_buf(), + max_active_keys: 3, }; - let keys: Vec = utils.load_keys().unwrap().into_iter().collect(); - assert_eq!(5, keys.len()); + utils.initialize_key_repository().await.unwrap(); + assert!(tmp_dir.path().join("0").exists()); + + utils.rotate().await.unwrap(); + assert!(tmp_dir.path().join("1").exists(), "staged key promoted"); + assert!(tmp_dir.path().join("0").exists(), "fresh key staged"); } #[tokio::test] - async fn test_load_keys_all_invalid() { - let tmp_dir = tempdir().unwrap(); - for i in 0..5 { - let file_path = tmp_dir.path().join(format!("{i}")); - let mut tmp_file = File::create(file_path).unwrap(); - write!(tmp_file, "{i}").unwrap(); - } - // write dummy file to check it is ignored - let file_path = tmp_dir.path().join("dummy"); - let mut tmp_file = File::create(file_path).unwrap(); - write!(tmp_file, "foo").unwrap(); + async fn test_check_startup_null_key_refuses_by_default() { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE; + let tmp_dir = tempdir().unwrap(); let utils = FernetUtils { - key_repository: tmp_dir.keep(), - ..Default::default() + key_repository: tmp_dir.path().to_path_buf(), + max_active_keys: 3, }; - let res = utils.load_keys(); - - if let Err(FernetDriverError::FernetKeysMissing) = res { - } else { - panic!("Should have raised an exception"); - } + let null_key = URL_SAFE.encode([0u8; 32]); + std::fs::write(tmp_dir.path().join("0"), null_key).unwrap(); + + assert!(matches!( + utils.check_startup_null_key(false).await, + Err(FernetDriverError::NullKeyDetected) + )); + assert!(utils.check_startup_null_key(true).await.is_ok()); } #[tokio::test] - async fn test_load_keys_trim() { + async fn test_start_cached_encrypts_and_decrypts() { let tmp_dir = tempdir().unwrap(); - for i in 0..5 { - let file_path = tmp_dir.path().join(format!("{i}")); - let mut tmp_file = File::create(file_path).unwrap(); - writeln!(tmp_file, "{}", Fernet::generate_key()).unwrap(); - } let utils = FernetUtils { - key_repository: tmp_dir.keep(), - ..Default::default() + key_repository: tmp_dir.path().to_path_buf(), + max_active_keys: 3, }; - let keys: Vec = utils.load_keys().unwrap().into_iter().collect(); - assert_eq!(5, keys.len()); + utils.initialize_key_repository().await.unwrap(); + + let cached = utils.start_cached(false).await.unwrap(); + let token = cached.current().multi_fernet.encrypt(b"payload"); + assert_eq!( + cached.current().multi_fernet.decrypt(&token).unwrap(), + b"payload" + ); } #[test] diff --git a/tests/integration/src/common.rs b/tests/integration/src/common.rs index 0a1ca2da5..0159912d1 100644 --- a/tests/integration/src/common.rs +++ b/tests/integration/src/common.rs @@ -146,7 +146,7 @@ pub async fn get_state() -> Result<(Arc, TempDir)> { max_active_keys: cfg.fernet_tokens.max_active_keys, }; cfg.federation.default_authorization_ttl = 20; - fernet_utils.initialize_key_repository()?; + fernet_utils.initialize_key_repository().await?; cfg.mapping.cluster_salt = Some(SecretString::new( "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" .to_string() @@ -173,7 +173,7 @@ pub async fn get_state() -> Result<(Arc, TempDir)> { cfg.k8s_auth.driver = "raft".to_string(); } - let plugin_manager = PluginManager::with_config(&cfg); + let plugin_manager = PluginManager::with_config(&cfg).await?; let k8s_http_client = Arc::new(openstack_keystone::k8s_auth_client::MockK8sHttpClient::default()); let provider = Provider::new(&cfg, &plugin_manager, k8s_http_client)?; diff --git a/tests/integration/src/credential.rs b/tests/integration/src/credential.rs index 5778dca27..c82b3e5de 100644 --- a/tests/integration/src/credential.rs +++ b/tests/integration/src/credential.rs @@ -60,7 +60,9 @@ pub async fn get_state() -> Result<(ServiceState, TempDir)> { let key_repository = tmp_dir.path().join("credential-keys"); create_dir(&key_repository)?; - FernetKeyRepository::new(key_repository.clone()).setup()?; + FernetKeyRepository::new(key_repository.clone()) + .setup() + .await?; { let mut cfg = state.config_manager.config.write().await;