mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-31 18:42:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97de62e742 | |||
| fe7b592e42 | |||
| 842fe5590f |
@@ -46,7 +46,10 @@ use zeroize::Zeroizing;
|
||||
/// `key_dir` is accepted, so identifiers already in use by existing deployments keep
|
||||
/// resolving. Only separators, traversal and the degenerate cases are refused, which is
|
||||
/// what stops `key_dir.join(...)` from escaping.
|
||||
fn validate_key_id(key_id: &str) -> Result<()> {
|
||||
///
|
||||
/// pub(crate) because the backup restore path applies the same containment
|
||||
/// rule to key identifiers recovered from bundle artifacts.
|
||||
pub(crate) fn validate_key_id(key_id: &str) -> Result<()> {
|
||||
if key_id.is_empty() {
|
||||
return Err(KmsError::invalid_key("key identifier must not be empty"));
|
||||
}
|
||||
@@ -68,7 +71,15 @@ fn validate_key_id(key_id: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_KMS_MASTER_KEY_SALT_FILE: &str = ".master-key.salt";
|
||||
// The salt and restore-marker file names are pub(crate) so the backup/restore
|
||||
// modules (`crate::backup`) address the exact on-disk names instead of copies
|
||||
// that could drift.
|
||||
pub(crate) const LOCAL_KMS_MASTER_KEY_SALT_FILE: &str = ".master-key.salt";
|
||||
/// Commit marker of an in-progress Local restore cutover (see
|
||||
/// `crate::backup::local_restore`). Its presence means the key directory is
|
||||
/// mid-cutover: startup must fail closed until the restore is rolled forward
|
||||
/// or explicitly aborted.
|
||||
pub(crate) const LOCAL_RESTORE_COMMIT_MARKER_FILE: &str = ".restore-commit.json";
|
||||
// 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.
|
||||
@@ -87,7 +98,11 @@ pub(crate) const LOCAL_KMS_ARGON2_P_COST: u32 = 1;
|
||||
/// `foo.tmp-<uuid>` is stored as `foo.tmp-<uuid>.key` — so the `.key` guard
|
||||
/// plus the exact hyphenated-UUID check makes it impossible to match an
|
||||
/// authoritative file.
|
||||
fn is_orphan_commit_temp_name(file_name: &str) -> bool {
|
||||
///
|
||||
/// pub(crate) because the backup restore path applies the same classification
|
||||
/// when it re-enters an interrupted run: a leftover commit temp is never
|
||||
/// authoritative state, so it does not make a target non-empty.
|
||||
pub(crate) fn is_orphan_commit_temp_name(file_name: &str) -> bool {
|
||||
if file_name.ends_with(".key") {
|
||||
return false;
|
||||
}
|
||||
@@ -110,12 +125,15 @@ fn is_orphan_commit_temp_name(file_name: &str) -> bool {
|
||||
///
|
||||
/// This intentionally mirrors ecstore's fsync helpers without depending on the
|
||||
/// ecstore crate: the KMS backend stays decoupled from storage internals.
|
||||
mod durable_file {
|
||||
///
|
||||
/// pub(crate) because the backup restore path (`crate::backup::local_restore`)
|
||||
/// commits staged files and its cutover marker through the same protocol.
|
||||
pub(crate) mod durable_file {
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// How the fully written temp file becomes visible under its final name.
|
||||
pub(super) enum Publish {
|
||||
pub(crate) enum Publish {
|
||||
/// Atomically replace whatever is at the destination via `rename`.
|
||||
Replace,
|
||||
/// Publish via `hard_link`, failing with [`CommitError::AlreadyExists`]
|
||||
@@ -124,7 +142,7 @@ mod durable_file {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum CommitError {
|
||||
pub(crate) enum CommitError {
|
||||
AlreadyExists,
|
||||
Io(io::Error),
|
||||
/// Test-only simulated crash: the protocol stops after the given step
|
||||
@@ -158,14 +176,14 @@ mod durable_file {
|
||||
/// to prove that every interrupted prefix recovers to either the complete
|
||||
/// old state or the complete new state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum CommitStep {
|
||||
pub(crate) enum CommitStep {
|
||||
TempWritten,
|
||||
FileSynced,
|
||||
Published,
|
||||
DirSynced,
|
||||
}
|
||||
|
||||
pub(super) async fn commit(
|
||||
pub(crate) async fn commit(
|
||||
temp_path: PathBuf,
|
||||
final_path: PathBuf,
|
||||
content: Vec<u8>,
|
||||
@@ -179,7 +197,7 @@ mod durable_file {
|
||||
|
||||
/// Remove a published file durably: without the parent directory fsync a
|
||||
/// deleted key could resurface after power loss.
|
||||
pub(super) async fn remove_durably(path: PathBuf) -> io::Result<()> {
|
||||
pub(crate) async fn remove_durably(path: PathBuf) -> io::Result<()> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::fs::remove_file(&path)?;
|
||||
let parent = path
|
||||
@@ -191,6 +209,41 @@ mod durable_file {
|
||||
.map_err(io::Error::other)?
|
||||
}
|
||||
|
||||
/// Publish an already-durable file under a second name via `hard_link`,
|
||||
/// then fsync the destination's parent directory.
|
||||
///
|
||||
/// This is the restore cutover primitive: the source (a staged file that
|
||||
/// went through [`commit`]) is already durable, so linking plus a parent
|
||||
/// fsync is a complete publish. `AlreadyExists` is idempotent success only
|
||||
/// when the destination content is byte-identical to the source — that is
|
||||
/// exactly the re-entry case of a cutover interrupted after this link —
|
||||
/// and a hard failure otherwise, so the primitive can never clobber or
|
||||
/// silently accept foreign state.
|
||||
pub(crate) async fn link_durably(source: PathBuf, dest: PathBuf) -> io::Result<()> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
match std::fs::hard_link(&source, &dest) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
||||
let existing = std::fs::read(&dest)?;
|
||||
let staged = std::fs::read(&source)?;
|
||||
if existing != staged {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("destination {} already exists with different content", dest.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
let parent = dest
|
||||
.parent()
|
||||
.ok_or_else(|| io::Error::other("destination has no parent directory"))?;
|
||||
fsync_dir(parent)
|
||||
})
|
||||
.await
|
||||
.map_err(io::Error::other)?
|
||||
}
|
||||
|
||||
fn commit_blocking(
|
||||
temp_path: &Path,
|
||||
final_path: &Path,
|
||||
@@ -355,7 +408,7 @@ mod durable_file {
|
||||
/// Test-only failpoints simulating a crash after a given commit step.
|
||||
/// Armed per directory so parallel tests never affect each other.
|
||||
#[cfg(test)]
|
||||
pub(super) mod failpoint {
|
||||
pub(crate) mod failpoint {
|
||||
use super::CommitStep;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -460,6 +513,11 @@ impl LocalKmsClient {
|
||||
debug!(path = ?config.key_dir, "KMS key directory created");
|
||||
}
|
||||
|
||||
// The restore-marker guard must run before anything else touches the
|
||||
// directory (in particular before salt load/creation): a directory
|
||||
// mid-cutover holds an arbitrary mix of old and new state.
|
||||
Self::ensure_no_restore_marker(&config).await?;
|
||||
|
||||
// Initialize master cipher if master key is provided
|
||||
let (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key {
|
||||
let salt = Self::load_or_create_master_key_salt(&config).await?;
|
||||
@@ -491,6 +549,7 @@ impl LocalKmsClient {
|
||||
if !fs::try_exists(&config.key_dir).await? {
|
||||
return Err(KmsError::configuration_error("Local KMS key directory does not exist"));
|
||||
}
|
||||
Self::ensure_no_restore_marker(&config).await?;
|
||||
|
||||
let (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key {
|
||||
let legacy_key = Self::derive_legacy_master_key(master_key)?;
|
||||
@@ -567,8 +626,36 @@ impl LocalKmsClient {
|
||||
Self::master_key_salt_path(&self.config)
|
||||
}
|
||||
|
||||
/// Operator-configured master key string, exposed for the backup export
|
||||
/// module so it can record a one-way verifier in the bundle manifest.
|
||||
/// Never log or persist this value.
|
||||
pub(crate) fn configured_master_key(&self) -> Option<&str> {
|
||||
self.config.master_key.as_deref()
|
||||
}
|
||||
|
||||
/// Fail closed while a restore cutover marker is present: the directory
|
||||
/// then holds an arbitrary mix of pre-restore and restored state, and the
|
||||
/// only valid next steps are re-running the restore with the same bundle
|
||||
/// (roll forward) or explicitly aborting it. This mirrors the missing-salt
|
||||
/// guard: startup must never paper over a half-applied restore.
|
||||
async fn ensure_no_restore_marker(config: &LocalConfig) -> Result<()> {
|
||||
let marker = config.key_dir.join(LOCAL_RESTORE_COMMIT_MARKER_FILE);
|
||||
if fs::try_exists(&marker).await? {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"Local KMS key directory has an unfinished restore (marker {} present); \
|
||||
re-run the restore with the same bundle to roll it forward or abort it explicitly",
|
||||
marker.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a 256-bit key from the master key string using a persistent Argon2id salt.
|
||||
fn derive_master_key(master_key: &str, salt: &[u8]) -> Result<Key<Aes256Gcm>> {
|
||||
///
|
||||
/// pub(crate) because the backup restore path derives the same key from
|
||||
/// the operator-supplied master key and the bundled salt for its verifier
|
||||
/// check and staged decryption probe.
|
||||
pub(crate) fn derive_master_key(master_key: &str, salt: &[u8]) -> Result<Key<Aes256Gcm>> {
|
||||
let params = Params::new(
|
||||
LOCAL_KMS_ARGON2_M_COST_KIB,
|
||||
LOCAL_KMS_ARGON2_T_COST,
|
||||
@@ -585,7 +672,7 @@ impl LocalKmsClient {
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn derive_legacy_master_key(master_key: &str) -> Result<Key<Aes256Gcm>> {
|
||||
pub(crate) fn derive_legacy_master_key(master_key: &str) -> Result<Key<Aes256Gcm>> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(master_key.as_bytes());
|
||||
hasher.update(b"rustfs-kms-local");
|
||||
@@ -2453,6 +2540,34 @@ mod tests {
|
||||
assert!(!is_orphan_commit_temp_name("mykey.tmp-"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn link_durably_publishes_no_clobber_and_is_content_idempotent() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let source = temp_dir.path().join("staged");
|
||||
let dest = temp_dir.path().join("published");
|
||||
fs::write(&source, b"staged-content").await.expect("write source");
|
||||
|
||||
durable_file::link_durably(source.clone(), dest.clone())
|
||||
.await
|
||||
.expect("first link must succeed");
|
||||
assert_eq!(fs::read(&dest).await.expect("read dest"), b"staged-content");
|
||||
|
||||
// Re-entry with identical content is idempotent success — exactly the
|
||||
// resumed-cutover case.
|
||||
durable_file::link_durably(source.clone(), dest.clone())
|
||||
.await
|
||||
.expect("re-linking identical content must be idempotent");
|
||||
|
||||
// Existing content that differs is a hard failure, never a clobber.
|
||||
let foreign = temp_dir.path().join("foreign");
|
||||
fs::write(&foreign, b"different-content").await.expect("write foreign");
|
||||
let error = durable_file::link_durably(foreign, dest.clone())
|
||||
.await
|
||||
.expect_err("differing content must not be clobbered");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(fs::read(&dest).await.expect("dest unchanged"), b"staged-content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn durable_commit_fsyncs_every_write_path() {
|
||||
use durable_file::fsync_recorder;
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
//! Local backend backup export: sealed, KEK-protected bundle production.
|
||||
//!
|
||||
//! This is the producer side only; restore lives in a follow-up change. The
|
||||
//! admin API is not wired here either — callers construct the request and
|
||||
//! supply the backup KEK explicitly.
|
||||
//! This is the producer side; the consumer side lives in
|
||||
//! [`crate::backup::local_restore`]. The admin API is not wired here —
|
||||
//! callers construct the request and supply the backup KEK explicitly.
|
||||
//!
|
||||
//! # Bundle layout
|
||||
//!
|
||||
@@ -63,6 +63,7 @@ use aes_gcm::{
|
||||
use jiff::Zoned;
|
||||
use rand::RngExt;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -76,6 +77,12 @@ const SALT_ARTIFACT_PATH: &str = "artifacts/master-key.salt.enc";
|
||||
const AEAD_NONCE_LEN: usize = 12;
|
||||
/// Domain-separation context for the artifact AAD binding.
|
||||
const BUNDLE_AAD_CONTEXT: &str = "rustfs-kms-local-backup:v1";
|
||||
/// Domain-separation context for the master-key verifier.
|
||||
const MASTER_KEY_VERIFIER_CONTEXT: &str = "rustfs-kms-local-master-key-verifier:v1";
|
||||
/// Verifier scheme prefix for the Argon2id (salted) derivation.
|
||||
pub(crate) const MASTER_KEY_VERIFIER_ARGON2ID_PREFIX: &str = "argon2id-v1:";
|
||||
/// Verifier scheme prefix for the legacy pre-beta.9 SHA-256 derivation.
|
||||
pub(crate) const MASTER_KEY_VERIFIER_LEGACY_PREFIX: &str = "legacy-sha256-v1:";
|
||||
|
||||
/// Caller-supplied backup KEK: a trust root deliberately separate from the
|
||||
/// business KMS hierarchy (it must not be a key that is itself part of the
|
||||
@@ -208,7 +215,15 @@ pub async fn export_local_backup(
|
||||
));
|
||||
}
|
||||
|
||||
let manifest = build_and_write_bundle(kek, request, &snapshot).await?;
|
||||
// The verifier lets a restore detect a wrong operator-supplied master key
|
||||
// before touching any target state; dev-mode directories have no master
|
||||
// key to verify.
|
||||
let master_key_verifier = match client.configured_master_key() {
|
||||
Some(master_key) => Some(compute_master_key_verifier(master_key, snapshot.salt.as_deref(), &request.backup_id)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let manifest = build_and_write_bundle(kek, request, &snapshot, master_key_verifier).await?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
@@ -262,6 +277,21 @@ pub async fn decrypt_bundle_artifact(
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
decrypt_artifact_payload(&manifest.backup_id, manifest.snapshot_generation, descriptor, kek, &payload)
|
||||
}
|
||||
|
||||
/// Verify and decrypt one artifact payload already read into memory.
|
||||
///
|
||||
/// Fail-closed order: declared length, encrypted digest, nonce framing, then
|
||||
/// AEAD authentication. Shared by [`decrypt_bundle_artifact`] and the export
|
||||
/// pre-seal probe so producer and consumer can never drift on the framing.
|
||||
fn decrypt_artifact_payload(
|
||||
backup_id: &str,
|
||||
snapshot_generation: u64,
|
||||
descriptor: &ArtifactDescriptor,
|
||||
kek: &BackupKek,
|
||||
payload: &[u8],
|
||||
) -> Result<Zeroizing<Vec<u8>>> {
|
||||
if (payload.len() as u64) < descriptor.len {
|
||||
return Err(BackupError::truncated(format!(
|
||||
"artifact '{}' is {} bytes, manifest declares {}",
|
||||
@@ -280,7 +310,7 @@ pub async fn decrypt_bundle_artifact(
|
||||
))
|
||||
.into());
|
||||
}
|
||||
if ContentDigest::sha256_of(&payload) != descriptor.encrypted_digest {
|
||||
if ContentDigest::sha256_of(payload) != descriptor.encrypted_digest {
|
||||
return Err(BackupError::corrupted(format!("artifact '{}' does not match its manifest digest", descriptor.path)).into());
|
||||
}
|
||||
if payload.len() < AEAD_NONCE_LEN {
|
||||
@@ -290,7 +320,7 @@ pub async fn decrypt_bundle_artifact(
|
||||
let (nonce_bytes, ciphertext) = payload.split_at(AEAD_NONCE_LEN);
|
||||
let mut nonce = [0u8; AEAD_NONCE_LEN];
|
||||
nonce.copy_from_slice(nonce_bytes);
|
||||
let aad = artifact_aad(&manifest.backup_id, manifest.snapshot_generation, &descriptor.path);
|
||||
let aad = artifact_aad(backup_id, snapshot_generation, &descriptor.path);
|
||||
let plaintext = kek
|
||||
.cipher()
|
||||
.decrypt(
|
||||
@@ -360,6 +390,7 @@ async fn build_and_write_bundle(
|
||||
kek: &BackupKek,
|
||||
request: &LocalBackupExportRequest,
|
||||
snapshot: &CollectedSnapshot,
|
||||
master_key_verifier: Option<String>,
|
||||
) -> Result<BackupManifest> {
|
||||
let mut artifacts = Vec::with_capacity(snapshot.records.len() + 1);
|
||||
for record in &snapshot.records {
|
||||
@@ -392,7 +423,7 @@ async fn build_and_write_bundle(
|
||||
snapshot_generation: request.snapshot_generation,
|
||||
backup_kek: kek.descriptor(),
|
||||
artifacts,
|
||||
local_kdf: Some(local_kdf_descriptor(snapshot)),
|
||||
local_kdf: Some(local_kdf_descriptor(snapshot, master_key_verifier)),
|
||||
key_versions: None,
|
||||
capability_discovery: None,
|
||||
completeness: CompletenessState::InProgress,
|
||||
@@ -448,13 +479,25 @@ async fn encrypt_and_write_artifact(
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ArtifactDescriptor {
|
||||
let descriptor = ArtifactDescriptor {
|
||||
kind,
|
||||
path: artifact_path.to_string(),
|
||||
len: payload.len() as u64,
|
||||
aead_algorithm: AeadAlgorithm::Aes256Gcm,
|
||||
encrypted_digest: digest,
|
||||
})
|
||||
};
|
||||
|
||||
// Pre-seal decryption probe: digest equality only proves the ciphertext
|
||||
// landed intact; this proves the stored artifact actually opens under the
|
||||
// backup KEK and AAD binding before the manifest may reference it.
|
||||
let reopened = decrypt_artifact_payload(&request.backup_id, request.snapshot_generation, &descriptor, kek, &written)?;
|
||||
if reopened.as_slice() != plaintext {
|
||||
return Err(KmsError::internal_error(format!(
|
||||
"bundle artifact '{artifact_path}' failed the pre-seal decryption probe"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
/// AAD binding an artifact to its bundle identity and path. A JSON tuple
|
||||
@@ -464,6 +507,29 @@ fn artifact_aad(backup_id: &str, snapshot_generation: u64, artifact_path: &str)
|
||||
.expect("AAD tuple of strings and integers always serializes")
|
||||
}
|
||||
|
||||
/// Compute the opaque one-way master-key verifier recorded in the manifest:
|
||||
/// `<scheme-prefix>` + `hex(SHA-256(json(context, backup_id) || derived_key))`.
|
||||
///
|
||||
/// The derivation follows the directory's KDF state: Argon2id over the
|
||||
/// persistent salt when one exists, the legacy SHA-256 derivation otherwise.
|
||||
/// The verifier is not an offline-guessing oracle: computing a candidate
|
||||
/// requires the salt, which exists only inside the KEK-sealed bundle, and a
|
||||
/// party holding the KEK already has the strictly stronger oracle of the
|
||||
/// artifact AEAD tags — while every guess still pays the full Argon2id cost.
|
||||
/// Binding `backup_id` prevents cross-bundle fingerprint correlation.
|
||||
pub(crate) fn compute_master_key_verifier(master_key: &str, salt: Option<&[u8]>, backup_id: &str) -> Result<String> {
|
||||
let framing = serde_json::to_vec(&(MASTER_KEY_VERIFIER_CONTEXT, backup_id))
|
||||
.expect("verifier framing tuple of strings always serializes");
|
||||
let (prefix, derived) = match salt {
|
||||
Some(salt) => (MASTER_KEY_VERIFIER_ARGON2ID_PREFIX, LocalKmsClient::derive_master_key(master_key, salt)?),
|
||||
None => (MASTER_KEY_VERIFIER_LEGACY_PREFIX, LocalKmsClient::derive_legacy_master_key(master_key)?),
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&framing);
|
||||
hasher.update(derived.as_slice());
|
||||
Ok(format!("{prefix}{}", hex::encode(hasher.finalize())))
|
||||
}
|
||||
|
||||
/// The bundle-level protection label is the weakest state observed across
|
||||
/// records: any plaintext-dev-only record marks the whole bundle, then any
|
||||
/// legacy-unspecified marker (unknown until read), and only a uniformly
|
||||
@@ -484,7 +550,7 @@ fn weakest_observed_protection(records: &[CollectedRecord]) -> AtRestProtection
|
||||
}
|
||||
}
|
||||
|
||||
fn local_kdf_descriptor(snapshot: &CollectedSnapshot) -> LocalKdfDescriptor {
|
||||
fn local_kdf_descriptor(snapshot: &CollectedSnapshot, master_key_verifier: Option<String>) -> LocalKdfDescriptor {
|
||||
let mut modes = Vec::new();
|
||||
for (marker, mode) in [
|
||||
(StoredKeyProtection::EncryptedMasterKey, AtRestProtection::EncryptedMasterKey),
|
||||
@@ -508,9 +574,7 @@ fn local_kdf_descriptor(snapshot: &CollectedSnapshot) -> LocalKdfDescriptor {
|
||||
LocalKdfDescriptor {
|
||||
derivation,
|
||||
protection_modes: modes,
|
||||
// The verifier shape is left to the restore change; the schema keeps
|
||||
// it optional so bundles without one stay valid.
|
||||
master_key_verifier: None,
|
||||
master_key_verifier,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,8 +607,9 @@ async fn write_new_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
|
||||
/// Fsync a directory so freshly created bundle entries survive power loss.
|
||||
/// No-op on non-Unix platforms where directories cannot be opened for
|
||||
/// syncing (mirrors the local backend's durable commit helper).
|
||||
async fn fsync_dir(path: &Path) -> Result<()> {
|
||||
/// syncing (mirrors the local backend's durable commit helper). Shared with
|
||||
/// the restore module for the same durability points on the target side.
|
||||
pub(crate) async fn fsync_dir(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = path.to_path_buf();
|
||||
@@ -560,7 +625,6 @@ async fn fsync_dir(path: &Path) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backends::KmsClient;
|
||||
use crate::config::LocalConfig;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
@@ -726,6 +790,37 @@ mod tests {
|
||||
assert_eq!(decrypted.as_slice(), source_record.as_slice());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_records_a_master_key_verifier_matching_recomputation() {
|
||||
let (client, _key_dir) = encrypted_client().await;
|
||||
client.create_key("verified", "AES_256", None).await.expect("create key");
|
||||
|
||||
let bundle = TempDir::new().expect("bundle dir");
|
||||
let manifest = export_local_backup(&client, &test_kek(), &export_request(bundle.path().join("bundle")))
|
||||
.await
|
||||
.expect("export should succeed");
|
||||
|
||||
let verifier = manifest
|
||||
.local_kdf
|
||||
.as_ref()
|
||||
.expect("local kdf descriptor")
|
||||
.master_key_verifier
|
||||
.as_deref()
|
||||
.expect("encrypted bundles must record a verifier");
|
||||
assert!(verifier.starts_with(MASTER_KEY_VERIFIER_ARGON2ID_PREFIX), "got {verifier:?}");
|
||||
|
||||
// The verifier is a pure function of (master key, salt, backup id):
|
||||
// the restore side recomputes it from the operator-supplied key and
|
||||
// the bundled salt.
|
||||
let salt = fs::read(client.master_key_salt_file()).await.expect("salt");
|
||||
let recomputed = compute_master_key_verifier("test-master-key", Some(&salt), "backup-0001").expect("recompute");
|
||||
assert_eq!(recomputed, verifier);
|
||||
let wrong_key = compute_master_key_verifier("wrong-master-key", Some(&salt), "backup-0001").expect("recompute");
|
||||
assert_ne!(wrong_key, verifier, "a different master key must change the verifier");
|
||||
let other_bundle = compute_master_key_verifier("test-master-key", Some(&salt), "backup-0002").expect("recompute");
|
||||
assert_ne!(other_bundle, verifier, "verifiers must be bundle-bound");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn export_fence_blocks_writers_until_released() {
|
||||
let (client, _key_dir) = encrypted_client().await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,9 +16,10 @@
|
||||
//!
|
||||
//! The contract side defines the versioned backup manifest, the per-backend
|
||||
//! responsibility matrix, typed failure modes, and the restore dry-run
|
||||
//! report. [`local_export`] implements the producer side for the Local
|
||||
//! backend as a crate-internal API; restore orchestration and the admin API
|
||||
//! build on these pieces in follow-up changes.
|
||||
//! report. [`local_export`] implements the producer side and
|
||||
//! [`local_restore`] the consumer side for the Local backend as
|
||||
//! crate-internal APIs; the admin API builds on these pieces in follow-up
|
||||
//! changes.
|
||||
//!
|
||||
//! # Bundle model
|
||||
//!
|
||||
@@ -51,6 +52,7 @@ mod capability;
|
||||
mod dry_run;
|
||||
mod error;
|
||||
pub mod local_export;
|
||||
pub mod local_restore;
|
||||
mod manifest;
|
||||
|
||||
pub use capability::{AtRestProtection, BackupBackendKind, BackupResponsibility};
|
||||
@@ -62,6 +64,10 @@ pub use local_export::{
|
||||
BackupKek, LOCAL_BUNDLE_MANIFEST_FILE, LocalBackupExportRequest, decrypt_bundle_artifact, export_local_backup,
|
||||
read_local_bundle_manifest,
|
||||
};
|
||||
pub use local_restore::{
|
||||
LocalRestoreReport, LocalRestoreRequest, RestoreConflictPolicy, abort_local_restore, dry_run_local_restore,
|
||||
restore_local_backup,
|
||||
};
|
||||
pub use manifest::{
|
||||
AeadAlgorithm, ArtifactDescriptor, ArtifactKind, BackupKekDescriptor, BackupManifest, CompletenessState, ContentDigest,
|
||||
DigestAlgorithm, LocalKdfDescriptor, LocalKeyDerivation, ReservedSlot,
|
||||
|
||||
Reference in New Issue
Block a user