mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-07 05:43:14 +00:00
62cc19e937
* 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>
499 lines
21 KiB
Rust
499 lines
21 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: exporting the Local backend as a sealed backup bundle.
|
|
//!
|
|
//! A backup is only worth having if it is *provably* restorable, so the bundle
|
|
//! format is fail-closed at every step. The properties asserted here:
|
|
//!
|
|
//! * **Nothing leaves unwrapped.** Every artifact is AEAD-encrypted under a
|
|
//! backup KEK that is deliberately outside the KMS hierarchy — including
|
|
//! plaintext-dev-only key records, whose material would otherwise be readable
|
|
//! straight out of the bundle.
|
|
//! * **Sealing is all-or-nothing.** The manifest is written last and carries a
|
|
//! completeness marker plus a digest over its own canonical bytes, so an
|
|
//! interrupted export is permanently non-restorable rather than subtly short.
|
|
//! * **The KEK is checked before anything else.** Presenting the wrong KEK
|
|
//! identity fails on identity, not on a decryption error, so a mismatch is
|
|
//! diagnosable rather than looking like corruption.
|
|
//! * **Tampering is detected.** The manifest digest covers the manifest, and
|
|
//! each artifact carries a digest of its *encrypted* bytes, so verification
|
|
//! never needs the KEK.
|
|
|
|
mod common;
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use common::{TestKms, without_probe_key};
|
|
use rustfs_kms::backends::local::LocalKmsClient;
|
|
use rustfs_kms::backup::{
|
|
ArtifactKind, BackupError, BackupKek, BackupManifest, CompletenessState, ContentDigest, LOCAL_BUNDLE_MANIFEST_FILE,
|
|
LocalBackupExportRequest, decrypt_bundle_artifact, export_local_backup, read_local_bundle_manifest,
|
|
};
|
|
use rustfs_kms::{KmsError, LocalConfig};
|
|
use tempfile::TempDir;
|
|
|
|
const KEK_ID: &str = "backup-kek";
|
|
const KEK_VERSION: u32 = 3;
|
|
|
|
fn kek() -> BackupKek {
|
|
BackupKek::new(KEK_ID, KEK_VERSION, [0x7eu8; 32]).expect("KEK should build")
|
|
}
|
|
|
|
fn export_request(destination: PathBuf) -> LocalBackupExportRequest {
|
|
LocalBackupExportRequest {
|
|
backup_id: "backup-behavior-001".to_string(),
|
|
deployment_identity: "deployment-under-test".to_string(),
|
|
rustfs_version: "0.0.0-behavior".to_string(),
|
|
snapshot_generation: 7,
|
|
destination,
|
|
// These specs cover the key-material path; the sanitized configuration
|
|
// is the admin layer's own artifact and is exercised separately.
|
|
sanitized_config: None,
|
|
}
|
|
}
|
|
|
|
/// A KMS with a few keys, plus a client over the same directory — the shape an
|
|
/// admin-layer export takes.
|
|
async fn seeded_kms(key_names: &[&str]) -> (TestKms, LocalKmsClient) {
|
|
let kms = TestKms::local().await;
|
|
for name in key_names {
|
|
kms.create_key(name).await;
|
|
}
|
|
let key_dir = kms.key_dir().expect("local backend has a key dir");
|
|
let client = LocalKmsClient::new(LocalConfig {
|
|
key_dir,
|
|
master_key: None,
|
|
file_permissions: Some(0o600),
|
|
})
|
|
.await
|
|
.expect("client over the same key directory");
|
|
(kms, client)
|
|
}
|
|
|
|
async fn read_bytes(path: &Path) -> Vec<u8> {
|
|
tokio::fs::read(path).await.expect("bundle file should be readable")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_sealed_bundle_describes_and_yields_its_contents() {
|
|
let (_kms, client) = seeded_kms(&["backup-a", "backup-b"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
let bundle_dir = out.path().join("bundle");
|
|
|
|
let manifest = export_local_backup(&client, &kek(), &export_request(bundle_dir.clone()))
|
|
.await
|
|
.expect("export should succeed");
|
|
|
|
// --- manifest identity ------------------------------------------------
|
|
assert_eq!(manifest.format_version, BackupManifest::FORMAT_VERSION);
|
|
assert_eq!(manifest.backup_id, "backup-behavior-001");
|
|
assert_eq!(manifest.deployment_identity, "deployment-under-test");
|
|
assert_eq!(manifest.rustfs_version, "0.0.0-behavior");
|
|
assert_eq!(
|
|
manifest.snapshot_generation, 7,
|
|
"the caller-supplied generation must be recorded verbatim"
|
|
);
|
|
assert_eq!(manifest.completeness, CompletenessState::Complete, "a returned manifest must be sealed");
|
|
assert_eq!(manifest.backup_kek.kek_id, KEK_ID, "the manifest records the KEK identity");
|
|
assert_eq!(manifest.backup_kek.kek_version, KEK_VERSION);
|
|
assert!(
|
|
manifest.local_kdf.is_some(),
|
|
"a Local bundle must record its KDF parameters so a restore can detect drift"
|
|
);
|
|
assert!(
|
|
manifest.key_versions.is_none() && manifest.capability_discovery.is_none(),
|
|
"reserved slots must stay empty in format version 1"
|
|
);
|
|
manifest.validate().expect("the produced manifest must validate");
|
|
manifest.verify_digest().expect("the produced manifest's digest must verify");
|
|
|
|
// --- what was read back off disk agrees -------------------------------
|
|
let from_disk = read_local_bundle_manifest(&bundle_dir)
|
|
.await
|
|
.expect("the written manifest must read back");
|
|
assert_eq!(from_disk, manifest, "the manifest on disk must equal the returned one");
|
|
|
|
// --- artifacts --------------------------------------------------------
|
|
assert!(!manifest.artifacts.is_empty(), "a bundle must carry artifacts");
|
|
let key_material = manifest
|
|
.require_artifact(ArtifactKind::KeyMaterial)
|
|
.expect("a Local bundle must carry key material");
|
|
assert!(!key_material.path.starts_with('/'), "artifact paths must stay bundle-relative");
|
|
assert!(!key_material.path.contains(".."), "artifact paths must not traverse");
|
|
|
|
for artifact in &manifest.artifacts {
|
|
assert!(!artifact.kind.is_reserved(), "reserved artifact kinds must not be produced");
|
|
let payload = read_bytes(&bundle_dir.join(&artifact.path)).await;
|
|
assert_eq!(
|
|
payload.len() as u64,
|
|
artifact.len,
|
|
"artifact {} must be exactly the declared length",
|
|
artifact.path
|
|
);
|
|
assert_eq!(
|
|
ContentDigest::sha256_of(&payload),
|
|
artifact.encrypted_digest,
|
|
"artifact {} must match its declared digest of the *encrypted* bytes",
|
|
artifact.path
|
|
);
|
|
|
|
// The KEK opens it, and the plaintext is real content.
|
|
let plaintext = decrypt_bundle_artifact(&bundle_dir, &manifest, artifact, &kek())
|
|
.await
|
|
.unwrap_or_else(|error| panic!("artifact {} must decrypt under the right KEK: {error:?}", artifact.path));
|
|
assert!(!plaintext.is_empty(), "artifact {} decrypted to nothing", artifact.path);
|
|
}
|
|
|
|
// Key material is one artifact per key, so every key must be represented
|
|
// and every artifact must decrypt to the record it claims.
|
|
let exported = exported_key_ids(&bundle_dir, &manifest).await;
|
|
assert_eq!(
|
|
exported,
|
|
vec!["backup-a".to_string(), "backup-b".to_string()],
|
|
"every key must be exported exactly once"
|
|
);
|
|
for key_id in ["backup-a", "backup-b"] {
|
|
assert!(
|
|
manifest
|
|
.artifacts
|
|
.iter()
|
|
.any(|artifact| artifact.kind == ArtifactKind::KeyMaterial && artifact.path.contains(key_id)),
|
|
"the bundle must carry a dedicated key-material artifact for {key_id}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Decrypt every key-material artifact and return the key ids it contains,
|
|
/// sorted, so a bundle's coverage can be asserted as a set.
|
|
async fn exported_key_ids(bundle_dir: &Path, manifest: &BackupManifest) -> Vec<String> {
|
|
let mut found = Vec::new();
|
|
for artifact in manifest
|
|
.artifacts
|
|
.iter()
|
|
.filter(|artifact| artifact.kind == ArtifactKind::KeyMaterial)
|
|
{
|
|
let plaintext = decrypt_bundle_artifact(bundle_dir, manifest, artifact, &kek())
|
|
.await
|
|
.unwrap_or_else(|error| panic!("artifact {} must decrypt: {error:?}", artifact.path));
|
|
let record: serde_json::Value = serde_json::from_slice(&plaintext)
|
|
.unwrap_or_else(|error| panic!("artifact {} must be a key record: {error:?}", artifact.path));
|
|
let key_id = record
|
|
.get("key_id")
|
|
.and_then(|value| value.as_str())
|
|
.unwrap_or_else(|| panic!("artifact {} record has no key_id", artifact.path));
|
|
found.push(key_id.to_string());
|
|
}
|
|
found.sort();
|
|
// The probe key is exported like any other — correctly so — but these specs
|
|
// assert over the keys they seeded.
|
|
without_probe_key(found)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn nothing_readable_leaves_the_bundle_unwrapped() {
|
|
// Dev-mode Local records store key material in the clear on disk; the whole
|
|
// point of the bundle KEK is that such material must not be readable from a
|
|
// backup. Compare the on-disk record against every bundle byte.
|
|
let (kms, client) = seeded_kms(&["leak-check"]).await;
|
|
let key_dir = kms.key_dir().expect("key dir");
|
|
let on_disk = read_bytes(&key_dir.join("leak-check.key")).await;
|
|
|
|
let out = TempDir::new().expect("temp dir");
|
|
let bundle_dir = out.path().join("bundle");
|
|
let manifest = export_local_backup(&client, &kek(), &export_request(bundle_dir.clone()))
|
|
.await
|
|
.expect("export should succeed");
|
|
|
|
for artifact in &manifest.artifacts {
|
|
let payload = read_bytes(&bundle_dir.join(&artifact.path)).await;
|
|
assert!(
|
|
!payload
|
|
.windows(on_disk.len().min(payload.len()).max(1))
|
|
.any(|window| window == on_disk.as_slice()),
|
|
"artifact {} carries the raw on-disk record",
|
|
artifact.path
|
|
);
|
|
// A cheap structural check too: an encrypted payload is not JSON.
|
|
assert_ne!(payload.first(), Some(&b'{'), "artifact {} looks like plaintext JSON", artifact.path);
|
|
}
|
|
|
|
// The manifest itself is not encrypted, so assert directly that it carries
|
|
// no material — only identities and digests.
|
|
let manifest_bytes = read_bytes(&bundle_dir.join(LOCAL_BUNDLE_MANIFEST_FILE)).await;
|
|
let manifest_text = String::from_utf8(manifest_bytes).expect("the manifest is JSON");
|
|
assert!(
|
|
!manifest_text.contains("encrypted_key_material"),
|
|
"the manifest must not inline key material"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_wrong_kek_is_refused_on_identity_not_on_decryption() {
|
|
let (_kms, client) = seeded_kms(&["kek-check"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
let bundle_dir = out.path().join("bundle");
|
|
let manifest = export_local_backup(&client, &kek(), &export_request(bundle_dir.clone()))
|
|
.await
|
|
.expect("export should succeed");
|
|
let artifact = manifest
|
|
.require_artifact(ArtifactKind::KeyMaterial)
|
|
.expect("key material artifact");
|
|
|
|
// Wrong id, wrong version, and right identity with wrong material are all
|
|
// distinct failure modes and must be reported as such.
|
|
let wrong_id = BackupKek::new("some-other-kek", KEK_VERSION, [0x7eu8; 32]).expect("KEK");
|
|
match decrypt_bundle_artifact(&bundle_dir, &manifest, artifact, &wrong_id).await {
|
|
Err(KmsError::Backup(BackupError::WrongKek {
|
|
required_kek_id,
|
|
supplied_kek_id,
|
|
..
|
|
})) => {
|
|
assert_eq!(required_kek_id, KEK_ID);
|
|
assert_eq!(supplied_kek_id, "some-other-kek");
|
|
}
|
|
other => panic!("expected WrongKek for a mismatched id, got {other:?}"),
|
|
}
|
|
|
|
let wrong_version = BackupKek::new(KEK_ID, KEK_VERSION + 1, [0x7eu8; 32]).expect("KEK");
|
|
match decrypt_bundle_artifact(&bundle_dir, &manifest, artifact, &wrong_version).await {
|
|
Err(KmsError::Backup(BackupError::WrongKek {
|
|
required_kek_version,
|
|
supplied_kek_version,
|
|
..
|
|
})) => {
|
|
assert_eq!(required_kek_version, KEK_VERSION);
|
|
assert_eq!(supplied_kek_version, KEK_VERSION + 1);
|
|
}
|
|
other => panic!("expected WrongKek for a mismatched version, got {other:?}"),
|
|
}
|
|
|
|
// Right identity, wrong material: identity passes, AEAD does not.
|
|
let impostor = BackupKek::new(KEK_ID, KEK_VERSION, [0x00u8; 32]).expect("KEK");
|
|
let error = decrypt_bundle_artifact(&bundle_dir, &manifest, artifact, &impostor)
|
|
.await
|
|
.expect_err("a KEK with the right identity but wrong material must not open the bundle");
|
|
assert!(
|
|
!matches!(error, KmsError::Backup(BackupError::WrongKek { .. })),
|
|
"an identity match followed by an AEAD failure must not be reported as WrongKek: {error:?}"
|
|
);
|
|
|
|
// The correct KEK still works, so the failures above are about the KEK.
|
|
decrypt_bundle_artifact(&bundle_dir, &manifest, artifact, &kek())
|
|
.await
|
|
.expect("the correct KEK must still open the artifact");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_tampered_bundle_is_detected() {
|
|
let (_kms, client) = seeded_kms(&["tamper-check"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
let bundle_dir = out.path().join("bundle");
|
|
let manifest = export_local_backup(&client, &kek(), &export_request(bundle_dir.clone()))
|
|
.await
|
|
.expect("export should succeed");
|
|
let artifact = manifest
|
|
.require_artifact(ArtifactKind::KeyMaterial)
|
|
.expect("key material artifact")
|
|
.clone();
|
|
let artifact_path = bundle_dir.join(&artifact.path);
|
|
let original = read_bytes(&artifact_path).await;
|
|
|
|
// Flipping a byte breaks the declared digest, which is checked before the
|
|
// KEK is ever applied — so detection does not depend on holding the KEK.
|
|
let mut tampered = original.clone();
|
|
let middle = tampered.len() / 2;
|
|
tampered[middle] ^= 0xff;
|
|
tokio::fs::write(&artifact_path, &tampered).await.expect("write tampered");
|
|
assert_ne!(
|
|
ContentDigest::sha256_of(&tampered),
|
|
artifact.encrypted_digest,
|
|
"the tampered payload must no longer match its declared digest"
|
|
);
|
|
assert!(
|
|
decrypt_bundle_artifact(&bundle_dir, &manifest, &artifact, &kek())
|
|
.await
|
|
.is_err(),
|
|
"a tampered artifact must be refused"
|
|
);
|
|
|
|
// Truncation is caught by the declared length.
|
|
tokio::fs::write(&artifact_path, &original[..original.len() / 2])
|
|
.await
|
|
.expect("write truncated");
|
|
match decrypt_bundle_artifact(&bundle_dir, &manifest, &artifact, &kek()).await {
|
|
Err(KmsError::Backup(BackupError::Truncated { .. })) => {}
|
|
other => panic!("expected Truncated for a short artifact, got {other:?}"),
|
|
}
|
|
|
|
// A missing artifact is its own failure mode.
|
|
tokio::fs::remove_file(&artifact_path).await.expect("remove artifact");
|
|
match decrypt_bundle_artifact(&bundle_dir, &manifest, &artifact, &kek()).await {
|
|
Err(KmsError::Backup(BackupError::MissingArtifact { .. })) => {}
|
|
other => panic!("expected MissingArtifact, got {other:?}"),
|
|
}
|
|
|
|
// Restoring the bytes restores the bundle: the failures were the tampering.
|
|
tokio::fs::write(&artifact_path, &original).await.expect("restore artifact");
|
|
decrypt_bundle_artifact(&bundle_dir, &manifest, &artifact, &kek())
|
|
.await
|
|
.expect("the restored artifact must open again");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_tampered_manifest_fails_its_own_digest() {
|
|
let (_kms, client) = seeded_kms(&["manifest-tamper"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
let bundle_dir = out.path().join("bundle");
|
|
export_local_backup(&client, &kek(), &export_request(bundle_dir.clone()))
|
|
.await
|
|
.expect("export should succeed");
|
|
|
|
let manifest_path = bundle_dir.join(LOCAL_BUNDLE_MANIFEST_FILE);
|
|
let original = read_bytes(&manifest_path).await;
|
|
let text = String::from_utf8(original.clone()).expect("manifest is JSON");
|
|
|
|
// Rewrite a semantically meaningful field, leaving the digest untouched.
|
|
let tampered = text.replace("\"snapshot_generation\":7", "\"snapshot_generation\":99");
|
|
assert_ne!(tampered, text, "the rewrite must actually change the manifest");
|
|
tokio::fs::write(&manifest_path, tampered.as_bytes())
|
|
.await
|
|
.expect("write tampered manifest");
|
|
|
|
assert!(
|
|
read_local_bundle_manifest(&bundle_dir).await.is_err(),
|
|
"a manifest whose contents no longer match its digest must be rejected"
|
|
);
|
|
|
|
// Truncating the manifest is rejected too, and so is deleting it — the
|
|
// latter is exactly what an interrupted export leaves behind.
|
|
tokio::fs::write(&manifest_path, &original[..original.len() / 2])
|
|
.await
|
|
.expect("write truncated manifest");
|
|
assert!(
|
|
read_local_bundle_manifest(&bundle_dir).await.is_err(),
|
|
"a truncated manifest must be rejected"
|
|
);
|
|
|
|
tokio::fs::remove_file(&manifest_path).await.expect("remove manifest");
|
|
match read_local_bundle_manifest(&bundle_dir).await {
|
|
Err(KmsError::Backup(BackupError::IncompleteBundle { .. })) => {}
|
|
other => panic!("a bundle with no manifest is an unsealed export, got {other:?}"),
|
|
}
|
|
|
|
tokio::fs::write(&manifest_path, &original).await.expect("restore manifest");
|
|
read_local_bundle_manifest(&bundle_dir)
|
|
.await
|
|
.expect("the restored manifest must read back");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn export_refuses_inputs_that_would_produce_an_unrestorable_bundle() {
|
|
let (_kms, client) = seeded_kms(&["input-validation"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
|
|
// Identity fields are the only way a restore can tell bundles apart.
|
|
for (field, mutate) in [
|
|
(
|
|
"backup_id",
|
|
(|r: &mut LocalBackupExportRequest| r.backup_id.clear()) as fn(&mut LocalBackupExportRequest),
|
|
),
|
|
("deployment_identity", |r: &mut LocalBackupExportRequest| r.deployment_identity.clear()),
|
|
("rustfs_version", |r: &mut LocalBackupExportRequest| r.rustfs_version.clear()),
|
|
] {
|
|
let destination = out.path().join(format!("bundle-{field}"));
|
|
let mut request = export_request(destination.clone());
|
|
mutate(&mut request);
|
|
let error = export_local_backup(&client, &kek(), &request)
|
|
.await
|
|
.err()
|
|
.unwrap_or_else(|| panic!("an empty {field} must be refused, but the export produced a bundle"));
|
|
match error {
|
|
KmsError::ValidationError { message } => {
|
|
assert!(message.contains(field), "the refusal must name the offending field {field}: {message}")
|
|
}
|
|
other => panic!("an empty {field} must be a validation error, got {other:?}"),
|
|
}
|
|
assert!(
|
|
!destination.join(LOCAL_BUNDLE_MANIFEST_FILE).exists(),
|
|
"a refused export must not leave a sealed manifest behind for {field}"
|
|
);
|
|
}
|
|
|
|
// An empty KEK id cannot identify a trust root.
|
|
assert!(BackupKek::new("", KEK_VERSION, [0u8; 32]).is_err(), "an empty KEK id must be refused");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exporting_an_empty_key_directory_is_refused() {
|
|
// An empty bundle would restore to an empty KMS, silently destroying state.
|
|
let kms = TestKms::local().await;
|
|
let key_dir = kms.key_dir().expect("key dir");
|
|
let client = LocalKmsClient::new(LocalConfig {
|
|
key_dir,
|
|
master_key: None,
|
|
file_permissions: Some(0o600),
|
|
})
|
|
.await
|
|
.expect("client");
|
|
|
|
let out = TempDir::new().expect("temp dir");
|
|
let error = export_local_backup(&client, &kek(), &export_request(out.path().join("empty")))
|
|
.await
|
|
.expect_err("an export with no key records must be refused");
|
|
assert!(
|
|
matches!(error, KmsError::InvalidOperation { .. }),
|
|
"refusing an empty bundle is an invalid-operation, got {error:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_bundle_taken_after_a_change_reflects_that_change() {
|
|
// Two generations of the same deployment: the second bundle must contain
|
|
// the key the first did not, proving the export reads live state rather
|
|
// than a cached snapshot.
|
|
let (kms, client) = seeded_kms(&["generation-one"]).await;
|
|
let out = TempDir::new().expect("temp dir");
|
|
|
|
let first_dir = out.path().join("gen-1");
|
|
let first = export_local_backup(&client, &kek(), &export_request(first_dir.clone()))
|
|
.await
|
|
.expect("first export");
|
|
assert_eq!(
|
|
exported_key_ids(&first_dir, &first).await,
|
|
vec!["generation-one".to_string()],
|
|
"the first bundle must contain exactly the key that existed when it was taken"
|
|
);
|
|
|
|
kms.create_key("generation-two").await;
|
|
|
|
let second_dir = out.path().join("gen-2");
|
|
let mut request = export_request(second_dir.clone());
|
|
request.backup_id = "backup-behavior-002".to_string();
|
|
request.snapshot_generation = 8;
|
|
let second = export_local_backup(&client, &kek(), &request).await.expect("second export");
|
|
assert_eq!(second.snapshot_generation, 8, "the newer bundle carries the newer generation");
|
|
|
|
assert_eq!(
|
|
exported_key_ids(&second_dir, &second).await,
|
|
vec!["generation-one".to_string(), "generation-two".to_string()],
|
|
"the second bundle must contain both the old and the newly created key"
|
|
);
|
|
|
|
// The first bundle is untouched by the second export.
|
|
read_local_bundle_manifest(&first_dir)
|
|
.await
|
|
.expect("the earlier bundle must still verify");
|
|
}
|