mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-30 08:49:26 +00:00
feat(kms): add link_durably primitive and restore-marker startup guard
The local backend gains the two pieces the bundle restore path builds on: a no-clobber hard-link publish primitive whose AlreadyExists case is idempotent only for byte-identical content, and a fail-closed startup guard that refuses to open a key directory holding a restore cutover marker. The durable commit protocol and the key-id containment check become pub(crate) so the restore module reuses them instead of copies.
This commit is contained in:
@@ -46,7 +46,10 @@ use zeroize::Zeroizing;
|
|||||||
/// `key_dir` is accepted, so identifiers already in use by existing deployments keep
|
/// `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
|
/// resolving. Only separators, traversal and the degenerate cases are refused, which is
|
||||||
/// what stops `key_dir.join(...)` from escaping.
|
/// 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() {
|
if key_id.is_empty() {
|
||||||
return Err(KmsError::invalid_key("key identifier must not be 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
|
// The KDF parameters are pub(crate) so the backup manifest contract
|
||||||
// (`crate::backup`) records the exact compiled-in derivation instead of a
|
// (`crate::backup`) records the exact compiled-in derivation instead of a
|
||||||
// copy that could drift.
|
// 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
|
/// `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
|
/// plus the exact hyphenated-UUID check makes it impossible to match an
|
||||||
/// authoritative file.
|
/// 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") {
|
if file_name.ends_with(".key") {
|
||||||
return false;
|
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
|
/// This intentionally mirrors ecstore's fsync helpers without depending on the
|
||||||
/// ecstore crate: the KMS backend stays decoupled from storage internals.
|
/// 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::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
/// How the fully written temp file becomes visible under its final name.
|
/// 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`.
|
/// Atomically replace whatever is at the destination via `rename`.
|
||||||
Replace,
|
Replace,
|
||||||
/// Publish via `hard_link`, failing with [`CommitError::AlreadyExists`]
|
/// Publish via `hard_link`, failing with [`CommitError::AlreadyExists`]
|
||||||
@@ -124,7 +142,7 @@ mod durable_file {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(super) enum CommitError {
|
pub(crate) enum CommitError {
|
||||||
AlreadyExists,
|
AlreadyExists,
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
/// Test-only simulated crash: the protocol stops after the given step
|
/// 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
|
/// to prove that every interrupted prefix recovers to either the complete
|
||||||
/// old state or the complete new state.
|
/// old state or the complete new state.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(super) enum CommitStep {
|
pub(crate) enum CommitStep {
|
||||||
TempWritten,
|
TempWritten,
|
||||||
FileSynced,
|
FileSynced,
|
||||||
Published,
|
Published,
|
||||||
DirSynced,
|
DirSynced,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn commit(
|
pub(crate) async fn commit(
|
||||||
temp_path: PathBuf,
|
temp_path: PathBuf,
|
||||||
final_path: PathBuf,
|
final_path: PathBuf,
|
||||||
content: Vec<u8>,
|
content: Vec<u8>,
|
||||||
@@ -179,7 +197,7 @@ mod durable_file {
|
|||||||
|
|
||||||
/// Remove a published file durably: without the parent directory fsync a
|
/// Remove a published file durably: without the parent directory fsync a
|
||||||
/// deleted key could resurface after power loss.
|
/// 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 || {
|
tokio::task::spawn_blocking(move || {
|
||||||
std::fs::remove_file(&path)?;
|
std::fs::remove_file(&path)?;
|
||||||
let parent = path
|
let parent = path
|
||||||
@@ -191,6 +209,41 @@ mod durable_file {
|
|||||||
.map_err(io::Error::other)?
|
.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(
|
fn commit_blocking(
|
||||||
temp_path: &Path,
|
temp_path: &Path,
|
||||||
final_path: &Path,
|
final_path: &Path,
|
||||||
@@ -355,7 +408,7 @@ mod durable_file {
|
|||||||
/// Test-only failpoints simulating a crash after a given commit step.
|
/// Test-only failpoints simulating a crash after a given commit step.
|
||||||
/// Armed per directory so parallel tests never affect each other.
|
/// Armed per directory so parallel tests never affect each other.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(super) mod failpoint {
|
pub(crate) mod failpoint {
|
||||||
use super::CommitStep;
|
use super::CommitStep;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
@@ -460,6 +513,11 @@ impl LocalKmsClient {
|
|||||||
debug!(path = ?config.key_dir, "KMS key directory created");
|
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
|
// Initialize master cipher if master key is provided
|
||||||
let (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key {
|
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?;
|
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? {
|
if !fs::try_exists(&config.key_dir).await? {
|
||||||
return Err(KmsError::configuration_error("Local KMS key directory does not exist"));
|
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 (master_cipher, legacy_master_cipher) = if let Some(ref master_key) = config.master_key {
|
||||||
let legacy_key = Self::derive_legacy_master_key(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)
|
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.
|
/// 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(
|
let params = Params::new(
|
||||||
LOCAL_KMS_ARGON2_M_COST_KIB,
|
LOCAL_KMS_ARGON2_M_COST_KIB,
|
||||||
LOCAL_KMS_ARGON2_T_COST,
|
LOCAL_KMS_ARGON2_T_COST,
|
||||||
@@ -585,7 +672,7 @@ impl LocalKmsClient {
|
|||||||
Ok(key)
|
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();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(master_key.as_bytes());
|
hasher.update(master_key.as_bytes());
|
||||||
hasher.update(b"rustfs-kms-local");
|
hasher.update(b"rustfs-kms-local");
|
||||||
@@ -2453,6 +2540,34 @@ mod tests {
|
|||||||
assert!(!is_orphan_commit_temp_name("mykey.tmp-"));
|
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]
|
#[tokio::test]
|
||||||
async fn durable_commit_fsyncs_every_write_path() {
|
async fn durable_commit_fsyncs_every_write_path() {
|
||||||
use durable_file::fsync_recorder;
|
use durable_file::fsync_recorder;
|
||||||
|
|||||||
Reference in New Issue
Block a user