mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-30 18:12:14 +00:00
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.
This commit is contained in:
@@ -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 (`<prefix>.tmp-<uuid>`).
|
||||
///
|
||||
@@ -396,9 +399,11 @@ pub struct LocalKmsClient {
|
||||
key_write_locks: Mutex<HashMap<String, Arc<tokio::sync::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,
|
||||
|
||||
@@ -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<Self> {
|
||||
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<T: Serialize>(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\"");
|
||||
}
|
||||
}
|
||||
@@ -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<RestoreBlocker>,
|
||||
/// Conflicts with existing target state.
|
||||
pub conflicts: Vec<RestoreConflict>,
|
||||
/// External dependency mismatches.
|
||||
pub external_mismatches: Vec<ExternalDependencyMismatch>,
|
||||
}
|
||||
|
||||
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<T>()
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
assert_plain_data::<RestoreDryRunReport>();
|
||||
assert_plain_data::<RestoreBlocker>();
|
||||
assert_plain_data::<RestoreConflict>();
|
||||
assert_plain_data::<ExternalDependencyMismatch>();
|
||||
}
|
||||
}
|
||||
@@ -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<S: Into<String>>(reason: S) -> Self {
|
||||
Self::Corrupted { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create a truncated-manifest error.
|
||||
pub fn truncated<S: Into<String>>(reason: S) -> Self {
|
||||
Self::Truncated { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create an incomplete-bundle error.
|
||||
pub fn incomplete_bundle<S: Into<String>>(reason: S) -> Self {
|
||||
Self::IncompleteBundle { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create a missing-artifact error.
|
||||
pub fn missing_artifact<S: Into<String>>(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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
// Core modules
|
||||
pub mod api_types;
|
||||
pub mod backends;
|
||||
pub mod backup;
|
||||
mod cache;
|
||||
pub mod config;
|
||||
mod encryption;
|
||||
|
||||
Reference in New Issue
Block a user