mod adoption;
mod membership;
pub mod peer;
pub mod rotation;
mod seed;
mod storage;
pub use peer::EnrollmentReadiness;
pub use storage::{FileProtectedStorage, KEYRING_ITEM, ProtectedStorage, ProtectedStorageLock};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::Path;
use std::sync::{Arc, Mutex};
#[cfg(any(test, feature = "test-support"))]
use std::{path::PathBuf, sync::atomic::AtomicU64};
use crate::db::Database;
use crate::sync::{
EncryptedLocalSharedStatePackage, LocalSharedStatePackageContext, LocalSharedStatePackageKey,
NeverDispatchedLocalSharedCapture,
};
use sha2::{Digest, Sha256};
use zeroize::{Zeroize, Zeroizing};
const KEYRING_MAGIC: &[u8; 8] = b"AVENLKR1";
const MARKER_MAGIC: &[u8; 8] = b"AVENLKM1";
const KEYRING_BYTES: usize = 8 + 32 + 32 + 32 + 32;
const MARKER_BYTES: usize = 8 + 32 + 32;
const AUTHORITY_RECORD: &str = "authority";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProtectedLocalKeyStoreErrorKind {
MissingAuthority,
Unavailable,
Corrupt,
WriteFailed,
WrongDatabase,
SetupMismatch,
UnsupportedPlatform,
}
#[derive(Debug)]
pub struct ProtectedLocalKeyStoreError {
kind: ProtectedLocalKeyStoreErrorKind,
}
impl ProtectedLocalKeyStoreError {
pub fn new(kind: ProtectedLocalKeyStoreErrorKind) -> Self {
Self { kind }
}
pub fn kind(&self) -> ProtectedLocalKeyStoreErrorKind {
self.kind
}
}
impl fmt::Display for ProtectedLocalKeyStoreError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self.kind {
ProtectedLocalKeyStoreErrorKind::MissingAuthority => {
"protected local key authority is missing"
}
ProtectedLocalKeyStoreErrorKind::Unavailable => {
"protected local key storage is unavailable"
}
ProtectedLocalKeyStoreErrorKind::Corrupt => {
"protected local key storage is corrupt or unsafe"
}
ProtectedLocalKeyStoreErrorKind::WriteFailed => {
"protected local key storage could not be saved"
}
ProtectedLocalKeyStoreErrorKind::WrongDatabase => {
"protected local key store belongs to a different database installation"
}
ProtectedLocalKeyStoreErrorKind::SetupMismatch => {
"protected setup invitation does not match"
}
ProtectedLocalKeyStoreErrorKind::UnsupportedPlatform => {
"protected local key storage is unsupported on this platform"
}
};
formatter.write_str(message)
}
}
impl std::error::Error for ProtectedLocalKeyStoreError {}
pub type StoreResult<T> = Result<T, ProtectedLocalKeyStoreError>;
pub struct ProtectedLocalPackageKey {
context: LocalSharedStatePackageContext,
key: LocalSharedStatePackageKey,
}
impl ProtectedLocalPackageKey {
pub fn context(&self) -> LocalSharedStatePackageContext {
self.context
}
pub fn package_key(&self) -> &LocalSharedStatePackageKey {
&self.key
}
}
impl fmt::Debug for ProtectedLocalPackageKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ProtectedLocalPackageKey")
.field("context", &self.context)
.field("key", &"[REDACTED]")
.finish()
}
}
pub struct ProtectedLocalKeyStore {
account: String,
storage: Arc<dyn ProtectedStorage>,
index: Arc<Mutex<Option<Index>>>,
#[cfg(any(test, feature = "test-support"))]
directory: PathBuf,
#[cfg(any(test, feature = "test-support"))]
enrollment_clock: Option<Arc<AtomicU64>>,
}
#[derive(Default)]
struct Index {
listing: Option<Listing>,
pins: Option<HashMap<String, Vec<u8>>>,
values: HashMap<String, Zeroizing<Vec<u8>>>,
}
struct Listing {
items: HashSet<String>,
markers: HashSet<String>,
}
pub(crate) struct StoreLock {
_storage: ProtectedStorageLock,
index: Arc<Mutex<Option<Index>>>,
}
impl Drop for StoreLock {
fn drop(&mut self) {
*self.index.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
#[cfg(any(test, feature = "test-support"))]
impl ProtectedLocalKeyStore {
pub fn set_enrollment_clock(&mut self, clock: Arc<AtomicU64>) {
self.enrollment_clock = Some(clock);
}
}
impl ProtectedLocalKeyStore {
pub async fn open(
database: &Database,
storage: Arc<dyn ProtectedStorage>,
) -> StoreResult<Self> {
let account = database_account(storage.as_ref(), database).await?;
Ok(Self::with_account(account, storage))
}
fn with_account(account: String, storage: Arc<dyn ProtectedStorage>) -> Self {
Self {
account,
storage,
index: Arc::default(),
#[cfg(any(test, feature = "test-support"))]
directory: PathBuf::new(),
#[cfg(any(test, feature = "test-support"))]
enrollment_clock: None,
}
}
pub fn load_or_create(&self) -> StoreResult<ProtectedLocalPackageKey> {
self.prepare()?;
let _guard = self.lock()?;
let marker = self.read_marker()?;
match self.load_secret(KEYRING_ITEM, KEYRING_BYTES)? {
Some(bytes) => {
let key = decode_keyring(bytes)?;
match marker {
Some(expected) if expected != key.context => {
Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt))
}
Some(_) => Ok(key),
None => {
self.write_marker(key.context)?;
Ok(key)
}
}
}
None if marker.is_some() => {
Err(error(ProtectedLocalKeyStoreErrorKind::MissingAuthority))
}
None => {
if self.seed_authority_exists()? || self.peer_authority_exists()? {
return Err(error(ProtectedLocalKeyStoreErrorKind::MissingAuthority));
}
let key = generate_keyring()?;
let encoded = encode_keyring(&key);
self.create_secret(KEYRING_ITEM, &encoded)?;
self.write_marker(key.context)?;
Ok(key)
}
}
}
pub fn load_required(&self) -> StoreResult<ProtectedLocalPackageKey> {
self.prepare()?;
let _guard = self.lock()?;
self.load_required_locked()
}
fn load_required_locked(&self) -> StoreResult<ProtectedLocalPackageKey> {
let marker = self.read_marker()?;
let bytes = self
.load_secret(KEYRING_ITEM, KEYRING_BYTES)?
.ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::MissingAuthority))?;
let key = decode_keyring(bytes)?;
match marker {
Some(expected) if expected != key.context => {
Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt))
}
Some(_) => Ok(key),
None => {
self.write_marker(key.context)?;
Ok(key)
}
}
}
pub async fn package_local_capture(
&self,
database: &Database,
blob_dir: &Path,
membership_predecessor: [u8; 32],
) -> Result<EncryptedLocalSharedStatePackage, anyhow::Error> {
self.validate_database(database).await?;
let protected = if database
.has_local_shared_state_package_never_dispatched()
.await?
|| database.local_seed_genesis_commitment().await?.is_some()
{
self.load_required()?
} else {
self.load_or_create()?
};
database
.package_local_shared_state_never_dispatched(
blob_dir,
protected.context(),
protected.package_key(),
membership_predecessor,
)
.await
}
pub(crate) async fn package_local_capture_validated(
&self,
database: &Database,
blob_dir: &Path,
membership_predecessor: [u8; 32],
capture: Option<NeverDispatchedLocalSharedCapture>,
) -> Result<crate::sync::shared_state::validated::ValidatedSeed, anyhow::Error> {
self.validate_database(database).await?;
let protected = if database
.has_local_shared_state_package_never_dispatched()
.await?
|| database.local_seed_genesis_commitment().await?.is_some()
{
self.load_required()?
} else {
self.load_or_create()?
};
database
.package_and_validate(
blob_dir,
protected.context(),
protected.package_key(),
membership_predecessor,
capture,
)
.await
}
pub fn erase(&self) -> StoreResult<()> {
self.prepare()?;
let _guard = self.lock()?;
for item in self.storage.list_secrets(&self.account)? {
self.delete_secret(&item)?;
}
for name in self.storage.list_records(&self.account)? {
self.remove_record(&name)?;
}
Ok(())
}
async fn validate_database(&self, database: &Database) -> StoreResult<()> {
if database_account(self.storage.as_ref(), database).await? != self.account {
return Err(error(ProtectedLocalKeyStoreErrorKind::WrongDatabase));
}
Ok(())
}
fn read_marker(&self) -> StoreResult<Option<LocalSharedStatePackageContext>> {
let Some(bytes) = self.read_record(AUTHORITY_RECORD, MARKER_BYTES)? else {
return Ok(None);
};
if &bytes[..8] != MARKER_MAGIC {
return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
}
let mut vault_id = [0_u8; 32];
let mut generation_id = [0_u8; 32];
vault_id.copy_from_slice(&bytes[8..40]);
generation_id.copy_from_slice(&bytes[40..]);
Ok(Some(LocalSharedStatePackageContext {
vault_id,
generation_id,
}))
}
fn write_marker(&self, context: LocalSharedStatePackageContext) -> StoreResult<()> {
self.write_record(AUTHORITY_RECORD, &marker_bytes(context))
}
fn prepare(&self) -> StoreResult<()> {
self.storage.prepare(&self.account)
}
fn lock(&self) -> StoreResult<StoreLock> {
let storage = self.storage.lock(&self.account)?;
*self.index.lock().unwrap_or_else(|e| e.into_inner()) = Some(Index::default());
Ok(StoreLock {
_storage: storage,
index: self.index.clone(),
})
}
fn with_index<T>(&self, f: impl FnOnce(&mut Index) -> T) -> Option<T> {
self.index
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.map(f)
}
fn list_kinds(&self) -> StoreResult<Listing> {
let items = self.storage.list_secrets(&self.account)?;
let markers = self
.storage
.list_records(&self.account)?
.iter()
.filter_map(|name| name.strip_suffix("-authority").map(str::to_string))
.collect();
Ok(Listing { items, markers })
}
fn load_secret(
&self,
item: &str,
expected_len: usize,
) -> StoreResult<Option<Zeroizing<Vec<u8>>>> {
#[cfg(any(test, feature = "test-support"))]
test_support::BACKEND_LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let bytes = self
.storage
.load_secret(&self.account, item, expected_len)?;
if bytes
.as_ref()
.is_some_and(|bytes| bytes.len() != expected_len)
{
return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
}
Ok(bytes)
}
fn create_secret(&self, item: &str, bytes: &[u8]) -> StoreResult<()> {
self.storage.create_secret(&self.account, item, bytes)
}
fn delete_secret(&self, item: &str) -> StoreResult<()> {
self.storage.delete_secret(&self.account, item)
}
fn read_record(&self, name: &str, expected_len: usize) -> StoreResult<Option<Vec<u8>>> {
let bytes = self
.storage
.read_record(&self.account, name, expected_len)?;
if bytes
.as_ref()
.is_some_and(|bytes| bytes.len() != expected_len)
{
return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
}
Ok(bytes)
}
fn write_record(&self, name: &str, bytes: &[u8]) -> StoreResult<()> {
self.storage.create_record(&self.account, name, bytes)
}
fn remove_record(&self, name: &str) -> StoreResult<()> {
self.storage.remove_record(&self.account, name)
}
}
fn error(kind: ProtectedLocalKeyStoreErrorKind) -> ProtectedLocalKeyStoreError {
ProtectedLocalKeyStoreError::new(kind)
}
async fn database_account(
storage: &dyn ProtectedStorage,
database: &Database,
) -> StoreResult<String> {
let unavailable = || error(ProtectedLocalKeyStoreErrorKind::Unavailable);
let location = storage.database_location(database.path())?;
let client_id = database
.meta("client_id")
.await
.map_err(|_| unavailable())?
.filter(|id| !id.is_empty())
.ok_or_else(unavailable)?;
Ok(account_for(&location, &client_id))
}
fn account_for(location: &[u8], client_id: &str) -> String {
let mut digest = Sha256::new();
digest.update(b"aven-protected-keys-v2\0");
digest.update(location);
digest.update(b"\0");
digest.update(client_id.as_bytes());
hex::encode(digest.finalize())
}
fn generate_keyring() -> StoreResult<ProtectedLocalPackageKey> {
let mut bytes = Zeroizing::new([0_u8; 96]);
getrandom::fill(bytes.as_mut())
.map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
let mut vault_id = [0_u8; 32];
let mut generation_id = [0_u8; 32];
let mut key = [0_u8; 32];
vault_id.copy_from_slice(&bytes[..32]);
generation_id.copy_from_slice(&bytes[32..64]);
key.copy_from_slice(&bytes[64..]);
Ok(ProtectedLocalPackageKey {
context: LocalSharedStatePackageContext {
vault_id,
generation_id,
},
key: LocalSharedStatePackageKey::new(key),
})
}
fn encode_keyring(key: &ProtectedLocalPackageKey) -> Zeroizing<Vec<u8>> {
let mut encoded = Zeroizing::new(Vec::with_capacity(KEYRING_BYTES));
encoded.extend_from_slice(KEYRING_MAGIC);
encoded.extend_from_slice(&key.context.vault_id);
encoded.extend_from_slice(&key.context.generation_id);
encoded.extend_from_slice(key.key.protected_storage_bytes());
let checksum = Sha256::digest(encoded.as_slice());
encoded.extend_from_slice(&checksum);
encoded
}
fn decode_keyring(mut encoded: Zeroizing<Vec<u8>>) -> StoreResult<ProtectedLocalPackageKey> {
if encoded.len() != KEYRING_BYTES || &encoded[..8] != KEYRING_MAGIC {
return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
}
let checksum_offset = KEYRING_BYTES - 32;
let expected = Sha256::digest(&encoded[..checksum_offset]);
if expected.as_slice() != &encoded[checksum_offset..] {
return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
}
let mut vault_id = [0_u8; 32];
let mut generation_id = [0_u8; 32];
let mut key = [0_u8; 32];
vault_id.copy_from_slice(&encoded[8..40]);
generation_id.copy_from_slice(&encoded[40..72]);
key.copy_from_slice(&encoded[72..104]);
encoded.zeroize();
Ok(ProtectedLocalPackageKey {
context: LocalSharedStatePackageContext {
vault_id,
generation_id,
},
key: LocalSharedStatePackageKey::new(key),
})
}
fn marker_bytes(context: LocalSharedStatePackageContext) -> [u8; MARKER_BYTES] {
let mut bytes = [0_u8; MARKER_BYTES];
bytes[..8].copy_from_slice(MARKER_MAGIC);
bytes[8..40].copy_from_slice(&context.vault_id);
bytes[40..].copy_from_slice(&context.generation_id);
bytes
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::storage::canonical_location;
use super::*;
tokio::task_local! {
static SCOPED_BACKEND_LOADS: std::sync::atomic::AtomicU64;
}
pub struct BackendLoads(std::sync::atomic::AtomicU64);
impl BackendLoads {
pub fn fetch_add(&self, value: u64, ordering: std::sync::atomic::Ordering) -> u64 {
let previous = self.0.fetch_add(value, ordering);
let _ = SCOPED_BACKEND_LOADS.try_with(|loads| {
loads.fetch_add(value, ordering);
});
previous
}
pub fn load(&self, ordering: std::sync::atomic::Ordering) -> u64 {
self.0.load(ordering)
}
pub async fn measure<F: std::future::Future>(&self, future: F) -> (F::Output, u64) {
SCOPED_BACKEND_LOADS
.scope(std::sync::atomic::AtomicU64::new(0), async {
let output = future.await;
let loads = SCOPED_BACKEND_LOADS
.with(|count| count.load(std::sync::atomic::Ordering::Relaxed));
(output, loads)
})
.await
}
}
pub static BACKEND_LOADS: BackendLoads = BackendLoads(std::sync::atomic::AtomicU64::new(0));
pub async fn isolated_store(database: &Database, root: &Path) -> ProtectedLocalKeyStore {
let storage = FileProtectedStorage::new(root.to_path_buf());
account_store(database_account(&storage, database).await.unwrap(), root)
}
pub fn raw_account(path: &Path) -> String {
account_for(&canonical_location(path).unwrap(), "raw-file")
}
pub fn account_store(account: String, root: &Path) -> ProtectedLocalKeyStore {
let storage = Arc::new(FileProtectedStorage::new(root.to_path_buf()));
let mut store = ProtectedLocalKeyStore::with_account(account, storage);
store.directory = root.to_path_buf();
store
}
#[derive(Clone, Copy, Debug)]
pub enum Fault {
FailWrite,
Unavailable,
}
struct FaultyStorage {
files: FileProtectedStorage,
fault: Fault,
}
impl ProtectedStorage for FaultyStorage {
fn prepare(&self, namespace: &str) -> StoreResult<()> {
self.files.prepare(namespace)
}
fn lock(&self, namespace: &str) -> StoreResult<ProtectedStorageLock> {
self.files.lock(namespace)
}
fn load_secret(
&self,
_: &str,
_: &str,
_: usize,
) -> StoreResult<Option<Zeroizing<Vec<u8>>>> {
match self.fault {
Fault::FailWrite => Ok(None),
Fault::Unavailable => Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
}
}
fn create_secret(&self, _: &str, _: &str, _: &[u8]) -> StoreResult<()> {
Err(error(match self.fault {
Fault::FailWrite => ProtectedLocalKeyStoreErrorKind::WriteFailed,
Fault::Unavailable => ProtectedLocalKeyStoreErrorKind::Unavailable,
}))
}
fn delete_secret(&self, _: &str, _: &str) -> StoreResult<()> {
self.create_secret("", "", &[])
}
fn read_record(
&self,
namespace: &str,
name: &str,
len: usize,
) -> StoreResult<Option<Vec<u8>>> {
self.files.read_record(namespace, name, len)
}
fn create_record(&self, namespace: &str, name: &str, bytes: &[u8]) -> StoreResult<()> {
self.files.create_record(namespace, name, bytes)
}
fn remove_record(&self, namespace: &str, name: &str) -> StoreResult<()> {
self.files.remove_record(namespace, name)
}
fn list_secrets(&self, _: &str) -> StoreResult<HashSet<String>> {
match self.fault {
Fault::FailWrite => Ok(HashSet::new()),
Fault::Unavailable => Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
}
}
fn list_records(&self, namespace: &str) -> StoreResult<HashSet<String>> {
self.files.list_records(namespace)
}
}
pub fn faulty_store(account: String, root: &Path, fault: Fault) -> ProtectedLocalKeyStore {
let storage = Arc::new(FaultyStorage {
files: FileProtectedStorage::new(root.to_path_buf()),
fault,
});
let mut store = ProtectedLocalKeyStore::with_account(account, storage);
store.directory = root.to_path_buf();
store
}
impl ProtectedLocalKeyStore {
pub fn account(&self) -> &str {
&self.account
}
pub fn directory(&self) -> &Path {
&self.directory
}
pub fn file_path(&self, name: &str) -> PathBuf {
self.directory.join(format!("{}.{name}", self.account))
}
pub fn marker_path(&self) -> PathBuf {
self.file_path(AUTHORITY_RECORD)
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
use crate::api::{CreateTask, Store};
use crate::choices::{TaskPriority, TaskStatus};
use std::fs::{self, File};
use std::io::Read;
use std::path::PathBuf;
pub(super) async fn captured_database(root: &Path) -> Database {
let path = root.join("source.sqlite");
let store = Store::open(&path).await.unwrap();
let workspace = store.list_workspaces().await.unwrap().remove(0);
store
.create_task(
&workspace.id,
CreateTask {
title: "protected package".into(),
description: String::new(),
project: "app".into(),
status: TaskStatus::Todo,
priority: TaskPriority::None,
metadata: vec![],
available_at: None,
due_on: None,
},
)
.await
.unwrap();
drop(store);
let database = Database::open(&path).await.unwrap();
database
.capture_local_shared_state_never_dispatched()
.await
.unwrap();
database
}
#[tokio::test]
async fn protected_store_reopens_and_reuses_the_package_key() {
let temp = tempfile::tempdir().unwrap();
let database = captured_database(temp.path()).await;
let key_root = temp.path().join("authority");
let store = isolated_store(&database, &key_root).await;
let first = store
.package_local_capture(&database, temp.path(), [0x64; 32])
.await
.unwrap();
drop(store);
drop(database);
let database = Database::open(&temp.path().join("source.sqlite"))
.await
.unwrap();
let reopened = isolated_store(&database, &key_root).await;
fs::remove_file(reopened.marker_path()).unwrap();
let retry = reopened
.package_local_capture(&database, temp.path(), [0x64; 32])
.await
.unwrap();
assert_eq!(first, retry);
assert!(reopened.marker_path().exists());
let key_path = reopened.file_path(KEYRING_ITEM);
let encoded = Zeroizing::new(fs::read(key_path).unwrap());
let raw_key = &encoded[72..104];
for path in [
database.path().to_path_buf(),
PathBuf::from(format!("{}-wal", database.path().display())),
] {
match fs::read(path) {
Ok(bytes) => assert!(!bytes.windows(raw_key.len()).any(|window| window == raw_key)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!("cannot inspect database bytes: {error}"),
}
}
let exported = serde_json::to_vec(
&database
.export_data("2026-09-22T00:00:00Z".into())
.await
.unwrap(),
)
.unwrap();
assert!(
!exported
.windows(raw_key.len())
.any(|window| window == raw_key)
);
let backup = temp.path().join("backup.tar.zst");
database
.create_backup_archive(&temp.path().join("blobs"), &backup)
.await
.unwrap();
let target_dir = tempfile::tempdir().unwrap();
let target = Database::open(&target_dir.path().join("target.sqlite"))
.await
.unwrap();
let protected = reopened.load_required().unwrap();
target
.decrypt_and_install_local_shared_state_package(&retry, protected.package_key())
.await
.unwrap();
let installed = target
.export_data("2026-09-22T00:00:00Z".into())
.await
.unwrap();
assert_eq!(installed.tables.tasks[0].title, "protected package");
database
.cancel_local_shared_state_never_dispatched(first.candidate_id())
.await
.unwrap();
database
.create_backup_archive(&temp.path().join("blobs"), &backup)
.await
.unwrap();
let decoder = zstd::Decoder::new(File::open(backup).unwrap()).unwrap();
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes).unwrap();
assert!(!bytes.windows(raw_key.len()).any(|window| window == raw_key));
}
}
#[tokio::test]
async fn existing_package_never_regenerates_destroyed_authority() {
let temp = tempfile::tempdir().unwrap();
let database = captured_database(temp.path()).await;
let key_root = temp.path().join("authority");
let store = isolated_store(&database, &key_root).await;
store
.package_local_capture(&database, temp.path(), [0x64; 32])
.await
.unwrap();
let key_path = store.file_path(KEYRING_ITEM);
fs::remove_file(&key_path).unwrap();
fs::remove_file(store.marker_path()).unwrap();
let error = store
.package_local_capture(&database, temp.path(), [0x64; 32])
.await
.unwrap_err();
assert_eq!(
error
.downcast_ref::<ProtectedLocalKeyStoreError>()
.unwrap()
.kind(),
ProtectedLocalKeyStoreErrorKind::MissingAuthority
);
assert!(!key_path.exists());
assert!(!store.marker_path().exists());
}
#[tokio::test]
async fn package_store_rejects_a_different_database_before_storage_effects() {
let first_root = tempfile::tempdir().unwrap();
let first = Database::open(&first_root.path().join("first.sqlite"))
.await
.unwrap();
let second_root = tempfile::tempdir().unwrap();
let second = captured_database(second_root.path()).await;
let key_root = first_root.path().join("authority");
let store = isolated_store(&first, &key_root).await;
let error = store
.package_local_capture(&second, second_root.path(), [0x64; 32])
.await
.unwrap_err();
assert_eq!(
error
.downcast_ref::<ProtectedLocalKeyStoreError>()
.unwrap()
.kind(),
ProtectedLocalKeyStoreErrorKind::WrongDatabase
);
assert!(!key_root.exists());
}
fn remove_database(path: &Path) {
for suffix in ["", "-wal", "-shm"] {
let _ = fs::remove_file(format!("{}{suffix}", path.display()));
}
}
#[tokio::test]
async fn new_database_at_reused_path_ignores_stale_markers() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("db.sqlite");
let key_root = temp.path().join("keys");
let database = Database::open(&path).await.unwrap();
let old = isolated_store(&database, &key_root).await;
old.prepare_seed_claim(&database, [9; 32]).await.unwrap();
drop(database);
remove_database(&path);
fs::remove_file(key_root.join(format!("{}.keyring", old.account))).unwrap();
old.delete_secret("seed").unwrap();
assert_eq!(
old.load_or_create().unwrap_err().kind(),
ProtectedLocalKeyStoreErrorKind::MissingAuthority
);
let database = Database::open(&path).await.unwrap();
let store = isolated_store(&database, &key_root).await;
assert_ne!(store.account, old.account);
store.prepare_seed_claim(&database, [7; 32]).await.unwrap();
assert!(old.marker_path().exists());
}
#[tokio::test]
async fn new_database_at_reused_path_never_inherits_old_keys() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("db.sqlite");
let key_root = temp.path().join("keys");
let database = Database::open(&path).await.unwrap();
let old = isolated_store(&database, &key_root).await;
let old_seed = old.prepare_seed_claim(&database, [9; 32]).await.unwrap();
drop(database);
remove_database(&path);
let database = Database::open(&path).await.unwrap();
let store = isolated_store(&database, &key_root).await;
let seed = store.prepare_seed_claim(&database, [7; 32]).await.unwrap();
assert_ne!(seed.genesis().context(), old_seed.genesis().context());
assert_ne!(seed.genesis().commitment(), old_seed.genesis().commitment());
assert_eq!(
old.load_required().unwrap().context(),
old_seed.genesis().context()
);
}
#[tokio::test]
async fn rolled_back_copy_of_the_same_database_keeps_missing_authority_detection() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("db.sqlite");
let before_setup = temp.path().join("before-setup.sqlite");
let key_root = temp.path().join("keys");
let database = Database::open(&path).await.unwrap();
let mut conn = crate::test_support::acquire(&database).await.unwrap();
sqlx::query("VACUUM INTO ?")
.bind(before_setup.to_str().unwrap())
.execute(&mut *conn)
.await
.unwrap();
drop(conn);
let store = isolated_store(&database, &key_root).await;
store.prepare_seed_claim(&database, [9; 32]).await.unwrap();
drop(database);
remove_database(&path);
fs::copy(&before_setup, &path).unwrap();
fs::remove_file(key_root.join(format!("{}.keyring", store.account))).unwrap();
let database = Database::open(&path).await.unwrap();
let rolled_back = isolated_store(&database, &key_root).await;
assert_eq!(rolled_back.account, store.account);
let error = rolled_back
.prepare_seed_claim(&database, [9; 32])
.await
.unwrap_err();
assert_eq!(
error
.downcast_ref::<ProtectedLocalKeyStoreError>()
.unwrap()
.kind(),
ProtectedLocalKeyStoreErrorKind::MissingAuthority
);
}
#[test]
fn missing_and_corrupt_authority_never_regenerate() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("database.sqlite");
File::create(&database_path).unwrap();
let root = temp.path().join("authority");
let store = account_store(raw_account(&database_path), &root);
let original = store.load_or_create().unwrap().context();
let key_path = store.file_path(KEYRING_ITEM);
fs::remove_file(&key_path).unwrap();
let error = store.load_or_create().unwrap_err();
assert_eq!(
error.kind(),
ProtectedLocalKeyStoreErrorKind::MissingAuthority
);
assert!(!key_path.exists());
fs::remove_file(store.marker_path()).unwrap();
fs::write(&key_path, vec![0_u8; KEYRING_BYTES]).unwrap();
let mut permissions = fs::metadata(&key_path).unwrap().permissions();
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o600);
fs::set_permissions(&key_path, permissions).unwrap();
let error = store.load_or_create().unwrap_err();
assert_eq!(error.kind(), ProtectedLocalKeyStoreErrorKind::Corrupt);
assert_ne!(
store.load_or_create().unwrap_err().kind(),
ProtectedLocalKeyStoreErrorKind::WriteFailed
);
let _ = original;
}
#[test]
fn unavailable_and_failed_writes_do_not_create_authority() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("database.sqlite");
File::create(&database_path).unwrap();
for (fault, expected) in [
(
Fault::Unavailable,
ProtectedLocalKeyStoreErrorKind::Unavailable,
),
(
Fault::FailWrite,
ProtectedLocalKeyStoreErrorKind::WriteFailed,
),
] {
let root = temp.path().join(format!("authority-{expected:?}"));
let store = faulty_store(raw_account(&database_path), &root, fault);
let error = store.load_or_create().unwrap_err();
assert_eq!(error.kind(), expected);
assert!(!store.marker_path().exists());
}
}
#[test]
fn incomplete_marker_write_recovers_existing_key_without_replacement() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("database.sqlite");
File::create(&database_path).unwrap();
let root = temp.path().join("authority");
let store = account_store(raw_account(&database_path), &root);
store.prepare().unwrap();
let key = generate_keyring().unwrap();
let encoded = encode_keyring(&key);
store.create_secret(KEYRING_ITEM, &encoded).unwrap();
let recovered = store.load_or_create().unwrap();
assert_eq!(recovered.context(), key.context());
assert!(store.marker_path().exists());
}
#[test]
fn unsafe_file_permissions_fail_closed_without_secret_diagnostics() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("database.sqlite");
File::create(&database_path).unwrap();
let root = temp.path().join("authority");
let store = account_store(raw_account(&database_path), &root);
let protected = store.load_or_create().unwrap();
let key_path = store.file_path(KEYRING_ITEM);
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
let error = store.load_required().unwrap_err();
assert_eq!(error.kind(), ProtectedLocalKeyStoreErrorKind::Corrupt);
let diagnostic = format!("{error:?} {error}");
assert!(!diagnostic.contains(&hex::encode(protected.context().vault_id)));
assert!(!diagnostic.contains(database_path.to_string_lossy().as_ref()));
}
}