mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-30 01:58:59 +00:00
feat(kms): add local key export functionality for SSE-S3 migration tests
This commit is contained in:
@@ -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='<local-kms-at-rest-master-key>'
|
||||
export RUSTFS_SSE_S3_MASTER_KEY="$(
|
||||
cargo run -q -p rustfs-kms --example local_kms_key_decrypt -- \
|
||||
/absolute/path/to/<key-id>.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.
|
||||
|
||||
@@ -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} <local-kms-key-file>\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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>,
|
||||
) -> 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<Self> {
|
||||
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<Key<Aes256Gcm>> {
|
||||
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<Zeroizing<[u8; 32]>> {
|
||||
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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user