From de39579877cf2661d057a13b608957b7103134cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Wed, 29 Jul 2026 20:03:45 +0800 Subject: [PATCH] feat(kms): add local key export functionality for SSE-S3 migration tests --- crates/kms/AGENTS.md | 22 +++ crates/kms/examples/local_kms_key_decrypt.rs | 112 +++++++++++++++ crates/kms/src/backends/local.rs | 141 +++++++++++++++++++ crates/kms/src/lib.rs | 1 + 4 files changed, 276 insertions(+) create mode 100644 crates/kms/examples/local_kms_key_decrypt.rs diff --git a/crates/kms/AGENTS.md b/crates/kms/AGENTS.md index 3f6cb014a..5555c8071 100644 --- a/crates/kms/AGENTS.md +++ b/crates/kms/AGENTS.md @@ -25,3 +25,25 @@ For local KMS end-to-end tests, keep proxy bypass settings: NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \ cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1 ``` + +## Local Key Export for SSE-S3 Migration Tests + +Use the read-only `local_kms_key_decrypt` example to export an AES-256 Local +KMS key as the base64 value expected by `RUSTFS_SSE_S3_MASTER_KEY`: + +```bash +export RUSTFS_KMS_LOCAL_MASTER_KEY='' +export RUSTFS_SSE_S3_MASTER_KEY="$( + cargo run -q -p rustfs-kms --example local_kms_key_decrypt -- \ + /absolute/path/to/.key +)" +``` + +For a `plaintext-dev-only` Local KMS key file, +`RUSTFS_KMS_LOCAL_MASTER_KEY` is not required. + +The example writes only the base64-encoded 32-byte key to stdout. Diagnostics +go to stderr. Never paste its output into logs, shell history, issue comments, +or committed configuration. The export path must remain read-only and must +reuse `LocalKmsClient` decoding so current Argon2id and legacy key-file +compatibility stay aligned with the backend. diff --git a/crates/kms/examples/local_kms_key_decrypt.rs b/crates/kms/examples/local_kms_key_decrypt.rs new file mode 100644 index 000000000..37f174ccc --- /dev/null +++ b/crates/kms/examples/local_kms_key_decrypt.rs @@ -0,0 +1,112 @@ +// Copyright 2024 RustFS Team +// +// 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. + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rustfs_kms::{LocalConfig, backends::local::LocalKmsClient}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use zeroize::Zeroizing; + +const LOCAL_KMS_MASTER_KEY_ENV: &str = "RUSTFS_KMS_LOCAL_MASTER_KEY"; + +fn usage(program: &str) -> String { + format!( + "Usage: {program} \n\ + Reads {LOCAL_KMS_MASTER_KEY_ENV} when the key file is encrypted.\n\ + Writes only the base64-encoded 32-byte key to stdout." + ) +} + +fn resolve_key_file(path: &Path) -> Result<(PathBuf, String), String> { + let canonical = std::fs::canonicalize(path).map_err(|error| format!("cannot open Local KMS key file: {error}"))?; + if canonical.extension().and_then(|extension| extension.to_str()) != Some("key") { + return Err("Local KMS key file must have a .key extension".to_string()); + } + let key_dir = canonical + .parent() + .ok_or_else(|| "Local KMS key file must have a parent directory".to_string())? + .to_path_buf(); + let key_id = canonical + .file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.is_empty()) + .ok_or_else(|| "Local KMS key file name must contain a valid UTF-8 key ID".to_string())? + .to_string(); + Ok((key_dir, key_id)) +} + +async fn run() -> Result<(), String> { + let mut args = std::env::args(); + let program = args.next().unwrap_or_else(|| "local_kms_key_decrypt".to_string()); + let Some(key_file) = args.next() else { + return Err(usage(&program)); + }; + if args.next().is_some() { + return Err(usage(&program)); + } + + let (key_dir, key_id) = resolve_key_file(Path::new(&key_file))?; + let master_key = std::env::var(LOCAL_KMS_MASTER_KEY_ENV).ok().filter(|value| !value.is_empty()); + let client = LocalKmsClient::new_for_key_export(LocalConfig { + key_dir, + master_key, + file_permissions: Some(0o600), + }) + .await + .map_err(|error| error.to_string())?; + let key_material = client + .decrypt_key_material_for_export(&key_id) + .await + .map_err(|error| error.to_string())?; + let encoded = Zeroizing::new(BASE64_STANDARD.encode(key_material.as_ref())); + + let mut stdout = io::stdout().lock(); + writeln!(stdout, "{}", encoded.as_str()).map_err(|error| format!("failed to write decrypted key: {error}")) +} + +#[tokio::main] +async fn main() { + if let Err(error) = run().await { + let _ = writeln!(io::stderr().lock(), "local_kms_key_decrypt: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_key_file_extracts_directory_and_key_id() { + let directory = tempfile::tempdir().expect("create temporary directory"); + let key_file = directory.path().join("migration-key.key"); + std::fs::write(&key_file, b"{}").expect("create key file"); + + let (key_dir, key_id) = resolve_key_file(&key_file).expect("resolve key file"); + + assert_eq!(key_dir, directory.path().canonicalize().expect("canonical directory")); + assert_eq!(key_id, "migration-key"); + } + + #[test] + fn resolve_key_file_rejects_non_key_extension() { + let directory = tempfile::tempdir().expect("create temporary directory"); + let key_file = directory.path().join("migration-key.json"); + std::fs::write(&key_file, b"{}").expect("create key file"); + + let error = resolve_key_file(&key_file).expect_err("non-key file must be rejected"); + + assert!(error.contains(".key")); + } +} diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index ada43f00b..c82b33619 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -36,6 +36,7 @@ use std::path::{Component, Path, PathBuf}; use std::time::Duration; use tokio::fs; use tracing::{debug, warn}; +use zeroize::Zeroizing; /// Reject key identifiers that would not name a single file directly inside the key /// directory. @@ -73,6 +74,47 @@ const LOCAL_KMS_ARGON2_M_COST_KIB: u32 = 19 * 1024; const LOCAL_KMS_ARGON2_T_COST: u32 = 2; const LOCAL_KMS_ARGON2_P_COST: u32 = 1; +/// Decrypt a Local KMS data-key envelope with explicitly supplied AES-256 key material. +/// +/// This is intentionally narrower than constructing a Local KMS backend: it does +/// not read key files, mutate backend state, or select a key by an untrusted +/// envelope identifier. The caller supplies the expected key ID and object +/// encryption context, and both are checked before any plaintext DEK is returned. +/// +/// The function exists for SSE-S3 migration/fallback reads where the object was +/// historically written while Local KMS was active but only the exported raw key +/// remains available. It must not be used to bypass an SSE-KMS availability +/// requirement. +pub async fn decrypt_local_data_key_envelope( + ciphertext: &[u8], + master_key: &[u8; 32], + expected_master_key_id: &str, + expected_context: &HashMap, +) -> Result<[u8; 32]> { + let envelope: DataKeyEnvelope = serde_json::from_slice(ciphertext)?; + if envelope.master_key_id != expected_master_key_id { + return Err(KmsError::invalid_key( + "Local KMS data-key envelope master key ID does not match object metadata", + )); + } + if envelope.key_spec != "AES_256" { + return Err(KmsError::unsupported_algorithm(envelope.key_spec)); + } + for (key, expected_value) in &envelope.encryption_context { + if expected_context.get(key) != Some(expected_value) { + return Err(KmsError::context_mismatch(format!( + "Local KMS data-key envelope context does not match the current object for key {key:?}" + ))); + } + } + + let plaintext = AesDekCrypto::new() + .decrypt(master_key, &envelope.encrypted_key, &envelope.nonce) + .await?; + let actual = plaintext.len(); + plaintext.try_into().map_err(|_| KmsError::invalid_key_size(32, actual)) +} + /// Local KMS client that stores keys in local files pub struct LocalKmsClient { config: LocalConfig, @@ -146,6 +188,45 @@ impl LocalKmsClient { Ok(client) } + /// Open a Local KMS key directory without creating or modifying any files. + /// + /// This constructor is restricted to explicit key-export tooling. Normal + /// backend operation must use [`Self::new`]. + pub async fn new_for_key_export(config: LocalConfig) -> Result { + if !fs::try_exists(&config.key_dir).await? { + return Err(KmsError::configuration_error("Local KMS key directory does not exist")); + } + + let (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key { + let legacy_key = Self::derive_legacy_master_key(master_key)?; + let legacy_master_cipher = Aes256Gcm::new(&legacy_key); + let salt_path = Self::master_key_salt_path(&config); + let master_cipher = if fs::try_exists(&salt_path).await? { + let salt = fs::read(&salt_path).await?; + let salt: [u8; LOCAL_KMS_MASTER_KEY_SALT_LEN] = salt.try_into().map_err(|_| { + KmsError::configuration_error(format!( + "Local KMS master key salt at {} must be exactly {} bytes", + salt_path.display(), + LOCAL_KMS_MASTER_KEY_SALT_LEN + )) + })?; + Aes256Gcm::new(&Self::derive_master_key(master_key, &salt)?) + } else { + Aes256Gcm::new(&legacy_key) + }; + (Some(master_cipher), Some(legacy_master_cipher)) + } else { + (None, None) + }; + + Ok(Self { + config, + master_cipher, + legacy_master_cipher, + dek_crypto: AesDekCrypto::new(), + }) + } + /// Derive a 256-bit key from the master key string using a persistent Argon2id salt. fn derive_master_key(master_key: &str, salt: &[u8]) -> Result> { let params = Params::new( @@ -435,6 +516,20 @@ impl LocalKmsClient { Ok(key_material) } + /// Decrypt an AES-256 Local KMS key for explicit migration tooling. + /// + /// The returned buffer is zeroized on drop. Callers must treat the value as + /// plaintext key material and avoid logging or persisting it. + pub async fn decrypt_key_material_for_export(&self, key_id: &str) -> Result> { + let (stored_key, key_material) = self.decode_stored_key(key_id).await?; + if stored_key.algorithm != "AES_256" { + return Err(KmsError::unsupported_algorithm(stored_key.algorithm)); + } + let actual = key_material.len(); + let key_material = key_material.try_into().map_err(|_| KmsError::invalid_key_size(32, actual))?; + Ok(Zeroizing::new(key_material)) + } + async fn validate_existing_keys(&self) -> Result<()> { let mut entries = fs::read_dir(&self.config.key_dir).await?; while let Some(entry) = entries.next_entry().await? { @@ -1208,6 +1303,52 @@ mod tests { assert!(matches!(wrong_master_error, KmsError::CryptographicError { .. })); } + #[tokio::test] + async fn key_export_uses_existing_local_decryption_path_without_writing_files() { + let (client, _temp_dir) = create_test_client().await; + let key_id = "export-key"; + client + .create_key(key_id, "AES_256", None) + .await + .expect("create encrypted key"); + let expected = client.get_key_material(key_id).await.expect("load expected key material"); + let salt_path = LocalKmsClient::master_key_salt_path(&client.config); + let salt_before = fs::read(&salt_path).await.expect("read existing salt"); + + let export_client = LocalKmsClient::new_for_key_export(client.config.clone()) + .await + .expect("open read-only export client"); + let exported = export_client + .decrypt_key_material_for_export(key_id) + .await + .expect("decrypt key for export"); + + assert_eq!(exported.as_ref(), expected.as_slice()); + assert_eq!(fs::read(&salt_path).await.expect("read unchanged salt"), salt_before); + } + + #[tokio::test] + async fn key_export_accepts_plaintext_dev_only_key_without_master_key() { + let (client, _temp_dir) = create_dev_mode_client().await; + let key_id = "plaintext-export-key"; + client + .create_key(key_id, "AES_256", None) + .await + .expect("create plaintext-dev-only key"); + let expected = client.get_key_material(key_id).await.expect("load expected key material"); + + let export_client = LocalKmsClient::new_for_key_export(client.config.clone()) + .await + .expect("open read-only export client"); + let exported = export_client + .decrypt_key_material_for_export(key_id) + .await + .expect("export plaintext-dev-only key"); + + assert_eq!(exported.as_ref(), expected.as_slice()); + assert!(!LocalKmsClient::master_key_salt_path(&client.config).exists()); + } + #[tokio::test] async fn test_plaintext_dev_only_storage_is_explicit_and_loadable() { let (client, _temp_dir) = create_dev_mode_client().await; diff --git a/crates/kms/src/lib.rs b/crates/kms/src/lib.rs index db4791ec3..35a9f57b2 100644 --- a/crates/kms/src/lib.rs +++ b/crates/kms/src/lib.rs @@ -83,6 +83,7 @@ pub use api_types::{ StartKmsResponse, StopKmsResponse, TagKeyRequest, TagKeyResponse, UntagKeyRequest, UntagKeyResponse, UpdateKeyDescriptionRequest, UpdateKeyDescriptionResponse, }; +pub use backends::local::decrypt_local_data_key_envelope; pub use config::*; pub use encryption::is_data_key_envelope; pub use error::{KmsError, KmsUnavailableError, Result};