From b457c6abcc91cd0dbe9dfcde8a6dbef117b8b78e Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Fri, 31 Jul 2026 01:49:21 +0800 Subject: [PATCH] feat(kms): add backup manifest and responsibility contract types (#5483) Contract-only module for KMS backup/restore (no handler or backend wiring): versioned manifest schema with completeness marker and sealed digest, the (backend, at-rest protection) responsibility matrix, typed fail-closed errors, and the zero-write restore dry-run report. Fields whose shape depends on in-flight contracts are reserved and reject data in format version 1. --- crates/kms/src/backends/local.rs | 17 +- crates/kms/src/backup/capability.rs | 233 ++++++ crates/kms/src/backup/dry_run.rs | 280 +++++++ crates/kms/src/backup/error.rs | 133 +++ crates/kms/src/backup/manifest.rs | 1165 +++++++++++++++++++++++++++ crates/kms/src/backup/mod.rs | 63 ++ crates/kms/src/error.rs | 4 + crates/kms/src/lib.rs | 1 + 8 files changed, 1890 insertions(+), 6 deletions(-) create mode 100644 crates/kms/src/backup/capability.rs create mode 100644 crates/kms/src/backup/dry_run.rs create mode 100644 crates/kms/src/backup/error.rs create mode 100644 crates/kms/src/backup/manifest.rs create mode 100644 crates/kms/src/backup/mod.rs diff --git a/crates/kms/src/backends/local.rs b/crates/kms/src/backends/local.rs index 38c80477d..1cbad8a1f 100644 --- a/crates/kms/src/backends/local.rs +++ b/crates/kms/src/backends/local.rs @@ -69,11 +69,14 @@ fn validate_key_id(key_id: &str) -> Result<()> { } const LOCAL_KMS_MASTER_KEY_SALT_FILE: &str = ".master-key.salt"; -const LOCAL_KMS_MASTER_KEY_SALT_LEN: usize = 16; -const LOCAL_KMS_MASTER_KEY_LEN: usize = 32; -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; +// The KDF parameters are pub(crate) so the backup manifest contract +// (`crate::backup`) records the exact compiled-in derivation instead of a +// copy that could drift. +pub(crate) const LOCAL_KMS_MASTER_KEY_SALT_LEN: usize = 16; +pub(crate) const LOCAL_KMS_MASTER_KEY_LEN: usize = 32; +pub(crate) const LOCAL_KMS_ARGON2_M_COST_KIB: u32 = 19 * 1024; +pub(crate) const LOCAL_KMS_ARGON2_T_COST: u32 = 2; +pub(crate) const LOCAL_KMS_ARGON2_P_COST: u32 = 1; /// Strict matcher for leftover commit temp files (`.tmp-`). /// @@ -396,9 +399,11 @@ pub struct LocalKmsClient { key_write_locks: Mutex>>>, } +// pub(crate) so the backup contract tests can anchor the manifest's +// protection-state wire names against the marker values written to disk. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -enum StoredKeyProtection { +pub(crate) enum StoredKeyProtection { #[default] LegacyUnspecified, EncryptedMasterKey, diff --git a/crates/kms/src/backup/capability.rs b/crates/kms/src/backup/capability.rs new file mode 100644 index 000000000..97ea531bc --- /dev/null +++ b/crates/kms/src/backup/capability.rs @@ -0,0 +1,233 @@ +// 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. + +//! Backup responsibility matrix: what a RustFS backup bundle owns per backend. +//! +//! The matrix is two-dimensional on purpose: responsibility is a function of +//! the backend *and* its at-rest protection state, not of the backend alone. +//! This keeps the schema stable when a backend changes protection direction — +//! switching Vault KV2 between storage-only and Transit-wrapped operation +//! selects a different existing row instead of changing the contract. +//! +//! These enums are backup-domain contract types. Once the backlog#1571 +//! capability-discovery contract lands, the discovery surface is expected to +//! converge on (or map onto) the states defined here. + +use serde::{Deserialize, Serialize}; + +/// Backend discriminant recorded in a backup manifest. +/// +/// Wire names are aligned with [`crate::config::BackendConfig`] and +/// [`crate::config::KmsBackend`] (including the legacy `Vault` alias) so that +/// a manifest and a persisted KMS configuration never disagree about how the +/// same backend is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BackupBackendKind { + /// Local file-based backend. + Local, + /// Vault KV v2 storage backend. + #[serde(rename = "VaultKV2", alias = "Vault")] + VaultKv2, + /// Vault Transit backend. + VaultTransit, + /// Static single-key backend. + Static, +} + +/// At-rest protection state of master key material, as observed at snapshot +/// time. +/// +/// The first three states mirror the Local backend's on-disk protection +/// marker (`StoredKeyProtection` in `backends/local.rs`, kebab-case wire +/// names). The remaining states describe the non-local backends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AtRestProtection { + /// Local key files AEAD-encrypted under the Argon2id-derived master key. + EncryptedMasterKey, + /// Local development-only plaintext key files. Such files must never + /// enter a bundle as-is; bundle artifacts are always re-wrapped under the + /// backup KEK. + PlaintextDevOnly, + /// Pre-beta.9 local key files without a protection marker; the effective + /// mode is resolved at read time. Treated like [`Self::PlaintextDevOnly`] + /// for bundling purposes: re-wrap is mandatory. + LegacyUnspecified, + /// Vault KV2 as currently shipped: material confidentiality relies on + /// Vault ACLs, KV2 at-rest encryption, and TLS only (the backend reports + /// `at_rest_protection = "vault-kv2-acl"`); RustFS applies no + /// cryptographic wrapping of its own. + StorageOnly, + /// Vault KV2 with material wrapped by Vault Transit before storage. Not + /// produced by any current backend; the row exists so a future direction + /// change selects a state instead of changing the schema. + TransitWrapped, + /// Vault Transit: the cryptographic root lives in Vault and is not + /// exportable. RustFS can only ever own metadata and references. + ExternalNonExportable, + /// Static backend: the secret is delivered externally at startup and + /// RustFS persists no key material at all. + ExternalSecretDelivery, +} + +/// What a RustFS backup bundle is responsible for, per (backend, protection) +/// combination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BackupResponsibility { + /// The bundle carries the complete recoverable state: encrypted key + /// material for every version, salt, metadata, and configuration. + /// + /// Restore precondition for the Local backend: the operator re-supplies + /// the master key out of band. The master key itself is outside the + /// backup domain; the manifest stores at most an opaque verifier. + FullMaterial, + /// The bundle carries only non-sensitive references and verification + /// information. The source of truth is external secret delivery and the + /// operator re-provides the secret during restore. Embedding the secret + /// itself in a bundle is forbidden. + ReferenceOnly, + /// The bundle carries RustFS-side metadata, configuration references and + /// verification data, while the cryptographic root is protected by the + /// external system's native snapshot/disaster-recovery flow (Vault/HSM). + /// Restore must re-establish the external trust root first. + MetadataPlusExternalRoot, +} + +impl BackupResponsibility { + /// Resolve the responsibility matrix for one (backend, protection) cell. + /// + /// Returns `None` for combinations that no supported deployment can + /// produce; manifests declaring such a combination are rejected as + /// corrupted. This function is total and the unit tests anchor every + /// cell, so any change to the matrix is a deliberate contract change. + pub fn for_backend(backend: BackupBackendKind, protection: AtRestProtection) -> Option { + use AtRestProtection::*; + use BackupBackendKind::*; + + match (backend, protection) { + (Local, EncryptedMasterKey | PlaintextDevOnly | LegacyUnspecified) => Some(Self::FullMaterial), + (Local, _) => None, + // Storage-only KV2 offers no external cryptographic root, so the + // bundle must own the material (re-wrapped under the backup KEK). + (VaultKv2, StorageOnly) => Some(Self::FullMaterial), + (VaultKv2, TransitWrapped) => Some(Self::MetadataPlusExternalRoot), + (VaultKv2, _) => None, + (VaultTransit, ExternalNonExportable) => Some(Self::MetadataPlusExternalRoot), + (VaultTransit, _) => None, + (Static, ExternalSecretDelivery) => Some(Self::ReferenceOnly), + (Static, _) => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::KmsBackend; + + fn json(value: &T) -> String { + serde_json::to_string(value).expect("serialization should succeed") + } + + #[test] + fn backend_kind_wire_names_match_kms_backend() { + let pairs = [ + (BackupBackendKind::Local, KmsBackend::Local), + (BackupBackendKind::VaultKv2, KmsBackend::VaultKv2), + (BackupBackendKind::VaultTransit, KmsBackend::VaultTransit), + (BackupBackendKind::Static, KmsBackend::Static), + ]; + for (backup_kind, config_kind) in pairs { + assert_eq!(json(&backup_kind), json(&config_kind), "wire name drifted for {backup_kind:?}"); + } + } + + #[test] + fn backend_kind_accepts_legacy_vault_alias() { + let decoded: BackupBackendKind = serde_json::from_str("\"Vault\"").expect("legacy alias should decode"); + assert_eq!(decoded, BackupBackendKind::VaultKv2); + } + + #[test] + fn responsibility_matrix_is_anchored_cell_by_cell() { + use AtRestProtection::*; + use BackupBackendKind::*; + use BackupResponsibility::*; + + // Every (backend, protection) cell, exhaustively. Changing any row is + // a contract change and must be made here consciously. + let matrix = [ + (Local, EncryptedMasterKey, Some(FullMaterial)), + (Local, PlaintextDevOnly, Some(FullMaterial)), + (Local, LegacyUnspecified, Some(FullMaterial)), + (Local, StorageOnly, None), + (Local, TransitWrapped, None), + (Local, ExternalNonExportable, None), + (Local, ExternalSecretDelivery, None), + (VaultKv2, EncryptedMasterKey, None), + (VaultKv2, PlaintextDevOnly, None), + (VaultKv2, LegacyUnspecified, None), + (VaultKv2, StorageOnly, Some(FullMaterial)), + (VaultKv2, TransitWrapped, Some(MetadataPlusExternalRoot)), + (VaultKv2, ExternalNonExportable, None), + (VaultKv2, ExternalSecretDelivery, None), + (VaultTransit, EncryptedMasterKey, None), + (VaultTransit, PlaintextDevOnly, None), + (VaultTransit, LegacyUnspecified, None), + (VaultTransit, StorageOnly, None), + (VaultTransit, TransitWrapped, None), + (VaultTransit, ExternalNonExportable, Some(MetadataPlusExternalRoot)), + (VaultTransit, ExternalSecretDelivery, None), + (Static, EncryptedMasterKey, None), + (Static, PlaintextDevOnly, None), + (Static, LegacyUnspecified, None), + (Static, StorageOnly, None), + (Static, TransitWrapped, None), + (Static, ExternalNonExportable, None), + (Static, ExternalSecretDelivery, Some(ReferenceOnly)), + ]; + assert_eq!(matrix.len(), 28, "matrix must stay exhaustive: 4 backends x 7 protection states"); + for (backend, protection, expected) in matrix { + assert_eq!( + BackupResponsibility::for_backend(backend, protection), + expected, + "matrix cell drifted for ({backend:?}, {protection:?})" + ); + } + } + + #[test] + fn local_protection_wire_names_match_stored_key_protection() { + use crate::backends::local::StoredKeyProtection; + + // The manifest must record exactly the marker values the Local + // backend writes to disk, or a restore could misread protection. + let pairs = [ + (AtRestProtection::EncryptedMasterKey, StoredKeyProtection::EncryptedMasterKey), + (AtRestProtection::PlaintextDevOnly, StoredKeyProtection::PlaintextDevOnly), + (AtRestProtection::LegacyUnspecified, StoredKeyProtection::LegacyUnspecified), + ]; + for (backup_state, stored_state) in pairs { + assert_eq!(json(&backup_state), json(&stored_state), "wire name drifted for {backup_state:?}"); + } + } + + #[test] + fn responsibility_wire_names_are_frozen() { + assert_eq!(json(&BackupResponsibility::FullMaterial), "\"full-material\""); + assert_eq!(json(&BackupResponsibility::ReferenceOnly), "\"reference-only\""); + assert_eq!(json(&BackupResponsibility::MetadataPlusExternalRoot), "\"metadata-plus-external-root\""); + } +} diff --git a/crates/kms/src/backup/dry_run.rs b/crates/kms/src/backup/dry_run.rs new file mode 100644 index 000000000..54bd7e42a --- /dev/null +++ b/crates/kms/src/backup/dry_run.rs @@ -0,0 +1,280 @@ +// 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. + +//! Restore dry-run report contract. +//! +//! A restore dry-run is a zero-write preflight: it evaluates a bundle against +//! a target and reports every blocker, conflict, and external dependency +//! mismatch without modifying the target in any way. The report itself is +//! plain data — producing, serializing, or discarding it has no side effects, +//! and an implementation that writes anything during a dry-run violates this +//! contract. All values in a report are identifiers and references; secrets, +//! tokens, and key material never appear in it. + +use crate::backup::error::BackupError; +use serde::{Deserialize, Serialize}; + +/// Machine-readable category of a restore blocker. +/// +/// The first six codes mirror the [`BackupError`] variants; the remaining +/// codes cover preflight conditions that are not bundle defects. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RestoreBlockerCode { + /// The bundle failed structural or integrity validation. + BundleCorrupted, + /// The manifest input ended prematurely. + BundleTruncated, + /// The manifest format version is unknown to this build. + UnknownFormatVersion, + /// The supplied backup KEK does not match the bundle's KEK. + WrongBackupKek, + /// A required artifact is absent from the bundle. + MissingArtifact, + /// The bundle has no completeness marker or is marked in-progress. + IncompleteBundle, + /// The target backend cannot satisfy the bundle's responsibility model. + UnsupportedBackend, + /// The bundle was produced by a different deployment than the target and + /// no explicit cross-deployment authorization applies. + DeploymentMismatch, + /// An external dependency (Vault cluster, mount, Transit key, ...) that + /// the bundle references is unreachable or missing. + ExternalDependencyUnavailable, +} + +/// One condition that forbids the restore outright. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoreBlocker { + /// Machine-readable category. + pub code: RestoreBlockerCode, + /// Human-readable detail. Identifiers only; never secrets or material. + pub detail: String, +} + +impl From<&BackupError> for RestoreBlocker { + fn from(error: &BackupError) -> Self { + let code = match error { + BackupError::Corrupted { .. } => RestoreBlockerCode::BundleCorrupted, + BackupError::Truncated { .. } => RestoreBlockerCode::BundleTruncated, + BackupError::UnknownVersion { .. } => RestoreBlockerCode::UnknownFormatVersion, + BackupError::WrongKek { .. } => RestoreBlockerCode::WrongBackupKek, + BackupError::MissingArtifact { .. } => RestoreBlockerCode::MissingArtifact, + BackupError::IncompleteBundle { .. } => RestoreBlockerCode::IncompleteBundle, + }; + Self { + code, + detail: error.to_string(), + } + } +} + +/// Kind of a conflict between bundle state and existing target state. +/// +/// Restore is non-destructive by default: every conflict blocks the restore +/// unless an explicit, audited conflict policy resolves it. Silent overwrite +/// or merge is never an option. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RestoreConflictKind { + /// The target already has a key with this stable id. + KeyAlreadyExists, + /// Restoring would lower a key version the target has already observed. + VersionRegression, + /// Restoring would lower the snapshot generation the target has already + /// observed. + GenerationRegression, + /// Restoring would revive a key the target has deleted or scheduled for + /// deletion. + StateRegression, +} + +/// One conflict with existing target state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoreConflict { + /// Stable key id the conflict concerns. + pub key_id: String, + /// Machine-readable category. + pub kind: RestoreConflictKind, + /// Human-readable detail. Identifiers only; never secrets or material. + pub detail: String, +} + +/// A mismatch between an external dependency reference recorded in the bundle +/// and what the target environment observes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalDependencyMismatch { + /// Which dependency is affected (for example a Vault mount or Transit + /// key name). References only; never credentials. + pub dependency: String, + /// The value the bundle recorded. + pub expected: String, + /// The value the target environment reports. + pub observed: String, +} + +/// Result of a restore dry-run preflight. +/// +/// # Zero-write contract +/// +/// A dry-run must not write to the target: no staging directories, no +/// repaired records, no metadata fixes triggered along the read path. The +/// report is pure data over values already known to the caller. +/// +/// ``` +/// use rustfs_kms::backup::{RestoreBlocker, RestoreBlockerCode, RestoreDryRunReport}; +/// +/// let clean = RestoreDryRunReport { +/// backup_id: "backup-0001".to_string(), +/// target_deployment_identity: "deployment-a".to_string(), +/// blockers: Vec::new(), +/// conflicts: Vec::new(), +/// external_mismatches: Vec::new(), +/// }; +/// assert!(clean.restore_permitted()); +/// +/// let blocked = RestoreDryRunReport { +/// blockers: vec![RestoreBlocker { +/// code: RestoreBlockerCode::IncompleteBundle, +/// detail: "manifest has no completeness marker".to_string(), +/// }], +/// ..clean +/// }; +/// assert!(!blocked.restore_permitted()); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RestoreDryRunReport { + /// Identifier of the evaluated bundle. + pub backup_id: String, + /// Identity of the restore target the bundle was evaluated against. + pub target_deployment_identity: String, + /// Conditions that forbid the restore outright. + pub blockers: Vec, + /// Conflicts with existing target state. + pub conflicts: Vec, + /// External dependency mismatches. + pub external_mismatches: Vec, +} + +impl RestoreDryRunReport { + /// Whether the restore may proceed: true only when the preflight found + /// no blockers, no conflicts, and no external dependency mismatches. + pub fn restore_permitted(&self) -> bool { + self.blockers.is_empty() && self.conflicts.is_empty() && self.external_mismatches.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_report() -> RestoreDryRunReport { + RestoreDryRunReport { + backup_id: "backup-0001".to_string(), + target_deployment_identity: "deployment-b".to_string(), + blockers: vec![RestoreBlocker { + code: RestoreBlockerCode::DeploymentMismatch, + detail: "bundle was produced by deployment-a".to_string(), + }], + conflicts: vec![RestoreConflict { + key_id: "object-key".to_string(), + kind: RestoreConflictKind::VersionRegression, + detail: "target observed version 5, bundle carries version 3".to_string(), + }], + external_mismatches: vec![ExternalDependencyMismatch { + dependency: "vault transit key rustfs-master".to_string(), + expected: "min_version=2".to_string(), + observed: "min_version=4".to_string(), + }], + } + } + + #[test] + fn report_round_trips_through_json() { + let report = sample_report(); + let json = serde_json::to_string(&report).expect("serialization should succeed"); + let decoded: RestoreDryRunReport = serde_json::from_str(&json).expect("deserialization should succeed"); + assert_eq!(decoded, report); + } + + #[test] + fn restore_permitted_requires_every_section_empty() { + assert!(!sample_report().restore_permitted()); + + let clean = RestoreDryRunReport { + blockers: Vec::new(), + conflicts: Vec::new(), + external_mismatches: Vec::new(), + ..sample_report() + }; + assert!(clean.restore_permitted()); + + for section in 0..3 { + let mut report = clean.clone(); + match section { + 0 => report.blockers = sample_report().blockers, + 1 => report.conflicts = sample_report().conflicts, + _ => report.external_mismatches = sample_report().external_mismatches, + } + assert!(!report.restore_permitted(), "section {section} alone must block the restore"); + } + } + + #[test] + fn every_backup_error_maps_to_a_blocker_code() { + let cases = [ + (BackupError::corrupted("x"), RestoreBlockerCode::BundleCorrupted), + (BackupError::truncated("x"), RestoreBlockerCode::BundleTruncated), + ( + BackupError::UnknownVersion { found: 2, supported: 1 }, + RestoreBlockerCode::UnknownFormatVersion, + ), + ( + BackupError::WrongKek { + required_kek_id: "a".to_string(), + required_kek_version: 1, + supplied_kek_id: "b".to_string(), + supplied_kek_version: 1, + }, + RestoreBlockerCode::WrongBackupKek, + ), + (BackupError::missing_artifact("key-material"), RestoreBlockerCode::MissingArtifact), + (BackupError::incomplete_bundle("x"), RestoreBlockerCode::IncompleteBundle), + ]; + for (error, expected_code) in cases { + let blocker = RestoreBlocker::from(&error); + assert_eq!(blocker.code, expected_code, "wrong code for {error:?}"); + assert_eq!(blocker.detail, error.to_string()); + } + } + + /// The zero-write contract in practice: a report is plain serializable + /// data with no handles, no I/O, and no drop side effects. + #[test] + fn report_types_are_plain_data() { + fn assert_plain_data() + where + T: serde::Serialize + serde::de::DeserializeOwned + Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static, + { + } + assert_plain_data::(); + assert_plain_data::(); + assert_plain_data::(); + assert_plain_data::(); + } +} diff --git a/crates/kms/src/backup/error.rs b/crates/kms/src/backup/error.rs new file mode 100644 index 000000000..c0967b74a --- /dev/null +++ b/crates/kms/src/backup/error.rs @@ -0,0 +1,133 @@ +// 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. + +//! Typed failures for the backup/restore bundle contract. + +use thiserror::Error; + +/// Typed failures raised while decoding or validating a backup bundle. +/// +/// Every variant is a fail-closed condition: a restore surface observing any +/// of them must abort before touching target state. Messages carry only +/// identifiers (backup ids, KEK ids, artifact kinds and paths) — never key +/// material, bundle plaintext, or credentials. +#[derive(Error, Debug, Clone, PartialEq, Eq)] +pub enum BackupError { + /// Manifest or bundle content fails structural, schema, or integrity + /// validation (unknown fields, duplicate fields, digest mismatch, + /// contradictory responsibility declarations, ...). + #[error("backup bundle corrupted: {reason}")] + Corrupted { reason: String }, + + /// Input ended before a complete manifest could be decoded. + #[error("backup manifest truncated: {reason}")] + Truncated { reason: String }, + + /// Manifest declares a format version this build does not understand. + /// Unknown versions are always rejected; there is no best-effort read. + #[error("unknown backup manifest format version {found} (this build supports version {supported})")] + UnknownVersion { found: u32, supported: u32 }, + + /// Bundle is protected by a different backup KEK than the one supplied. + #[error( + "backup bundle requires KEK '{required_kek_id}' version {required_kek_version}; \ + supplied KEK '{supplied_kek_id}' version {supplied_kek_version} cannot open it" + )] + WrongKek { + required_kek_id: String, + required_kek_version: u32, + supplied_kek_id: String, + supplied_kek_version: u32, + }, + + /// Manifest requires an artifact that is not present in the bundle. + #[error("backup bundle is missing a required artifact: {artifact}")] + MissingArtifact { artifact: String }, + + /// Bundle has no completeness marker or records an in-progress state. + /// A bundle that never reached its completeness marker must never be + /// restored, regardless of how much of it is readable. + #[error("backup bundle is incomplete ({reason}); incomplete bundles must never be restored")] + IncompleteBundle { reason: String }, +} + +impl BackupError { + /// Create a corrupted-bundle error. + pub fn corrupted>(reason: S) -> Self { + Self::Corrupted { reason: reason.into() } + } + + /// Create a truncated-manifest error. + pub fn truncated>(reason: S) -> Self { + Self::Truncated { reason: reason.into() } + } + + /// Create an incomplete-bundle error. + pub fn incomplete_bundle>(reason: S) -> Self { + Self::IncompleteBundle { reason: reason.into() } + } + + /// Create a missing-artifact error. + pub fn missing_artifact>(artifact: S) -> Self { + Self::MissingArtifact { + artifact: artifact.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::KmsError; + + #[test] + fn backup_errors_convert_into_kms_error_transparently() { + let error = BackupError::corrupted("manifest digest mismatch"); + let kms_error: KmsError = error.clone().into(); + assert_eq!(kms_error.to_string(), error.to_string()); + assert!(matches!(kms_error, KmsError::Backup(inner) if inner == error)); + } + + #[test] + fn error_messages_carry_identifiers_only() { + // The display strings must stay descriptive without ever embedding + // material or bundle plaintext; each variant only interpolates the + // identifiers below. + let wrong_kek = BackupError::WrongKek { + required_kek_id: "backup-kek-1".to_string(), + required_kek_version: 3, + supplied_kek_id: "backup-kek-2".to_string(), + supplied_kek_version: 1, + }; + assert_eq!( + wrong_kek.to_string(), + "backup bundle requires KEK 'backup-kek-1' version 3; supplied KEK 'backup-kek-2' version 1 cannot open it" + ); + + let unknown = BackupError::UnknownVersion { found: 9, supported: 1 }; + assert_eq!( + unknown.to_string(), + "unknown backup manifest format version 9 (this build supports version 1)" + ); + + assert_eq!( + BackupError::missing_artifact("key-material").to_string(), + "backup bundle is missing a required artifact: key-material" + ); + assert_eq!( + BackupError::incomplete_bundle("manifest has no completeness marker").to_string(), + "backup bundle is incomplete (manifest has no completeness marker); incomplete bundles must never be restored" + ); + } +} diff --git a/crates/kms/src/backup/manifest.rs b/crates/kms/src/backup/manifest.rs new file mode 100644 index 000000000..052642428 --- /dev/null +++ b/crates/kms/src/backup/manifest.rs @@ -0,0 +1,1165 @@ +// 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. + +//! Versioned backup manifest schema (format version 1). +//! +//! Schema evolution policy: every struct rejects unknown fields, so adding, +//! removing, or reshaping any field requires bumping +//! [`BackupManifest::FORMAT_VERSION`]. Decoders reject unknown versions +//! outright — there is no best-effort read of a manifest this build does not +//! understand. + +use crate::backup::capability::{AtRestProtection, BackupBackendKind, BackupResponsibility}; +use crate::backup::error::BackupError; +use jiff::Zoned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::path::{Component, Path}; + +/// AEAD algorithm identifiers for bundle protection. +/// +/// This is deliberately not [`crate::types::EncryptionAlgorithm`]: that enum +/// carries S3-facing wire names and the non-AEAD `aws:kms` marker. The backup +/// domain only ever names a concrete AEAD. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AeadAlgorithm { + /// AES-256-GCM. + #[serde(rename = "aes-256-gcm")] + Aes256Gcm, + /// ChaCha20-Poly1305. + #[serde(rename = "chacha20-poly1305")] + ChaCha20Poly1305, +} + +/// Digest algorithm identifiers for manifest and artifact digests. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DigestAlgorithm { + /// SHA-256. + #[serde(rename = "sha-256")] + Sha256, +} + +/// A content digest: algorithm plus lowercase hex value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ContentDigest { + /// Digest algorithm. + pub algorithm: DigestAlgorithm, + /// Lowercase hex encoding of the digest value. + pub hex: String, +} + +impl ContentDigest { + /// Hex length of a SHA-256 digest. + pub const SHA256_HEX_LEN: usize = 64; + + /// Compute the SHA-256 digest of `bytes`. + pub fn sha256_of(bytes: &[u8]) -> Self { + Self { + algorithm: DigestAlgorithm::Sha256, + hex: hex::encode(Sha256::digest(bytes)), + } + } + + /// The placeholder written into the digest slot while computing the + /// canonical manifest bytes (see [`BackupManifest::compute_digest`]). + fn placeholder(algorithm: DigestAlgorithm) -> Self { + Self { + algorithm, + hex: String::new(), + } + } + + /// Whether the hex value is well-formed for the declared algorithm. + pub fn is_well_formed(&self) -> bool { + match self.algorithm { + DigestAlgorithm::Sha256 => { + self.hex.len() == Self::SHA256_HEX_LEN + && self.hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + } + } + } +} + +/// Identity of the backup KEK protecting a bundle. +/// +/// The backup KEK is a trust root separate from the business KMS hierarchy: +/// a bundle must never be encrypted with a key that is itself part of the +/// state being backed up. The manifest records only the KEK identity — never +/// material that could open the bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackupKekDescriptor { + /// Stable identifier of the backup KEK. + pub kek_id: String, + /// Version of the backup KEK used for this bundle. + pub kek_version: u32, + /// AEAD algorithm protecting the bundle artifacts. + pub aead_algorithm: AeadAlgorithm, +} + +impl BackupKekDescriptor { + /// Fail closed unless the supplied KEK identity matches the one this + /// bundle was sealed with. + pub fn ensure_matches(&self, supplied_kek_id: &str, supplied_kek_version: u32) -> Result<(), BackupError> { + if self.kek_id != supplied_kek_id || self.kek_version != supplied_kek_version { + return Err(BackupError::WrongKek { + required_kek_id: self.kek_id.clone(), + required_kek_version: self.kek_version, + supplied_kek_id: supplied_kek_id.to_string(), + supplied_kek_version, + }); + } + Ok(()) + } +} + +/// Kind of an artifact referenced by the manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ArtifactKind { + /// Encrypted master key material (all versions of one or more keys). + KeyMaterial, + /// Key metadata records (status, usage, timestamps, tags). + KeyMetadata, + /// The Local backend's persistent master-key KDF salt. + MasterKeySalt, + /// The sanitized KMS configuration. The persisted `KmsConfig` contains + /// plaintext credentials (Vault token / AppRole secret id); the config + /// artifact must be the sanitized form and never those secrets. + KmsConfig, + /// Reserved: no alias feature exists in the codebase. The name is frozen + /// so a future format version can use it; format version 1 manifests + /// containing it are rejected. + Alias, + /// Reserved: key policies are accepted on `CreateKeyRequest` but never + /// consumed. Same rejection rule as [`Self::Alias`]. + Policy, +} + +impl ArtifactKind { + /// Whether this kind is reserved for a future format version. + pub fn is_reserved(&self) -> bool { + matches!(self, Self::Alias | Self::Policy) + } +} + +/// One artifact in the bundle payload set. +/// +/// Every artifact payload is AEAD-encrypted under the backup KEK regardless +/// of how the source material was protected at rest. In particular, +/// plaintext-dev-only Local key files must never enter a bundle unwrapped. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactDescriptor { + /// What the artifact contains. + pub kind: ArtifactKind, + /// Bundle-relative path of the artifact payload. Absolute paths and path + /// traversal are rejected. + pub path: String, + /// Length of the encrypted payload in bytes. + pub len: u64, + /// AEAD algorithm the payload is encrypted with. + pub aead_algorithm: AeadAlgorithm, + /// Digest of the encrypted payload bytes (not of the plaintext, so + /// verification never requires decryption). + pub encrypted_digest: ContentDigest, +} + +/// Key-derivation description for a Local backend bundle. +/// +/// Values mirror the constants in `backends/local.rs`; recording them in the +/// manifest lets a restore detect a KDF parameter drift instead of silently +/// deriving a different master key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub enum LocalKeyDerivation { + /// Argon2id with explicit parameters and a persistent on-disk salt. + Argon2id { + /// Argon2 version (0x13 = 19). + version: u32, + /// Memory cost in KiB. + memory_kib: u32, + /// Iteration count (time cost). + iterations: u32, + /// Lane count (parallelism). + parallelism: u32, + /// Derived key length in bytes. + output_len: u32, + /// Persistent salt length in bytes. + salt_len: u32, + }, + /// Pre-beta.9 SHA-256 derivation without a salt. Recorded so legacy key + /// directories can be restored; new bundles always use Argon2id. + LegacySha256, +} + +impl LocalKeyDerivation { + /// The derivation currently performed by `backends/local.rs` + /// (`derive_master_key`): Argon2id v0x13 with the crate's compiled-in + /// parameters. + pub fn current_argon2id() -> Self { + use crate::backends::local::{ + LOCAL_KMS_ARGON2_M_COST_KIB, LOCAL_KMS_ARGON2_P_COST, LOCAL_KMS_ARGON2_T_COST, LOCAL_KMS_MASTER_KEY_LEN, + LOCAL_KMS_MASTER_KEY_SALT_LEN, + }; + + Self::Argon2id { + version: 0x13, + memory_kib: LOCAL_KMS_ARGON2_M_COST_KIB, + iterations: LOCAL_KMS_ARGON2_T_COST, + parallelism: LOCAL_KMS_ARGON2_P_COST, + output_len: LOCAL_KMS_MASTER_KEY_LEN as u32, + salt_len: LOCAL_KMS_MASTER_KEY_SALT_LEN as u32, + } + } + + fn validate(&self) -> Result<(), BackupError> { + match self { + Self::Argon2id { + version, + memory_kib, + iterations, + parallelism, + output_len, + salt_len, + } => { + if *version == 0 || *memory_kib == 0 || *iterations == 0 || *parallelism == 0 || *salt_len == 0 { + return Err(BackupError::corrupted("local KDF descriptor has zero-valued Argon2 parameters")); + } + if *output_len != 32 { + return Err(BackupError::corrupted("local KDF descriptor must derive a 32-byte key for AES-256")); + } + Ok(()) + } + Self::LegacySha256 => Ok(()), + } + } +} + +/// Local backend section of the manifest. +/// +/// The Local master key itself is outside the backup domain: it is supplied +/// via environment or configuration and the operator must re-supply it before +/// restore. The manifest stores at most an opaque one-way verifier. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocalKdfDescriptor { + /// How the master key string is turned into the file-encryption key. + pub derivation: LocalKeyDerivation, + /// Protection states observed across the stored key files in this + /// snapshot (deduplicated). Restricted to the Local rows of the + /// responsibility matrix. + pub protection_modes: Vec, + /// Opaque one-way verifier of the operator-supplied master key, used to + /// detect a wrong master key before restore touches anything. Never key + /// material; the exact derivation is defined by the Local export + /// implementation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub master_key_verifier: Option, +} + +impl LocalKdfDescriptor { + /// Build a descriptor for the current compiled-in derivation. + pub fn current(protection_modes: Vec, master_key_verifier: Option) -> Self { + Self { + derivation: LocalKeyDerivation::current_argon2id(), + protection_modes, + master_key_verifier, + } + } + + fn validate(&self) -> Result<(), BackupError> { + self.derivation.validate()?; + if self.protection_modes.is_empty() { + return Err(BackupError::corrupted("local KDF descriptor lists no protection modes")); + } + let mut seen = BTreeSet::new(); + for mode in &self.protection_modes { + if BackupResponsibility::for_backend(BackupBackendKind::Local, *mode).is_none() { + return Err(BackupError::corrupted(format!( + "local KDF descriptor lists non-local protection mode {mode:?}" + ))); + } + if !seen.insert(format!("{mode:?}")) { + return Err(BackupError::corrupted(format!( + "local KDF descriptor lists protection mode {mode:?} more than once" + ))); + } + } + if self.master_key_verifier.as_deref() == Some("") { + return Err(BackupError::corrupted("local master key verifier must not be empty when present")); + } + Ok(()) + } +} + +/// Completeness marker of a bundle. +/// +/// A producer writes `in-progress` state (or no manifest at all) until the +/// final integrity checks pass, then seals the bundle as `complete`. Anything +/// other than `complete` must never be restored. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CompletenessState { + /// The bundle never reached its final integrity checks. + InProgress, + /// The bundle passed its final integrity checks and was sealed. + Complete, +} + +/// Placeholder for a manifest field whose shape is intentionally not frozen +/// yet. +/// +/// Reserved fields hold their name in the schema but may not carry data in +/// format version 1: deserializing any non-null value fails, and +/// [`BackupManifest::validate`] rejects a populated slot. A later format +/// version replaces the slot with the real type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct ReservedSlot; + +impl<'de> Deserialize<'de> for ReservedSlot { + fn deserialize(_deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Err(serde::de::Error::custom( + "reserved manifest field must not carry data in format version 1", + )) + } +} + +/// Version probe decoded before the full manifest so that version and +/// completeness failures surface as their own typed errors instead of +/// generic decode errors. +#[derive(Deserialize)] +struct ManifestProbe { + format_version: u32, + #[serde(default)] + completeness: Option, +} + +/// Versioned backup manifest (format version 1). +/// +/// The manifest is the authoritative description of one backup bundle: what +/// was captured, under which snapshot generation, protected by which backup +/// KEK, and which restore responsibility applies. Field order is part of the +/// canonical digest form and is frozen for this format version. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackupManifest { + /// Manifest format version; see [`Self::FORMAT_VERSION`]. + pub format_version: u32, + /// Unique identifier of this backup. + pub backup_id: String, + /// Creation time of the bundle. + #[serde(with = "crate::time_serde::zoned")] + pub created_at: Zoned, + /// RustFS version that produced the bundle. + pub rustfs_version: String, + /// Opaque identity of the producing deployment, used by restore preflight + /// to detect cross-deployment restores. The value source is decided by + /// the producing implementation; the contract freezes only that it is an + /// opaque non-empty string. + pub deployment_identity: String, + /// Backend the bundle was captured from. + pub backend: BackupBackendKind, + /// At-rest protection state observed at snapshot time. + pub at_rest_protection: AtRestProtection, + /// Declared restore responsibility. Must equal the responsibility matrix + /// row for `(backend, at_rest_protection)`; a mismatch is rejected. + pub responsibility: BackupResponsibility, + /// Monotonic snapshot generation. All state in one bundle belongs to this + /// single generation; restore preflight uses it to block rollbacks onto a + /// target that has already observed a higher generation. + pub snapshot_generation: u64, + /// Identity of the backup KEK protecting the bundle. + pub backup_kek: BackupKekDescriptor, + /// The bundle payload set. + pub artifacts: Vec, + /// Local backend section; required exactly when `backend` is `Local`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local_kdf: Option, + /// Reserved for the per-key version/envelope inventory defined by the + /// backlog#1565 contract. May not carry data in format version 1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_versions: Option, + /// Reserved for the backlog#1571 capability-discovery contract. May not + /// carry data in format version 1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability_discovery: Option, + /// Completeness marker; anything but `complete` is never restorable. + pub completeness: CompletenessState, + /// Digest over the canonical manifest bytes (see + /// [`Self::compute_digest`]); the final integrity anchor of the bundle. + pub manifest_digest: ContentDigest, +} + +impl BackupManifest { + /// The manifest format version this build reads and writes. + pub const FORMAT_VERSION: u32 = 1; + + /// Decode and fully validate a manifest from JSON bytes. + /// + /// Fail-closed order: truncated or malformed input, then unknown format + /// version, then a missing completeness marker, then schema decoding + /// (unknown fields, duplicate fields, missing fields), then semantic + /// validation including digest verification. + pub fn decode(bytes: &[u8]) -> Result { + let probe: ManifestProbe = serde_json::from_slice(bytes).map_err(map_serde_error)?; + if probe.format_version != Self::FORMAT_VERSION { + return Err(BackupError::UnknownVersion { + found: probe.format_version, + supported: Self::FORMAT_VERSION, + }); + } + if probe.completeness.is_none() { + return Err(BackupError::incomplete_bundle("manifest has no completeness marker")); + } + let manifest: Self = serde_json::from_slice(bytes).map_err(map_serde_error)?; + manifest.validate()?; + Ok(manifest) + } + + /// Serialize a sealed, valid manifest to its canonical JSON bytes. + /// + /// Encoding validates first so an unsealed or inconsistent manifest can + /// never be published. + pub fn encode(&self) -> Result, BackupError> { + self.validate()?; + serde_json::to_vec(self).map_err(|error| BackupError::corrupted(format!("manifest serialization failed: {error}"))) + } + + /// Seal the manifest: mark it complete and stamp the canonical digest. + pub fn seal(mut self) -> Result { + self.completeness = CompletenessState::Complete; + self.manifest_digest = self.compute_digest()?; + Ok(self) + } + + /// Compute the digest over the canonical manifest bytes. + /// + /// Canonical form: compact JSON serialization of this manifest with the + /// digest hex emptied. Field order is struct declaration order and is + /// frozen for format version 1, so the same manifest content always + /// hashes to the same value. + pub fn compute_digest(&self) -> Result { + let mut unsealed = self.clone(); + unsealed.manifest_digest = ContentDigest::placeholder(self.manifest_digest.algorithm); + let canonical = serde_json::to_vec(&unsealed) + .map_err(|error| BackupError::corrupted(format!("manifest canonicalization failed: {error}")))?; + match self.manifest_digest.algorithm { + DigestAlgorithm::Sha256 => Ok(ContentDigest::sha256_of(&canonical)), + } + } + + /// Verify the sealed digest against the current manifest content. + pub fn verify_digest(&self) -> Result<(), BackupError> { + if !self.manifest_digest.is_well_formed() { + return Err(BackupError::corrupted("manifest digest is not a well-formed digest value")); + } + if self.compute_digest()? != self.manifest_digest { + return Err(BackupError::corrupted( + "manifest digest mismatch: content does not match the sealed digest", + )); + } + Ok(()) + } + + /// Look up a required artifact by kind, failing closed when absent. + pub fn require_artifact(&self, kind: ArtifactKind) -> Result<&ArtifactDescriptor, BackupError> { + self.artifacts + .iter() + .find(|artifact| artifact.kind == kind) + .ok_or_else(|| BackupError::missing_artifact(artifact_kind_name(kind))) + } + + /// Validate the full manifest contract. + /// + /// This is decode-side validation and also guards [`Self::encode`], so a + /// producer cannot publish a manifest a decoder would reject. + pub fn validate(&self) -> Result<(), BackupError> { + if self.format_version != Self::FORMAT_VERSION { + return Err(BackupError::UnknownVersion { + found: self.format_version, + supported: Self::FORMAT_VERSION, + }); + } + if self.completeness != CompletenessState::Complete { + return Err(BackupError::incomplete_bundle("completeness marker records an in-progress bundle")); + } + self.verify_digest()?; + require_non_empty("backup_id", &self.backup_id)?; + require_non_empty("rustfs_version", &self.rustfs_version)?; + require_non_empty("deployment_identity", &self.deployment_identity)?; + require_non_empty("backup_kek.kek_id", &self.backup_kek.kek_id)?; + if self.key_versions.is_some() { + return Err(BackupError::corrupted("reserved field key_versions must not carry data")); + } + if self.capability_discovery.is_some() { + return Err(BackupError::corrupted("reserved field capability_discovery must not carry data")); + } + self.validate_responsibility()?; + self.validate_local_kdf()?; + self.validate_artifacts()?; + Ok(()) + } + + fn validate_responsibility(&self) -> Result<(), BackupError> { + match BackupResponsibility::for_backend(self.backend, self.at_rest_protection) { + None => Err(BackupError::corrupted(format!( + "at-rest protection {:?} is not a defined state for backend {:?}", + self.at_rest_protection, self.backend + ))), + Some(expected) if expected != self.responsibility => Err(BackupError::corrupted(format!( + "declared responsibility {:?} contradicts the matrix row {expected:?} for ({:?}, {:?})", + self.responsibility, self.backend, self.at_rest_protection + ))), + Some(_) => Ok(()), + } + } + + fn validate_local_kdf(&self) -> Result<(), BackupError> { + match (self.backend, &self.local_kdf) { + (BackupBackendKind::Local, None) => { + Err(BackupError::corrupted("local backend manifest must carry a local_kdf descriptor")) + } + (BackupBackendKind::Local, Some(descriptor)) => descriptor.validate(), + (_, Some(_)) => Err(BackupError::corrupted("local_kdf descriptor is only valid for the Local backend")), + (_, None) => Ok(()), + } + } + + fn validate_artifacts(&self) -> Result<(), BackupError> { + let mut paths = BTreeSet::new(); + for artifact in &self.artifacts { + if artifact.kind.is_reserved() { + return Err(BackupError::corrupted(format!( + "artifact kind {:?} is reserved and not valid in format version 1", + artifact.kind + ))); + } + validate_bundle_relative_path(&artifact.path)?; + if artifact.len == 0 { + return Err(BackupError::corrupted(format!( + "artifact '{}' declares a zero-length payload; an AEAD payload is never empty", + artifact.path + ))); + } + if !artifact.encrypted_digest.is_well_formed() { + return Err(BackupError::corrupted(format!( + "artifact '{}' has a malformed encrypted digest", + artifact.path + ))); + } + if !paths.insert(artifact.path.as_str()) { + return Err(BackupError::corrupted(format!( + "artifact path '{}' appears more than once", + artifact.path + ))); + } + } + + let has_key_material = self + .artifacts + .iter() + .any(|artifact| artifact.kind == ArtifactKind::KeyMaterial); + match self.responsibility { + BackupResponsibility::FullMaterial => { + if !has_key_material { + return Err(BackupError::missing_artifact(artifact_kind_name(ArtifactKind::KeyMaterial))); + } + } + BackupResponsibility::ReferenceOnly | BackupResponsibility::MetadataPlusExternalRoot => { + if has_key_material { + return Err(BackupError::corrupted(format!( + "a {:?} bundle must not embed key material artifacts", + self.responsibility + ))); + } + } + } + Ok(()) + } +} + +fn require_non_empty(field: &str, value: &str) -> Result<(), BackupError> { + if value.is_empty() { + return Err(BackupError::corrupted(format!("security-critical field {field} must not be empty"))); + } + Ok(()) +} + +fn artifact_kind_name(kind: ArtifactKind) -> String { + // Wire name of the kind, reusing the serde rename rules so error text and + // schema never diverge. + serde_json::to_value(kind) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_else(|| format!("{kind:?}")) +} + +fn validate_bundle_relative_path(path: &str) -> Result<(), BackupError> { + if path.is_empty() { + return Err(BackupError::corrupted("artifact path must not be empty")); + } + if path.contains('\\') || path.contains('\0') { + return Err(BackupError::corrupted(format!( + "artifact path {path:?} must not contain backslashes or NUL" + ))); + } + let parsed = Path::new(path); + if parsed.is_absolute() { + return Err(BackupError::corrupted(format!("artifact path {path:?} must be bundle-relative"))); + } + for component in parsed.components() { + match component { + Component::Normal(_) => {} + _ => { + return Err(BackupError::corrupted(format!( + "artifact path {path:?} must not contain traversal or special components" + ))); + } + } + } + Ok(()) +} + +fn map_serde_error(error: serde_json::Error) -> BackupError { + if error.classify() == serde_json::error::Category::Eof { + BackupError::truncated(error.to_string()) + } else { + BackupError::corrupted(error.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DUMMY_ARTIFACT_DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + /// Handwritten independently of the serializer so a silent serde-side + /// change cannot keep the round-trip test green while breaking the wire + /// format. The digest hex is the sealed digest of exactly this content. + const FIXTURE: &str = r#"{ + "format_version": 1, + "backup_id": "backup-0001", + "created_at": "2026-07-30T00:00:00+00:00[UTC]", + "rustfs_version": "1.0.0-test", + "deployment_identity": "deployment-a", + "backend": "Local", + "at_rest_protection": "encrypted-master-key", + "responsibility": "full-material", + "snapshot_generation": 7, + "backup_kek": { + "kek_id": "backup-kek-1", + "kek_version": 1, + "aead_algorithm": "aes-256-gcm" + }, + "artifacts": [ + { + "kind": "key-material", + "path": "keys/key-material.enc", + "len": 256, + "aead_algorithm": "aes-256-gcm", + "encrypted_digest": { + "algorithm": "sha-256", + "hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + { + "kind": "key-metadata", + "path": "keys/key-metadata.enc", + "len": 256, + "aead_algorithm": "aes-256-gcm", + "encrypted_digest": { + "algorithm": "sha-256", + "hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + { + "kind": "master-key-salt", + "path": "master-key.salt.enc", + "len": 256, + "aead_algorithm": "aes-256-gcm", + "encrypted_digest": { + "algorithm": "sha-256", + "hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + }, + { + "kind": "kms-config", + "path": "config/kms-config.enc", + "len": 256, + "aead_algorithm": "aes-256-gcm", + "encrypted_digest": { + "algorithm": "sha-256", + "hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } + } + ], + "local_kdf": { + "derivation": { + "argon2id": { + "version": 19, + "memory_kib": 19456, + "iterations": 2, + "parallelism": 1, + "output_len": 32, + "salt_len": 16 + } + }, + "protection_modes": ["encrypted-master-key"], + "master_key_verifier": "verifier-opaque-1" + }, + "completeness": "complete", + "manifest_digest": { + "algorithm": "sha-256", + "hex": "SEALED_DIGEST_HEX" + } + }"#; + + /// Sealed digest of the fixture content above; computed once from the + /// canonical form and frozen. If serialization layout or field order + /// changes, this value changes and the fixture test fails — which is the + /// point: that is a format-version bump, not a patch. + const FIXTURE_DIGEST_HEX: &str = "01accb3e2bc51e12d17d1efc52ad6ab9441c50bec52c3fb29e0a9f92b725cdaa"; + + fn fixture() -> String { + FIXTURE.replace("SEALED_DIGEST_HEX", FIXTURE_DIGEST_HEX) + } + + fn artifact(kind: ArtifactKind, path: &str) -> ArtifactDescriptor { + ArtifactDescriptor { + kind, + path: path.to_string(), + len: 256, + aead_algorithm: AeadAlgorithm::Aes256Gcm, + encrypted_digest: ContentDigest { + algorithm: DigestAlgorithm::Sha256, + hex: DUMMY_ARTIFACT_DIGEST.to_string(), + }, + } + } + + fn local_manifest_unsealed() -> BackupManifest { + BackupManifest { + format_version: BackupManifest::FORMAT_VERSION, + backup_id: "backup-0001".to_string(), + created_at: "2026-07-30T00:00:00+00:00[UTC]" + .parse() + .expect("fixture timestamp should parse"), + rustfs_version: "1.0.0-test".to_string(), + deployment_identity: "deployment-a".to_string(), + backend: BackupBackendKind::Local, + at_rest_protection: AtRestProtection::EncryptedMasterKey, + responsibility: BackupResponsibility::FullMaterial, + snapshot_generation: 7, + backup_kek: BackupKekDescriptor { + kek_id: "backup-kek-1".to_string(), + kek_version: 1, + aead_algorithm: AeadAlgorithm::Aes256Gcm, + }, + artifacts: vec![ + artifact(ArtifactKind::KeyMaterial, "keys/key-material.enc"), + artifact(ArtifactKind::KeyMetadata, "keys/key-metadata.enc"), + artifact(ArtifactKind::MasterKeySalt, "master-key.salt.enc"), + artifact(ArtifactKind::KmsConfig, "config/kms-config.enc"), + ], + local_kdf: Some(LocalKdfDescriptor::current( + vec![AtRestProtection::EncryptedMasterKey], + Some("verifier-opaque-1".to_string()), + )), + key_versions: None, + capability_discovery: None, + completeness: CompletenessState::InProgress, + manifest_digest: ContentDigest { + algorithm: DigestAlgorithm::Sha256, + hex: String::new(), + }, + } + } + + fn static_manifest_unsealed() -> BackupManifest { + BackupManifest { + backend: BackupBackendKind::Static, + at_rest_protection: AtRestProtection::ExternalSecretDelivery, + responsibility: BackupResponsibility::ReferenceOnly, + artifacts: vec![artifact(ArtifactKind::KmsConfig, "config/kms-config.enc")], + local_kdf: None, + ..local_manifest_unsealed() + } + } + + fn seal(manifest: BackupManifest) -> BackupManifest { + manifest.seal().expect("sealing should succeed") + } + + fn expect_corrupted(result: Result, needle: &str) { + match result { + Err(BackupError::Corrupted { reason }) => { + assert!( + reason.contains(needle), + "expected corruption reason containing {needle:?}, got {reason:?}" + ); + } + other => panic!("expected Corrupted error containing {needle:?}, got {other:?}"), + } + } + + #[test] + fn sealed_manifest_round_trips() { + let sealed = seal(local_manifest_unsealed()); + let bytes = sealed.encode().expect("encoding should succeed"); + let decoded = BackupManifest::decode(&bytes).expect("decoding should succeed"); + assert_eq!(decoded, sealed); + assert_eq!(decoded.completeness, CompletenessState::Complete); + } + + #[test] + fn handwritten_fixture_decodes_and_matches_serialization() { + let decoded = BackupManifest::decode(fixture().as_bytes()).expect("fixture should decode"); + let expected = seal(local_manifest_unsealed()); + assert_eq!(decoded, expected); + + // The serializer must produce exactly the handwritten wire content. + let serialized: serde_json::Value = + serde_json::from_slice(&expected.encode().expect("encoding should succeed")).expect("re-parse should succeed"); + let fixture_value: serde_json::Value = serde_json::from_str(&fixture()).expect("fixture should parse"); + assert_eq!(serialized, fixture_value); + } + + #[test] + fn unknown_format_version_is_rejected() { + let tampered = fixture().replace("\"format_version\": 1", "\"format_version\": 99"); + match BackupManifest::decode(tampered.as_bytes()) { + Err(BackupError::UnknownVersion { found, supported }) => { + assert_eq!(found, 99); + assert_eq!(supported, BackupManifest::FORMAT_VERSION); + } + other => panic!("expected UnknownVersion, got {other:?}"), + } + } + + #[test] + fn missing_completeness_marker_fails_closed() { + let mut value: serde_json::Value = serde_json::from_str(&fixture()).expect("fixture should parse"); + value + .as_object_mut() + .expect("fixture should be an object") + .remove("completeness"); + let bytes = serde_json::to_vec(&value).expect("serialization should succeed"); + match BackupManifest::decode(&bytes) { + Err(BackupError::IncompleteBundle { reason }) => { + assert!(reason.contains("no completeness marker"), "unexpected reason {reason:?}"); + } + other => panic!("expected IncompleteBundle, got {other:?}"), + } + } + + #[test] + fn in_progress_completeness_fails_closed() { + let tampered = fixture().replace("\"complete\"", "\"in-progress\""); + match BackupManifest::decode(tampered.as_bytes()) { + Err(BackupError::IncompleteBundle { reason }) => { + assert!(reason.contains("in-progress"), "unexpected reason {reason:?}"); + } + other => panic!("expected IncompleteBundle, got {other:?}"), + } + } + + #[test] + fn missing_security_critical_field_fails_closed() { + let mut value: serde_json::Value = serde_json::from_str(&fixture()).expect("fixture should parse"); + value + .as_object_mut() + .expect("fixture should be an object") + .remove("backup_kek"); + let bytes = serde_json::to_vec(&value).expect("serialization should succeed"); + expect_corrupted(BackupManifest::decode(&bytes), "missing field"); + } + + #[test] + fn duplicate_field_fails_closed() { + let tampered = fixture().replace("\"snapshot_generation\": 7", "\"snapshot_generation\": 7, \"snapshot_generation\": 7"); + expect_corrupted(BackupManifest::decode(tampered.as_bytes()), "duplicate field"); + } + + #[test] + fn unknown_field_fails_closed() { + let tampered = fixture().replace("\"snapshot_generation\": 7", "\"surprise\": true, \"snapshot_generation\": 7"); + expect_corrupted(BackupManifest::decode(tampered.as_bytes()), "unknown field"); + } + + #[test] + fn truncated_manifest_fails_closed() { + let full = fixture(); + let truncated = &full.as_bytes()[..full.len() / 2]; + match BackupManifest::decode(truncated) { + Err(BackupError::Truncated { .. }) => {} + other => panic!("expected Truncated, got {other:?}"), + } + } + + #[test] + fn tampered_generation_is_rejected() { + let tampered = fixture().replace("\"snapshot_generation\": 7", "\"snapshot_generation\": 8"); + expect_corrupted(BackupManifest::decode(tampered.as_bytes()), "digest mismatch"); + } + + #[test] + fn tampered_digest_is_rejected() { + let last_char = FIXTURE_DIGEST_HEX.as_bytes()[FIXTURE_DIGEST_HEX.len() - 1]; + let flipped = if last_char == b'0' { '1' } else { '0' }; + let mut tampered_digest = FIXTURE_DIGEST_HEX[..FIXTURE_DIGEST_HEX.len() - 1].to_string(); + tampered_digest.push(flipped); + let tampered = fixture().replace(FIXTURE_DIGEST_HEX, &tampered_digest); + expect_corrupted(BackupManifest::decode(tampered.as_bytes()), "digest mismatch"); + } + + #[test] + fn malformed_digest_value_is_rejected() { + let tampered = fixture().replace(FIXTURE_DIGEST_HEX, "not-hex"); + expect_corrupted(BackupManifest::decode(tampered.as_bytes()), "well-formed"); + } + + #[test] + fn wrong_kek_fails_closed() { + let sealed = seal(local_manifest_unsealed()); + sealed + .backup_kek + .ensure_matches("backup-kek-1", 1) + .expect("matching KEK should pass"); + + match sealed.backup_kek.ensure_matches("backup-kek-2", 1) { + Err(BackupError::WrongKek { + required_kek_id, + supplied_kek_id, + .. + }) => { + assert_eq!(required_kek_id, "backup-kek-1"); + assert_eq!(supplied_kek_id, "backup-kek-2"); + } + other => panic!("expected WrongKek, got {other:?}"), + } + assert!(matches!( + sealed.backup_kek.ensure_matches("backup-kek-1", 2), + Err(BackupError::WrongKek { .. }) + )); + } + + #[test] + fn full_material_bundle_requires_key_material_artifact() { + let mut manifest = local_manifest_unsealed(); + manifest + .artifacts + .retain(|artifact| artifact.kind != ArtifactKind::KeyMaterial); + match seal(manifest).validate() { + Err(BackupError::MissingArtifact { artifact }) => assert_eq!(artifact, "key-material"), + other => panic!("expected MissingArtifact, got {other:?}"), + } + } + + #[test] + fn reference_only_bundle_must_not_embed_key_material() { + let mut manifest = static_manifest_unsealed(); + manifest + .artifacts + .push(artifact(ArtifactKind::KeyMaterial, "keys/forbidden.enc")); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("must not embed key material"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn reserved_artifact_kinds_are_rejected() { + for kind in [ArtifactKind::Alias, ArtifactKind::Policy] { + assert!(kind.is_reserved()); + let mut manifest = local_manifest_unsealed(); + manifest.artifacts.push(artifact(kind, "reserved/artifact.enc")); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("reserved"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted for {kind:?}, got {other:?}"), + } + } + } + + #[test] + fn reserved_fields_reject_data() { + let with_data = fixture().replace("\"completeness\"", "\"key_versions\": {}, \"completeness\""); + expect_corrupted(BackupManifest::decode(with_data.as_bytes()), "reserved"); + + let with_discovery = fixture().replace("\"completeness\"", "\"capability_discovery\": [1], \"completeness\""); + expect_corrupted(BackupManifest::decode(with_discovery.as_bytes()), "reserved"); + + // Explicit null carries no data and is tolerated as absence; digest + // verification still passes because null slots are skipped on + // serialization. + let with_null = fixture().replace("\"completeness\"", "\"key_versions\": null, \"completeness\""); + let decoded = BackupManifest::decode(with_null.as_bytes()).expect("null reserved slot should decode"); + assert_eq!(decoded.key_versions, None); + } + + #[test] + fn responsibility_contradicting_matrix_is_rejected() { + let mut manifest = local_manifest_unsealed(); + manifest.responsibility = BackupResponsibility::ReferenceOnly; + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("contradicts the matrix"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + + let mut manifest = local_manifest_unsealed(); + manifest.at_rest_protection = AtRestProtection::StorageOnly; + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("not a defined state"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn local_kdf_descriptor_presence_is_tied_to_backend() { + let mut manifest = local_manifest_unsealed(); + manifest.local_kdf = None; + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("local_kdf"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + + let mut manifest = static_manifest_unsealed(); + manifest.local_kdf = Some(LocalKdfDescriptor::current(vec![AtRestProtection::EncryptedMasterKey], None)); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("only valid for the Local backend"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn local_kdf_descriptor_rejects_non_local_and_duplicate_modes() { + let mut manifest = local_manifest_unsealed(); + manifest.local_kdf = Some(LocalKdfDescriptor::current(vec![AtRestProtection::StorageOnly], None)); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("non-local protection mode"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + + let mut manifest = local_manifest_unsealed(); + manifest.local_kdf = Some(LocalKdfDescriptor::current( + vec![AtRestProtection::EncryptedMasterKey, AtRestProtection::EncryptedMasterKey], + None, + )); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("more than once"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn artifact_paths_must_be_bundle_relative_and_unique() { + for bad_path in ["/etc/keys.enc", "../escape.enc", "a/../b.enc", ""] { + let mut manifest = local_manifest_unsealed(); + manifest.artifacts.push(artifact(ArtifactKind::KeyMetadata, bad_path)); + assert!( + matches!(seal(manifest).validate(), Err(BackupError::Corrupted { .. })), + "path {bad_path:?} should be rejected" + ); + } + + let mut manifest = local_manifest_unsealed(); + let duplicate = manifest.artifacts[0].clone(); + manifest.artifacts.push(duplicate); + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("more than once"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn zero_length_artifacts_are_rejected() { + let mut manifest = local_manifest_unsealed(); + manifest.artifacts[0].len = 0; + match seal(manifest).validate() { + Err(BackupError::Corrupted { reason }) => { + assert!(reason.contains("zero-length"), "unexpected reason {reason:?}"); + } + other => panic!("expected Corrupted, got {other:?}"), + } + } + + #[test] + fn unsealed_manifest_cannot_be_encoded() { + let manifest = local_manifest_unsealed(); + assert!(matches!(manifest.encode(), Err(BackupError::IncompleteBundle { .. }))); + } + + #[test] + fn require_artifact_reports_missing_kind() { + let sealed = seal(static_manifest_unsealed()); + sealed + .require_artifact(ArtifactKind::KmsConfig) + .expect("config artifact should be present"); + match sealed.require_artifact(ArtifactKind::MasterKeySalt) { + Err(BackupError::MissingArtifact { artifact }) => assert_eq!(artifact, "master-key-salt"), + other => panic!("expected MissingArtifact, got {other:?}"), + } + } + + #[test] + fn current_local_kdf_matches_backend_constants() { + // The descriptor is defined in terms of the local.rs constants, so + // this pins the concrete values: changing a KDF parameter must be a + // conscious contract decision, not a drive-by edit. + assert_eq!( + LocalKeyDerivation::current_argon2id(), + LocalKeyDerivation::Argon2id { + version: 0x13, + memory_kib: 19 * 1024, + iterations: 2, + parallelism: 1, + output_len: 32, + salt_len: 16, + } + ); + } + + #[test] + fn static_bundle_round_trips() { + let sealed = seal(static_manifest_unsealed()); + let bytes = sealed.encode().expect("encoding should succeed"); + let decoded = BackupManifest::decode(&bytes).expect("decoding should succeed"); + assert_eq!(decoded, sealed); + assert_eq!(decoded.responsibility, BackupResponsibility::ReferenceOnly); + } +} diff --git a/crates/kms/src/backup/mod.rs b/crates/kms/src/backup/mod.rs new file mode 100644 index 000000000..59cdba350 --- /dev/null +++ b/crates/kms/src/backup/mod.rs @@ -0,0 +1,63 @@ +// 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. + +//! Backup/restore contract types for KMS state. +//! +//! This module is contract-only: it defines the versioned backup manifest, +//! the per-backend responsibility matrix, typed failure modes, and the +//! restore dry-run report. Nothing here is wired into handlers or backends; +//! backup export, restore orchestration, and the admin API build on these +//! types in follow-up changes. +//! +//! # Bundle model +//! +//! A backup bundle is a set of AEAD-encrypted artifacts described by a single +//! [`BackupManifest`]. All state in a bundle belongs to one snapshot +//! generation — there is no partially consistent bundle. The bundle is +//! protected by a backup KEK that is deliberately outside the business KMS +//! trust hierarchy, and the manifest is sealed with a completeness marker and +//! a final digest; a bundle that never reached its marker is permanently +//! non-restorable. +//! +//! # Restore ordering +//! +//! Restore implementations must follow this order: re-establish the external +//! trust root first (Vault/HSM native restore where one exists), then +//! material and version records into staging, then metadata and +//! configuration, then verification, and only then an explicit atomic +//! cutover. A dry-run ([`RestoreDryRunReport`]) performs zero writes. +//! +//! # Deliberately unfrozen +//! +//! Fields whose shape depends on contracts still in flight are reserved +//! rather than guessed (see [`ReservedSlot`]): the per-key version inventory +//! (backlog#1565) and capability discovery (backlog#1571). Alias and policy +//! artifacts are reserved names for features that do not exist yet. Reserved +//! slots reject data in format version 1 and become real types in a later +//! format version. + +mod capability; +mod dry_run; +mod error; +mod manifest; + +pub use capability::{AtRestProtection, BackupBackendKind, BackupResponsibility}; +pub use dry_run::{ + ExternalDependencyMismatch, RestoreBlocker, RestoreBlockerCode, RestoreConflict, RestoreConflictKind, RestoreDryRunReport, +}; +pub use error::BackupError; +pub use manifest::{ + AeadAlgorithm, ArtifactDescriptor, ArtifactKind, BackupKekDescriptor, BackupManifest, CompletenessState, ContentDigest, + DigestAlgorithm, LocalKdfDescriptor, LocalKeyDerivation, ReservedSlot, +}; diff --git a/crates/kms/src/error.rs b/crates/kms/src/error.rs index 8a7ef6563..98fbb5008 100644 --- a/crates/kms/src/error.rs +++ b/crates/kms/src/error.rs @@ -124,6 +124,10 @@ pub enum KmsError { /// Requested master key version has no persisted material for the key #[error("Key version {version} not found for key {key_id}")] KeyVersionNotFound { key_id: String, version: u32 }, + + /// Backup/restore bundle contract violation; see [`crate::backup::BackupError`] + #[error(transparent)] + Backup(#[from] crate::backup::BackupError), } impl KmsError { diff --git a/crates/kms/src/lib.rs b/crates/kms/src/lib.rs index 3867ef7b0..f05d45e16 100644 --- a/crates/kms/src/lib.rs +++ b/crates/kms/src/lib.rs @@ -66,6 +66,7 @@ // Core modules pub mod api_types; pub mod backends; +pub mod backup; mod cache; pub mod config; mod encryption;