use rusqlite::{Connection, OptionalExtension};
use super::envelope::check_meta_fec;
use super::error::VaultError;
const GUARD_TABLES: [&str; 4] = ["sessions", "messages", "knowledge", "memories"];
const MISSING_TABLE_MARKER: &str = "no such table";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TableCounts {
pub vault: Option<i64>,
pub sessions: Option<i64>,
pub messages: Option<i64>,
pub knowledge: Option<i64>,
pub memories: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnoseVerdict {
WrongPassphrasePossible,
Corrupt,
Fresh,
}
impl DiagnoseVerdict {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::WrongPassphrasePossible => "wrong-passphrase-possible",
Self::Corrupt => "corrupt",
Self::Fresh => "fresh",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnoseReport {
pub envelope_present: bool,
pub fec_ok: Option<bool>,
pub counts: TableCounts,
pub verdict: DiagnoseVerdict,
pub names: Option<Vec<String>>,
}
enum MetaState {
TableMissing,
NoEnvelope,
PartialSaltOnly,
Envelope {
wrapped_dek_fec: Vec<u8>,
salt_fec: Option<Vec<u8>>,
},
}
fn is_missing_table(err: &rusqlite::Error) -> bool {
err.to_string().contains(MISSING_TABLE_MARKER)
}
fn count_or_missing(conn: &Connection, table: &str) -> Result<Option<i64>, VaultError> {
match conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) {
Ok(n) => Ok(Some(n)),
Err(e) if is_missing_table(&e) => Ok(None),
Err(e) => Err(VaultError::Storage(e.to_string())),
}
}
fn read_table_counts(conn: &Connection) -> Result<TableCounts, VaultError> {
let vault = count_or_missing(conn, "vault")?;
let mut guard_counts = [None; GUARD_TABLES.len()];
for (slot, table) in guard_counts.iter_mut().zip(GUARD_TABLES) {
*slot = count_or_missing(conn, table)?;
}
let [sessions, messages, knowledge, memories] = guard_counts;
Ok(TableCounts {
vault,
sessions,
messages,
knowledge,
memories,
})
}
fn read_meta_state(conn: &Connection) -> Result<MetaState, VaultError> {
let wrapped_dek_fec: Option<Vec<u8>> = match conn
.query_row(
"SELECT value FROM vault_meta WHERE key = 'wrapped_dek'",
[],
|r| r.get(0),
)
.optional()
{
Ok(v) => v,
Err(e) if is_missing_table(&e) => return Ok(MetaState::TableMissing),
Err(e) => return Err(VaultError::Storage(e.to_string())),
};
let salt_fec: Option<Vec<u8>> = conn
.query_row("SELECT value FROM vault_meta WHERE key = 'salt'", [], |r| {
r.get(0)
})
.optional()
.map_err(|e| VaultError::Storage(e.to_string()))?;
match (wrapped_dek_fec, salt_fec) {
(None, None) => Ok(MetaState::NoEnvelope),
(None, Some(_)) => Ok(MetaState::PartialSaltOnly),
(Some(wrapped_dek_fec), salt_fec) => Ok(MetaState::Envelope {
wrapped_dek_fec,
salt_fec,
}),
}
}
fn read_vault_names(conn: &Connection) -> Result<Vec<String>, VaultError> {
let mut stmt = match conn.prepare("SELECT name FROM vault ORDER BY name ASC") {
Ok(stmt) => stmt,
Err(e) if is_missing_table(&e) => return Ok(Vec::new()),
Err(e) => return Err(VaultError::Storage(e.to_string())),
};
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| VaultError::Storage(e.to_string()))?;
let mut names = Vec::new();
for row in rows {
names.push(row.map_err(|e| VaultError::Storage(e.to_string()))?);
}
Ok(names)
}
fn any_guard_table_missing(counts: &TableCounts) -> bool {
[
counts.sessions,
counts.messages,
counts.knowledge,
counts.memories,
]
.iter()
.any(Option::is_none)
}
fn guard_verdict(counts: &TableCounts) -> DiagnoseVerdict {
if any_guard_table_missing(counts) {
return DiagnoseVerdict::Corrupt;
}
let entries = [
counts.sessions,
counts.messages,
counts.knowledge,
counts.memories,
];
if entries.iter().flatten().any(|&n| n > 0) {
DiagnoseVerdict::Corrupt
} else {
DiagnoseVerdict::Fresh
}
}
pub fn diagnose(conn: &Connection, include_names: bool) -> Result<DiagnoseReport, VaultError> {
let counts = read_table_counts(conn)?;
let meta = read_meta_state(conn)?;
let (envelope_present, fec_ok, verdict) = match meta {
MetaState::TableMissing => (false, None, DiagnoseVerdict::Corrupt),
MetaState::NoEnvelope => (false, None, guard_verdict(&counts)),
MetaState::PartialSaltOnly => (false, None, DiagnoseVerdict::Corrupt),
MetaState::Envelope {
wrapped_dek_fec,
salt_fec: None,
} => {
let _ = wrapped_dek_fec;
(true, Some(false), DiagnoseVerdict::Corrupt)
}
MetaState::Envelope {
wrapped_dek_fec,
salt_fec: Some(salt_fec),
} => {
let ok = check_meta_fec(&salt_fec, &wrapped_dek_fec).is_ok();
let verdict = if !ok || any_guard_table_missing(&counts) {
DiagnoseVerdict::Corrupt
} else {
DiagnoseVerdict::WrongPassphrasePossible
};
(true, Some(ok), verdict)
}
};
let names = if include_names {
Some(read_vault_names(conn)?)
} else {
None
};
Ok(DiagnoseReport {
envelope_present,
fec_ok,
counts,
verdict,
names,
})
}
const COUNT_LABELS: [&str; 5] = ["vault", "sessions", "messages", "knowledge", "memories"];
fn format_count(count: Option<i64>) -> String {
match count {
Some(n) => n.to_string(),
None => "missing".to_string(),
}
}
#[must_use]
pub fn format_diagnose_report(report: &DiagnoseReport) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!(
"envelope: {}",
if report.envelope_present {
"present"
} else {
"absent"
}
));
lines.push(format!(
"fec: {}",
match report.fec_ok {
Some(true) => "ok",
Some(false) => "corrupt",
None => "n/a",
}
));
lines.push(format!("verdict: {}", report.verdict.as_str()));
lines.push("counts:".to_string());
let counts = [
report.counts.vault,
report.counts.sessions,
report.counts.messages,
report.counts.knowledge,
report.counts.memories,
];
for (label, count) in COUNT_LABELS.iter().zip(counts) {
lines.push(format!(" {label}: {}", format_count(count)));
}
if let Some(names) = &report.names {
if names.is_empty() {
lines.push("names: (none)".to_string());
} else {
lines.push(format!("names: {}", names.join(", ")));
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::{bootstrap_envelope, wire, MaskedDek, SecretStore};
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
const INIT_SCHEMA_SQL: &str = "
CREATE TABLE sessions (id TEXT PRIMARY KEY);
CREATE TABLE messages (id INTEGER PRIMARY KEY);
CREATE TABLE knowledge (key TEXT PRIMARY KEY);
CREATE TABLE memories (id TEXT PRIMARY KEY);
CREATE TABLE vault_meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);
";
fn fresh_conn() -> Connection {
let conn = Connection::open_in_memory().expect("mem db");
conn.execute_batch(INIT_SCHEMA_SQL).expect("schema");
conn
}
#[test]
fn test_fresh_db_with_no_envelope_and_empty_tables_is_fresh() {
let conn = fresh_conn();
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(!report.envelope_present);
assert_eq!(report.fec_ok, None);
assert_eq!(report.verdict, DiagnoseVerdict::Fresh);
assert_eq!(report.counts.sessions, Some(0));
assert_eq!(report.counts.vault, None, "vault table not yet created");
assert!(report.names.is_none());
let rendered = format_diagnose_report(&report).join("\n");
assert!(!rendered.to_lowercase().contains("secret"));
}
#[test]
fn test_bootstrapped_envelope_with_empty_tables_is_wrong_passphrase_possible() {
let conn = fresh_conn();
let vault = cryptovault::CryptoVault::default();
let (salt_fec, wrapped_fec, _dek) =
bootstrap_envelope(&vault, "bootstrap-master-passphrase").expect("bootstrap");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('salt', ?1)",
[&salt_fec],
)
.expect("insert salt");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
[&wrapped_fec],
)
.expect("insert wrapped_dek");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(report.envelope_present);
assert_eq!(report.fec_ok, Some(true));
assert_eq!(report.verdict, DiagnoseVerdict::WrongPassphrasePossible);
assert_eq!(report.counts.sessions, Some(0));
}
#[test]
fn test_data_present_without_envelope_is_corrupt_and_never_touches_the_db() {
let conn = fresh_conn();
conn.execute("INSERT INTO sessions (id) VALUES ('s1')", [])
.expect("seed a session row");
let before: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
.expect("count before");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(!report.envelope_present);
assert_eq!(report.verdict, DiagnoseVerdict::Corrupt);
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
.expect("count after");
assert_eq!(before, after, "diagnose must never mutate the DB");
assert_eq!(after, 1);
}
#[test]
fn test_missing_guard_table_is_corrupt() {
let conn = Connection::open_in_memory().expect("mem db");
conn.execute_batch(
"CREATE TABLE sessions (id TEXT PRIMARY KEY);
CREATE TABLE messages (id INTEGER PRIMARY KEY);
CREATE TABLE knowledge (key TEXT PRIMARY KEY);
CREATE TABLE vault_meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);",
)
.expect("partial schema");
let report = diagnose(&conn, false).expect("diagnose ok");
assert_eq!(report.verdict, DiagnoseVerdict::Corrupt);
assert_eq!(report.counts.memories, None);
}
#[test]
fn test_fec_corrupt_vault_meta_is_corrupt() {
let conn = fresh_conn();
let vault = cryptovault::CryptoVault::default();
let (salt_fec, mut wrapped_fec, _dek) =
bootstrap_envelope(&vault, "bootstrap-master-passphrase").expect("bootstrap");
for b in wrapped_fec.iter_mut() {
*b ^= 0xFF; }
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('salt', ?1)",
[&salt_fec],
)
.expect("insert salt");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
[&wrapped_fec],
)
.expect("insert corrupt wrapped_dek");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(report.envelope_present);
assert_eq!(report.fec_ok, Some(false));
assert_eq!(report.verdict, DiagnoseVerdict::Corrupt);
}
#[test]
fn test_wrapped_dek_without_salt_row_is_corrupt() {
let conn = fresh_conn();
let vault = cryptovault::CryptoVault::default();
let (_salt_fec, wrapped_fec, _dek) =
bootstrap_envelope(&vault, "bootstrap-master-passphrase").expect("bootstrap");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
[&wrapped_fec],
)
.expect("insert wrapped_dek without salt");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(report.envelope_present);
assert_eq!(report.fec_ok, Some(false));
assert_eq!(report.verdict, DiagnoseVerdict::Corrupt);
}
#[test]
fn test_salt_without_wrapped_dek_is_corrupt_not_fresh() {
let conn = fresh_conn();
let vault = cryptovault::CryptoVault::default();
let (salt_fec, _wrapped_fec, _dek) =
bootstrap_envelope(&vault, "bootstrap-master-passphrase").expect("bootstrap");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('salt', ?1)",
[&salt_fec],
)
.expect("insert salt without wrapped_dek");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(
!report.envelope_present,
"no wrapped_dek row means no envelope, by definition"
);
assert_eq!(
report.verdict,
DiagnoseVerdict::Corrupt,
"a salt-only row must never be reported as Fresh"
);
}
#[test]
fn test_envelope_present_but_data_table_missing_is_corrupt() {
let conn = Connection::open_in_memory().expect("mem db");
conn.execute_batch(
"CREATE TABLE sessions (id TEXT PRIMARY KEY);
CREATE TABLE messages (id INTEGER PRIMARY KEY);
CREATE TABLE knowledge (key TEXT PRIMARY KEY);
CREATE TABLE vault_meta (key TEXT PRIMARY KEY, value BLOB NOT NULL);",
)
.expect("partial schema");
let vault = cryptovault::CryptoVault::default();
let (salt_fec, wrapped_fec, _dek) =
bootstrap_envelope(&vault, "bootstrap-master-passphrase").expect("bootstrap");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('salt', ?1)",
[&salt_fec],
)
.expect("insert salt");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
[&wrapped_fec],
)
.expect("insert wrapped_dek");
let report = diagnose(&conn, false).expect("diagnose ok");
assert!(report.envelope_present);
assert_eq!(report.fec_ok, Some(true));
assert_eq!(
report.verdict,
DiagnoseVerdict::Corrupt,
"a missing data table alongside an intact envelope must be Corrupt, \
never WrongPassphrasePossible"
);
assert_eq!(report.counts.memories, None);
}
#[test]
fn test_names_opt_in_lists_names_never_values() {
let conn = fresh_conn();
let dek = MaskedDek::new(Zeroizing::new(vec![7u8; 32])).expect("32B dek");
let mut store = wire(Arc::new(Mutex::new(conn)), dek).expect("wire");
store
.set("OPENAI_API_KEY", "sk-super-secret-PROBE-value")
.expect("seed a secret");
let conn = store.debug_conn();
let guard = conn.lock().expect("lock");
let report_no_names = diagnose(&guard, false).expect("diagnose ok");
assert!(report_no_names.names.is_none());
assert_eq!(report_no_names.counts.vault, Some(1));
let report_names = diagnose(&guard, true).expect("diagnose ok");
let names = report_names.names.clone().expect("names populated");
assert_eq!(names, vec!["OPENAI_API_KEY".to_string()]);
let rendered = format_diagnose_report(&report_names).join("\n");
assert!(rendered.contains("OPENAI_API_KEY"));
assert!(!rendered.contains("sk-super-secret-PROBE-value"));
}
#[test]
fn test_guard_verdict_does_not_overflow_or_panic_on_huge_counts() {
let counts = TableCounts {
vault: None,
sessions: Some(i64::MAX),
messages: Some(i64::MAX),
knowledge: Some(0),
memories: Some(0),
};
assert_eq!(guard_verdict(&counts), DiagnoseVerdict::Corrupt);
}
#[test]
fn test_guard_verdict_is_fresh_when_huge_counts_are_all_zero() {
let counts = TableCounts {
vault: None,
sessions: Some(0),
messages: Some(0),
knowledge: Some(0),
memories: Some(0),
};
assert_eq!(guard_verdict(&counts), DiagnoseVerdict::Fresh);
}
#[test]
fn test_diagnose_never_requires_or_accepts_a_passphrase() {
let conn = fresh_conn();
let vault = cryptovault::CryptoVault::default();
let (salt_fec, wrapped_fec, _dek) =
bootstrap_envelope(&vault, "some-real-passphrase").expect("bootstrap");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('salt', ?1)",
[&salt_fec],
)
.expect("insert salt");
conn.execute(
"INSERT INTO vault_meta (key, value) VALUES ('wrapped_dek', ?1)",
[&wrapped_fec],
)
.expect("insert wrapped_dek");
assert!(diagnose(&conn, false).is_ok());
}
}