feat(security): zeroize long-lived engine key material on drop #238

Merged
KofTwentyTwo merged 1 commits from story/m6-zeroize-keys into dev 2026-07-30 03:10:02 +00:00
6 changed files with 69 additions and 9 deletions

No files matched your search

Generated
+2
View File
@@ -2561,6 +2561,7 @@ dependencies = [
"tokio",
"tracing",
"wiremock",
"zeroize",
]
[[package]]
@@ -2653,6 +2654,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"wiremock",
"zeroize",
]
[[package]]
+1
View File
@@ -25,6 +25,7 @@ async-trait.workspace = true
chrono.workspace = true
reqwest.workspace = true
hex.workspace = true
zeroize.workspace = true
[dev-dependencies]
tempfile.workspace = true
+3 -2
View File
@@ -8,6 +8,7 @@ use serde_json::json;
use sha2::Sha256;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use zeroize::Zeroizing;
type HmacSha256 = Hmac<Sha256>;
@@ -28,7 +29,7 @@ pub enum WebhookError {
/// Outbound Webhook Dispatcher executing authenticated HTTP POST requests.
pub struct WebhookDispatcher {
client: Client,
secret_key: String,
secret_key: Zeroizing<String>,
}
impl WebhookDispatcher {
@@ -42,7 +43,7 @@ impl WebhookDispatcher {
Self {
client,
secret_key: secret_key.into(),
secret_key: Zeroizing::new(secret_key.into()),
}
}
+56 -5
View File
@@ -6,6 +6,7 @@ use std::path::Path;
use std::str::FromStr;
use tempfile::TempDir;
use thiserror::Error;
use zeroize::Zeroizing;
/// Raw row shape for a pending remote mutation record fetched from SQLite.
type PendingMutationRow = (String, String, String, String, String, String, i64, i64);
@@ -102,9 +103,9 @@ impl DatabaseError {
#[derive(Clone)]
pub struct DatabaseEngine {
pool: SqlitePool,
storage_key: [u8; 32],
worm_key: Vec<u8>,
ledger_key: Vec<u8>,
storage_key: Zeroizing<[u8; 32]>,
worm_key: Zeroizing<Vec<u8>>,
ledger_key: Zeroizing<Vec<u8>>,
}
impl std::fmt::Debug for DatabaseEngine {
@@ -122,7 +123,7 @@ impl std::fmt::Debug for DatabaseEngine {
#[allow(clippy::type_complexity)]
fn resolve_engine_keys(
secrets: &crate::vault::SecretManager,
) -> Result<([u8; 32], Vec<u8>, Vec<u8>), DatabaseError> {
) -> Result<(Zeroizing<[u8; 32]>, Zeroizing<Vec<u8>>, Zeroizing<Vec<u8>>), DatabaseError> {
let storage_key_bytes = secrets
.get_or_create_key_bytes(crate::vault::STORAGE_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
@@ -135,7 +136,11 @@ fn resolve_engine_keys(
let ledger_key = secrets
.get_or_create_key_bytes(crate::vault::LEDGER_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
Ok((storage_key, worm_key, ledger_key))
Ok((
Zeroizing::new(storage_key),
Zeroizing::new(worm_key),
Zeroizing::new(ledger_key),
))
}
/// Reconstruct a [`nuncio_contacts::Contact`] from a `contacts` table row.
@@ -3268,4 +3273,50 @@ mod tests {
assert_eq!(report.record_count, 1);
assert_eq!(report.first_broken_seq, Some(1));
}
/// Proves the zeroize mechanism `Zeroizing`'s `Drop` relies on is real, not
/// decorative: explicitly invoking `Zeroize::zeroize` on key-shaped byte
/// buffers overwrites every byte with zero. This is checked directly
/// (without dropping the value) because inspecting freed heap memory would
/// require `unsafe`, which this workspace forbids.
#[test]
fn zeroize_mechanism_actually_wipes_key_bytes() {
use zeroize::Zeroize;
let mut fixed = Zeroizing::new([0xABu8; 32]);
assert!(fixed.iter().all(|&b| b != 0), "fixture must start non-zero");
fixed.zeroize();
assert!(
fixed.iter().all(|&b| b == 0),
"Zeroize::zeroize must overwrite every byte of a fixed-size key buffer"
);
let mut variable = Zeroizing::new(vec![0xCDu8; 32]);
assert!(
variable.iter().all(|&b| b != 0),
"fixture must start non-zero"
);
variable.zeroize();
assert!(
variable.iter().all(|&b| b == 0),
"Zeroize::zeroize must overwrite every byte of a heap-allocated key buffer"
);
}
/// `DatabaseEngine`'s manual `Debug` impl must never print the raw
/// storage/WORM/ledger key bytes it now holds in `Zeroizing` wrappers,
/// even after they were introduced.
#[tokio::test]
async fn debug_impl_never_prints_raw_key_bytes() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let debug_output = format!("{engine:?}");
assert!(
!debug_output.contains("storage_key")
&& !debug_output.contains("worm_key")
&& !debug_output.contains("ledger_key"),
"Debug output must never expose key field names/values: {debug_output}"
);
}
}
+1
View File
@@ -33,6 +33,7 @@ async-trait.workspace = true
subtle.workspace = true
hex.workspace = true
bytes.workspace = true
zeroize.workspace = true
[dev-dependencies]
nuncio-cli = { path = "../nuncio-cli" }
+6 -2
View File
@@ -511,10 +511,14 @@ impl Accounts for AccountsGrpcService {
request: Request<AddAccountRequest>,
) -> Result<Response<AddAccountResponse>, Status> {
let req = request.into_inner();
// Moved into a zeroizing wrapper immediately so the plaintext credential's
// backing memory is overwritten as soon as this function returns, rather
// than lingering as an ordinary `String` for the rest of the process.
let password = zeroize::Zeroizing::new(req.password);
let proto_config = req
.config
.ok_or_else(|| Status::invalid_argument("config is required"))?;
if req.password.is_empty() {
if password.is_empty() {
return Err(Status::invalid_argument("password is required"));
}
@@ -538,7 +542,7 @@ impl Accounts for AccountsGrpcService {
// The original persistence error is always what the caller sees --
// a rollback failure is reported alongside it, never in place of it.
self.secrets
.set_secret(&config.keyring_secret_key, &req.password)
.set_secret(&config.keyring_secret_key, &password)
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
if let Err(e) = self.db.save_account(&config).await {