Files
rustfs/crates/kms/tests/behavior_lifecycle.rs
T
唐小鸭 62cc19e937 fix(kms): repair unopenable ciphertext and cover the Vault backends (#5668)
* Add black-box behavior tests for KMS resilience and serialization

* fix(kms): repair unopenable ciphertext across backends

Black-box testing of the KMS crate surfaced several defects that make
encrypted data permanently unreadable.

Symmetric envelopes. The Local and Vault Transit backends returned raw
cipher output from `encrypt` while `decrypt` parsed a JSON envelope, so
anything sealed through the master-key path could never be opened again.
Local also discarded the AES-GCM nonce. Both now emit the same envelope
`decrypt` consumes, matching the Static backend.

Deterministic AAD. The object layer derived AEAD additional data by
serializing a `HashMap` directly. Iteration order differs per instance,
so a context rebuilt from storage produced different AAD bytes than the
one used to seal and the object stopped opening. Ordering by key removes
that dependency, matching the Static backend's existing `context_aad`.
Objects written with the default single-key context are unaffected,
since a one-entry map has only one serialization.

Cipher in the header projection. `metadata_to_headers` recorded the SSE
mode (`AES256` / `aws:kms`), which cannot represent ChaCha20-Poly1305,
so a ChaCha-sealed object came back claiming `aws:kms` and was opened
with the wrong cipher. The cipher now travels in
`x-rustfs-encryption-algorithm` — the header the storage layer already
reads but nothing ever wrote. Objects without it fall back as before.

Also: the Static backend ignored `key_spec` and always issued 256-bit
data keys; Local `list_keys` hardcoded `truncated: false`, ignored
`marker`, and paginated over unordered `read_dir`, so a paginating
client silently saw a partial key list; and Local and Vault KV2 reported
`key_id: "unknown"` from `decrypt` despite the envelope naming the
master key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): cover both Vault backends and key rotation

The behavior suite ran only against Local and Static, and its own harness
documented the gap: the Vault backends had no business-capability
coverage at all. Setting `RUSTFS_KMS_VAULT_TOKEN` now adds Vault KV2 and
Vault Transit to every `for_each_backend` spec against a live server.
That lane is what surfaced the Transit envelope defect fixed in the
previous commit.

`rotate` and `versioning` are advertised only by the Vault backends, so
until now every capability-gated branch for them took the
`UnsupportedCapability` side and the working half was never asserted — a
rotation that dropped prior key versions would have gone green. The new
`behavior_rotation.rs` pins that half: material sealed before a rotation
still opens after it, repeated rotations accumulate versions rather than
overwriting a single spare, and the history survives a restart.

Two test defects fixed. `objects_round_trip_across_sizes_and_algorithms`
asserted a 1-byte object differs from its own ciphertext, which collides
once every 256 runs; the assertion now applies only where a collision is
not realistic, and small objects stay covered by the tag check and the
decrypt round-trip. `test_from_env_selects_token_file` depended on
`RUSTFS_KMS_VAULT_TOKEN` being absent from the caller's environment and
now clears it explicitly.

The snapshots directory was also removed from `.gitignore`: insta
snapshots are the assertions themselves, so leaving them untracked gives
CI nothing to compare against. Only `.snap.new` scratch files are
ignored now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(kms): adapt behavior suite to current key APIs

Rebasing onto main brought four API changes the suite predates.

`DeleteKeyRequest` gained `confirm_key_id`, and immediate deletion is now
gated on the server's `allow_immediate_deletion`. Scheduled deletions pass
`None`; the four specs that destroy a key outright echo the key id back
and opt the harness config in, which is what the gate asks of a real
caller.

`LocalBackupExportRequest` gained `sanitized_config`. These specs cover
the key-material path, so they seal no configuration and pass `None`.

`KmsCacheStats` became a named struct with real hit, miss, and eviction
counters. `cache_stats_returns_an_entry_count_and_no_hit_or_miss_data`
existed to pin the old placeholder behavior — that the second tuple
element was always zero — which main has since fixed, so it is now
`cache_stats_reports_hits_and_misses_separately` and asserts the counters
actually move.

Starting the service provisions the reserved probe key, so it shows up in
listings and backup bundles. Exact-set assertions filter it through a new
`without_probe_key` helper rather than naming it, keeping those specs
about the keys they seeded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(kms): bind the AAD to the stored context bytes

Review caught that canonicalizing the AAD on decrypt breaks objects sealed
before canonicalization existed, and it was right. The AAD is the
*serialization* of the encryption context, and `x-rustfs-encryption-context`
stores that exact byte sequence: `encrypt_object` fed one `HashMap` to the
AEAD and then moved the same map into the metadata the header is written
from, so the stored string is byte-identical to the AAD the object was
sealed under. Those objects are therefore recoverable — but only while
nothing round-trips the value through a `HashMap` and re-serializes it.

Recomputing sorted AAD on decrypt would have turned a readable object into
a permanently unreadable one. The previous behavior was worse than the
first analysis credited: it did not merely fail intermittently, it made
the failure deterministic.

`EncryptionMetadata` now carries `context_aad`, the bytes the object was
actually sealed with. Encryption records what it fed the AEAD, the header
projection stores those bytes verbatim (and preserves a legacy ordering
across a re-projection rather than rewriting it into sorted form), and
`headers_to_metadata` carries the stored string through untouched. Both
decrypt paths, SSE-KMS and SSE-C, prefer it and fall back to canonical
serialization only when no stored serialization exists. Canonicalization
still applies to everything newly sealed, so the original ordering bug
cannot recur.

Two tests pin this: a legacy record whose sealed bytes are non-canonical
must survive a full header round trip unchanged, and a context header
rewritten to an equivalent-but-reordered serialization must fail
authentication rather than silently re-deriving a working AAD. Both were
mutation-checked against the reinstated bug on each side.

Also from review: the lifecycle churn test asserted only that every
request was accounted for, which holds whether the state gate exists or
not, so both branches are now pinned deterministically after the churn
(asserting `refused > 0` on the concurrent phase would only trade the hole
for a scheduling flake). And the Local and Vault KV2 envelopes compare
`encryption_context` without authenticating it — `DekCrypto` seals only
the plaintext — which is now documented at both sites; closing it needs a
versioned envelope, since existing ciphertext was sealed without AAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:33:08 +08:00

457 lines
20 KiB
Rust

// 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.
//! Black-box behavior: KMS service lifecycle, configuration, and redaction.
//!
//! Pins the contract the admin API depends on:
//!
//! * an unconfigured manager hands out nothing and refuses to start;
//! * `configure` is for the stopped state only — changing a running service is
//! `reconfigure`, which never exposes a stopped interval;
//! * a configuration is validated *before* anything is published, and a failing
//! persistence callback leaves the previous state completely intact;
//! * the Local backend's identity (directory, master key, dev-mode) is frozen
//! once configured, because changing it silently orphans existing key files;
//! * redacted views never carry key material.
mod common;
use std::sync::Arc;
use std::time::Duration;
use common::{STATIC_KEY_ID, TestKms, assert_configuration_error, assert_no_secret_leak, static_secret_key};
use rustfs_kms::{BackendConfig, KmsConfig, KmsError, KmsServiceManager, KmsServiceStatus, KmsStartOutcome, LocalConfig};
use tempfile::TempDir;
use url::Url;
fn local_dev_config(dir: &TempDir) -> KmsConfig {
KmsConfig::local(dir.path().to_path_buf()).with_insecure_development_defaults()
}
#[tokio::test]
async fn unconfigured_manager_exposes_nothing_and_refuses_to_start() {
let manager = KmsServiceManager::new();
assert_eq!(manager.get_status().await, KmsServiceStatus::NotConfigured);
assert!(manager.get_config().await.is_none(), "no config before configure");
assert!(manager.get_redacted_config().await.is_none(), "no redacted config either");
assert!(manager.get_manager().await.is_none(), "no manager handle before start");
assert!(manager.get_encryption_service().await.is_none(), "no service handle before start");
assert!(manager.get_service_version().await.is_none(), "no version before start");
// A health check on a service that was never started is a definite "not
// healthy", not an error: callers use it as a readiness probe.
assert!(
!manager.health_check().await.expect("health check must not error when idle"),
"an unstarted service must report unhealthy"
);
assert_configuration_error(manager.start().await, "no configuration provided");
// The failed start records the reason and still exposes no service.
match manager.get_status().await {
KmsServiceStatus::Error(message) => {
assert!(message.contains("no configuration"), "error status should explain why: {message}")
}
other => panic!("expected Error status after starting without config, got {other:?}"),
}
assert!(manager.get_encryption_service().await.is_none());
assert!(manager.get_service_version().await.is_none());
}
#[tokio::test]
async fn configure_start_stop_restart_state_machine() {
let dir = TempDir::new().expect("temp dir");
let manager = KmsServiceManager::new();
let config = local_dev_config(&dir);
manager.configure(config.clone()).await.expect("configure should succeed");
assert_eq!(manager.get_status().await, KmsServiceStatus::Configured);
assert!(
manager.get_encryption_service().await.is_none(),
"configured but not started must not expose a service"
);
assert!(manager.get_config().await.is_some(), "configure publishes the config");
manager.start().await.expect("start should succeed");
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
assert_eq!(manager.get_service_version().await, Some(1), "first start is version 1");
assert!(manager.get_manager().await.is_some());
assert!(manager.get_encryption_service().await.is_some());
assert!(manager.health_check().await.expect("health check"), "a started local KMS is healthy");
manager.stop().await.expect("stop should succeed");
assert_eq!(
manager.get_status().await,
KmsServiceStatus::Configured,
"stop falls back to Configured, not NotConfigured, because the config survives"
);
assert!(manager.get_encryption_service().await.is_none(), "stop withdraws the service");
assert!(manager.get_service_version().await.is_none(), "stop clears the published version");
assert!(manager.get_config().await.is_some(), "stop keeps the configuration");
assert!(!manager.health_check().await.expect("health check"), "a stopped service is unhealthy");
// Restart after stop reuses the surviving config and takes the next version.
manager.start().await.expect("restart after stop should succeed");
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
assert_eq!(
manager.get_service_version().await,
Some(2),
"the version counter is monotonic across a stop/start cycle"
);
}
#[tokio::test]
async fn start_or_restart_reports_the_action_it_took() {
let dir = TempDir::new().expect("temp dir");
let manager = KmsServiceManager::new();
manager
.configure(local_dev_config(&dir))
.await
.expect("configure should succeed");
assert_eq!(
manager.start_or_restart(false).await.expect("first start"),
KmsStartOutcome::Started,
"starting a stopped service reports Started"
);
assert_eq!(manager.get_service_version().await, Some(1));
let service_v1 = manager.get_encryption_service().await.expect("service after start");
assert_eq!(
manager.start_or_restart(false).await.expect("non-forced start while running"),
KmsStartOutcome::AlreadyRunning,
"a non-forced start on a running service is a no-op"
);
assert_eq!(manager.get_service_version().await, Some(1), "the no-op path must not consume a version");
assert!(
Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service still running")),
"a no-op start must not swap the live service instance"
);
assert_eq!(
manager.start_or_restart(true).await.expect("forced restart"),
KmsStartOutcome::Restarted,
"a forced start on a running service reports Restarted"
);
assert_eq!(manager.get_service_version().await, Some(2), "a forced restart takes a new version");
assert!(
!Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service after restart")),
"a forced restart must publish a different instance"
);
// The withdrawn instance keeps working for operations that already hold it.
assert!(
service_v1.health_check().await.expect("old handle health check"),
"a replaced service stays usable while callers still hold it"
);
}
#[tokio::test]
async fn configure_is_rejected_while_running() {
let dir = TempDir::new().expect("temp dir");
let manager = KmsServiceManager::new();
let config = local_dev_config(&dir);
manager.configure(config.clone()).await.expect("configure");
manager.start().await.expect("start");
assert_configuration_error(manager.configure(config.clone()).await, "use reconfigure instead");
// The rejected call must not have disturbed the running service.
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
assert_eq!(manager.get_service_version().await, Some(1), "a rejected configure takes no version");
}
#[tokio::test]
async fn reconfigure_swaps_the_service_without_a_stopped_interval() {
let dir = TempDir::new().expect("temp dir");
let manager = KmsServiceManager::new();
let config = local_dev_config(&dir);
manager.configure(config.clone()).await.expect("configure");
manager.start().await.expect("start");
let service_v1 = manager.get_encryption_service().await.expect("service v1");
let manager_v1 = manager.get_manager().await.expect("manager v1");
let mut next = config.clone();
next.timeout = Duration::from_secs(45);
manager.reconfigure(next).await.expect("reconfigure should succeed");
assert_eq!(manager.get_status().await, KmsServiceStatus::Running, "reconfigure never reports stopped");
assert_eq!(manager.get_service_version().await, Some(2));
assert_eq!(
manager.get_config().await.expect("config after reconfigure").timeout,
Duration::from_secs(45),
"the published config must be the new one"
);
let service_v2 = manager.get_encryption_service().await.expect("service v2");
let manager_v2 = manager.get_manager().await.expect("manager v2");
assert!(!Arc::ptr_eq(&service_v1, &service_v2), "reconfigure publishes a new service");
assert!(!Arc::ptr_eq(&manager_v1, &manager_v2), "reconfigure publishes a new manager");
// Both generations stay functional: that is the whole point of the swap.
assert!(service_v1.health_check().await.expect("v1 health"), "old generation still serves");
assert!(service_v2.health_check().await.expect("v2 health"), "new generation serves");
}
#[tokio::test]
async fn failed_persistence_does_not_publish_the_new_configuration() {
let dir = TempDir::new().expect("temp dir");
let manager = KmsServiceManager::new();
// configure_with_persistence: nothing is published when persistence fails.
let result = manager
.configure_with_persistence(local_dev_config(&dir), || async {
Err(KmsError::backend_error("simulated persistence failure"))
})
.await;
assert!(
matches!(result, Err(KmsError::BackendError { .. })),
"the persistence error must propagate verbatim, got {result:?}"
);
assert_eq!(
manager.get_status().await,
KmsServiceStatus::NotConfigured,
"a failed persist must leave the manager unconfigured"
);
assert!(manager.get_config().await.is_none(), "a failed persist must publish no config");
// Now bring it up for real, then fail persistence on a reconfigure.
let config = local_dev_config(&dir);
manager.configure(config.clone()).await.expect("configure");
manager.start().await.expect("start");
let service_v1 = manager.get_encryption_service().await.expect("service v1");
let mut next = config.clone();
next.timeout = Duration::from_secs(99);
let result = manager
.reconfigure_with_persistence(next, || async { Err(KmsError::backend_error("simulated persistence failure")) })
.await;
assert!(result.is_err(), "reconfigure must fail when persistence fails, got {result:?}");
assert_eq!(manager.get_status().await, KmsServiceStatus::Running, "the old service keeps running");
assert_eq!(
manager.get_config().await.expect("config").timeout,
config.timeout,
"the old configuration must still be the published one"
);
assert!(
Arc::ptr_eq(&service_v1, &manager.get_encryption_service().await.expect("service")),
"a failed reconfigure must not swap the live service"
);
}
#[tokio::test]
async fn local_backend_identity_is_frozen_after_configuration() {
let dir = TempDir::new().expect("temp dir");
let other_dir = TempDir::new().expect("other temp dir");
let manager = KmsServiceManager::new();
let config = local_dev_config(&dir);
manager.configure(config.clone()).await.expect("configure");
manager.start().await.expect("start");
// Moving the key directory would orphan every existing key file.
let mut moved = config.clone();
moved.backend_config = BackendConfig::Local(LocalConfig {
key_dir: other_dir.path().to_path_buf(),
master_key: None,
file_permissions: Some(0o600),
});
assert_configuration_error(manager.reconfigure(moved).await, "key directory cannot be changed");
// Changing the at-rest master key would make stored material undecryptable.
let mut rekeyed = config.clone();
rekeyed.backend_config = BackendConfig::Local(LocalConfig {
key_dir: dir.path().to_path_buf(),
master_key: Some("a-different-master-key".to_string()),
file_permissions: Some(0o600),
});
assert_configuration_error(manager.reconfigure(rekeyed).await, "master key cannot be changed");
// Flipping dev mode changes the at-rest protection of the same directory.
let mut hardened = config.clone();
hardened.allow_insecure_dev_defaults = false;
hardened.backend_config = BackendConfig::Local(LocalConfig {
key_dir: dir.path().to_path_buf(),
master_key: Some("a-master-key".to_string()),
file_permissions: Some(0o600),
});
assert!(
manager.reconfigure(hardened).await.is_err(),
"flipping development mode on a configured local backend must be refused"
);
// Switching away from Local entirely is refused for the same reason.
let switched = KmsConfig::static_kms(STATIC_KEY_ID.to_string(), static_secret_key());
assert_configuration_error(manager.reconfigure(switched).await, "backend cannot be changed");
// Every rejection above left the original service untouched.
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
assert_eq!(
manager.get_service_version().await,
Some(1),
"rejected transitions must not consume service versions"
);
}
#[tokio::test]
async fn invalid_configurations_are_rejected_before_anything_starts() {
let dir = TempDir::new().expect("temp dir");
// A zero timeout or zero retry budget would make every operation fail.
let mut zero_timeout = local_dev_config(&dir);
zero_timeout.timeout = Duration::ZERO;
assert_configuration_error(zero_timeout.validate(), "Timeout must be greater than 0");
let mut zero_retries = local_dev_config(&dir);
zero_retries.retry_attempts = 0;
assert_configuration_error(zero_retries.validate(), "Retry attempts must be greater than 0");
// Cache enabled with no capacity is contradictory.
let mut empty_cache = local_dev_config(&dir);
empty_cache.enable_cache = true;
empty_cache.cache_config.max_keys = 0;
assert_configuration_error(empty_cache.validate(), "max_keys must be greater than 0");
// A relative key directory is ambiguous relative to the server's cwd.
let mut relative = local_dev_config(&dir);
relative.backend_config = BackendConfig::Local(LocalConfig {
key_dir: "relative/kms".into(),
master_key: None,
file_permissions: Some(0o600),
});
assert_configuration_error(relative.validate(), "must be an absolute path");
// Production-shaped Local config: no master key is refused without opt-in.
let production_without_master_key = KmsConfig::local("/var/lib/rustfs/kms-behavior".into());
assert_configuration_error(production_without_master_key.validate(), "requires a master key");
// ...and so is a key directory under the process temp dir.
let temp_backed = KmsConfig {
backend_config: BackendConfig::Local(LocalConfig {
key_dir: dir.path().to_path_buf(),
master_key: Some("a-master-key".to_string()),
file_permissions: Some(0o600),
}),
..KmsConfig::local(dir.path().to_path_buf())
};
assert_configuration_error(temp_backed.validate(), "temp directory");
// Static backend: the secret must be base64 and exactly 32 bytes.
assert_configuration_error(
KmsConfig::static_kms("k".to_string(), "not base64!!".to_string()).validate(),
"not valid base64",
);
assert_configuration_error(
KmsConfig::static_kms("k".to_string(), base64_of(&[0u8; 16])).validate(),
"exactly 32 bytes",
);
assert_configuration_error(
KmsConfig::static_kms(String::new(), static_secret_key()).validate(),
"key_id cannot be empty",
);
assert_configuration_error(
KmsConfig::static_kms("k".to_string(), String::new()).validate(),
"secret_key cannot be empty",
);
// Vault: plaintext HTTP and the built-in dev token are dev-only.
let http = Url::parse("http://127.0.0.1:8200").expect("url");
assert_configuration_error(KmsConfig::vault(http.clone(), "a-real-token".to_string()).validate(), "requires HTTPS");
let https = Url::parse("https://vault.example.com:8200").expect("url");
assert_configuration_error(
KmsConfig::vault(https.clone(), "dev-token".to_string()).validate(),
"dev-token is not allowed",
);
// An AppRole with no credential at all cannot authenticate.
let approle = KmsConfig::vault_approle(https, "role".to_string(), String::new());
assert_configuration_error(approle.validate(), "requires a secret_id");
// None of the above may be startable through the manager either.
let manager = KmsServiceManager::new();
assert_configuration_error(manager.configure(zero_timeout).await, "Timeout must be greater than 0");
assert_eq!(
manager.get_status().await,
KmsServiceStatus::NotConfigured,
"a rejected configure must not move the manager out of NotConfigured"
);
}
#[tokio::test]
async fn redacted_views_never_carry_key_material() {
let secret = static_secret_key();
let manager = KmsServiceManager::new();
manager
.configure(KmsConfig::static_kms(STATIC_KEY_ID.to_string(), secret.clone()))
.await
.expect("configure static");
manager.start().await.expect("start static");
let redacted = manager.get_redacted_config().await.expect("redacted config");
let static_config = redacted.static_config().expect("static config");
assert!(static_config.secret_key.is_empty(), "the redacted view must zero the secret key");
assert_eq!(static_config.key_id, STATIC_KEY_ID, "the key id is not secret and must survive");
let (status, from_state) = manager.get_redacted_state().await;
assert_eq!(status, KmsServiceStatus::Running, "redacted state carries the live status");
let from_state = from_state.expect("redacted state config");
assert!(
from_state.static_config().expect("static config").secret_key.is_empty(),
"get_redacted_state must redact exactly like get_redacted_config"
);
// Neither the Debug rendering nor a JSON serialization may carry material.
assert_no_secret_leak(&format!("{redacted:?}"), &[&secret]);
assert_no_secret_leak(&serde_json::to_string(&redacted).expect("redacted config serializes"), &[&secret]);
// Even the unredacted config must keep the secret out of Debug and JSON:
// `Debug` is what ends up in logs, and `secret_key` is `skip_serializing`.
let live = manager.get_config().await.expect("live config");
assert_no_secret_leak(&format!("{live:?}"), &[&secret]);
assert_no_secret_leak(&serde_json::to_string(&live).expect("live config serializes"), &[&secret]);
// A Vault token is subject to the same rule in Debug output.
let vault = KmsConfig::vault(
Url::parse("https://vault.example.com:8200").expect("url"),
"super-secret-vault-token".to_string(),
);
assert_no_secret_leak(&format!("{vault:?}"), &["super-secret-vault-token"]);
}
#[tokio::test]
async fn harness_restart_brings_the_service_back_over_the_same_state() {
let mut kms = TestKms::local().await;
assert_eq!(kms.manager().get_service_version().await, Some(1));
kms.restart().await;
assert_eq!(kms.manager().get_status().await, KmsServiceStatus::Running);
assert_eq!(
kms.manager().get_service_version().await,
Some(1),
"a restarted process starts its version counter over"
);
assert!(
kms.manager().health_check().await.expect("health check"),
"the restarted service is healthy"
);
}
fn base64_of(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}