fix(kms): version local key records safely (#5638)

This commit is contained in:
Zhengchao An
2026-08-03 00:30:23 +08:00
committed by GitHub
parent a918f1a48a
commit 0800f74874
10 changed files with 469 additions and 7 deletions
+1
View File
@@ -72,6 +72,7 @@ impl From<&BackupError> for RestoreBlocker {
BackupError::Corrupted { .. } => RestoreBlockerCode::BundleCorrupted,
BackupError::Truncated { .. } => RestoreBlockerCode::BundleTruncated,
BackupError::UnknownVersion { .. } => RestoreBlockerCode::UnknownFormatVersion,
BackupError::UnsupportedFormatVersion { .. } => RestoreBlockerCode::UnknownFormatVersion,
BackupError::WrongKek { .. } => RestoreBlockerCode::WrongBackupKek,
BackupError::MissingArtifact { .. } => RestoreBlockerCode::MissingArtifact,
BackupError::IncompleteBundle { .. } => RestoreBlockerCode::IncompleteBundle,
+14
View File
@@ -51,6 +51,11 @@ pub enum BackupError {
supplied_kek_version: u32,
},
/// A bundled key record declares a format version this build does not
/// understand.
#[error("bundled key record '{key_id}' declares unsupported format version {version}; this build cannot restore it")]
UnsupportedFormatVersion { key_id: String, version: String },
/// Manifest requires an artifact that is not present in the bundle.
#[error("backup bundle is missing a required artifact: {artifact}")]
MissingArtifact { artifact: String },
@@ -128,6 +133,15 @@ mod tests {
"unknown backup manifest format version 9 (this build supports version 1)"
);
assert_eq!(
BackupError::UnsupportedFormatVersion {
key_id: "alpha".to_string(),
version: "9".to_string(),
}
.to_string(),
"bundled key record 'alpha' declares unsupported format version 9; this build cannot restore it"
);
assert_eq!(
BackupError::missing_artifact("key-material").to_string(),
"backup bundle is missing a required artifact: key-material"
+32 -1
View File
@@ -48,7 +48,10 @@
//! published. A crash at any earlier point leaves a bundle without a
//! manifest, which decodes as an incomplete bundle and can never be restored.
use crate::backends::local::{LocalKmsClient, StoredKeyProtection, unknown_protection_marker};
use crate::backends::local::{
LocalKmsClient, STORED_MASTER_KEY_FORMAT_VERSION, StoredKeyProtection, stored_master_key_format_version,
unknown_protection_marker,
};
use crate::backup::capability::{AtRestProtection, BackupBackendKind, BackupResponsibility};
use crate::backup::error::BackupError;
use crate::backup::manifest::{
@@ -380,6 +383,12 @@ async fn collect_snapshot(client: &LocalKmsClient) -> Result<CollectedSnapshot>
// classified first so a record from a newer build keeps its own
// verdict — an operator who reads "material corrupt" starts a
// disaster recovery for what is only a version mismatch.
let format_version = stored_master_key_format_version(&raw).map_err(|error| {
KmsError::material_corrupt(&stem, format!("stored key record is not a readable JSON object: {error}"))
})?;
if format_version > STORED_MASTER_KEY_FORMAT_VERSION {
return Err(KmsError::unsupported_format_version(&stem, format_version.to_string()));
}
let unknown_marker = unknown_protection_marker(&raw).map_err(|error| {
KmsError::material_corrupt(&stem, format!("stored key record is not a readable JSON object: {error}"))
})?;
@@ -1150,4 +1159,26 @@ mod tests {
"got {error:?}"
);
}
#[tokio::test]
async fn numeric_record_format_version_from_a_newer_build_aborts_export_as_unsupported_format() {
let (client, _key_dir) = encrypted_client().await;
client.create_key("alpha", "AES_256", None).await.expect("create key");
let record_path = client.key_directory().join("alpha.key");
let mut record: serde_json::Value =
serde_json::from_slice(&std::fs::read(&record_path).expect("read record")).expect("decode record");
record["format_version"] = serde_json::json!(99);
std::fs::write(&record_path, serde_json::to_vec_pretty(&record).expect("encode record")).expect("write record");
let bundle = TempDir::new().expect("bundle dir");
let error = export_local_backup(&client, &test_kek(), &export_request(bundle.path().join("bundle")))
.await
.expect_err("a newer record format must abort the export");
assert!(
matches!(&error, KmsError::UnsupportedFormatVersion { key_id, version }
if key_id == "alpha" && version == "99"),
"got {error:?}"
);
}
}
+38 -1
View File
@@ -54,7 +54,8 @@
use crate::backends::local::{
LOCAL_KMS_MASTER_KEY_SALT_FILE, LOCAL_KMS_MASTER_KEY_SALT_LEN, LOCAL_RESTORE_COMMIT_MARKER_FILE, LocalKmsClient,
StoredKeyProtection, durable_file, is_orphan_commit_temp_name, unknown_protection_marker, validate_key_id,
STORED_MASTER_KEY_FORMAT_VERSION, StoredKeyProtection, durable_file, is_orphan_commit_temp_name,
stored_master_key_format_version, unknown_protection_marker, validate_key_id,
};
use crate::backup::capability::AtRestProtection;
use crate::backup::dry_run::{
@@ -608,6 +609,15 @@ fn decode_key_record(
// Classify the protection marker before the schema parse: a record from a
// newer build is not a damaged bundle, and reporting it as corruption
// sends the operator into disaster recovery instead of a version change.
let format_version = stored_master_key_format_version(&plaintext)
.map_err(|error| BackupError::corrupted(format!("bundled key record '{stem}' is not a readable JSON object: {error}")))?;
if format_version > STORED_MASTER_KEY_FORMAT_VERSION {
return Err(BackupError::UnsupportedFormatVersion {
key_id: stem,
version: format_version.to_string(),
}
.into());
}
let unknown_marker = unknown_protection_marker(&plaintext)
.map_err(|error| BackupError::corrupted(format!("bundled key record '{stem}' is not a readable JSON object: {error}")))?;
if let Some(version) = unknown_marker {
@@ -2102,6 +2112,33 @@ mod tests {
);
}
#[test]
fn bundled_record_format_version_from_a_newer_build_is_not_reported_as_corruption() {
let record = serde_json::json!({"format_version": 99});
let error = match decode_key_record(
"artifacts/keys/alpha.key.enc",
Zeroizing::new(serde_json::to_vec(&record).expect("encode record")),
&[AtRestProtection::EncryptedMasterKey],
) {
Ok(_) => panic!("a record this build cannot interpret must be rejected"),
Err(error) => error,
};
let KmsError::Backup(inner) = &error else {
panic!("expected a backup error, got {error:?}");
};
assert!(
matches!(inner, BackupError::UnsupportedFormatVersion { key_id, version }
if key_id == "alpha" && version == "99"),
"got {inner:?}"
);
assert_eq!(
RestoreBlocker::from(inner).code,
RestoreBlockerCode::UnknownFormatVersion,
"a dry run must report a version blocker, not a corruption blocker"
);
}
/// The commit marker's format-version branch, symmetric with the Vault
/// restore marker's: an unknown version is refused outright everywhere the
/// marker is read, and the backend refuses to start while it is present.