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:
10 files changed
+450
-104
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user