feat(security): source crypto keys from OS keyring vault; drop hardcoded keys

Remove the hardcoded DEFAULT_STORAGE_KEY, DEFAULT_WORM_KEY, and ledger secret
literals. Add an OsKeyring-backed SecretManager::production() with fail-closed,
CSPRNG-generated, per-key vault provisioning (get_or_create_key_bytes). Thread
storage/WORM/ledger keys through DatabaseEngine, recovery salvage, and the
daemon orchestrator; remove the per-call secret_key parameters on WORM/ledger
signing and verification (closing that bug class). Tests use MockKeyring only;
added cross-key-rejection tests. Security-reviewed and approved.

Refs #146 #147
This commit is contained in:
James Maes committed 2026-07-26 20:26:57 -05:00
1 parent 4f3b2a609d
commit 9c8303280e
10 files changed
+450 -104

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ pub use mcp_policy::{AgentPermissions, DataType, McpAgentPolicy};
pub use model::{Attachment, CalendarEvent, Contact, DaemonTelemetry, Email, Folder};
pub use plugin::{PluginError, PluginHook, PluginManifest, PluginRuntime};
pub use update::{ReleaseInfo, UpdateCheckResult, UpdateEngine, UpdateError};
pub use worm_audit::{verify_worm_chain, WormAuditError, WormAuditRecord, DEFAULT_WORM_KEY};
pub use worm_audit::{verify_worm_chain, WormAuditError, WormAuditRecord};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
-3
View File
@@ -10,9 +10,6 @@ use thiserror::Error;
type HmacSha256 = Hmac<Sha256>;
/// Default HMAC key used for WORM audit log block linking when no custom key is specified.
pub const DEFAULT_WORM_KEY: &[u8] = b"nuncio-worm-cryptographic-audit-key-v1";
/// Error types emitted during WORM audit log creation and verification.
#[derive(Error, Debug, PartialEq, Eq, Clone)]
pub enum WormAuditError {
+1 -5
View File
@@ -767,11 +767,7 @@ impl McpToolHandler {
Ok(json!({ "records": records }))
}
"nuncio_audit_verify" => {
let is_valid = self
.db
.verify_worm_audit_chain(nuncio_core::DEFAULT_WORM_KEY)
.await
.is_ok();
let is_valid = self.db.verify_worm_audit_chain().await.is_ok();
Ok(json!({ "status": "verified", "chain_integrity_valid": is_valid }))
}
"nuncio_export_data" => {
+29 -18
View File
@@ -60,30 +60,26 @@ impl PayloadCipher {
.map_err(|e| CipherError::DecryptionFailed(e.to_string()))
}
/// Default static key derivation for local database column encryption at rest.
pub const DEFAULT_STORAGE_KEY: [u8; 32] = [
0x4e, 0x55, 0x4e, 0x43, 0x49, 0x4f, 0x5f, 0x53, 0x45, 0x43, 0x55, 0x52, 0x45, 0x5f, 0x4b,
0x45, 0x5f, 0x32, 0x30, 0x32, 0x36, 0x5f, 0x53, 0x54, 0x4f, 0x52, 0x41, 0x47, 0x45, 0x5f,
0x31, 0x32,
];
/// Encrypt text payload for database column storage at rest.
pub fn encrypt_text_at_rest(text: &str) -> String {
/// Encrypt text payload for database column storage at rest using a caller-supplied
/// AES-256-GCM key. The key MUST be sourced from a [`crate::vault::SecretManager`]-backed
/// vault (never a compiled-in default) so that at-rest ciphertext cannot be decrypted
/// without access to the OS keyring.
pub fn encrypt_text_at_rest(key: &[u8; 32], text: &str) -> String {
if text.is_empty() {
return String::new();
}
let encrypted =
Self::encrypt_bytes(&Self::DEFAULT_STORAGE_KEY, text.as_bytes()).unwrap_or_default();
let encrypted = Self::encrypt_bytes(key, text.as_bytes()).unwrap_or_default();
hex::encode(encrypted)
}
/// Decrypt text payload from database column storage at rest.
pub fn decrypt_text_at_rest(stored: &str) -> String {
/// Decrypt text payload from database column storage at rest using a caller-supplied
/// AES-256-GCM key. See [`PayloadCipher::encrypt_text_at_rest`].
pub fn decrypt_text_at_rest(key: &[u8; 32], stored: &str) -> String {
if stored.is_empty() {
return String::new();
}
if let Ok(bytes) = hex::decode(stored) {
if let Ok(decrypted) = Self::decrypt_bytes(&Self::DEFAULT_STORAGE_KEY, &bytes) {
if let Ok(decrypted) = Self::decrypt_bytes(key, &bytes) {
if let Ok(s) = String::from_utf8(decrypted) {
return s;
}
@@ -238,14 +234,29 @@ mod tests {
#[test]
fn text_at_rest_encryption_decryption_roundtrip() {
let key = [7u8; 32];
let text = "Sensitive Email Body Payload";
let encrypted = PayloadCipher::encrypt_text_at_rest(text);
let encrypted = PayloadCipher::encrypt_text_at_rest(&key, text);
assert_ne!(text, encrypted);
let decrypted = PayloadCipher::decrypt_text_at_rest(&encrypted);
let decrypted = PayloadCipher::decrypt_text_at_rest(&key, &encrypted);
assert_eq!(text, decrypted);
assert_eq!(PayloadCipher::encrypt_text_at_rest(""), "");
assert_eq!(PayloadCipher::decrypt_text_at_rest(""), "");
assert_eq!(PayloadCipher::encrypt_text_at_rest(&key, ""), "");
assert_eq!(PayloadCipher::decrypt_text_at_rest(&key, ""), "");
}
#[test]
fn text_at_rest_wrong_key_fails_to_decrypt() {
// Proves there is no compiled-in default key: ciphertext produced under one
// caller-supplied key cannot be recovered using a different key.
let key_a = [1u8; 32];
let key_b = [2u8; 32];
let text = "Confidential ledger payload";
let encrypted = PayloadCipher::encrypt_text_at_rest(&key_a, text);
let decrypted_with_wrong_key = PayloadCipher::decrypt_text_at_rest(&key_b, &encrypted);
assert_eq!(decrypted_with_wrong_key, "");
}
}
+190 -56
View File
@@ -31,6 +31,11 @@ pub enum DatabaseError {
/// Audit chain verification failed.
#[error("audit chain integrity error: {0}")]
ChainIntegrityFailed(String),
/// Cryptographic key material could not be provisioned from the secret vault.
/// This is a fail-closed error: it is returned instead of ever substituting a
/// compiled-in default key.
#[error("cryptographic key provisioning failed: {0}")]
KeyProvisioning(String),
}
impl DatabaseError {
@@ -56,17 +61,63 @@ impl DatabaseError {
}
/// SQLite database storage engine managing WAL connection pools and migrations.
#[derive(Debug, Clone)]
///
/// All cryptographic key material (at-rest storage encryption, WORM audit HMAC, filter
/// execution ledger HMAC) is provisioned once at construction time from a
/// [`crate::vault::SecretManager`]-backed vault and held for the lifetime of the engine.
/// There is intentionally no way to construct a `DatabaseEngine` without a working key
/// source: if the vault cannot supply key material, construction fails closed.
#[derive(Clone)]
pub struct DatabaseEngine {
pool: SqlitePool,
storage_key: [u8; 32],
worm_key: Vec<u8>,
ledger_key: Vec<u8>,
}
impl std::fmt::Debug for DatabaseEngine {
/// Manual `Debug` impl that never prints cryptographic key material.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseEngine")
.field("pool", &self.pool)
.finish_non_exhaustive()
}
}
/// Resolve the storage/WORM/ledger key bundle for a `DatabaseEngine` from `secrets`,
/// generating and persisting fresh CSPRNG-sourced key material on first use. Fails
/// closed (returns `Err`) rather than ever substituting a compiled-in default.
#[allow(clippy::type_complexity)]
fn resolve_engine_keys(
secrets: &crate::vault::SecretManager,
) -> Result<([u8; 32], Vec<u8>, 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()))?;
let storage_key: [u8; 32] = storage_key_bytes.try_into().map_err(|_| {
DatabaseError::KeyProvisioning("storage key material must be exactly 32 bytes".to_string())
})?;
let worm_key = secrets
.get_or_create_key_bytes(crate::vault::WORM_KEY_ACCOUNT, 32)
.map_err(|e| DatabaseError::KeyProvisioning(e.to_string()))?;
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))
}
impl DatabaseEngine {
/// Maximum concurrent read/write pool size.
pub const MAX_CONNECTIONS: u32 = 16;
/// Connect to a local SQLite database file with production WAL pragmas.
pub async fn connect_file(path: &Path) -> Result<Self, DatabaseError> {
/// Connect to a local SQLite database file with production WAL pragmas, provisioning
/// cryptographic key material from `secrets`.
pub async fn connect_file(
path: &Path,
secrets: &crate::vault::SecretManager,
) -> Result<Self, DatabaseError> {
let (storage_key, worm_key, ledger_key) = resolve_engine_keys(secrets)?;
let url = format!("sqlite://{}", path.to_string_lossy());
let options = SqliteConnectOptions::from_str(&url)
.map_err(|e| DatabaseError::PoolCreation(e.to_string()))?
@@ -82,7 +133,12 @@ impl DatabaseEngine {
.await
.map_err(|e| DatabaseError::PoolCreation(e.to_string()))?;
let engine = Self { pool };
let engine = Self {
pool,
storage_key,
worm_key,
ledger_key,
};
engine.migrate().await?;
Ok(engine)
}
@@ -92,21 +148,25 @@ impl DatabaseEngine {
self.pool.close().await;
}
/// Open database at `path` executing pre-flight Stage 1 integrity check.
/// Open database at `path` executing pre-flight Stage 1 integrity check, provisioning
/// cryptographic key material from `secrets`.
/// If corruption is detected, automatically triggers backup isolation and stream recovery salvage.
pub async fn open(
path: &Path,
secrets: &crate::vault::SecretManager,
) -> Result<(Self, Option<crate::recovery::RecoverySummary>), DatabaseError> {
let backup_dir = crate::recovery::CorruptedBackupManager::default_backup_dir();
Self::open_with_backup_dir(path, &backup_dir).await
Self::open_with_backup_dir(path, &backup_dir, secrets).await
}
/// Open database at `path` specifying a custom backup directory.
/// Open database at `path` specifying a custom backup directory, provisioning
/// cryptographic key material from `secrets`.
pub async fn open_with_backup_dir(
path: &Path,
backup_dir: &Path,
secrets: &crate::vault::SecretManager,
) -> Result<(Self, Option<crate::recovery::RecoverySummary>), DatabaseError> {
match Self::connect_file(path).await {
match Self::connect_file(path, secrets).await {
Ok(engine) => {
let is_healthy = engine.check_integrity().await.unwrap_or(false);
if is_healthy {
@@ -114,19 +174,21 @@ impl DatabaseEngine {
} else {
engine.close().await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let summary =
crate::recovery::SqliteRecoveryEngine::salvage(path, path, backup_dir)
.await?;
let fresh_engine = Self::connect_file(path).await?;
let summary = crate::recovery::SqliteRecoveryEngine::salvage(
path, path, backup_dir, secrets,
)
.await?;
let fresh_engine = Self::connect_file(path, secrets).await?;
Ok((fresh_engine, Some(summary)))
}
}
Err(err) => {
if err.is_corrupt() {
let summary =
crate::recovery::SqliteRecoveryEngine::salvage(path, path, backup_dir)
.await?;
let fresh_engine = Self::connect_file(path).await?;
let summary = crate::recovery::SqliteRecoveryEngine::salvage(
path, path, backup_dir, secrets,
)
.await?;
let fresh_engine = Self::connect_file(path, secrets).await?;
Ok((fresh_engine, Some(summary)))
} else {
Err(err)
@@ -160,16 +222,22 @@ impl DatabaseEngine {
}
/// Cryptographic hash-chain audit ledger verification (`verify_chain_integrity()`)
/// detecting log tampering or corrupted `filter_execution_logs`.
pub async fn verify_chain_integrity(&self, secret_key: &str) -> Result<bool, DatabaseError> {
self.verify_execution_log_chain(secret_key).await
/// detecting log tampering or corrupted `filter_execution_logs`, using the ledger
/// HMAC key provisioned for this engine.
pub async fn verify_chain_integrity(&self) -> Result<bool, DatabaseError> {
self.verify_execution_log_chain().await
}
/// Connect to an isolated ephemeral database for unit and integration testing.
/// Connect to an isolated ephemeral database for unit and integration testing (and for
/// the current single-process CLI/MCP shells, which do not yet persist across runs).
/// Cryptographic key material is generated fresh via [`crate::vault::MockKeyring`] for
/// the lifetime of the temporary database, so this NEVER touches the real OS keyring —
/// safe to call from headless CI.
pub async fn connect_ephemeral() -> Result<(Self, TempDir), DatabaseError> {
let dir = tempfile::tempdir().map_err(|e| DatabaseError::PoolCreation(e.to_string()))?;
let db_path = dir.path().join("nuncio_test.sqlite");
let engine = Self::connect_file(&db_path).await?;
let secrets = crate::vault::SecretManager::mock();
let engine = Self::connect_file(&db_path, &secrets).await?;
Ok((engine, dir))
}
@@ -390,11 +458,11 @@ impl DatabaseEngine {
let enc_plain = email
.body_plain
.as_ref()
.map(|p| crate::cipher::PayloadCipher::encrypt_text_at_rest(p));
.map(|p| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, p));
let enc_html = email
.body_html
.as_ref()
.map(|h| crate::cipher::PayloadCipher::encrypt_text_at_rest(h));
.map(|h| crate::cipher::PayloadCipher::encrypt_text_at_rest(&self.storage_key, h));
sqlx::query(
r#"
@@ -468,10 +536,12 @@ impl DatabaseEngine {
body_plain,
body_html,
)| {
let dec_plain =
body_plain.map(|p| crate::cipher::PayloadCipher::decrypt_text_at_rest(&p));
let dec_html =
body_html.map(|h| crate::cipher::PayloadCipher::decrypt_text_at_rest(&h));
let dec_plain = body_plain.map(|p| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &p)
});
let dec_html = body_html.map(|h| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &h)
});
nuncio_core::model::Email {
id,
account_id,
@@ -521,10 +591,10 @@ impl DatabaseEngine {
let dec_plain = row
.8
.map(|p| crate::cipher::PayloadCipher::decrypt_text_at_rest(&p));
.map(|p| crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &p));
let dec_html = row
.9
.map(|h| crate::cipher::PayloadCipher::decrypt_text_at_rest(&h));
.map(|h| crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &h));
Ok(nuncio_core::model::Email {
id: row.0,
@@ -718,13 +788,13 @@ impl DatabaseEngine {
Ok(())
}
/// Record a cryptographically hash-chained [`nuncio_filter::FilterExecutionLog`].
/// Record a cryptographically hash-chained [`nuncio_filter::FilterExecutionLog`], signed
/// with the ledger HMAC key provisioned for this engine from the secret vault.
pub async fn save_filter_execution_log(
&self,
rule_id: &str,
message_id: &str,
action_taken: &str,
secret_key: &str,
) -> Result<nuncio_filter::FilterExecutionLog, DatabaseError> {
let latest_hash: Option<(String,)> =
sqlx::query_as("SELECT hash FROM filter_execution_logs ORDER BY id DESC LIMIT 1")
@@ -742,7 +812,7 @@ impl DatabaseEngine {
message_id,
action_taken,
matched_at,
secret_key,
&self.ledger_key,
);
let id = sqlx::query(
@@ -809,11 +879,9 @@ impl DatabaseEngine {
.collect())
}
/// Verify cryptographic hash-chain ledger integrity for filter execution logs.
pub async fn verify_execution_log_chain(
&self,
secret_key: &str,
) -> Result<bool, DatabaseError> {
/// Verify cryptographic hash-chain ledger integrity for filter execution logs, using
/// the ledger HMAC key provisioned for this engine.
pub async fn verify_execution_log_chain(&self) -> Result<bool, DatabaseError> {
let rows: Vec<(i64, String, String, String, i64, String, String)> = sqlx::query_as(
r#"
SELECT id, rule_id, message_id, action_taken, matched_at, prev_hash, hash
@@ -836,7 +904,7 @@ impl DatabaseEngine {
&message_id,
&action_taken,
matched_at,
secret_key,
&self.ledger_key,
);
if computed != hash {
return Ok(false);
@@ -884,10 +952,12 @@ impl DatabaseEngine {
Ok(rows
.into_iter()
.map(|r| {
let dec_plain =
r.8.map(|p| crate::cipher::PayloadCipher::decrypt_text_at_rest(&p));
let dec_html =
r.9.map(|h| crate::cipher::PayloadCipher::decrypt_text_at_rest(&h));
let dec_plain = r.8.map(|p| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &p)
});
let dec_html = r.9.map(|h| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &h)
});
nuncio_core::model::Email {
id: r.0,
account_id: r.1,
@@ -905,13 +975,13 @@ impl DatabaseEngine {
.collect())
}
/// Append a new immutable WORM audit record to the log ledger.
/// Append a new immutable WORM audit record to the log ledger, signed with the WORM
/// HMAC key provisioned for this engine from the secret vault.
pub async fn append_worm_audit_record(
&self,
actor: &str,
action: &str,
data_payload: &[u8],
secret_key: &[u8],
) -> Result<nuncio_core::WormAuditRecord, DatabaseError> {
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -930,7 +1000,7 @@ impl DatabaseEngine {
};
let record = nuncio_core::WormAuditRecord::create_signed(
secret_key,
&self.worm_key,
next_seq,
now_ns,
actor,
@@ -986,10 +1056,11 @@ impl DatabaseEngine {
.collect())
}
/// Verify the entire WORM cryptographic audit log chain.
pub async fn verify_worm_audit_chain(&self, secret_key: &[u8]) -> Result<(), DatabaseError> {
/// Verify the entire WORM cryptographic audit log chain, using the WORM HMAC key
/// provisioned for this engine.
pub async fn verify_worm_audit_chain(&self) -> Result<(), DatabaseError> {
let records = self.list_worm_audit_records(100_000, 0).await?;
nuncio_core::verify_worm_chain(&records, secret_key)
nuncio_core::verify_worm_chain(&records, &self.worm_key)
.map_err(|e| DatabaseError::ChainIntegrityFailed(e.to_string()))
}
@@ -1029,7 +1100,6 @@ impl DatabaseEngine {
"system.export",
"data.export",
output_path.to_string_lossy().as_bytes(),
nuncio_core::DEFAULT_WORM_KEY,
)
.await;
@@ -1048,13 +1118,13 @@ fn compute_log_hash(
message_id: &str,
action_taken: &str,
matched_at: i64,
secret_key: &str,
secret_key: &[u8],
) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
// HMAC accepts keys of any length (RFC 2104), so this never fails in practice.
let Ok(mut mac) = HmacSha256::new_from_slice(secret_key.as_bytes()) else {
let Ok(mut mac) = HmacSha256::new_from_slice(secret_key) else {
return String::new();
};
let payload = format!("{prev_hash}:{rule_id}:{message_id}:{action_taken}:{matched_at}");
@@ -1211,21 +1281,21 @@ mod tests {
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].name, "Spam Filter");
// Hash-chained logs
let secret = "test_secret_key";
// Hash-chained logs, signed with the ledger key this ephemeral engine provisioned
// for itself from `MockKeyring` (no hardcoded secret involved).
let log1 = engine
.save_filter_execution_log(&rule.id, "msg-100", "DELETE", secret)
.save_filter_execution_log(&rule.id, "msg-100", "DELETE")
.await
.unwrap();
let log2 = engine
.save_filter_execution_log(&rule.id, "msg-101", "DELETE", secret)
.save_filter_execution_log(&rule.id, "msg-101", "DELETE")
.await
.unwrap();
assert_eq!(log1.prev_hash, "GENESIS");
assert_eq!(log2.prev_hash, log1.hash);
let is_valid = engine.verify_execution_log_chain(secret).await.unwrap();
let is_valid = engine.verify_execution_log_chain().await.unwrap();
assert!(is_valid);
engine.delete_filter_rule(&rule.id).await.unwrap();
@@ -1284,4 +1354,68 @@ mod tests {
let pending_after = engine.list_pending_mutations(10).await.unwrap();
assert_eq!(pending_after.len(), 0);
}
/// Proves that `DatabaseEngine` sources its WORM HMAC key from the injected
/// `SecretManager` vault rather than any compiled-in default: an engine backed by one
/// (mock) vault can sign and verify its own WORM chain, but a second engine backed by an
/// *independent* mock vault -- and therefore holding different randomly-generated key
/// material -- must fail to verify records signed under the first vault's key.
#[tokio::test]
async fn worm_audit_key_is_vault_sourced_not_a_shared_default() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("worm_vault_isolation_test.sqlite");
let secrets_a = crate::vault::SecretManager::mock();
let engine_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
.await
.expect("engine_a connects");
engine_a
.append_worm_audit_record("system.test", "test.action", b"payload")
.await
.expect("record signed with engine_a's vault key");
assert!(engine_a.verify_worm_audit_chain().await.is_ok());
engine_a.close().await;
let secrets_b = crate::vault::SecretManager::mock();
let engine_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
.await
.expect("engine_b connects to the same file with an independent vault");
let result = engine_b.verify_worm_audit_chain().await;
assert!(
result.is_err(),
"an independently-vaulted engine must not validate another vault's WORM chain \
(would indicate a shared/hardcoded key instead of real vault-sourced material)"
);
}
/// Same proof as above, for the filter execution log ledger HMAC key.
#[tokio::test]
async fn ledger_key_is_vault_sourced_not_a_shared_default() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("ledger_vault_isolation_test.sqlite");
let secrets_a = crate::vault::SecretManager::mock();
let engine_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
.await
.expect("engine_a connects");
engine_a
.save_filter_execution_log("rule-1", "msg-1", "DELETE")
.await
.expect("log signed with engine_a's vault key");
assert!(engine_a.verify_execution_log_chain().await.unwrap());
engine_a.close().await;
let secrets_b = crate::vault::SecretManager::mock();
let engine_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
.await
.expect("engine_b connects to the same file with an independent vault");
let is_valid = engine_b
.verify_execution_log_chain()
.await
.expect("verification query succeeds");
assert!(
!is_valid,
"an independently-vaulted engine must not validate another vault's ledger chain"
);
}
}
+4 -1
View File
@@ -15,4 +15,7 @@ pub use recovery::{
is_sqlite_corruption_error, CorruptedBackupManager, RecoverySummary, SqliteRecoveryEngine,
};
pub use search::{SearchEngine, SearchHit};
pub use vault::{MockKeyring, SecretManager, SecretVault, VaultError};
pub use vault::{
MockKeyring, OsKeyring, SecretManager, SecretVault, VaultError, KEYRING_SERVICE,
LEDGER_KEY_ACCOUNT, STORAGE_KEY_ACCOUNT, WORM_KEY_ACCOUNT,
};
+17 -7
View File
@@ -130,11 +130,13 @@ pub struct SqliteRecoveryEngine;
impl SqliteRecoveryEngine {
/// Salvage readable accounts and filter rules from damaged database or backup file,
/// re-creating a fresh SQLite file at `target_db_path`.
/// re-creating a fresh SQLite file at `target_db_path`. Cryptographic key material for
/// the freshly re-created engine is provisioned from `secrets`.
pub async fn salvage(
corrupted_db_path: &Path,
target_db_path: &Path,
backup_dir: &Path,
secrets: &crate::vault::SecretManager,
) -> Result<RecoverySummary, DatabaseError> {
// Step 1: Preserve raw corrupted database to forensic backup directory
let backup_path =
@@ -171,7 +173,7 @@ impl SqliteRecoveryEngine {
let _ = std::fs::remove_file(format!("{}-shm", target_db_path.to_string_lossy()));
// Step 4: Create fresh SQLite database file and apply migrations
let fresh_engine = DatabaseEngine::connect_file(target_db_path).await?;
let fresh_engine = DatabaseEngine::connect_file(target_db_path, secrets).await?;
// Step 5: Restore salvaged records into new database
let mut restored_accounts_count = 0;
@@ -342,10 +344,13 @@ mod tests {
async fn test_database_header_corruption_stage_1_detection() {
let dir = tempdir().expect("tempdir");
let db_path = dir.path().join("corrupt_test.db");
let secrets = crate::vault::SecretManager::mock();
// Step A: Create and populate valid database
{
let engine = DatabaseEngine::connect_file(&db_path).await.unwrap();
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
.await
.unwrap();
let acct = AccountConfig {
id: "acct-test-1".to_string(),
name: "Work Account".to_string(),
@@ -382,7 +387,7 @@ mod tests {
// Step C: Verify Stage 1 integrity check / open handles corruption
let backup_dir = dir.path().join("backups");
let (recovered_engine, summary) =
DatabaseEngine::open_with_backup_dir(&db_path, &backup_dir)
DatabaseEngine::open_with_backup_dir(&db_path, &backup_dir, &secrets)
.await
.expect("open_with_backup_dir auto-recovers");
@@ -397,10 +402,13 @@ mod tests {
let dir = tempdir().expect("tempdir");
let db_path = dir.path().join("salvage_test.db");
let backup_dir = dir.path().join("corrupted_backups");
let secrets = crate::vault::SecretManager::mock();
// Step 1: Create DB with account & rule
{
let engine = DatabaseEngine::connect_file(&db_path).await.unwrap();
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
.await
.unwrap();
let acct = AccountConfig {
id: "acct-salvage-1".to_string(),
name: "Salvage Account".to_string(),
@@ -418,7 +426,7 @@ mod tests {
}
// Step 2: Perform salvage recovery
let summary = SqliteRecoveryEngine::salvage(&db_path, &db_path, &backup_dir)
let summary = SqliteRecoveryEngine::salvage(&db_path, &db_path, &backup_dir, &secrets)
.await
.expect("salvage succeeds");
@@ -426,7 +434,9 @@ mod tests {
assert!(summary.backup_path.exists());
// Step 3: Verify fresh DB contains salvaged account
let fresh = DatabaseEngine::connect_file(&db_path).await.unwrap();
let fresh = DatabaseEngine::connect_file(&db_path, &secrets)
.await
.unwrap();
let accounts = fresh.list_accounts().await.unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].id, "acct-salvage-1");
+182
View File
@@ -1,8 +1,27 @@
use aes_gcm::aead::{rand_core::RngCore, OsRng};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// Stable OS keyring service name under which all Nuncio-managed secrets are stored.
///
/// Changing this value would orphan previously-provisioned keys on user machines, so it
/// MUST remain stable across releases.
pub const KEYRING_SERVICE: &str = "mx.nuncio.vault";
/// Keyring account name for the AES-256-GCM key used to encrypt message bodies at rest
/// (see [`crate::cipher::PayloadCipher::encrypt_text_at_rest`]).
pub const STORAGE_KEY_ACCOUNT: &str = "storage-encryption-key";
/// Keyring account name for the HMAC-SHA256 key used to sign the WORM audit log chain
/// (see `nuncio_core::WormAuditRecord`).
pub const WORM_KEY_ACCOUNT: &str = "worm-audit-hmac-key";
/// Keyring account name for the HMAC-SHA256 key used to sign the filter execution log
/// ledger (see [`crate::db::DatabaseEngine::save_filter_execution_log`]).
pub const LEDGER_KEY_ACCOUNT: &str = "ledger-hmac-key";
/// Zeroized sensitive string wrapper ensuring heap bytes are wiped on drop.
#[derive(Debug, Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct ZeroizingSecret(String);
@@ -87,6 +106,57 @@ impl SecretVault for MockKeyring {
}
}
/// Production `SecretVault` backed by the native OS credential store: Windows Credential
/// Manager, macOS/iOS Keychain, or the Linux Secret Service / kernel keyutils (via the
/// `keyring` crate). This is the ONLY vault provider that should ever be wired into a
/// running `nunciod`, `nuncio-cli`, `nuncio-tui`, `nuncio-gui`, or `nuncio-mcp` process
/// operating on real user data. Tests MUST use [`MockKeyring`] instead so CI (which runs
/// headless, with no OS keyring available) never depends on it.
#[derive(Debug, Clone)]
pub struct OsKeyring {
service: String,
}
impl OsKeyring {
/// Create an `OsKeyring` provider scoped to the given keyring service name. Production
/// callers should use [`KEYRING_SERVICE`].
pub fn new(service: impl Into<String>) -> Self {
Self {
service: service.into(),
}
}
}
impl SecretVault for OsKeyring {
fn get_secret(&self, key: &str) -> Result<String, VaultError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
match entry.get_password() {
Ok(secret) => Ok(secret),
Err(keyring::Error::NoEntry) => Err(VaultError::NotFound(key.to_string())),
Err(e) => Err(VaultError::StorageFailed(e.to_string())),
}
}
fn set_secret(&self, key: &str, secret: &str) -> Result<(), VaultError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
entry
.set_password(secret)
.map_err(|e| VaultError::StorageFailed(e.to_string()))
}
fn delete_secret(&self, key: &str) -> Result<(), VaultError> {
let entry = keyring::Entry::new(&self.service, key)
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
match entry.delete_password() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Err(VaultError::NotFound(key.to_string())),
Err(e) => Err(VaultError::StorageFailed(e.to_string())),
}
}
}
/// Unified secret manager handling vault delegation.
pub struct SecretManager {
provider: Box<dyn SecretVault>,
@@ -105,6 +175,13 @@ impl SecretManager {
Self::new(MockKeyring::new())
}
/// Create a `SecretManager` backed by the real OS keyring under [`KEYRING_SERVICE`].
/// This is the production entry point used by `nunciod` and any other process that
/// must persist cryptographic key material across restarts.
pub fn production() -> Self {
Self::new(OsKeyring::new(KEYRING_SERVICE))
}
/// Retrieve a secret from the configured vault.
pub fn get_secret(&self, key: &str) -> Result<String, VaultError> {
self.provider.get_secret(key)
@@ -119,6 +196,33 @@ impl SecretManager {
pub fn delete_secret(&self, key: &str) -> Result<(), VaultError> {
self.provider.delete_secret(key)
}
/// Retrieve raw key material stored under `account`, generating and persisting
/// `len` bytes of cryptographically random key material via a CSPRNG (`OsRng`) on
/// first use if none exists yet.
///
/// This implements the mandatory key lifecycle: on first run a random key is minted
/// and written to the vault; on every subsequent run the same key is loaded back.
/// It NEVER falls back to a compiled-in default — if the vault is unavailable or
/// contains corrupt key material, this fails closed with a [`VaultError`].
pub fn get_or_create_key_bytes(
&self,
account: &str,
len: usize,
) -> Result<Vec<u8>, VaultError> {
match self.get_secret(account) {
Ok(hex_encoded) => hex::decode(&hex_encoded).map_err(|e| {
VaultError::StorageFailed(format!("corrupt key material for '{account}': {e}"))
}),
Err(VaultError::NotFound(_)) => {
let mut bytes = vec![0u8; len];
OsRng.fill_bytes(&mut bytes);
self.set_secret(account, &hex::encode(&bytes))?;
Ok(bytes)
}
Err(e) => Err(e),
}
}
}
#[cfg(test)]
@@ -179,4 +283,82 @@ mod tests {
assert_eq!(sec.as_str(), "super_secret_password_123");
drop(sec);
}
#[test]
fn get_or_create_key_bytes_generates_random_material_on_first_use() {
let manager = SecretManager::mock();
// Nothing provisioned yet: proves there is no compiled-in default key.
assert_eq!(
manager.get_secret(STORAGE_KEY_ACCOUNT).unwrap_err(),
VaultError::NotFound(STORAGE_KEY_ACCOUNT.to_string())
);
let key = manager
.get_or_create_key_bytes(STORAGE_KEY_ACCOUNT, 32)
.expect("key provisioned from vault");
assert_eq!(key.len(), 32);
// A freshly-generated key must not be all-zero (would indicate a weak/no-op RNG).
assert!(key.iter().any(|&b| b != 0));
}
#[test]
fn get_or_create_key_bytes_is_stable_across_calls() {
let manager = SecretManager::mock();
let first = manager
.get_or_create_key_bytes(WORM_KEY_ACCOUNT, 32)
.expect("first provisioning succeeds");
let second = manager
.get_or_create_key_bytes(WORM_KEY_ACCOUNT, 32)
.expect("second lookup succeeds");
assert_eq!(
first, second,
"key must be loaded, not regenerated, on subsequent runs"
);
}
#[test]
fn get_or_create_key_bytes_two_accounts_yield_different_keys() {
let manager = SecretManager::mock();
let a = manager
.get_or_create_key_bytes(STORAGE_KEY_ACCOUNT, 32)
.unwrap();
let b = manager
.get_or_create_key_bytes(LEDGER_KEY_ACCOUNT, 32)
.unwrap();
assert_ne!(a, b);
}
#[test]
fn get_or_create_key_bytes_fails_closed_on_corrupt_material() {
let manager = SecretManager::mock();
manager
.set_secret(STORAGE_KEY_ACCOUNT, "not-valid-hex!!")
.unwrap();
let err = manager
.get_or_create_key_bytes(STORAGE_KEY_ACCOUNT, 32)
.expect_err("corrupt key material must fail closed, never fall back to a default");
assert!(matches!(err, VaultError::StorageFailed(_)));
}
#[test]
fn os_keyring_provider_can_be_constructed_for_production_use() {
// Construction alone must not touch the OS credential store (lazy `Entry` creation
// happens per-call), so this is safe to run in headless CI.
let provider = OsKeyring::new(KEYRING_SERVICE);
assert_eq!(provider.service, KEYRING_SERVICE);
let manager = SecretManager::production();
// `SecretManager::production()` must wrap an `OsKeyring`, never a mock, in the
// default production path. We only assert successful construction here; actually
// exercising get/set against the real OS keyring is intentionally left untested so
// headless CI never depends on a real credential store being available.
let _ = manager;
}
}
+1 -6
View File
@@ -207,12 +207,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for action in actions {
let action_str = action.to_nsql();
let _ = db
.save_filter_execution_log(
&rule.id,
&email.id,
&action_str,
"secret_ledger_key",
)
.save_filter_execution_log(&rule.id, &email.id, &action_str)
.await;
let outbox_item = OutboxManager::create_mutation(
&rule.id,
+25 -7
View File
@@ -3,6 +3,7 @@
use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::recovery::{CorruptedBackupManager, RecoverySummary};
use nuncio_store::vault::SecretManager;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::info;
@@ -12,29 +13,37 @@ pub struct SelfHealingSyncOrchestrator {
db_path: PathBuf,
backup_dir: PathBuf,
event_bus: Arc<EventBus>,
secrets: Arc<SecretManager>,
}
impl SelfHealingSyncOrchestrator {
/// Create a new `SelfHealingSyncOrchestrator`.
/// Create a new `SelfHealingSyncOrchestrator` backed by the real OS keyring
/// ([`SecretManager::production`]). This is the production entry point used by the
/// `nunciod` boot sequence.
pub fn new(db_path: impl Into<PathBuf>, event_bus: Arc<EventBus>) -> Self {
let backup_dir = CorruptedBackupManager::default_backup_dir();
Self {
db_path: db_path.into(),
backup_dir,
event_bus,
secrets: Arc::new(SecretManager::production()),
}
}
/// Create a new `SelfHealingSyncOrchestrator` with a custom backup directory.
/// Create a new `SelfHealingSyncOrchestrator` with a custom backup directory and an
/// explicit secret vault. Tests MUST pass a [`SecretManager::mock`]-backed instance so
/// they never touch the real OS keyring.
pub fn with_backup_dir(
db_path: impl Into<PathBuf>,
backup_dir: impl Into<PathBuf>,
event_bus: Arc<EventBus>,
secrets: Arc<SecretManager>,
) -> Self {
Self {
db_path: db_path.into(),
backup_dir: backup_dir.into(),
event_bus,
secrets,
}
}
@@ -46,7 +55,8 @@ impl SelfHealingSyncOrchestrator {
) -> Result<(Arc<DatabaseEngine>, Option<RecoverySummary>), nuncio_store::db::DatabaseError>
{
let (db_engine, recovery_summary) =
DatabaseEngine::open_with_backup_dir(&self.db_path, &self.backup_dir).await?;
DatabaseEngine::open_with_backup_dir(&self.db_path, &self.backup_dir, &self.secrets)
.await?;
let engine = Arc::new(db_engine);
if let Some(summary) = &recovery_summary {
@@ -111,9 +121,10 @@ mod tests {
let db_path = dir.path().join("orchestrator_test.db");
let backup_dir = dir.path().join("backups");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let orchestrator =
SelfHealingSyncOrchestrator::with_backup_dir(&db_path, &backup_dir, event_bus);
SelfHealingSyncOrchestrator::with_backup_dir(&db_path, &backup_dir, event_bus, secrets);
let (db, summary) = orchestrator
.initialize_and_recover()
.await
@@ -133,10 +144,13 @@ mod tests {
let backup_dir = dir.path().join("backups");
let event_bus = Arc::new(EventBus::new());
let mut events = event_bus.subscribe_events();
let secrets = Arc::new(SecretManager::mock());
// Populate valid DB first
{
let engine = DatabaseEngine::connect_file(&db_path).await.unwrap();
let engine = DatabaseEngine::connect_file(&db_path, &secrets)
.await
.unwrap();
let acct = nuncio_core::AccountConfig {
id: "acct-orch-1".to_string(),
name: "Orch Account".to_string(),
@@ -164,8 +178,12 @@ mod tests {
let _ = std::fs::remove_file(format!("{}-wal", db_path.to_string_lossy()));
let _ = std::fs::remove_file(format!("{}-shm", db_path.to_string_lossy()));
let orchestrator =
SelfHealingSyncOrchestrator::with_backup_dir(&db_path, &backup_dir, event_bus.clone());
let orchestrator = SelfHealingSyncOrchestrator::with_backup_dir(
&db_path,
&backup_dir,
event_bus.clone(),
secrets,
);
let (db, summary) = orchestrator
.initialize_and_recover()
.await