mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-07 22:03: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>
471 lines
16 KiB
Rust
471 lines
16 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: completing a scheduled key deletion.
|
|
//!
|
|
//! `KmsBackend::remove_expired_key` is the only operation in this crate that
|
|
//! destroys key material, and the background sweep may call it concurrently on
|
|
//! several nodes and again after a crash. Its documented contract is therefore
|
|
//! unusually strict, and each clause is asserted below:
|
|
//!
|
|
//! * the deadline is honoured — a key that is not yet due is never removed;
|
|
//! * a cancellation observed after the caller inspected the key **wins**, so a
|
|
//! racing sweep reports `StateChanged` rather than destroying a key the
|
|
//! operator just rescued;
|
|
//! * removal is idempotent — re-running it after a crash, or on a key that is
|
|
//! already gone, succeeds rather than erroring;
|
|
//! * the deadline is persisted, so a restart does not reset the clock.
|
|
//!
|
|
//! The clock is a parameter of the operation, so every case here is
|
|
//! deterministic: nothing sleeps and nothing waits on wall-clock time.
|
|
//!
|
|
//! Backends without deletion support must report the capability gap instead of
|
|
//! silently doing nothing.
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use common::{TestKms, assert_key_not_found, assert_unsupported_capability, ctx};
|
|
use jiff::Zoned;
|
|
use rustfs_kms::backends::local::LocalKmsBackend;
|
|
use rustfs_kms::backends::static_kms::StaticKmsBackend;
|
|
use rustfs_kms::backends::{ExpiredKeyRemoval, KmsBackend};
|
|
use rustfs_kms::{
|
|
CancelKeyDeletionRequest, CreateKeyRequest, DecryptRequest, DeleteKeyRequest, DescribeKeyRequest, GenerateDataKeyRequest,
|
|
KeySpec, KeyState, KeyUsage, KmsConfig,
|
|
};
|
|
use tempfile::TempDir;
|
|
|
|
/// A backend built the way the service manager builds it, so the deletion
|
|
/// contract is exercised on the same object production uses.
|
|
async fn local_backend(dir: &TempDir) -> Arc<LocalKmsBackend> {
|
|
let config = KmsConfig::local(dir.path().to_path_buf()).with_insecure_development_defaults();
|
|
Arc::new(LocalKmsBackend::new(config).await.expect("local backend should build"))
|
|
}
|
|
|
|
async fn create(backend: &LocalKmsBackend, key_id: &str) {
|
|
backend
|
|
.create_key(CreateKeyRequest {
|
|
key_name: Some(key_id.to_string()),
|
|
key_usage: KeyUsage::EncryptDecrypt,
|
|
..Default::default()
|
|
})
|
|
.await
|
|
.expect("key should be created");
|
|
}
|
|
|
|
async fn schedule(backend: &LocalKmsBackend, key_id: &str, days: u32) {
|
|
backend
|
|
.delete_key(DeleteKeyRequest {
|
|
key_id: key_id.to_string(),
|
|
pending_window_in_days: Some(days),
|
|
force_immediate: None,
|
|
confirm_key_id: None,
|
|
})
|
|
.await
|
|
.expect("deletion should be scheduled");
|
|
}
|
|
|
|
fn days_from_now(days: u64) -> Zoned {
|
|
Zoned::now() + Duration::from_secs(days * 86_400)
|
|
}
|
|
|
|
async fn exists(backend: &LocalKmsBackend, key_id: &str) -> bool {
|
|
backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: key_id.to_string(),
|
|
})
|
|
.await
|
|
.is_ok()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removal_waits_for_the_deadline() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "not-yet-due").await;
|
|
schedule(&backend, "not-yet-due", 30).await;
|
|
|
|
// Right now, and at every point strictly inside the window, the key stays.
|
|
for elapsed_days in [0u64, 1, 15, 29] {
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("not-yet-due", &days_from_now(elapsed_days))
|
|
.await
|
|
.expect("the check itself must succeed"),
|
|
ExpiredKeyRemoval::NotExpired,
|
|
"a key {elapsed_days} days into a 30-day window is not due"
|
|
);
|
|
assert!(exists(&backend, "not-yet-due").await, "a key that is not due must survive");
|
|
}
|
|
|
|
// Past the deadline it goes.
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("not-yet-due", &days_from_now(31))
|
|
.await
|
|
.expect("removal should succeed"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
assert!(!exists(&backend, "not-yet-due").await, "an expired key must be gone");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removal_is_idempotent_across_restarts_and_nodes() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "idempotent").await;
|
|
schedule(&backend, "idempotent", 7).await;
|
|
|
|
let past_deadline = days_from_now(8);
|
|
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("idempotent", &past_deadline)
|
|
.await
|
|
.expect("first removal"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
|
|
// A second sweep — another node, or the same node after a restart — must
|
|
// report success rather than failing on the now-missing record.
|
|
for attempt in 0..3 {
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("idempotent", &past_deadline)
|
|
.await
|
|
.unwrap_or_else(|error| panic!("repeat removal {attempt} must succeed: {error:?}")),
|
|
ExpiredKeyRemoval::Removed,
|
|
"removing an already-removed key is a no-op success"
|
|
);
|
|
}
|
|
|
|
// A key that never existed is treated the same way, so a stale sweep entry
|
|
// cannot wedge the worker.
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("never-existed", &past_deadline)
|
|
.await
|
|
.expect("unknown key must not error"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_cancellation_beats_a_racing_sweep() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "rescued").await;
|
|
create(&backend, "doomed").await;
|
|
schedule(&backend, "rescued", 7).await;
|
|
schedule(&backend, "doomed", 7).await;
|
|
|
|
// The sweep has already decided both keys are due; the operator cancels one
|
|
// before the removal actually runs.
|
|
backend
|
|
.cancel_key_deletion(CancelKeyDeletionRequest {
|
|
key_id: "rescued".to_string(),
|
|
})
|
|
.await
|
|
.expect("cancel should succeed");
|
|
|
|
let past_deadline = days_from_now(8);
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("rescued", &past_deadline)
|
|
.await
|
|
.expect("the check must succeed"),
|
|
ExpiredKeyRemoval::StateChanged,
|
|
"a key rescued after inspection must report StateChanged, not be destroyed"
|
|
);
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("doomed", &past_deadline)
|
|
.await
|
|
.expect("removal should succeed"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
|
|
// The rescued key is not merely present: it is fully usable again.
|
|
let described = backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: "rescued".to_string(),
|
|
})
|
|
.await
|
|
.expect("the rescued key must survive");
|
|
assert_eq!(described.key_metadata.key_state, KeyState::Enabled);
|
|
assert!(described.key_metadata.deletion_date.is_none(), "the rescued key must carry no deadline");
|
|
backend
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: "rescued".to_string(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: ctx(&[("bucket", "deletion-behavior")]),
|
|
})
|
|
.await
|
|
.expect("the rescued key must accept new work");
|
|
|
|
assert!(!exists(&backend, "doomed").await, "the un-rescued key must be gone");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn keys_that_are_not_pending_deletion_are_never_removed() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "healthy").await;
|
|
create(&backend, "disabled").await;
|
|
backend.disable_key("disabled").await.expect("disable should succeed");
|
|
|
|
let far_future = days_from_now(3_650);
|
|
for key_id in ["healthy", "disabled"] {
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key(key_id, &far_future)
|
|
.await
|
|
.expect("the check must succeed"),
|
|
ExpiredKeyRemoval::StateChanged,
|
|
"{key_id} is not pending deletion, so no deadline can apply to it"
|
|
);
|
|
assert!(exists(&backend, key_id).await, "{key_id} must survive");
|
|
}
|
|
|
|
// Their states are untouched by the attempt.
|
|
assert_eq!(
|
|
backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: "healthy".to_string()
|
|
})
|
|
.await
|
|
.expect("describe")
|
|
.key_metadata
|
|
.key_state,
|
|
KeyState::Enabled
|
|
);
|
|
assert_eq!(
|
|
backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: "disabled".to_string()
|
|
})
|
|
.await
|
|
.expect("describe")
|
|
.key_metadata
|
|
.key_state,
|
|
KeyState::Disabled
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_deletion_deadline_survives_a_restart() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
|
|
// Schedule the deletion, then drop the backend entirely — a process restart.
|
|
{
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "scheduled-before-restart").await;
|
|
schedule(&backend, "scheduled-before-restart", 7).await;
|
|
}
|
|
|
|
let backend = local_backend(&dir).await;
|
|
let described = backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: "scheduled-before-restart".to_string(),
|
|
})
|
|
.await
|
|
.expect("the key must survive the restart");
|
|
assert_eq!(
|
|
described.key_metadata.key_state,
|
|
KeyState::PendingDeletion,
|
|
"the pending state must be persisted, not held in memory"
|
|
);
|
|
assert!(
|
|
described.key_metadata.deletion_date.is_some(),
|
|
"the deadline must be persisted so a restart does not reset the clock"
|
|
);
|
|
|
|
// The restarted process is still inside the window, then past it.
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("scheduled-before-restart", &days_from_now(1))
|
|
.await
|
|
.expect("check"),
|
|
ExpiredKeyRemoval::NotExpired,
|
|
"a restart must not make an un-due key removable"
|
|
);
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("scheduled-before-restart", &days_from_now(8))
|
|
.await
|
|
.expect("removal"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
assert!(!exists(&backend, "scheduled-before-restart").await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn removed_key_material_is_actually_destroyed() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
create(&backend, "material-gone").await;
|
|
|
|
let context = ctx(&[("bucket", "deletion-behavior"), ("object", "doomed.bin")]);
|
|
let dek = backend
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: "material-gone".to_string(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: context.clone(),
|
|
})
|
|
.await
|
|
.expect("generate a data key while the key still exists");
|
|
|
|
// While pending deletion, existing ciphertext must still open — this is the
|
|
// whole point of the pending window.
|
|
schedule(&backend, "material-gone", 7).await;
|
|
let during_window = backend
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: context.clone(),
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.expect("a pending-deletion key must still decrypt");
|
|
assert_eq!(during_window.plaintext, dek.plaintext_key);
|
|
|
|
// After the deadline the material is gone and the ciphertext is dead.
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("material-gone", &days_from_now(8))
|
|
.await
|
|
.expect("removal"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
assert_key_not_found(
|
|
backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: "material-gone".to_string(),
|
|
})
|
|
.await,
|
|
"material-gone",
|
|
);
|
|
assert!(
|
|
backend
|
|
.decrypt(DecryptRequest {
|
|
ciphertext: dek.ciphertext_blob.clone(),
|
|
encryption_context: context,
|
|
grant_tokens: Vec::new(),
|
|
})
|
|
.await
|
|
.is_err(),
|
|
"ciphertext wrapped by a destroyed key must no longer decrypt"
|
|
);
|
|
|
|
// And a fresh backend over the same directory agrees: the removal was
|
|
// durable, not just an in-memory state change.
|
|
let restarted = local_backend(&dir).await;
|
|
assert!(!exists(&restarted, "material-gone").await, "the removal must survive a restart");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deletion_only_touches_its_own_key() {
|
|
let dir = TempDir::new().expect("temp dir");
|
|
let backend = local_backend(&dir).await;
|
|
for key_id in ["neighbour-a", "target", "neighbour-b"] {
|
|
create(&backend, key_id).await;
|
|
}
|
|
schedule(&backend, "target", 7).await;
|
|
|
|
assert_eq!(
|
|
backend
|
|
.remove_expired_key("target", &days_from_now(8))
|
|
.await
|
|
.expect("removal"),
|
|
ExpiredKeyRemoval::Removed
|
|
);
|
|
|
|
for neighbour in ["neighbour-a", "neighbour-b"] {
|
|
let described = backend
|
|
.describe_key(DescribeKeyRequest {
|
|
key_id: neighbour.to_string(),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{neighbour} must be untouched: {error:?}"));
|
|
assert_eq!(described.key_metadata.key_state, KeyState::Enabled);
|
|
backend
|
|
.generate_data_key(GenerateDataKeyRequest {
|
|
key_id: neighbour.to_string(),
|
|
key_spec: KeySpec::Aes256,
|
|
encryption_context: ctx(&[("bucket", "deletion-behavior")]),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{neighbour} must still work: {error:?}"));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_backend_without_deletion_support_reports_the_capability_gap() {
|
|
let config = KmsConfig::static_kms(common::STATIC_KEY_ID.to_string(), common::static_secret_key());
|
|
let backend = StaticKmsBackend::new(config).await.expect("static backend should build");
|
|
|
|
assert!(
|
|
!backend.capabilities().schedule_deletion,
|
|
"the static backend must not advertise deletion scheduling"
|
|
);
|
|
assert_unsupported_capability(
|
|
backend.remove_expired_key(common::STATIC_KEY_ID, &Zoned::now()).await,
|
|
"remove_expired_key",
|
|
);
|
|
}
|
|
|
|
/// The service manager runs a background sweep for backends that support
|
|
/// deletion. It uses wall-clock time, so a real expiry cannot be forced from
|
|
/// outside; what *is* checkable from here — and what a spurious-deletion bug
|
|
/// would break — is that the sweep leaves un-due keys alone while it runs.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn the_background_sweep_never_removes_an_un_due_key() {
|
|
let kms = TestKms::local().await;
|
|
let manager = kms.kms().await;
|
|
let key_id = kms.create_key("swept-but-not-due").await;
|
|
manager
|
|
.delete_key(DeleteKeyRequest {
|
|
key_id: key_id.clone(),
|
|
pending_window_in_days: Some(7),
|
|
force_immediate: None,
|
|
confirm_key_id: None,
|
|
})
|
|
.await
|
|
.expect("schedule deletion");
|
|
|
|
// With the clock paused, tokio auto-advances through the sweep interval, so
|
|
// this loop drives many sweeps in a fraction of a second.
|
|
for _ in 0..40 {
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
}
|
|
|
|
let described = manager
|
|
.describe_key(DescribeKeyRequest { key_id: key_id.clone() })
|
|
.await
|
|
.expect("a key inside its window must survive every sweep");
|
|
assert_eq!(described.key_metadata.key_state, KeyState::PendingDeletion);
|
|
assert!(
|
|
described.key_metadata.deletion_date.is_some(),
|
|
"the sweep must not clear the deadline it is waiting on"
|
|
);
|
|
|
|
// And it can still be rescued afterwards.
|
|
manager
|
|
.cancel_key_deletion(CancelKeyDeletionRequest { key_id: key_id.clone() })
|
|
.await
|
|
.expect("a key the sweep left alone must still be cancellable");
|
|
}
|