Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 103 additions & 6 deletions crates/abigail-core/src/vault/unlock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const PASSPHRASE_ENV: &str = "ABIGAIL_VAULT_PASSPHRASE";
const RAW_KEK_ENV: &str = "ABIGAIL_VAULT_RAW_KEY";
const PASSPHRASE_SALT: &[u8] = b"abigail-vault-passphrase-salt-v1";
const KDF_METADATA_FILE: &str = "vault.kdf.json";
const WINDOWS_KEK_FALLBACK_FILE: &str = "vault.kek.dpapi";
const ARGON2_MEMORY_COST_KIB: u32 = 64 * 1024;
const ARGON2_TIME_COST: u32 = 3;
const ARGON2_PARALLELISM: u32 = 1;
Expand Down Expand Up @@ -97,6 +98,24 @@ impl UnlockProvider for HybridUnlockProvider {
// paper Sections 22-27 runtime verification:
// stable KEK + sentinel check must succeed before runtime unlock is accepted.
if let Some(kek) = os_keyring_load_optional()? {
let sentinel_value = verify_or_create_sentinel(&data_root, &kek)?;
if let Err(error) = persist_windows_kek_fallback(&data_root, &kek) {
tracing::warn!(
"Stable KEK loaded from OS keyring but fallback persistence failed: {}",
error
);
}
super::cache_session_root_kek(kek, sentinel_value);
return Ok(kek);
}

if let Some(kek) = load_windows_kek_fallback_optional(&data_root)? {
tracing::warn!(
"Stable KEK recovered from DPAPI fallback file because OS keyring lookup failed"
);
if let Err(error) = os_keyring_store_verified(&kek) {
Comment on lines +112 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the DPAPI fallback before repairing the keyring

In the fallback-recovery path, the KEK loaded from vault.kek.dpapi is written back into the OS keyring before verify_or_create_sentinel() proves that it can actually decrypt the existing vault. If that fallback file is corrupted or stale, startup not only fails, it also overwrites the previously-good keyring entry with the bad key, leaving later launches stuck on the same recovery error until the user manually repairs the keyring. The keyring repair needs to happen only after sentinel validation succeeds.

Useful? React with 👍 / 👎.

tracing::warn!("Failed to repair OS keyring from fallback KEK: {}", error);
}
let sentinel_value = verify_or_create_sentinel(&data_root, &kek)?;
super::cache_session_root_kek(kek, sentinel_value);
return Ok(kek);
Expand Down Expand Up @@ -131,12 +150,29 @@ impl UnlockProvider for HybridUnlockProvider {
// First boot only (no sentinel exists): create stable KEK once.
tracing::info!("No stable vault KEK found; bootstrapping initial root key");
let kek = generate_random_kek();
os_keyring_store(&kek).map_err(|e| {
CoreError::Vault(format!(
"Recovery Mode: failed to persist stable KEK '{}': {}",
KEYRING_ACCOUNT, e
))
})?;
let keyring_persisted = match os_keyring_store_verified(&kek) {
Ok(()) => true,
Err(error) => {
tracing::warn!("Stable KEK could not be verified in OS keyring: {}", error);
false
}
};
let fallback_persisted = match persist_windows_kek_fallback(&data_root, &kek) {
Ok(()) => true,
Err(error) => {
tracing::warn!(
"Stable KEK could not be persisted to DPAPI fallback file: {}",
error
);
false
}
};
if !keyring_persisted && !fallback_persisted {
return Err(CoreError::Vault(format!(
"Recovery Mode: failed to persist stable KEK '{}': Windows keyring verification failed and no DPAPI fallback could be written.",
KEYRING_ACCOUNT
)));
}
let sentinel_value = super::write_encrypted_sentinel(&data_root, &kek)?;
super::cache_session_root_kek(kek, sentinel_value);
Ok(kek)
Expand Down Expand Up @@ -337,6 +373,51 @@ fn os_keyring_store(kek: &[u8; KEK_LEN]) -> Result<()> {
Ok(())
}

fn os_keyring_store_verified(kek: &[u8; KEK_LEN]) -> Result<()> {
os_keyring_store(kek)?;
let reloaded = os_keyring_load()?;
if &reloaded != kek {
return Err(CoreError::Keyring(
"keyring verification failed: stored KEK did not round-trip".to_string(),
));
}
Ok(())
}

fn windows_kek_fallback_path(data_root: &Path) -> PathBuf {
data_root.join(WINDOWS_KEK_FALLBACK_FILE)
}

#[cfg(windows)]
fn persist_windows_kek_fallback(data_root: &Path, kek: &[u8; KEK_LEN]) -> Result<()> {
let encrypted = crate::dpapi::dpapi_encrypt(kek)?;
crate::secure_fs::write_bytes_atomic(&windows_kek_fallback_path(data_root), &encrypted)
}

#[cfg(not(windows))]
fn persist_windows_kek_fallback(_data_root: &Path, _kek: &[u8; KEK_LEN]) -> Result<()> {
Ok(())
}

#[cfg(windows)]
fn load_windows_kek_fallback_optional(data_root: &Path) -> Result<Option<[u8; KEK_LEN]>> {
let path = windows_kek_fallback_path(data_root);
if !path.exists() {
return Ok(None);
}
let encrypted = std::fs::read(&path)?;
let decrypted = crate::dpapi::dpapi_decrypt(&encrypted)?;
let kek: [u8; KEK_LEN] = decrypted
.try_into()
.map_err(|_| CoreError::Vault("DPAPI fallback KEK wrong length".to_string()))?;
Ok(Some(kek))
}

#[cfg(not(windows))]
fn load_windows_kek_fallback_optional(_data_root: &Path) -> Result<Option<[u8; KEK_LEN]>> {
Ok(None)
}

fn verify_or_create_sentinel(data_root: &Path, kek: &[u8; KEK_LEN]) -> Result<String> {
let sentinel_path = super::sentinel_path(data_root);
if sentinel_path.exists() {
Expand Down Expand Up @@ -374,6 +455,22 @@ mod tests {
assert_ne!(a.root_kek().unwrap(), b.root_kek().unwrap());
}

#[test]
fn windows_kek_fallback_roundtrips() {
let dir = std::env::temp_dir().join("abigail_windows_kek_fallback_roundtrip");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();

let mut kek = [0u8; KEK_LEN];
kek.copy_from_slice(&[7u8; KEK_LEN]);

persist_windows_kek_fallback(&dir, &kek).unwrap();
let loaded = load_windows_kek_fallback_optional(&dir).unwrap().unwrap();
assert_eq!(loaded, kek);

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn fresh_passphrase_bootstrap_creates_argon2_metadata() {
let dir = std::env::temp_dir().join("abigail_unlock_argon2_metadata");
Expand Down
83 changes: 17 additions & 66 deletions crates/abigail-memory/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ use crate::protected_topics::{
ProtectedTopicSummary, SecretKind, SecretMovePlan, TriangleEthicPreview,
};
use abigail_core::{AppConfig, HybridUnlockProvider, PassphraseUnlockProvider, UnlockProvider};
use abigail_persistence::{
migrate_legacy_layout, EntityScope, PersistenceError, PersistenceHandle, QueryBinding,
};
use abigail_persistence::{EntityScope, PersistenceError, PersistenceHandle, QueryBinding};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -198,13 +196,8 @@ impl MemoryStore {
}

pub fn open_with_config(config: &AppConfig) -> Result<Self> {
if uses_legacy_layout(config) {
migrate_legacy_layout(config)
.map_err(|error| StoreError::Migration(error.to_string()))?;
}

let shared_path = shared_db_path(config);
if let Some(parent) = shared_path.parent() {
let db_path = config.db_path.clone();
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| StoreError::Migration(error.to_string()))?;
Comment on lines 198 to 202
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve legacy SQLite import on first open_with_config

This change removes the migration step before opening the new Surreal store. IdentityManager::migrate_legacy_identity() still copies legacy abigail_memory.db / jobs.db files into the agent directory (crates/abigail-identity/src/lib.rs:738-755), and a repo-wide search now shows no remaining caller of migrate_legacy_layout(). For users upgrading from that layout, open_with_config() will skip the import entirely, so existing conversations, jobs, calendar, and KB rows stay stranded in the copied SQLite files and appear to disappear on first launch.

Useful? React with 👍 / 👎.

}
Expand All @@ -218,14 +211,24 @@ impl MemoryStore {
.and_then(|name| name.to_str())
.map(str::to_string)
};
tracing::info!(
"MemoryStore opening SurrealDB store: scope={} path={} data_dir={}",
if config.is_hive { "hive" } else { "entity" },
db_path.display(),
config.data_dir.display()
);
Self::open_internal(
shared_path,
db_path,
Arc::new(HybridUnlockProvider::new()),
entity_id,
None,
)
}

pub fn path(&self) -> &Path {
self.persistence.path()
}

pub fn capture_secret_message(
&self,
entity_id: &str,
Expand Down Expand Up @@ -829,44 +832,6 @@ struct PreparedProtectedSecret {
created_at: chrono::DateTime<Utc>,
}

fn uses_legacy_layout(config: &AppConfig) -> bool {
legacy_primary_db_path(config).is_some()
|| config.data_dir.join("jobs.db").exists()
|| config.data_dir.join("calendar.db").exists()
|| config.data_dir.join("kb.db").exists()
}

fn shared_db_path(config: &AppConfig) -> PathBuf {
if is_shared_db_path(&config.db_path) {
return config.db_path.clone();
}

if uses_legacy_layout(config) {
config
.data_dir
.parent()
.and_then(|parent| parent.parent())
.map(|root| root.join("memory.db"))
.unwrap_or_else(|| config.data_dir.join("memory.db"))
} else {
config.db_path.clone()
}
}

fn legacy_primary_db_path(config: &AppConfig) -> Option<&Path> {
if is_shared_db_path(&config.db_path) || !config.db_path.exists() {
return None;
}

Some(config.db_path.as_path())
}

fn is_shared_db_path(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "memory.db")
}

fn infer_entity_id(path: &Path) -> Option<String> {
path.parent()
.and_then(|parent| parent.file_name())
Expand Down Expand Up @@ -1015,27 +980,13 @@ mod tests {
}

#[test]
fn test_shared_db_path_honors_explicit_non_legacy_path() {
fn test_open_with_config_uses_configured_db_path() {
let tmp = std::env::temp_dir().join(format!("abigail_store_path_{}", Uuid::new_v4()));
let db_path = tmp.join("sandbox").join("test.db");
let config = test_config(&tmp, db_path.clone());
let store = MemoryStore::open_with_config(&config).unwrap();

assert!(!uses_legacy_layout(&config));
assert_eq!(shared_db_path(&config), db_path);
}

#[test]
fn test_shared_db_path_uses_hive_root_for_existing_legacy_db() {
let tmp = std::env::temp_dir().join(format!("abigail_store_legacy_{}", Uuid::new_v4()));
let agent_dir = tmp.join("identities").join(Uuid::new_v4().to_string());
let legacy_db = agent_dir.join("abigail_memory.db");
std::fs::create_dir_all(&agent_dir).unwrap();
std::fs::write(&legacy_db, b"legacy").unwrap();

let config = test_config(&tmp, legacy_db);

assert!(uses_legacy_layout(&config));
assert_eq!(shared_db_path(&config), tmp.join("memory.db"));
assert_eq!(store.path(), db_path.as_path());

let _ = std::fs::remove_dir_all(&tmp);
}
Expand Down
1 change: 1 addition & 0 deletions crates/abigail-persistence/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ serde_json.workspace = true
surrealdb.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
uuid.workspace = true
sqlx = { version = "0.8.1", default-features = false, features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] }
Loading
Loading