mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-03 11:57:43 +00:00
bd834297da
* feat(kms): add data key rewrap and wrapping inspection primitives Rewrap re-protects an existing data key envelope with the master key's current version without touching the data key itself, which is the precondition for ever retiring an older version: until every envelope a version wrapped has been moved off it, destroying that version orphans every object whose data key it wrapped. Adds KmsBackend::rewrap_data_key and its read-only counterpart describe_data_key_wrapping, both gated by a new BackendCapabilities::rewrap flag and defaulting to UnsupportedCapability. Vault KV2 unwraps with the frozen version record that wrapped the envelope and re-wraps with the current material; Vault Transit uses the native transit/rewrap endpoint so the data key never enters this process. No read or write path changes: nothing calls these yet. * test(kms): cover the rewrap primitive against a scripted Vault * fix(kms): resolve both key materials before the data key is unwrapped Keeps every fallible step out of the window in which the plaintext data key exists, so no error path can drop it without zeroizing it first.
427 lines
18 KiB
Rust
427 lines
18 KiB
Rust
// 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.
|
|
|
|
//! Audit contract for KMS management operations.
|
|
//!
|
|
//! [`KmsManager`](crate::manager::KmsManager) builds a [`KmsAuditRecord`] for
|
|
//! every management operation it serves and hands it to the installed
|
|
//! [`KmsAuditSink`]. No sink is installed by default, so a deployment that
|
|
//! does not consume KMS audit records pays nothing beyond the `Option` check.
|
|
//!
|
|
//! The record is deliberately a KMS-side type rather than an audit-crate
|
|
//! entry: keeping the dependency edge out of this crate lets the server map
|
|
//! records onto its existing audit pipeline (and its delivery semantics)
|
|
//! without KMS having to know that pipeline exists.
|
|
//!
|
|
//! # Redaction
|
|
//!
|
|
//! A record has no field that can hold key material, and every value that
|
|
//! originates from a caller passes through [`redact_encryption_context`]
|
|
//! before it is stored. Adding a field here means re-checking that invariant.
|
|
|
|
use crate::error::KmsError;
|
|
use crate::types::OperationContext;
|
|
use rustfs_s3_types::EventName;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::time::Duration;
|
|
use uuid::Uuid;
|
|
|
|
/// Encryption-context keys that describe *where* an object lives rather than
|
|
/// anything secret about it. Their values are recorded verbatim; every other
|
|
/// key is reduced to a digest.
|
|
const ENCRYPTION_CONTEXT_ALLOWLIST: [&str; 5] = ["bucket", "object", "object_key", "algorithm", "sse_type"];
|
|
|
|
/// Prefix marking a value that was replaced by a digest of itself.
|
|
const DIGEST_PREFIX: &str = "sha256:";
|
|
|
|
/// Hex characters of the digest that are kept. Enough to correlate repeated
|
|
/// values across records without being reversible in practice.
|
|
const DIGEST_LEN: usize = 16;
|
|
|
|
/// A KMS management operation that produces an audit record.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum KmsAuditOperation {
|
|
/// Creation of a new master key.
|
|
CreateKey,
|
|
/// Metadata lookup for a single key.
|
|
DescribeKey,
|
|
/// Enumeration of keys.
|
|
ListKeys,
|
|
/// Scheduling a key for deletion after the pending window.
|
|
ScheduleKeyDeletion,
|
|
/// Cancelling a previously scheduled deletion.
|
|
CancelKeyDeletion,
|
|
/// Returning a disabled key to service.
|
|
EnableKey,
|
|
/// Taking a key out of service without destroying it.
|
|
DisableKey,
|
|
/// Rotating a key to a new version.
|
|
RotateKey,
|
|
/// Irreversible removal of key material once the pending window expired.
|
|
DeleteKey,
|
|
}
|
|
|
|
impl KmsAuditOperation {
|
|
/// Stable operation name for audit consumers.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
KmsAuditOperation::CreateKey => "CreateKey",
|
|
KmsAuditOperation::DescribeKey => "DescribeKey",
|
|
KmsAuditOperation::ListKeys => "ListKeys",
|
|
KmsAuditOperation::ScheduleKeyDeletion => "ScheduleKeyDeletion",
|
|
KmsAuditOperation::CancelKeyDeletion => "CancelKeyDeletion",
|
|
KmsAuditOperation::EnableKey => "EnableKey",
|
|
KmsAuditOperation::DisableKey => "DisableKey",
|
|
KmsAuditOperation::RotateKey => "RotateKey",
|
|
KmsAuditOperation::DeleteKey => "DeleteKey",
|
|
}
|
|
}
|
|
|
|
/// Notification/audit event name carried by records for this operation.
|
|
pub fn event_name(&self) -> EventName {
|
|
match self {
|
|
KmsAuditOperation::CreateKey => EventName::KmsKeyCreated,
|
|
KmsAuditOperation::DescribeKey | KmsAuditOperation::ListKeys => EventName::KmsKeyAccessed,
|
|
KmsAuditOperation::ScheduleKeyDeletion => EventName::KmsKeyDeletionScheduled,
|
|
KmsAuditOperation::CancelKeyDeletion => EventName::KmsKeyDeletionCancelled,
|
|
KmsAuditOperation::EnableKey => EventName::KmsKeyEnabled,
|
|
KmsAuditOperation::DisableKey => EventName::KmsKeyDisabled,
|
|
KmsAuditOperation::RotateKey => EventName::KmsKeyRotated,
|
|
KmsAuditOperation::DeleteKey => EventName::KmsKeyDeleted,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether the audited operation completed.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum KmsAuditOutcome {
|
|
/// The operation completed and its effect is durable.
|
|
Success,
|
|
/// The operation failed; `error_class` carries the reason category.
|
|
Failure,
|
|
}
|
|
|
|
impl KmsAuditOutcome {
|
|
/// Stable outcome name for audit consumers.
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
KmsAuditOutcome::Success => "success",
|
|
KmsAuditOutcome::Failure => "failure",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Coarse, stable classification of a failed KMS operation.
|
|
///
|
|
/// Audit consumers alert on these, so the strings are a wire contract: add
|
|
/// new classes rather than renaming existing ones.
|
|
pub fn error_class(error: &KmsError) -> &'static str {
|
|
match error {
|
|
KmsError::ConfigurationError { .. } => "configuration",
|
|
KmsError::KeyNotFound { .. } => "key_not_found",
|
|
KmsError::InvalidKey { .. } => "invalid_key",
|
|
KmsError::CryptographicError { .. } => "cryptographic",
|
|
KmsError::BackendError { .. } => "backend",
|
|
KmsError::AccessDenied { .. } => "access_denied",
|
|
KmsError::KeyAlreadyExists { .. } => "key_already_exists",
|
|
KmsError::InvalidOperation { .. } => "invalid_operation",
|
|
KmsError::InternalError { .. } => "internal",
|
|
KmsError::SerializationError { .. } => "serialization",
|
|
KmsError::IoError { .. } => "io",
|
|
KmsError::CacheError { .. } => "cache",
|
|
KmsError::ValidationError { .. } => "validation",
|
|
KmsError::UnsupportedAlgorithm { .. } => "unsupported_algorithm",
|
|
KmsError::InvalidKeySize { .. } => "invalid_key_size",
|
|
KmsError::ContextMismatch { .. } => "context_mismatch",
|
|
KmsError::OperationTimedOut { .. } => "timeout",
|
|
KmsError::OperationCancelled { .. } => "cancelled",
|
|
KmsError::MaterialMissing { .. } => "material_missing",
|
|
KmsError::MaterialCorrupt { .. } => "material_corrupt",
|
|
KmsError::MaterialAuthenticationFailed { .. } => "material_authentication_failed",
|
|
KmsError::UnsupportedFormatVersion { .. } => "unsupported_format_version",
|
|
KmsError::KeyVersionNotFound { .. } => "key_version_not_found",
|
|
KmsError::Backup(_) => "backup",
|
|
KmsError::UnsupportedCapability { .. } => "unsupported_capability",
|
|
KmsError::CredentialsUnavailable { .. } => "credentials_unavailable",
|
|
KmsError::BaselineVersionLost { .. } => "baseline_version_lost",
|
|
KmsError::KeyStillReferenced { .. } => "key_still_referenced",
|
|
KmsError::RewrapWouldExposePlaintext { .. } => "rewrap_would_expose_plaintext",
|
|
}
|
|
}
|
|
|
|
/// One audited KMS management operation.
|
|
///
|
|
/// Everything an audit consumer needs to answer "who did what to which key,
|
|
/// against which backend, and did it work" — and nothing that could carry key
|
|
/// material. See the module docs for the redaction rules.
|
|
///
|
|
/// Tenant attribution is intentionally absent: multi-tenancy is not modelled
|
|
/// yet, and an invented tenant value is worse than a missing one. It will
|
|
/// arrive as an entry in [`Self::context`] once tenancy lands.
|
|
#[derive(Debug, Clone)]
|
|
pub struct KmsAuditRecord {
|
|
/// Correlates every record emitted for one logical request.
|
|
pub operation_id: Uuid,
|
|
/// The audited operation.
|
|
pub operation: KmsAuditOperation,
|
|
/// Event name published to audit consumers.
|
|
pub event: EventName,
|
|
/// Authenticated identity that requested the operation, or
|
|
/// [`OperationContext::INTERNAL_PRINCIPAL`] for server-initiated work.
|
|
pub principal: String,
|
|
/// Client address, when the caller supplied one.
|
|
pub source_ip: Option<String>,
|
|
/// Client user agent, when the caller supplied one.
|
|
pub user_agent: Option<String>,
|
|
/// Key the operation acted on. `None` for operations that span keys, such
|
|
/// as listing.
|
|
pub key_id: Option<String>,
|
|
/// Key version the operation resolved to, when the result identifies one.
|
|
/// Operations on a key as a whole leave this unset.
|
|
pub key_version: Option<u32>,
|
|
/// Whether the operation succeeded.
|
|
pub outcome: KmsAuditOutcome,
|
|
/// Failure category; `None` on success. See [`error_class`].
|
|
pub error_class: Option<&'static str>,
|
|
/// Backend that served the operation, e.g. `local` or `vault-transit`.
|
|
pub backend: &'static str,
|
|
/// Wall-clock time spent in the audited operation.
|
|
pub latency: Duration,
|
|
/// Retries performed above the audit point. `None` means the number is
|
|
/// not observable here: backend-internal retries are accounted for by the
|
|
/// `rustfs_kms_backend_operation_attempts` metric instead of being
|
|
/// duplicated (and possibly contradicted) in the audit trail.
|
|
pub retry_count: Option<u32>,
|
|
/// Server-supplied correlation values carried by the operation context.
|
|
/// Also the reserved slot for tenant attribution.
|
|
pub context: BTreeMap<String, String>,
|
|
/// Caller-supplied encryption context, after [`redact_encryption_context`].
|
|
pub encryption_context: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl KmsAuditRecord {
|
|
/// Start a record for `operation` performed under `context`.
|
|
///
|
|
/// The outcome defaults to failure so that a record which somehow escapes
|
|
/// without [`Self::with_result`] under-reports success rather than
|
|
/// inventing it.
|
|
pub fn new(operation: KmsAuditOperation, context: &OperationContext, backend: &'static str) -> Self {
|
|
Self {
|
|
operation_id: context.operation_id,
|
|
operation,
|
|
event: operation.event_name(),
|
|
principal: context.principal.clone(),
|
|
source_ip: context.source_ip.clone(),
|
|
user_agent: context.user_agent.clone(),
|
|
key_id: None,
|
|
key_version: None,
|
|
outcome: KmsAuditOutcome::Failure,
|
|
error_class: None,
|
|
backend,
|
|
latency: Duration::ZERO,
|
|
retry_count: None,
|
|
context: context
|
|
.additional_context
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect(),
|
|
encryption_context: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Attach the key the operation acted on.
|
|
pub fn with_key_id(mut self, key_id: Option<impl Into<String>>) -> Self {
|
|
self.key_id = key_id.map(Into::into);
|
|
self
|
|
}
|
|
|
|
/// Attach the key version the operation resolved to.
|
|
pub fn with_key_version(mut self, key_version: Option<u32>) -> Self {
|
|
self.key_version = key_version;
|
|
self
|
|
}
|
|
|
|
/// Attach the measured duration of the operation.
|
|
pub fn with_latency(mut self, latency: Duration) -> Self {
|
|
self.latency = latency;
|
|
self
|
|
}
|
|
|
|
/// Derive outcome and error class from the operation result.
|
|
pub fn with_result<T>(mut self, result: &crate::error::Result<T>) -> Self {
|
|
match result {
|
|
Ok(_) => {
|
|
self.outcome = KmsAuditOutcome::Success;
|
|
self.error_class = None;
|
|
}
|
|
Err(error) => {
|
|
self.outcome = KmsAuditOutcome::Failure;
|
|
self.error_class = Some(error_class(error));
|
|
}
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Attach the caller-supplied encryption context, redacted.
|
|
///
|
|
/// Redaction happens here rather than at the call site so no caller can
|
|
/// place a raw context value into a record by accident.
|
|
pub fn with_encryption_context(mut self, encryption_context: &HashMap<String, String>) -> Self {
|
|
self.encryption_context = redact_encryption_context(encryption_context);
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Reduce a caller-supplied encryption context to something safe to persist.
|
|
///
|
|
/// Keys naming the object's location are kept verbatim because they are the
|
|
/// reason the context is audited at all. Every other value is replaced by a
|
|
/// truncated digest, which still correlates repeated values across records
|
|
/// but does not reproduce whatever the caller put there.
|
|
pub fn redact_encryption_context(encryption_context: &HashMap<String, String>) -> BTreeMap<String, String> {
|
|
encryption_context
|
|
.iter()
|
|
.map(|(key, value)| {
|
|
let recorded = if ENCRYPTION_CONTEXT_ALLOWLIST.contains(&key.as_str()) {
|
|
value.clone()
|
|
} else {
|
|
digest_value(value)
|
|
};
|
|
(key.clone(), recorded)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn digest_value(value: &str) -> String {
|
|
let digest = hex::encode(Sha256::digest(value.as_bytes()));
|
|
format!("{DIGEST_PREFIX}{}", &digest[..DIGEST_LEN])
|
|
}
|
|
|
|
/// Receives KMS audit records.
|
|
///
|
|
/// Implementations must not block: they are called on the task that served
|
|
/// the KMS operation. Delivery failures are the sink's problem — the KMS
|
|
/// operation has already completed by the time a record is emitted, and its
|
|
/// result is never changed by what the sink does.
|
|
pub trait KmsAuditSink: Send + Sync {
|
|
/// Handle one audit record.
|
|
fn emit(&self, record: KmsAuditRecord);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn every_operation_maps_to_a_kms_event() {
|
|
let operations = [
|
|
KmsAuditOperation::CreateKey,
|
|
KmsAuditOperation::DescribeKey,
|
|
KmsAuditOperation::ListKeys,
|
|
KmsAuditOperation::ScheduleKeyDeletion,
|
|
KmsAuditOperation::CancelKeyDeletion,
|
|
KmsAuditOperation::EnableKey,
|
|
KmsAuditOperation::DisableKey,
|
|
KmsAuditOperation::RotateKey,
|
|
KmsAuditOperation::DeleteKey,
|
|
];
|
|
|
|
for operation in operations {
|
|
let event = operation.event_name();
|
|
assert!(event.is_kms(), "{} must map to a KMS event, got {event}", operation.as_str());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn record_defaults_to_failure_until_a_result_is_attached() {
|
|
let context = OperationContext::new("tester".to_string());
|
|
let record = KmsAuditRecord::new(KmsAuditOperation::CreateKey, &context, "local");
|
|
|
|
assert_eq!(record.outcome, KmsAuditOutcome::Failure);
|
|
assert!(record.error_class.is_none());
|
|
|
|
let ok: crate::error::Result<()> = Ok(());
|
|
assert_eq!(record.clone().with_result(&ok).outcome, KmsAuditOutcome::Success);
|
|
|
|
let denied: crate::error::Result<()> = Err(KmsError::access_denied("nope"));
|
|
let failed = record.with_result(&denied);
|
|
assert_eq!(failed.outcome, KmsAuditOutcome::Failure);
|
|
assert_eq!(failed.error_class, Some("access_denied"));
|
|
}
|
|
|
|
#[test]
|
|
fn record_carries_the_full_operation_context() {
|
|
let context = OperationContext::new("arn:aws:iam::user/alice".to_string())
|
|
.with_source_ip("10.0.0.7".to_string())
|
|
.with_user_agent("aws-cli/2".to_string())
|
|
.with_context("requestID".to_string(), "req-1".to_string());
|
|
|
|
let record = KmsAuditRecord::new(KmsAuditOperation::RotateKey, &context, "vault-transit")
|
|
.with_key_id(Some("key-1"))
|
|
.with_key_version(Some(3))
|
|
.with_latency(Duration::from_millis(12));
|
|
|
|
assert_eq!(record.operation_id, context.operation_id);
|
|
assert_eq!(record.principal, "arn:aws:iam::user/alice");
|
|
assert_eq!(record.source_ip.as_deref(), Some("10.0.0.7"));
|
|
assert_eq!(record.user_agent.as_deref(), Some("aws-cli/2"));
|
|
assert_eq!(record.key_id.as_deref(), Some("key-1"));
|
|
assert_eq!(record.key_version, Some(3));
|
|
assert_eq!(record.backend, "vault-transit");
|
|
assert_eq!(record.latency, Duration::from_millis(12));
|
|
assert_eq!(record.event, EventName::KmsKeyRotated);
|
|
assert_eq!(record.context.get("requestID").map(String::as_str), Some("req-1"));
|
|
}
|
|
|
|
#[test]
|
|
fn encryption_context_keeps_only_allowlisted_values_verbatim() {
|
|
let secret = "eyJhbGciOiJIUzI1NiJ9.super-secret-grant-token";
|
|
let context = HashMap::from([
|
|
("bucket".to_string(), "photos".to_string()),
|
|
("object_key".to_string(), "2026/cat.png".to_string()),
|
|
("algorithm".to_string(), "AES256".to_string()),
|
|
("grant_token".to_string(), secret.to_string()),
|
|
("customer_reference".to_string(), "acct-4711".to_string()),
|
|
]);
|
|
|
|
let redacted = redact_encryption_context(&context);
|
|
|
|
assert_eq!(redacted.get("bucket").map(String::as_str), Some("photos"));
|
|
assert_eq!(redacted.get("object_key").map(String::as_str), Some("2026/cat.png"));
|
|
assert_eq!(redacted.get("algorithm").map(String::as_str), Some("AES256"));
|
|
|
|
for key in ["grant_token", "customer_reference"] {
|
|
let value = redacted.get(key).expect("non-allowlisted key should be kept as a digest");
|
|
assert!(value.starts_with(DIGEST_PREFIX), "{key} should be digested, got {value}");
|
|
}
|
|
|
|
let rendered = format!("{redacted:?}");
|
|
assert!(!rendered.contains(secret), "redacted context must not reproduce the raw value");
|
|
assert!(!rendered.contains("acct-4711"), "redacted context must not reproduce the raw value");
|
|
}
|
|
|
|
#[test]
|
|
fn identical_values_digest_identically_across_records() {
|
|
// Correlation is the reason a digest is preferable to a fixed
|
|
// placeholder; assert it actually holds.
|
|
let first = redact_encryption_context(&HashMap::from([("tenant".to_string(), "alpha".to_string())]));
|
|
let second = redact_encryption_context(&HashMap::from([("tenant".to_string(), "alpha".to_string())]));
|
|
let other = redact_encryption_context(&HashMap::from([("tenant".to_string(), "beta".to_string())]));
|
|
|
|
assert_eq!(first.get("tenant"), second.get("tenant"));
|
|
assert_ne!(first.get("tenant"), other.get("tenant"));
|
|
}
|
|
}
|